diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..48a3939c --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Documentation MDX source directory +MDX=docs + +# Icon for the documentation site +ICON=🥑 diff --git a/.gitignore b/.gitignore index bcff41d2..fac2796b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ node_modules/ # pnpm .pnpm-debug.log* +# environment +.env.local +.env*.local + # other .next/ .DS_Store diff --git a/docs/authoring/components/buttons.mdx b/docs/authoring/components/buttons.mdx new file mode 100644 index 00000000..bb6b4a38 --- /dev/null +++ b/docs/authoring/components/buttons.mdx @@ -0,0 +1,13 @@ +--- +title: Buttons +description: Documentation for button components +nav: 1 +--- + +# Button Components + +This is a test page for nested navigation - demonstrates a 3-level deep structure. + +## Overview + +Test content for button components. diff --git a/docs/authoring/components/cards.mdx b/docs/authoring/components/cards.mdx new file mode 100644 index 00000000..98c9af49 --- /dev/null +++ b/docs/authoring/components/cards.mdx @@ -0,0 +1,13 @@ +--- +title: Cards +description: Documentation for card components +nav: 2 +--- + +# Card Components + +This is a test page for nested navigation - demonstrates a 3-level deep structure. + +## Overview + +Test content for card components. diff --git a/docs/authoring/components/introduction.mdx b/docs/authoring/components/introduction.mdx new file mode 100644 index 00000000..3d06f88f --- /dev/null +++ b/docs/authoring/components/introduction.mdx @@ -0,0 +1,9 @@ +--- +title: Components +description: Introduction to components +nav: 0 +--- + +# Components + +This is an introduction page for the components section. diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index c2f2460d..ad4684fe 100644 --- a/package.json +++ b/package.json @@ -48,12 +48,19 @@ "vitest": "^4.0.18" }, "dependencies": { + "@abernier/radix-tree": "^1.0.2", "@codesandbox/sandpack-react": "^2.20.0", "@fontsource/inconsolata": "^5.2.8", "@fontsource/inter": "^5.2.8", "@octokit/core": "^6.1.2", + "@radix-ui/primitive": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.0", + "@radix-ui/react-context": "^1.1.3", "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-direction": "^1.1.1", + "@radix-ui/react-primitive": "^2.1.4", + "@radix-ui/react-roving-focus": "^1.1.11", + "@radix-ui/react-use-controllable-state": "^1.2.2", "@radix-ui/react-visually-hidden": "^1.2.4", "@tailwindcss/aspect-ratio": "^0.4.2", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bd76dfb..52717ee3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@abernier/radix-tree': + specifier: ^1.0.2 + version: 1.0.2(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@codesandbox/sandpack-react': specifier: ^2.20.0 version: 2.20.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -20,12 +23,30 @@ importers: '@octokit/core': specifier: ^6.1.2 version: 6.1.6 + '@radix-ui/primitive': + specifier: ^1.1.3 + version: 1.1.3 '@radix-ui/react-collapsible': specifier: ^1.1.0 version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-context': + specifier: ^1.1.3 + version: 1.1.3(@types/react@19.2.8)(react@19.2.3) '@radix-ui/react-dialog': specifier: ^1.1.1 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-direction': + specifier: ^1.1.1 + version: 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': + specifier: ^2.1.4 + version: 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': + specifier: ^1.2.2 + version: 1.2.2(@types/react@19.2.8)(react@19.2.3) '@radix-ui/react-visually-hidden': specifier: ^1.2.4 version: 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -219,6 +240,12 @@ importers: packages: + '@abernier/radix-tree@1.0.2': + resolution: {integrity: sha512-44/f1e2qzTYYg6Q7RxZtOX7Gm3C93kxc4vawNfPLaEem3fuoX7mZDywxFY84IMPUbSPn2mmGyMo2ebWBkR5qiQ==} + peerDependencies: + react: ^19.2.3 + react-dom: ^19.2.3 + '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} @@ -1183,6 +1210,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -1201,6 +1241,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: @@ -1214,6 +1263,15 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dismissable-layer@1.1.11': resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} peerDependencies: @@ -1310,6 +1368,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -6057,6 +6128,20 @@ packages: snapshots: + '@abernier/radix-tree@1.0.2(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.3(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + '@adobe/css-tools@4.4.4': {} '@alloc/quick-lru@5.2.0': {} @@ -7090,6 +7175,18 @@ snapshots: '@types/react': 19.2.8 '@types/react-dom': 19.2.3(@types/react@19.2.8) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.8)(react@19.2.3)': dependencies: react: 19.2.3 @@ -7102,6 +7199,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.8 + '@radix-ui/react-context@1.1.3(@types/react@19.2.8)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -7124,6 +7227,12 @@ snapshots: '@types/react': 19.2.8 '@types/react-dom': 19.2.3(@types/react@19.2.8) + '@radix-ui/react-direction@1.1.1(@types/react@19.2.8)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -7199,6 +7308,23 @@ snapshots: '@types/react': 19.2.8 '@types/react-dom': 19.2.3(@types/react@19.2.8) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + '@radix-ui/react-slot@1.2.3(@types/react@19.2.8)(react@19.2.3)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) diff --git a/src/components/Nav/Nav.tsx b/src/components/Nav/Nav.tsx index c855d2be..3e518833 100644 --- a/src/components/Nav/Nav.tsx +++ b/src/components/Nav/Nav.tsx @@ -1,10 +1,22 @@ +'use client' + import { Doc } from '@/app/[...slug]/DocsContext' import cn from '@/lib/cn' +import * as Tree from '@abernier/radix-tree' +import { flatten, type FlatNode } from '@abernier/radix-tree' +import Link from 'next/link' import * as React from 'react' -import { NavCategory } from './NavCategory' -import { NavCategoryCollapsible } from './NavCategoryCollapsible' +import { IoIosArrowDown } from 'react-icons/io' + +const INDEX_PAGE = 'introduction' -type NavList = Record> +type NavNode = { + id: string + title: string + url?: string + nav: number + nodes?: NavNode[] +} export function Nav({ className, @@ -16,33 +28,168 @@ export function Nav({ asPath: string collapsible: boolean }) { - const nav = React.useMemo( - () => - docs.reduce((acc, doc) => { - const page = doc.slug.at(-1) - const category = doc.slug.at(-2) || 'root' - - acc[category] ??= {} - if (page) acc[category][page] = doc - - return acc - }, {} as NavList), - [docs], - ) + // Convert flat docs to nested structure (simpler than before) + const items = React.useMemo(() => docsToNavNodes(docs), [docs]) + + // Find active keys and their ancestors + const defaultOpenKeys = React.useMemo(() => { + const keys: string[] = [] + const currentPath = `/${asPath}` + + function findActiveKeys(nodes: NavNode[], ancestors: string[] = []) { + for (const node of nodes) { + const nodeAncestors = [...ancestors, node.id] + if (node.url === currentPath) { + keys.push(...nodeAncestors) + } + if (node.nodes) { + findActiveKeys(node.nodes, nodeAncestors) + } + } + } + + findActiveKeys(items) + return keys + }, [items, asPath]) + + const [openKeys, setOpenKeys] = React.useState(defaultOpenKeys) + + // Flatten items based on which keys are open + const flatItems = React.useMemo(() => flatten(items, new Set(openKeys)), [items, openKeys]) return ( -
    - {Object.entries(nav).map(([category, docs]) => { + { + const isActive = url === `/${asPath}` + return ( -
  • - {collapsible ? ( - +
    + {url ? ( + 0 && 'text-xs', + isActive ? 'bg-primary-container' : 'bg-surface', + )} + > + {title} + ) : ( - +
    0 && 'text-xs', + isActive ? 'bg-primary-container' : 'bg-surface', + )} + > + {title} +
    + )} + {collapsible && hasChildNodes && ( + )} -
  • + ) - })} -
+ }} + /> ) } + +// Convert flat docs array to nested NavNode structure +function docsToNavNodes(docs: Doc[]): NavNode[] { + const buildNode = (slugPath: string[], level: number = 0): NavNode[] => { + const nodes: NavNode[] = [] + const childrenByPrefix = new Map() + + // Find all docs at this level + docs.forEach((doc) => { + if (doc.slug.length > level) { + const matchesPrefix = slugPath.every((segment, i) => doc.slug[i] === segment) + if (matchesPrefix) { + const nextSegment = doc.slug[level] + const children = childrenByPrefix.get(nextSegment) || [] + children.push(doc) + childrenByPrefix.set(nextSegment, children) + } + } + }) + + // Create nodes for each segment + Array.from(childrenByPrefix.entries()) + .sort(([a], [b]) => { + // Find docs for sorting by nav property + const docsA = childrenByPrefix.get(a) || [] + const docsB = childrenByPrefix.get(b) || [] + // Match docs where the segment matches at the current level + const navA = docsA.find((d) => d.slug.length === level + 1 && d.slug[level] === a)?.nav || 0 + const navB = docsB.find((d) => d.slug.length === level + 1 && d.slug[level] === b)?.nav || 0 + return navA - navB + }) + .forEach(([segment, segmentDocs]) => { + const newPath = [...slugPath, segment] + + // Find if there's an index/introduction page for this segment + const indexDoc = segmentDocs.find((d) => { + return d.slug.length === newPath.length && d.slug[d.slug.length - 1] === INDEX_PAGE + }) + + // Find exact match doc (leaf node at this level) + const exactDoc = segmentDocs.find( + (d) => d.slug.length === newPath.length && d.slug.join('/') === newPath.join('/'), + ) + + // Get children recursively + const children = buildNode(newPath, level + 1) + + const node: NavNode = { + id: newPath.join('/'), + title: exactDoc?.title || segment.replace(/\-/g, ' '), + url: exactDoc?.url || indexDoc?.url, + nav: exactDoc?.nav || indexDoc?.nav || 0, + nodes: children.length > 0 ? children : undefined, + } + + nodes.push(node) + }) + + return nodes + } + + return buildNode([], 0) +}