From d0bb2b2b70eae07329a9b0b017370ed3eb1d9bb3 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:15 +0100 Subject: [PATCH] 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) --- .../composites/TreeView/TreeView.module.css | 99 ++++++ .../composites/TreeView/TreeView.test.tsx | 302 ++++++++++++++++++ .../composites/TreeView/TreeView.tsx | 289 +++++++++++++++++ 3 files changed, 690 insertions(+) create mode 100644 src/design-system/composites/TreeView/TreeView.module.css create mode 100644 src/design-system/composites/TreeView/TreeView.test.tsx create mode 100644 src/design-system/composites/TreeView/TreeView.tsx diff --git a/src/design-system/composites/TreeView/TreeView.module.css b/src/design-system/composites/TreeView/TreeView.module.css new file mode 100644 index 0000000..bbfa6b4 --- /dev/null +++ b/src/design-system/composites/TreeView/TreeView.module.css @@ -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; +} diff --git a/src/design-system/composites/TreeView/TreeView.test.tsx b/src/design-system/composites/TreeView/TreeView.test.tsx new file mode 100644 index 0000000..f7e31ec --- /dev/null +++ b/src/design-system/composites/TreeView/TreeView.test.tsx @@ -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: }, + ], + }, + { + id: 'root2', + label: 'Root Two', + }, +] + +describe('TreeView', () => { + describe('rendering', () => { + it('renders top-level nodes', () => { + render() + expect(screen.getByText('Root One')).toBeInTheDocument() + expect(screen.getByText('Root Two')).toBeInTheDocument() + }) + + it('does not render children of collapsed nodes', () => { + render() + expect(screen.queryByText('Child One')).not.toBeInTheDocument() + expect(screen.queryByText('Child Two')).not.toBeInTheDocument() + }) + + it('renders icon when provided', () => { + render( {}} />) + expect(screen.getByText('★')).toBeInTheDocument() + }) + + it('renders meta text when provided', () => { + render( {}} />) + expect(screen.getByText('meta-a')).toBeInTheDocument() + }) + + it('shows chevron on parent nodes', () => { + render() + // 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() + // 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() + expect(screen.getByRole('tree')).toBeInTheDocument() + }) + + it('has role="treeitem" on each node row', () => { + render() + const items = screen.getAllByRole('treeitem') + expect(items.length).toBeGreaterThanOrEqual(2) + }) + + it('sets aria-expanded="false" on collapsed parent nodes', () => { + render() + 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( {}} />) + 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() + const treeitem = screen.getByRole('treeitem', { name: /Root Two/i }) + expect(treeitem).not.toHaveAttribute('aria-expanded') + }) + + it('sets aria-selected on selected node', () => { + render() + 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() + 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() + 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() + 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( + {}} />, + ) + 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() + 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() + 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() + 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() + await user.click(screen.getByRole('treeitem', { name: /Root One/i })) + expect(onSelect).toHaveBeenCalledWith('root1') + }) + + it('marks the selected node visually', () => { + render() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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( + {}} + />, + ) + expect(screen.getByText('Grandchild One')).toBeInTheDocument() + expect(screen.getByText('Grandchild Two')).toBeInTheDocument() + }) + + it('does not render grandchildren when parent is collapsed', () => { + render( + {}} + />, + ) + expect(screen.queryByText('Grandchild One')).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/design-system/composites/TreeView/TreeView.tsx b/src/design-system/composites/TreeView/TreeView.tsx new file mode 100644 index 0000000..f16d2fa --- /dev/null +++ b/src/design-system/composites/TreeView/TreeView.tsx @@ -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, + 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>(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(null) + const treeRef = useRef(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) => { + 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 ( +
    + {nodes.map((node) => ( + + ))} +
+ ) +} + +interface TreeNodeRowProps { + node: TreeNode + depth: number + expandedSet: Set + 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 ( +
  • +
    onFocus(node.id)} + > + + {hasChildren ? ( + + ) : null} + + {node.icon && ( + + )} + {node.label} + {node.meta && ( + {node.meta} + )} +
    + {hasChildren && isExpanded && ( +
      + {node.children!.map((child) => ( + + ))} +
    + )} +
  • + ) +}