From 96556b2d33abd6f360f2220c139f1cf48991ac13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 08:19:37 +0000 Subject: [PATCH 1/5] Initial plan From e4a854f55e132e362aecad4b5571d0dd00bf11c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 08:29:47 +0000 Subject: [PATCH 2/5] Implement infinite nested navigation with Radix Tree primitive Co-authored-by: abernier <76580+abernier@users.noreply.github.com> --- docs/authoring/components/buttons.mdx | 13 + docs/authoring/components/cards.mdx | 13 + docs/authoring/components/introduction.mdx | 9 + package.json | 6 + pnpm-lock.yaml | 103 ++++ src/components/Nav/Nav.tsx | 187 ++++++- .../primitives/Tree/Tree.stories.tsx | 199 +++++++ src/components/primitives/Tree/Tree.tsx | 500 ++++++++++++++++++ src/components/primitives/Tree/index.ts | 1 + 9 files changed, 1006 insertions(+), 25 deletions(-) create mode 100644 docs/authoring/components/buttons.mdx create mode 100644 docs/authoring/components/cards.mdx create mode 100644 docs/authoring/components/introduction.mdx create mode 100644 src/components/primitives/Tree/Tree.stories.tsx create mode 100644 src/components/primitives/Tree/Tree.tsx create mode 100644 src/components/primitives/Tree/index.ts 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/package.json b/package.json index c2f2460d..09f6b6d2 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,14 @@ "@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..07900d7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,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) @@ -1183,6 +1201,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 +1232,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 +1254,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 +1359,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: @@ -7090,6 +7152,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 +7176,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 +7204,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 +7285,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..7834a651 100644 --- a/src/components/Nav/Nav.tsx +++ b/src/components/Nav/Nav.tsx @@ -1,10 +1,20 @@ +'use client' + import { Doc } from '@/app/[...slug]/DocsContext' import cn from '@/lib/cn' +import * as Tree from '@/components/primitives/Tree' +import type { RecursiveNode } from '@/components/primitives/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 = RecursiveNode & { + title: string + url?: string + nav: number +} export function Nav({ className, @@ -16,33 +26,160 @@ 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], - ) + const tree = React.useMemo(() => buildTree(docs), [docs]) + + // Find active keys (all nodes that contain the active path) + const activeKeys = 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 as NavNode[], nodeAncestors) + } + } + } + + findActiveKeys(tree) + return keys + }, [tree, asPath]) return ( - + }} + Group={({ children }) =>
{children}
} + /> ) } + +// Build a recursive tree structure from flat docs array +function buildTree(docs: Doc[]): NavNode[] { + const tree: NavNode[] = [] + + // Build tree recursively + 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 && doc.slug.length > level) { + 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) || [] + const navA = docsA.find((d) => d.slug[d.slug.length - 1] === a)?.nav || 0 + const navB = docsB.find((d) => d.slug[d.slug.length - 1] === 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) +} diff --git a/src/components/primitives/Tree/Tree.stories.tsx b/src/components/primitives/Tree/Tree.stories.tsx new file mode 100644 index 00000000..d5b8ea0c --- /dev/null +++ b/src/components/primitives/Tree/Tree.stories.tsx @@ -0,0 +1,199 @@ +import { Component, useMemo, useState, type ComponentProps } from 'react' + +import type { Meta, StoryObj } from '@storybook/nextjs-vite' + +import * as Tree from '.' +import { flatten } from '.' + +/** + * + * + * ## Anatomy + * + * ```tsx + * + * + * + * ... + * + * ... + * + * ``` + */ + +const meta: Meta = { + title: 'primitives/Tree', + component: Tree.Root, + args: { + defaultOpenKeys: ['composables', 'components', 'Home'], + defaultSelectedKeys: ['useAuth.ts', 'Home'], + }, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +// + +const TreeGroup = (props: ComponentProps) => ( + +) +const TreeItem = (props: ComponentProps) => ( + +) + +/** + * Dumb/fully declarative tree. + * + * Item/group are manually linked together, through `nodeId`/`parentId`. + * Item has to declare if it `hasChildNodes` + */ + +export const St1: Story = { + name: 'Default', + args: {}, + render: (args) => ( + + + 📁 composables - 🧩 + + + 📄 useAuth.ts - 🔑 + 📄 useUser.ts - 🧍🏼 + + + 📁 components - 🧱 + + + + 📁 Home - 🏠 + + + 📄 Card.vue - 💳 + 📄 Button.vue - 🔵 + + + 📄 app.vue - 👩🏻‍💻 + 📄 nuxt.config.ts - 🔺 + + ), +} + +// + +// export const St2: Story = { +// name: 'Multiselect', +// args: { +// multiselect: true, +// }, +// render: (args) => ( +// +// +// Root Item +// +// +// Child 1 +// Child 2 +// +// +// ), +// }; + +// + +type MyNode = { id: string; icon: string; nodes?: MyNode[] } + +const items: MyNode[] = [ + { + id: 'composables', + icon: '🧩', + nodes: [ + { id: 'useAuth.ts', icon: '🔑' }, + { id: 'useUser.ts', icon: '🧍🏼' }, + ], + }, + { + id: 'components', + icon: '🧱', + nodes: [ + { + id: 'Home', + icon: '🏠', + nodes: [ + { id: 'Card.vue', icon: '💳' }, + { id: 'Button.vue', icon: '🔵' }, + ], + }, + ], + }, + { id: 'app.vue', icon: '👩🏻‍💻' }, + { id: 'nuxt.config.ts', icon: '🔺' }, +] +const defaultOpenKeys = ['composables', 'components', 'Home'] +const defaultSelectedKeys = ['useAuth.ts', 'Home'] + +/** + * Item/group are automatically linked together given `items` structure. + * + * ## Anatomy + * + * ```tsx + * + * ``` + */ + +export const St3: StoryObj = { + name: 'Tree.Factory', + render: () => ( + ( +

+ {nodes ? `📁` : `📄`} {id} - {icon} +

+ )} + Group={({ children }) =>
{children}
} + /> + ), +} + +/** + * `items` are rendered in a (flat) virtualized list, with computed additional VItem props: `depth`, `hasChildNodes`. + * + * ## Anatomy + * + * ```tsx + * + * ``` + */ + +function Scene4(props: ComponentProps) { + const [openKeys, setOpenKeys] = useState(defaultOpenKeys) + const flatItems = useMemo(() => flatten(items, new Set(openKeys)), [openKeys]) + + return ( + ( +

+ {hasChildNodes ? `📁` : `📄`} {id} - {icon} +

+ )} + /> + ) +} + +export const St4: StoryObj = { + name: 'Tree.Factory virtualized', + render: (args) => , +} diff --git a/src/components/primitives/Tree/Tree.tsx b/src/components/primitives/Tree/Tree.tsx new file mode 100644 index 00000000..e05736fb --- /dev/null +++ b/src/components/primitives/Tree/Tree.tsx @@ -0,0 +1,500 @@ +import * as React from 'react' +import { useMemo } from 'react' + +import { composeEventHandlers } from '@radix-ui/primitive' +import { createContextScope, type Scope } from '@radix-ui/react-context' +import { useDirection } from '@radix-ui/react-direction' +import { Primitive } from '@radix-ui/react-primitive' +import * as RovingFocusGroup from '@radix-ui/react-roving-focus' +import { createRovingFocusGroupScope } from '@radix-ui/react-roving-focus' +import { useControllableState } from '@radix-ui/react-use-controllable-state' + +/* ------------------------------------------------------------------------------------------------- + * Tree + * ----------------------------------------------------------------------------------------------- */ + +const TREE_NAME = 'Tree' + +type ScopedProps

= P & { __scopeTree?: Scope } + +const [createTreeContext, createTreeScope] = createContextScope(TREE_NAME, [ + createRovingFocusGroupScope, +]) +const useRovingFocusGroupScope = createRovingFocusGroupScope() + +type RovingFocusGroupProps = React.ComponentPropsWithoutRef + +type TreeContextValue = { + orientation: RovingFocusGroupProps['orientation'] + dir: RovingFocusGroupProps['dir'] + multiselect: boolean + openKeys: Set + onToggleOpen(key: string): void + selectedKeys: Set + onSelect(key: string): void +} + +const [TreeProvider, useTreeContext] = createTreeContext(TREE_NAME) + +type TreeElement = React.ElementRef +type TreeProps = React.ComponentPropsWithoutRef & { + orientation?: RovingFocusGroupProps['orientation'] + loop?: RovingFocusGroupProps['loop'] + dir?: RovingFocusGroupProps['dir'] + multiselect?: boolean + + openKeys?: string[] + onOpenKeysChange?: (openKeys: string[]) => void + defaultOpenKeys?: string[] + + selectedKeys?: string[] + onSelectedKeysChange?: (selectedKeys: string[]) => void + defaultSelectedKeys?: string[] +} + +const Tree = React.forwardRef>((props, forwardedRef) => { + const { + __scopeTree, + orientation = 'vertical', + loop = true, + dir, + + multiselect = false, + + openKeys: openKeysProp, + onOpenKeysChange, + defaultOpenKeys = [], + + selectedKeys: selectedKeysProp, + defaultSelectedKeys = [], + onSelectedKeysChange, + + ...domProps + } = props + + // RovingFocus scope for focus management + const rovingFocusScope = useRovingFocusGroupScope(__scopeTree) + + // useControllableState for open keys + const [openKeysArray, setOpenKeysArray] = useControllableState({ + prop: openKeysProp, + onChange: onOpenKeysChange, + defaultProp: defaultOpenKeys, + }) + + // Toggling open/close + const handleToggleOpen = React.useCallback( + (key: string) => { + setOpenKeysArray((prevValue) => { + const prevSet = new Set(prevValue ?? []) + if (prevSet.has(key)) { + prevSet.delete(key) + } else { + prevSet.add(key) + } + return Array.from(prevSet) + }) + }, + [setOpenKeysArray], + ) + + // useControllableState for selected keys + const [selectedKeysArray, setSelectedKeysArray] = useControllableState({ + prop: selectedKeysProp, + onChange: onSelectedKeysChange, + defaultProp: defaultSelectedKeys, + }) + + const handleSelect = React.useCallback( + (key: string) => { + setSelectedKeysArray((prevValue) => { + const prevSet = new Set(prevValue ?? []) + + if (!multiselect) { + // single-select + if (prevSet.has(key)) { + return [] + } else { + return [key] + } + } + + // multi-select + if (prevSet.has(key)) { + prevSet.delete(key) + } else { + prevSet.add(key) + } + + return Array.from(prevSet) + }) + }, + [multiselect, setSelectedKeysArray], + ) + + // Convert direction + set up the context + const direction = useDirection(dir) + + // Convert arrays to sets for internal usage + const openKeysSet = React.useMemo(() => new Set(openKeysArray), [openKeysArray]) + const selectedKeysSet = React.useMemo(() => new Set(selectedKeysArray), [selectedKeysArray]) + + return ( + + + + + + ) +}) +Tree.displayName = TREE_NAME + +/* ------------------------------------------------------------------------------------------------- + * TreeItem + * ----------------------------------------------------------------------------------------------- */ + +type TreeItemElement = React.ElementRef +type TreeItemProps = React.ComponentPropsWithoutRef & { + nodeId: string + hasChildNodes?: boolean +} + +const TreeItem = React.forwardRef>( + (props, forwardedRef) => { + const { nodeId, hasChildNodes = false, __scopeTree, ...domProps } = props + const { openKeys, onToggleOpen, selectedKeys, onSelect, orientation } = useTreeContext( + TREE_NAME, + __scopeTree, + ) + + const isOpen = openKeys.has(nodeId) + const isSelected = selectedKeys.has(nodeId) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + const { key } = event + if (key === 'ArrowRight') { + if (!isOpen && hasChildNodes) { + onToggleOpen(nodeId) + event.preventDefault() + } + } else if (key === 'ArrowLeft') { + if (isOpen && hasChildNodes) { + onToggleOpen(nodeId) + event.preventDefault() + } + } else if (key === 'Enter') { + onSelect(nodeId) + } + }, + [onToggleOpen, onSelect, isOpen, nodeId, hasChildNodes], + ) + + const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeTree) + return ( + + onSelect(nodeId))} + {...domProps} + /> + + ) + }, +) +TreeItem.displayName = 'TreeItem' + +/* ------------------------------------------------------------------------------------------------- + * TreeGroup + * ----------------------------------------------------------------------------------------------- */ + +type TreeGroupElement = React.ElementRef +type TreeGroupProps = React.ComponentPropsWithoutRef & { + parentId: string +} + +const TreeGroup = React.forwardRef>( + (props, forwardedRef) => { + const { parentId, __scopeTree, ...domProps } = props + const { openKeys } = useTreeContext(TREE_NAME, __scopeTree) + const isOpen = openKeys.has(parentId) + + return ( + + ) + }, +) +TreeGroup.displayName = 'TreeGroup' + +/* ------------------------------------------------------------------------------------------------- + * TreeFactory + * ----------------------------------------------------------------------------------------------- */ + +export type RecursiveNode = { + id: string + nodes?: RecursiveNode[] +} + +export type FlatNode = Omit & { + depth: number + hasChildNodes: boolean + childNodesCount: number + parentId: string | null + ancestors: (string | null)[] +} + +type TreeFactoryBaseProps = { + /** */ +} & TreeProps + +type TreeFactoryProps = + | (TreeFactoryBaseProps & { + items: T[] + Item: (props: T) => React.ReactNode + Group: (props: T & { children: React.ReactNode }) => React.ReactNode + virtualized?: false + VItem?: never + flatItems?: never + }) + | (TreeFactoryBaseProps & { + virtualized: true + flatItems: FlatNode[] // pre-flattened items + VItem: (props: FlatNode) => React.ReactNode + items?: never + Item?: never + Group?: never + }) + +function TreeFactory({ + items, + flatItems, + Item, + Group, + virtualized, + VItem, + ...treeProps +}: TreeFactoryProps) { + // + // Recursive render of a node + // + + function renderNode(item: T) { + const { id, nodes } = item + const hasChildNodes = Boolean(nodes?.length) + + return ( + + + {Item!(item)} + + + {/* If there are children, wrap them in a */} + {hasChildNodes && ( + + {Group!({ + ...item, + children: nodes?.map((child) => renderNode(child as T)), + })} + + )} + + ) + } + + return ( + + {virtualized ? ( + {VItem} + ) : ( + items.map((item) => renderNode(item)) + )} + + ) +} + +// VItems + +// +// +// + +// function getAllSelectedKeysWithChildren(tree: RecursiveNode[], selectedKeys: Set) { +// const ret = new Set(); + +// // Depth-first collection of child keys, including the node's own key +// function collectAllKeys(node: RecursiveNode) { +// ret.add(node.id); +// if (node.nodes) { +// for (const child of node.nodes) { +// collectAllKeys(child); +// } +// } +// } + +// // Traverse the entire tree to find matching nodes whose key is selected +// function traverse(nodes: RecursiveNode[]) { +// for (const node of nodes) { +// // If node's key is in `selectedKeys`, collect it & its descendants +// if (selectedKeys.has(node.id)) { +// collectAllKeys(node); +// } +// // Keep traversing even if node's key isn't selected, because its children might be +// if (node.nodes?.length) { +// traverse(node.nodes); +// } +// } +// } + +// traverse(tree); +// return ret; +// } + +/** + * Flatten `nodes` into a 1-level array, with additional `depth` and `hasChildNodes` metadata + * Exclude the node if not expanded + * + * eg. from: + * ```ts + * [ + * { id: '1', nodes: [{ id: '2' }] }, + * { id: '3' }, + * { id: '4', nodes: [{ id: '5' }] }, + * ] + * ``` + * + * to: + * + * ```ts + * [ + * { id: '1', depth: 0, hasChildNodes: true, childNodesCount: 1 }, + * { id: '2', depth: 1, hasChildNodes: false, childNodesCount: 0 }, + * { id: '3', depth: 0, hasChildNodes: false, childNodesCount: 0 }, + * { id: '4', depth: 0, hasChildNodes: true, childNodesCount: 1 }, + * { id: '5', depth: 1, hasChildNodes: false, childNodesCount: 0 }, + * ] + */ + +export function flatten( + nodes: T[], + expandedNodes?: Set, + depth = 0, + parentId: string | null = null, + ancestors: (string | null)[] = depth === 0 ? [null] : [], +) { + const result: FlatNode[] = [] + + for (const node of nodes) { + const childNodesCount = node.nodes?.length ?? 0 + const hasChildNodes = childNodesCount > 0 + const currentAncestors = [...ancestors] + const flatNode = { + ...node, + depth, + hasChildNodes, + childNodesCount, + parentId, + ancestors: currentAncestors, + } + result.push(flatNode) + + // If the item has children and is expanded, process them recursively + if (hasChildNodes && (expandedNodes?.has(node.id) ?? true)) { + const childAncestors = [...currentAncestors, node.id] + result.push(...flatten(node.nodes as T[], expandedNodes, depth + 1, node.id, childAncestors)) + } + } + + return result +} + +function VItems( + props: ScopedProps<{ + flatItems: FlatNode[] + children: (props: FlatNode) => React.ReactNode + }>, +) { + const { flatItems, children: render, __scopeTree } = props + const { openKeys, selectedKeys } = useTreeContext(TREE_NAME, __scopeTree) + + // const allSelectedKeys = getAllSelectedKeysWithChildren(items, selectedKeys); + const allSelectedKeys = selectedKeys + + return ( + <> + {flatItems.map((flatItem) => ( + + {render(flatItem)} + + ))} + + ) +} + +/* ------------------------------------------------------------------------------------------------- + * Exports + * ----------------------------------------------------------------------------------------------- */ + +export const createTreePrimitiveScope = createTreeScope() + +const Root = Tree +const Group = TreeGroup +const Item = TreeItem + +const Factory = TreeFactory + +export { + createTreeScope, + // + Tree, + TreeGroup, + TreeItem, + TreeFactory, + // + Root, + Group, + Item, + Factory, +} + +export type { TreeProps, TreeItemProps } diff --git a/src/components/primitives/Tree/index.ts b/src/components/primitives/Tree/index.ts new file mode 100644 index 00000000..cf4b2a6f --- /dev/null +++ b/src/components/primitives/Tree/index.ts @@ -0,0 +1 @@ +export * from './Tree' From e864e2e9bd16df3ad56b6af7a4b9a95d9a3ed045 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 08:45:48 +0000 Subject: [PATCH 3/5] Address code review feedback - fix TypeScript errors, improve accessibility, remove dead code Co-authored-by: abernier <76580+abernier@users.noreply.github.com> --- .env.example | 5 ++ .gitignore | 4 + next-env.d.ts | 2 +- src/components/Nav/Nav.tsx | 15 ++-- .../primitives/Tree/Tree.stories.tsx | 2 +- src/components/primitives/Tree/Tree.tsx | 79 ++++++------------- 6 files changed, 43 insertions(+), 64 deletions(-) create mode 100644 .env.example 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/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/src/components/Nav/Nav.tsx b/src/components/Nav/Nav.tsx index 7834a651..f1b8bf54 100644 --- a/src/components/Nav/Nav.tsx +++ b/src/components/Nav/Nav.tsx @@ -92,11 +92,13 @@ export function Nav({ onClick={(e) => { e.preventDefault() e.stopPropagation() - // The tree handles the toggle via keyboard/click on the item + // Get the tree item element to check its expansion state const treeItem = e.currentTarget.closest('[role="treeitem"]') if (treeItem) { + const isExpanded = treeItem.getAttribute('aria-expanded') === 'true' + // Dispatch the appropriate key event to toggle const keyEvent = new KeyboardEvent('keydown', { - key: 'ArrowRight', + key: isExpanded ? 'ArrowLeft' : 'ArrowRight', bubbles: true, }) treeItem.dispatchEvent(keyEvent) @@ -106,7 +108,7 @@ export function Nav({ 'absolute right-0 top-1/2 -translate-y-1/2 p-(--NavItem-pad) transition-transform', 'aria-expanded:rotate-90', )} - aria-label="Toggle" + aria-label={`Toggle ${title} submenu`} > @@ -132,7 +134,7 @@ function buildTree(docs: Doc[]): NavNode[] { docs.forEach((doc) => { if (doc.slug.length > level) { const matchesPrefix = slugPath.every((segment, i) => doc.slug[i] === segment) - if (matchesPrefix && doc.slug.length > level) { + if (matchesPrefix) { const nextSegment = doc.slug[level] const children = childrenByPrefix.get(nextSegment) || [] children.push(doc) @@ -147,8 +149,9 @@ function buildTree(docs: Doc[]): NavNode[] { // Find docs for sorting by nav property const docsA = childrenByPrefix.get(a) || [] const docsB = childrenByPrefix.get(b) || [] - const navA = docsA.find((d) => d.slug[d.slug.length - 1] === a)?.nav || 0 - const navB = docsB.find((d) => d.slug[d.slug.length - 1] === b)?.nav || 0 + // 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]) => { diff --git a/src/components/primitives/Tree/Tree.stories.tsx b/src/components/primitives/Tree/Tree.stories.tsx index d5b8ea0c..d1314fdd 100644 --- a/src/components/primitives/Tree/Tree.stories.tsx +++ b/src/components/primitives/Tree/Tree.stories.tsx @@ -1,4 +1,4 @@ -import { Component, useMemo, useState, type ComponentProps } from 'react' +import { useMemo, useState, type ComponentProps } from 'react' import type { Meta, StoryObj } from '@storybook/nextjs-vite' diff --git a/src/components/primitives/Tree/Tree.tsx b/src/components/primitives/Tree/Tree.tsx index e05736fb..4c7055c5 100644 --- a/src/components/primitives/Tree/Tree.tsx +++ b/src/components/primitives/Tree/Tree.tsx @@ -275,9 +275,7 @@ export type FlatNode = Omit ancestors: (string | null)[] } -type TreeFactoryBaseProps = { - /** */ -} & TreeProps +type TreeFactoryBaseProps = TreeProps type TreeFactoryProps = | (TreeFactoryBaseProps & { @@ -306,6 +304,24 @@ function TreeFactory({ VItem, ...treeProps }: TreeFactoryProps) { + // Handle virtualized mode separately + if (virtualized) { + return ( + + {VItem} + + ) + } + + // Non-virtualized mode - Item and Group are guaranteed to be defined + if (!Item || !Group || !items) { + throw new Error('TreeFactory requires items, Item, and Group when not virtualized') + } + + // Store in constants to help TypeScript understand they're not undefined + const ItemComponent = Item + const GroupComponent = Group + // // Recursive render of a node // @@ -317,13 +333,13 @@ function TreeFactory({ return ( - {Item!(item)} + {ItemComponent(item)} {/* If there are children, wrap them in a */} {hasChildNodes && ( - {Group!({ + {GroupComponent({ ...item, children: nodes?.map((child) => renderNode(child as T)), })} @@ -333,54 +349,9 @@ function TreeFactory({ ) } - return ( - - {virtualized ? ( - {VItem} - ) : ( - items.map((item) => renderNode(item)) - )} - - ) + return {items.map((item) => renderNode(item))} } -// VItems - -// -// -// - -// function getAllSelectedKeysWithChildren(tree: RecursiveNode[], selectedKeys: Set) { -// const ret = new Set(); - -// // Depth-first collection of child keys, including the node's own key -// function collectAllKeys(node: RecursiveNode) { -// ret.add(node.id); -// if (node.nodes) { -// for (const child of node.nodes) { -// collectAllKeys(child); -// } -// } -// } - -// // Traverse the entire tree to find matching nodes whose key is selected -// function traverse(nodes: RecursiveNode[]) { -// for (const node of nodes) { -// // If node's key is in `selectedKeys`, collect it & its descendants -// if (selectedKeys.has(node.id)) { -// collectAllKeys(node); -// } -// // Keep traversing even if node's key isn't selected, because its children might be -// if (node.nodes?.length) { -// traverse(node.nodes); -// } -// } -// } - -// traverse(tree); -// return ret; -// } - /** * Flatten `nodes` into a 1-level array, with additional `depth` and `hasChildNodes` metadata * Exclude the node if not expanded @@ -448,9 +419,6 @@ function VItems( const { flatItems, children: render, __scopeTree } = props const { openKeys, selectedKeys } = useTreeContext(TREE_NAME, __scopeTree) - // const allSelectedKeys = getAllSelectedKeysWithChildren(items, selectedKeys); - const allSelectedKeys = selectedKeys - return ( <> {flatItems.map((flatItem) => ( @@ -458,10 +426,9 @@ function VItems( key={flatItem.id} nodeId={flatItem.id} hasChildNodes={flatItem.hasChildNodes} - aria-selected={allSelectedKeys.has(flatItem.id) || undefined} + aria-selected={selectedKeys.has(flatItem.id) || undefined} aria-expanded={openKeys.has(flatItem.id) || undefined} aria-level={flatItem.depth} - // {...flatItem} // depth asChild > {render(flatItem)} From 8bd7634d8c519f8d63c807c53623173bfbe81a06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:10:20 +0000 Subject: [PATCH 4/5] Use @abernier/radix-tree npm package instead of local implementation Co-authored-by: abernier <76580+abernier@users.noreply.github.com> --- package.json | 1 + pnpm-lock.yaml | 23 + src/components/Nav/Nav.tsx | 4 +- .../primitives/Tree/Tree.stories.tsx | 199 -------- src/components/primitives/Tree/Tree.tsx | 467 ------------------ src/components/primitives/Tree/index.ts | 1 - 6 files changed, 26 insertions(+), 669 deletions(-) delete mode 100644 src/components/primitives/Tree/Tree.stories.tsx delete mode 100644 src/components/primitives/Tree/Tree.tsx delete mode 100644 src/components/primitives/Tree/index.ts diff --git a/package.json b/package.json index 09f6b6d2..ad4684fe 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07900d7f..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) @@ -237,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==} @@ -6119,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': {} diff --git a/src/components/Nav/Nav.tsx b/src/components/Nav/Nav.tsx index f1b8bf54..f7823787 100644 --- a/src/components/Nav/Nav.tsx +++ b/src/components/Nav/Nav.tsx @@ -2,8 +2,8 @@ import { Doc } from '@/app/[...slug]/DocsContext' import cn from '@/lib/cn' -import * as Tree from '@/components/primitives/Tree' -import type { RecursiveNode } from '@/components/primitives/Tree' +import * as Tree from '@abernier/radix-tree' +import type { RecursiveNode } from '@abernier/radix-tree' import Link from 'next/link' import * as React from 'react' import { IoIosArrowDown } from 'react-icons/io' diff --git a/src/components/primitives/Tree/Tree.stories.tsx b/src/components/primitives/Tree/Tree.stories.tsx deleted file mode 100644 index d1314fdd..00000000 --- a/src/components/primitives/Tree/Tree.stories.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { useMemo, useState, type ComponentProps } from 'react' - -import type { Meta, StoryObj } from '@storybook/nextjs-vite' - -import * as Tree from '.' -import { flatten } from '.' - -/** - * - * - * ## Anatomy - * - * ```tsx - * - * - * - * ... - * - * ... - * - * ``` - */ - -const meta: Meta = { - title: 'primitives/Tree', - component: Tree.Root, - args: { - defaultOpenKeys: ['composables', 'components', 'Home'], - defaultSelectedKeys: ['useAuth.ts', 'Home'], - }, - tags: ['autodocs'], -} - -export default meta -type Story = StoryObj - -// - -const TreeGroup = (props: ComponentProps) => ( - -) -const TreeItem = (props: ComponentProps) => ( - -) - -/** - * Dumb/fully declarative tree. - * - * Item/group are manually linked together, through `nodeId`/`parentId`. - * Item has to declare if it `hasChildNodes` - */ - -export const St1: Story = { - name: 'Default', - args: {}, - render: (args) => ( - - - 📁 composables - 🧩 - - - 📄 useAuth.ts - 🔑 - 📄 useUser.ts - 🧍🏼 - - - 📁 components - 🧱 - - - - 📁 Home - 🏠 - - - 📄 Card.vue - 💳 - 📄 Button.vue - 🔵 - - - 📄 app.vue - 👩🏻‍💻 - 📄 nuxt.config.ts - 🔺 - - ), -} - -// - -// export const St2: Story = { -// name: 'Multiselect', -// args: { -// multiselect: true, -// }, -// render: (args) => ( -// -// -// Root Item -// -// -// Child 1 -// Child 2 -// -// -// ), -// }; - -// - -type MyNode = { id: string; icon: string; nodes?: MyNode[] } - -const items: MyNode[] = [ - { - id: 'composables', - icon: '🧩', - nodes: [ - { id: 'useAuth.ts', icon: '🔑' }, - { id: 'useUser.ts', icon: '🧍🏼' }, - ], - }, - { - id: 'components', - icon: '🧱', - nodes: [ - { - id: 'Home', - icon: '🏠', - nodes: [ - { id: 'Card.vue', icon: '💳' }, - { id: 'Button.vue', icon: '🔵' }, - ], - }, - ], - }, - { id: 'app.vue', icon: '👩🏻‍💻' }, - { id: 'nuxt.config.ts', icon: '🔺' }, -] -const defaultOpenKeys = ['composables', 'components', 'Home'] -const defaultSelectedKeys = ['useAuth.ts', 'Home'] - -/** - * Item/group are automatically linked together given `items` structure. - * - * ## Anatomy - * - * ```tsx - * - * ``` - */ - -export const St3: StoryObj = { - name: 'Tree.Factory', - render: () => ( - ( -

- {nodes ? `📁` : `📄`} {id} - {icon} -

- )} - Group={({ children }) =>
{children}
} - /> - ), -} - -/** - * `items` are rendered in a (flat) virtualized list, with computed additional VItem props: `depth`, `hasChildNodes`. - * - * ## Anatomy - * - * ```tsx - * - * ``` - */ - -function Scene4(props: ComponentProps) { - const [openKeys, setOpenKeys] = useState(defaultOpenKeys) - const flatItems = useMemo(() => flatten(items, new Set(openKeys)), [openKeys]) - - return ( - ( -

- {hasChildNodes ? `📁` : `📄`} {id} - {icon} -

- )} - /> - ) -} - -export const St4: StoryObj = { - name: 'Tree.Factory virtualized', - render: (args) => , -} diff --git a/src/components/primitives/Tree/Tree.tsx b/src/components/primitives/Tree/Tree.tsx deleted file mode 100644 index 4c7055c5..00000000 --- a/src/components/primitives/Tree/Tree.tsx +++ /dev/null @@ -1,467 +0,0 @@ -import * as React from 'react' -import { useMemo } from 'react' - -import { composeEventHandlers } from '@radix-ui/primitive' -import { createContextScope, type Scope } from '@radix-ui/react-context' -import { useDirection } from '@radix-ui/react-direction' -import { Primitive } from '@radix-ui/react-primitive' -import * as RovingFocusGroup from '@radix-ui/react-roving-focus' -import { createRovingFocusGroupScope } from '@radix-ui/react-roving-focus' -import { useControllableState } from '@radix-ui/react-use-controllable-state' - -/* ------------------------------------------------------------------------------------------------- - * Tree - * ----------------------------------------------------------------------------------------------- */ - -const TREE_NAME = 'Tree' - -type ScopedProps

= P & { __scopeTree?: Scope } - -const [createTreeContext, createTreeScope] = createContextScope(TREE_NAME, [ - createRovingFocusGroupScope, -]) -const useRovingFocusGroupScope = createRovingFocusGroupScope() - -type RovingFocusGroupProps = React.ComponentPropsWithoutRef - -type TreeContextValue = { - orientation: RovingFocusGroupProps['orientation'] - dir: RovingFocusGroupProps['dir'] - multiselect: boolean - openKeys: Set - onToggleOpen(key: string): void - selectedKeys: Set - onSelect(key: string): void -} - -const [TreeProvider, useTreeContext] = createTreeContext(TREE_NAME) - -type TreeElement = React.ElementRef -type TreeProps = React.ComponentPropsWithoutRef & { - orientation?: RovingFocusGroupProps['orientation'] - loop?: RovingFocusGroupProps['loop'] - dir?: RovingFocusGroupProps['dir'] - multiselect?: boolean - - openKeys?: string[] - onOpenKeysChange?: (openKeys: string[]) => void - defaultOpenKeys?: string[] - - selectedKeys?: string[] - onSelectedKeysChange?: (selectedKeys: string[]) => void - defaultSelectedKeys?: string[] -} - -const Tree = React.forwardRef>((props, forwardedRef) => { - const { - __scopeTree, - orientation = 'vertical', - loop = true, - dir, - - multiselect = false, - - openKeys: openKeysProp, - onOpenKeysChange, - defaultOpenKeys = [], - - selectedKeys: selectedKeysProp, - defaultSelectedKeys = [], - onSelectedKeysChange, - - ...domProps - } = props - - // RovingFocus scope for focus management - const rovingFocusScope = useRovingFocusGroupScope(__scopeTree) - - // useControllableState for open keys - const [openKeysArray, setOpenKeysArray] = useControllableState({ - prop: openKeysProp, - onChange: onOpenKeysChange, - defaultProp: defaultOpenKeys, - }) - - // Toggling open/close - const handleToggleOpen = React.useCallback( - (key: string) => { - setOpenKeysArray((prevValue) => { - const prevSet = new Set(prevValue ?? []) - if (prevSet.has(key)) { - prevSet.delete(key) - } else { - prevSet.add(key) - } - return Array.from(prevSet) - }) - }, - [setOpenKeysArray], - ) - - // useControllableState for selected keys - const [selectedKeysArray, setSelectedKeysArray] = useControllableState({ - prop: selectedKeysProp, - onChange: onSelectedKeysChange, - defaultProp: defaultSelectedKeys, - }) - - const handleSelect = React.useCallback( - (key: string) => { - setSelectedKeysArray((prevValue) => { - const prevSet = new Set(prevValue ?? []) - - if (!multiselect) { - // single-select - if (prevSet.has(key)) { - return [] - } else { - return [key] - } - } - - // multi-select - if (prevSet.has(key)) { - prevSet.delete(key) - } else { - prevSet.add(key) - } - - return Array.from(prevSet) - }) - }, - [multiselect, setSelectedKeysArray], - ) - - // Convert direction + set up the context - const direction = useDirection(dir) - - // Convert arrays to sets for internal usage - const openKeysSet = React.useMemo(() => new Set(openKeysArray), [openKeysArray]) - const selectedKeysSet = React.useMemo(() => new Set(selectedKeysArray), [selectedKeysArray]) - - return ( - - - - - - ) -}) -Tree.displayName = TREE_NAME - -/* ------------------------------------------------------------------------------------------------- - * TreeItem - * ----------------------------------------------------------------------------------------------- */ - -type TreeItemElement = React.ElementRef -type TreeItemProps = React.ComponentPropsWithoutRef & { - nodeId: string - hasChildNodes?: boolean -} - -const TreeItem = React.forwardRef>( - (props, forwardedRef) => { - const { nodeId, hasChildNodes = false, __scopeTree, ...domProps } = props - const { openKeys, onToggleOpen, selectedKeys, onSelect, orientation } = useTreeContext( - TREE_NAME, - __scopeTree, - ) - - const isOpen = openKeys.has(nodeId) - const isSelected = selectedKeys.has(nodeId) - - const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { - const { key } = event - if (key === 'ArrowRight') { - if (!isOpen && hasChildNodes) { - onToggleOpen(nodeId) - event.preventDefault() - } - } else if (key === 'ArrowLeft') { - if (isOpen && hasChildNodes) { - onToggleOpen(nodeId) - event.preventDefault() - } - } else if (key === 'Enter') { - onSelect(nodeId) - } - }, - [onToggleOpen, onSelect, isOpen, nodeId, hasChildNodes], - ) - - const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeTree) - return ( - - onSelect(nodeId))} - {...domProps} - /> - - ) - }, -) -TreeItem.displayName = 'TreeItem' - -/* ------------------------------------------------------------------------------------------------- - * TreeGroup - * ----------------------------------------------------------------------------------------------- */ - -type TreeGroupElement = React.ElementRef -type TreeGroupProps = React.ComponentPropsWithoutRef & { - parentId: string -} - -const TreeGroup = React.forwardRef>( - (props, forwardedRef) => { - const { parentId, __scopeTree, ...domProps } = props - const { openKeys } = useTreeContext(TREE_NAME, __scopeTree) - const isOpen = openKeys.has(parentId) - - return ( - - ) - }, -) -TreeGroup.displayName = 'TreeGroup' - -/* ------------------------------------------------------------------------------------------------- - * TreeFactory - * ----------------------------------------------------------------------------------------------- */ - -export type RecursiveNode = { - id: string - nodes?: RecursiveNode[] -} - -export type FlatNode = Omit & { - depth: number - hasChildNodes: boolean - childNodesCount: number - parentId: string | null - ancestors: (string | null)[] -} - -type TreeFactoryBaseProps = TreeProps - -type TreeFactoryProps = - | (TreeFactoryBaseProps & { - items: T[] - Item: (props: T) => React.ReactNode - Group: (props: T & { children: React.ReactNode }) => React.ReactNode - virtualized?: false - VItem?: never - flatItems?: never - }) - | (TreeFactoryBaseProps & { - virtualized: true - flatItems: FlatNode[] // pre-flattened items - VItem: (props: FlatNode) => React.ReactNode - items?: never - Item?: never - Group?: never - }) - -function TreeFactory({ - items, - flatItems, - Item, - Group, - virtualized, - VItem, - ...treeProps -}: TreeFactoryProps) { - // Handle virtualized mode separately - if (virtualized) { - return ( - - {VItem} - - ) - } - - // Non-virtualized mode - Item and Group are guaranteed to be defined - if (!Item || !Group || !items) { - throw new Error('TreeFactory requires items, Item, and Group when not virtualized') - } - - // Store in constants to help TypeScript understand they're not undefined - const ItemComponent = Item - const GroupComponent = Group - - // - // Recursive render of a node - // - - function renderNode(item: T) { - const { id, nodes } = item - const hasChildNodes = Boolean(nodes?.length) - - return ( - - - {ItemComponent(item)} - - - {/* If there are children, wrap them in a */} - {hasChildNodes && ( - - {GroupComponent({ - ...item, - children: nodes?.map((child) => renderNode(child as T)), - })} - - )} - - ) - } - - return {items.map((item) => renderNode(item))} -} - -/** - * Flatten `nodes` into a 1-level array, with additional `depth` and `hasChildNodes` metadata - * Exclude the node if not expanded - * - * eg. from: - * ```ts - * [ - * { id: '1', nodes: [{ id: '2' }] }, - * { id: '3' }, - * { id: '4', nodes: [{ id: '5' }] }, - * ] - * ``` - * - * to: - * - * ```ts - * [ - * { id: '1', depth: 0, hasChildNodes: true, childNodesCount: 1 }, - * { id: '2', depth: 1, hasChildNodes: false, childNodesCount: 0 }, - * { id: '3', depth: 0, hasChildNodes: false, childNodesCount: 0 }, - * { id: '4', depth: 0, hasChildNodes: true, childNodesCount: 1 }, - * { id: '5', depth: 1, hasChildNodes: false, childNodesCount: 0 }, - * ] - */ - -export function flatten( - nodes: T[], - expandedNodes?: Set, - depth = 0, - parentId: string | null = null, - ancestors: (string | null)[] = depth === 0 ? [null] : [], -) { - const result: FlatNode[] = [] - - for (const node of nodes) { - const childNodesCount = node.nodes?.length ?? 0 - const hasChildNodes = childNodesCount > 0 - const currentAncestors = [...ancestors] - const flatNode = { - ...node, - depth, - hasChildNodes, - childNodesCount, - parentId, - ancestors: currentAncestors, - } - result.push(flatNode) - - // If the item has children and is expanded, process them recursively - if (hasChildNodes && (expandedNodes?.has(node.id) ?? true)) { - const childAncestors = [...currentAncestors, node.id] - result.push(...flatten(node.nodes as T[], expandedNodes, depth + 1, node.id, childAncestors)) - } - } - - return result -} - -function VItems( - props: ScopedProps<{ - flatItems: FlatNode[] - children: (props: FlatNode) => React.ReactNode - }>, -) { - const { flatItems, children: render, __scopeTree } = props - const { openKeys, selectedKeys } = useTreeContext(TREE_NAME, __scopeTree) - - return ( - <> - {flatItems.map((flatItem) => ( - - {render(flatItem)} - - ))} - - ) -} - -/* ------------------------------------------------------------------------------------------------- - * Exports - * ----------------------------------------------------------------------------------------------- */ - -export const createTreePrimitiveScope = createTreeScope() - -const Root = Tree -const Group = TreeGroup -const Item = TreeItem - -const Factory = TreeFactory - -export { - createTreeScope, - // - Tree, - TreeGroup, - TreeItem, - TreeFactory, - // - Root, - Group, - Item, - Factory, -} - -export type { TreeProps, TreeItemProps } diff --git a/src/components/primitives/Tree/index.ts b/src/components/primitives/Tree/index.ts deleted file mode 100644 index cf4b2a6f..00000000 --- a/src/components/primitives/Tree/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Tree' From 9d129f5f53823770dd12fb9c60cbd33f396484a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:54:08 +0000 Subject: [PATCH 5/5] Use virtualized mode with flatten for better performance Co-authored-by: abernier <76580+abernier@users.noreply.github.com> --- src/components/Nav/Nav.tsx | 55 +++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/src/components/Nav/Nav.tsx b/src/components/Nav/Nav.tsx index f7823787..3e518833 100644 --- a/src/components/Nav/Nav.tsx +++ b/src/components/Nav/Nav.tsx @@ -3,17 +3,19 @@ import { Doc } from '@/app/[...slug]/DocsContext' import cn from '@/lib/cn' import * as Tree from '@abernier/radix-tree' -import type { RecursiveNode } from '@abernier/radix-tree' +import { flatten, type FlatNode } from '@abernier/radix-tree' import Link from 'next/link' import * as React from 'react' import { IoIosArrowDown } from 'react-icons/io' const INDEX_PAGE = 'introduction' -type NavNode = RecursiveNode & { +type NavNode = { + id: string title: string url?: string nav: number + nodes?: NavNode[] } export function Nav({ @@ -26,10 +28,11 @@ export function Nav({ asPath: string collapsible: boolean }) { - const tree = React.useMemo(() => buildTree(docs), [docs]) + // Convert flat docs to nested structure (simpler than before) + const items = React.useMemo(() => docsToNavNodes(docs), [docs]) - // Find active keys (all nodes that contain the active path) - const activeKeys = React.useMemo(() => { + // Find active keys and their ancestors + const defaultOpenKeys = React.useMemo(() => { const keys: string[] = [] const currentPath = `/${asPath}` @@ -40,35 +43,42 @@ export function Nav({ keys.push(...nodeAncestors) } if (node.nodes) { - findActiveKeys(node.nodes as NavNode[], nodeAncestors) + findActiveKeys(node.nodes, nodeAncestors) } } } - findActiveKeys(tree) + findActiveKeys(items) return keys - }, [tree, asPath]) + }, [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 ( { + VItem={({ id, title, url, depth, hasChildNodes }) => { const isActive = url === `/${asPath}` - const hasChildren = Boolean(nodes?.length) return ( -

+
{url ? ( 0 && 'text-xs', isActive ? 'bg-primary-container' : 'bg-surface', )} > @@ -78,16 +88,17 @@ export function Nav({
0 && 'text-xs', isActive ? 'bg-primary-container' : 'bg-surface', )} > {title}
)} - {collapsible && hasChildren && ( + {collapsible && hasChildNodes && (
) }} - Group={({ children }) =>
{children}
} /> ) } -// Build a recursive tree structure from flat docs array -function buildTree(docs: Doc[]): NavNode[] { - const tree: NavNode[] = [] - - // Build tree recursively +// 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()