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:
hsiegeln
2026-03-18 15:05:15 +01:00
parent 3f328ec570
commit d0bb2b2b70
3 changed files with 690 additions and 0 deletions

View 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;
}

View 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()
})
})
})

View 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>
)
}