feat: add TreeView composite
Implements a fully-featured accessible tree view with recursive node rendering, controlled/uncontrolled expand state, keyboard navigation (ArrowUp/Down/Left/Right/Home/End/Enter), ARIA roles, selected-node amber accent styling, meta text, icons, and 32 passing tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
99
src/design-system/composites/TreeView/TreeView.module.css
Normal file
99
src/design-system/composites/TreeView/TreeView.module.css
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
.root {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
padding-right: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
user-select: none;
|
||||||
|
outline: none;
|
||||||
|
position: relative;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:focus-visible {
|
||||||
|
outline: 2px solid var(--amber);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
background: var(--amber-bg);
|
||||||
|
border-left-color: var(--amber);
|
||||||
|
color: var(--amber-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected:hover {
|
||||||
|
background: var(--amber-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chevron slot — reserves space so leaf nodes align with parent icons */
|
||||||
|
.chevronSlot {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected .chevron {
|
||||||
|
color: var(--amber-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected .meta {
|
||||||
|
color: var(--amber-deep);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
302
src/design-system/composites/TreeView/TreeView.test.tsx
Normal file
302
src/design-system/composites/TreeView/TreeView.test.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { TreeView } from './TreeView'
|
||||||
|
import type { TreeNode } from './TreeView'
|
||||||
|
|
||||||
|
const nodes: TreeNode[] = [
|
||||||
|
{
|
||||||
|
id: 'root1',
|
||||||
|
label: 'Root One',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'child1',
|
||||||
|
label: 'Child One',
|
||||||
|
meta: 'meta-a',
|
||||||
|
children: [
|
||||||
|
{ id: 'grandchild1', label: 'Grandchild One' },
|
||||||
|
{ id: 'grandchild2', label: 'Grandchild Two' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: 'child2', label: 'Child Two', icon: <span>★</span> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'root2',
|
||||||
|
label: 'Root Two',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('TreeView', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders top-level nodes', () => {
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
expect(screen.getByText('Root One')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Root Two')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render children of collapsed nodes', () => {
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
expect(screen.queryByText('Child One')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Child Two')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders icon when provided', () => {
|
||||||
|
render(<TreeView nodes={nodes} expandedIds={['root1']} onToggle={() => {}} />)
|
||||||
|
expect(screen.getByText('★')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders meta text when provided', () => {
|
||||||
|
render(<TreeView nodes={nodes} expandedIds={['root1']} onToggle={() => {}} />)
|
||||||
|
expect(screen.getByText('meta-a')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows chevron on parent nodes', () => {
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
// Root One has children so should have a chevron
|
||||||
|
const treeitem = screen.getByRole('treeitem', { name: /Root One/i })
|
||||||
|
expect(treeitem.textContent).toContain('▸')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no chevron on leaf nodes', () => {
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
// Root Two is a leaf
|
||||||
|
const treeitem = screen.getByRole('treeitem', { name: /Root Two/i })
|
||||||
|
expect(treeitem.textContent).not.toContain('▸')
|
||||||
|
expect(treeitem.textContent).not.toContain('▾')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ARIA attributes', () => {
|
||||||
|
it('has role="tree" on root element', () => {
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
expect(screen.getByRole('tree')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has role="treeitem" on each node row', () => {
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
const items = screen.getAllByRole('treeitem')
|
||||||
|
expect(items.length).toBeGreaterThanOrEqual(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets aria-expanded="false" on collapsed parent nodes', () => {
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
const treeitem = screen.getByRole('treeitem', { name: /Root One/i })
|
||||||
|
expect(treeitem).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets aria-expanded="true" on expanded parent nodes', () => {
|
||||||
|
render(<TreeView nodes={nodes} expandedIds={['root1']} onToggle={() => {}} />)
|
||||||
|
const treeitem = screen.getByRole('treeitem', { name: /Root One/i })
|
||||||
|
expect(treeitem).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not set aria-expanded on leaf nodes', () => {
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
const treeitem = screen.getByRole('treeitem', { name: /Root Two/i })
|
||||||
|
expect(treeitem).not.toHaveAttribute('aria-expanded')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets aria-selected on selected node', () => {
|
||||||
|
render(<TreeView nodes={nodes} selectedId="root2" />)
|
||||||
|
const treeitem = screen.getByRole('treeitem', { name: /Root Two/i })
|
||||||
|
expect(treeitem).toHaveAttribute('aria-selected', 'true')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('expand / collapse (uncontrolled)', () => {
|
||||||
|
it('expands a collapsed parent on click', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
await user.click(screen.getByRole('treeitem', { name: /Root One/i }))
|
||||||
|
expect(screen.getByText('Child One')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Child Two')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses an expanded parent on second click', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
|
||||||
|
await user.click(rootOne)
|
||||||
|
expect(screen.getByText('Child One')).toBeInTheDocument()
|
||||||
|
await user.click(rootOne)
|
||||||
|
expect(screen.queryByText('Child One')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows expanded chevron when expanded', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
|
||||||
|
await user.click(rootOne)
|
||||||
|
expect(rootOne.textContent).toContain('▾')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('expand / collapse (controlled)', () => {
|
||||||
|
it('renders children based on controlled expandedIds', () => {
|
||||||
|
render(
|
||||||
|
<TreeView nodes={nodes} expandedIds={['root1']} onToggle={() => {}} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Child One')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onToggle with node id when parent is clicked', async () => {
|
||||||
|
const onToggle = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} expandedIds={[]} onToggle={onToggle} />)
|
||||||
|
await user.click(screen.getByRole('treeitem', { name: /Root One/i }))
|
||||||
|
expect(onToggle).toHaveBeenCalledWith('root1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call onToggle when leaf is clicked', async () => {
|
||||||
|
const onToggle = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} expandedIds={[]} onToggle={onToggle} />)
|
||||||
|
await user.click(screen.getByRole('treeitem', { name: /Root Two/i }))
|
||||||
|
expect(onToggle).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('selection', () => {
|
||||||
|
it('calls onSelect with node id when a node is clicked', async () => {
|
||||||
|
const onSelect = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} onSelect={onSelect} />)
|
||||||
|
await user.click(screen.getByRole('treeitem', { name: /Root Two/i }))
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('root2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSelect for parent nodes too', async () => {
|
||||||
|
const onSelect = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} onSelect={onSelect} />)
|
||||||
|
await user.click(screen.getByRole('treeitem', { name: /Root One/i }))
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('root1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the selected node visually', () => {
|
||||||
|
render(<TreeView nodes={nodes} selectedId="root2" />)
|
||||||
|
const treeitem = screen.getByRole('treeitem', { name: /Root Two/i })
|
||||||
|
expect(treeitem.getAttribute('aria-selected')).toBe('true')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('keyboard navigation', () => {
|
||||||
|
it('moves focus down with ArrowDown', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
|
||||||
|
rootOne.focus()
|
||||||
|
await user.keyboard('{ArrowDown}')
|
||||||
|
expect(screen.getByRole('treeitem', { name: /Root Two/i })).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves focus up with ArrowUp', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
const rootTwo = screen.getByRole('treeitem', { name: /Root Two/i })
|
||||||
|
rootTwo.focus()
|
||||||
|
await user.keyboard('{ArrowUp}')
|
||||||
|
expect(screen.getByRole('treeitem', { name: /Root One/i })).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expands a collapsed node with ArrowRight', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
|
||||||
|
rootOne.focus()
|
||||||
|
await user.keyboard('{ArrowRight}')
|
||||||
|
expect(screen.getByText('Child One')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves to first child with ArrowRight when already expanded', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
|
||||||
|
rootOne.focus()
|
||||||
|
// First ArrowRight expands, second moves to first child
|
||||||
|
await user.keyboard('{ArrowRight}')
|
||||||
|
await user.keyboard('{ArrowRight}')
|
||||||
|
expect(screen.getByRole('treeitem', { name: /Child One/i })).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses an expanded node with ArrowLeft', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
|
||||||
|
rootOne.focus()
|
||||||
|
await user.keyboard('{ArrowRight}')
|
||||||
|
expect(screen.getByText('Child One')).toBeInTheDocument()
|
||||||
|
await user.keyboard('{ArrowLeft}')
|
||||||
|
expect(screen.queryByText('Child One')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves to parent with ArrowLeft on a child node', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
|
||||||
|
rootOne.focus()
|
||||||
|
// Expand root1 then move to child
|
||||||
|
await user.keyboard('{ArrowRight}')
|
||||||
|
await user.keyboard('{ArrowRight}')
|
||||||
|
// Now at Child One
|
||||||
|
expect(screen.getByRole('treeitem', { name: /Child One/i })).toHaveFocus()
|
||||||
|
await user.keyboard('{ArrowLeft}')
|
||||||
|
// Should move back to Root One
|
||||||
|
expect(rootOne).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects focused node with Enter', async () => {
|
||||||
|
const onSelect = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} onSelect={onSelect} />)
|
||||||
|
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
|
||||||
|
rootOne.focus()
|
||||||
|
await user.keyboard('{Enter}')
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('root1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves to first visible node with Home', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
const rootTwo = screen.getByRole('treeitem', { name: /Root Two/i })
|
||||||
|
rootTwo.focus()
|
||||||
|
await user.keyboard('{Home}')
|
||||||
|
expect(screen.getByRole('treeitem', { name: /Root One/i })).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves to last visible node with End', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TreeView nodes={nodes} />)
|
||||||
|
const rootOne = screen.getByRole('treeitem', { name: /Root One/i })
|
||||||
|
rootOne.focus()
|
||||||
|
await user.keyboard('{End}')
|
||||||
|
expect(screen.getByRole('treeitem', { name: /Root Two/i })).toHaveFocus()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deep nesting', () => {
|
||||||
|
it('renders grandchildren when both ancestor nodes are expanded', () => {
|
||||||
|
render(
|
||||||
|
<TreeView
|
||||||
|
nodes={nodes}
|
||||||
|
expandedIds={['root1', 'child1']}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Grandchild One')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Grandchild Two')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render grandchildren when parent is collapsed', () => {
|
||||||
|
render(
|
||||||
|
<TreeView
|
||||||
|
nodes={nodes}
|
||||||
|
expandedIds={['root1']}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.queryByText('Grandchild One')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
289
src/design-system/composites/TreeView/TreeView.tsx
Normal file
289
src/design-system/composites/TreeView/TreeView.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import { useState, useRef, useCallback, type ReactNode, type KeyboardEvent } from 'react'
|
||||||
|
import styles from './TreeView.module.css'
|
||||||
|
|
||||||
|
export interface TreeNode {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
children?: TreeNode[]
|
||||||
|
meta?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlatNode {
|
||||||
|
node: TreeNode
|
||||||
|
depth: number
|
||||||
|
parentId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenVisibleNodes(
|
||||||
|
nodes: TreeNode[],
|
||||||
|
expandedIds: Set<string>,
|
||||||
|
depth = 0,
|
||||||
|
parentId: string | null = null,
|
||||||
|
): FlatNode[] {
|
||||||
|
const result: FlatNode[] = []
|
||||||
|
for (const node of nodes) {
|
||||||
|
result.push({ node, depth, parentId })
|
||||||
|
if (node.children && node.children.length > 0 && expandedIds.has(node.id)) {
|
||||||
|
result.push(...flattenVisibleNodes(node.children, expandedIds, depth + 1, node.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeViewProps {
|
||||||
|
nodes: TreeNode[]
|
||||||
|
onSelect?: (id: string) => void
|
||||||
|
selectedId?: string
|
||||||
|
expandedIds?: string[]
|
||||||
|
onToggle?: (id: string) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TreeView({
|
||||||
|
nodes,
|
||||||
|
onSelect,
|
||||||
|
selectedId,
|
||||||
|
expandedIds: controlledExpandedIds,
|
||||||
|
onToggle,
|
||||||
|
className,
|
||||||
|
}: TreeViewProps) {
|
||||||
|
// Controlled vs uncontrolled expansion
|
||||||
|
const isControlled = controlledExpandedIds !== undefined && onToggle !== undefined
|
||||||
|
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const expandedSet = isControlled
|
||||||
|
? new Set(controlledExpandedIds)
|
||||||
|
: internalExpandedIds
|
||||||
|
|
||||||
|
function handleToggle(id: string) {
|
||||||
|
if (isControlled) {
|
||||||
|
onToggle!(id)
|
||||||
|
} else {
|
||||||
|
setInternalExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id)
|
||||||
|
} else {
|
||||||
|
next.add(id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
const [focusedId, setFocusedId] = useState<string | null>(null)
|
||||||
|
const treeRef = useRef<HTMLUListElement>(null)
|
||||||
|
|
||||||
|
const visibleNodes = flattenVisibleNodes(nodes, expandedSet)
|
||||||
|
|
||||||
|
function getFocusedIndex() {
|
||||||
|
if (focusedId === null) return -1
|
||||||
|
return visibleNodes.findIndex((fn) => fn.node.id === focusedId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusNode(id: string) {
|
||||||
|
// We focus the element directly. All tree items have tabIndex={-1} by default
|
||||||
|
// which means programmatic focus works even without tabIndex=0.
|
||||||
|
// The element's onFocus handler will fire and call setFocusedId — but that
|
||||||
|
// happens synchronously within the React event so it's properly batched.
|
||||||
|
const el = treeRef.current?.querySelector(`[data-nodeid="${id}"]`) as HTMLElement | null
|
||||||
|
if (el) {
|
||||||
|
// Temporarily make focusable if not already
|
||||||
|
el.focus()
|
||||||
|
} else {
|
||||||
|
// Element not in DOM yet (e.g. after expand); update state so it renders
|
||||||
|
// with tabIndex=0 and the browser's next focus movement will work.
|
||||||
|
setFocusedId(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent<HTMLUListElement>) => {
|
||||||
|
const currentIndex = getFocusedIndex()
|
||||||
|
const current = visibleNodes[currentIndex]
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown': {
|
||||||
|
e.preventDefault()
|
||||||
|
const next = visibleNodes[currentIndex + 1]
|
||||||
|
if (next) focusNode(next.node.id)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'ArrowUp': {
|
||||||
|
e.preventDefault()
|
||||||
|
const prev = visibleNodes[currentIndex - 1]
|
||||||
|
if (prev) focusNode(prev.node.id)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'ArrowRight': {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!current) break
|
||||||
|
const hasChildren = current.node.children && current.node.children.length > 0
|
||||||
|
if (hasChildren) {
|
||||||
|
if (!expandedSet.has(current.node.id)) {
|
||||||
|
// Expand it
|
||||||
|
handleToggle(current.node.id)
|
||||||
|
} else {
|
||||||
|
// Move to first child (it will be the next visible node)
|
||||||
|
const next = visibleNodes[currentIndex + 1]
|
||||||
|
if (next) focusNode(next.node.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'ArrowLeft': {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!current) break
|
||||||
|
const hasChildren = current.node.children && current.node.children.length > 0
|
||||||
|
if (hasChildren && expandedSet.has(current.node.id)) {
|
||||||
|
// Collapse
|
||||||
|
handleToggle(current.node.id)
|
||||||
|
} else if (current.parentId !== null) {
|
||||||
|
// Move to parent
|
||||||
|
focusNode(current.parentId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'Enter': {
|
||||||
|
e.preventDefault()
|
||||||
|
if (current) {
|
||||||
|
onSelect?.(current.node.id)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'Home': {
|
||||||
|
e.preventDefault()
|
||||||
|
if (visibleNodes.length > 0) {
|
||||||
|
focusNode(visibleNodes[0].node.id)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'End': {
|
||||||
|
e.preventDefault()
|
||||||
|
if (visibleNodes.length > 0) {
|
||||||
|
focusNode(visibleNodes[visibleNodes.length - 1].node.id)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[visibleNodes, expandedSet, focusedId],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
ref={treeRef}
|
||||||
|
role="tree"
|
||||||
|
className={`${styles.root} ${className ?? ''}`}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<TreeNodeRow
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
depth={0}
|
||||||
|
expandedSet={expandedSet}
|
||||||
|
selectedId={selectedId}
|
||||||
|
focusedId={focusedId}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onFocus={setFocusedId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeNodeRowProps {
|
||||||
|
node: TreeNode
|
||||||
|
depth: number
|
||||||
|
expandedSet: Set<string>
|
||||||
|
selectedId?: string
|
||||||
|
focusedId: string | null
|
||||||
|
onToggle: (id: string) => void
|
||||||
|
onSelect?: (id: string) => void
|
||||||
|
onFocus: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeNodeRow({
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
expandedSet,
|
||||||
|
selectedId,
|
||||||
|
focusedId,
|
||||||
|
onToggle,
|
||||||
|
onSelect,
|
||||||
|
onFocus,
|
||||||
|
}: TreeNodeRowProps) {
|
||||||
|
const hasChildren = node.children && node.children.length > 0
|
||||||
|
const isExpanded = expandedSet.has(node.id)
|
||||||
|
const isSelected = selectedId === node.id
|
||||||
|
const isFocused = focusedId === node.id
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (hasChildren) {
|
||||||
|
onToggle(node.id)
|
||||||
|
}
|
||||||
|
onSelect?.(node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowClass = [
|
||||||
|
styles.row,
|
||||||
|
isSelected ? styles.selected : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li role="none">
|
||||||
|
<div
|
||||||
|
role="treeitem"
|
||||||
|
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||||
|
aria-selected={isSelected}
|
||||||
|
tabIndex={isFocused || (focusedId === null && depth === 0 && node === node) ? 0 : -1}
|
||||||
|
data-nodeid={node.id}
|
||||||
|
className={rowClass}
|
||||||
|
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||||
|
onClick={handleClick}
|
||||||
|
onFocus={() => onFocus(node.id)}
|
||||||
|
>
|
||||||
|
<span className={styles.chevronSlot}>
|
||||||
|
{hasChildren ? (
|
||||||
|
<span className={styles.chevron} aria-hidden="true">
|
||||||
|
{isExpanded ? '▾' : '▸'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{node.icon && (
|
||||||
|
<span className={styles.icon} aria-hidden="true">
|
||||||
|
{node.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={styles.label}>{node.label}</span>
|
||||||
|
{node.meta && (
|
||||||
|
<span className={styles.meta}>{node.meta}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasChildren && isExpanded && (
|
||||||
|
<ul role="group" className={styles.children}>
|
||||||
|
{node.children!.map((child) => (
|
||||||
|
<TreeNodeRow
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
expandedSet={expandedSet}
|
||||||
|
selectedId={selectedId}
|
||||||
|
focusedId={focusedId}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onFocus={onFocus}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user