import { ChevronDown, ChevronRight } from 'lucide-react'; import type { ReactNode } from 'react'; import { Tree as AriaTree, Button, Collection, ListLayout, TreeItem, TreeItemContent, Virtualizer, } from 'react-aria-components'; import { measures } from '../tokens/measures'; import { icon } from './icon'; import styles from './Tree.module.css'; /** The key of the row the event happened in. `null` — the event arrived past the rows. */ function rowKeyOf(target: EventTarget | null): string | null { if (!(target instanceof HTMLElement)) return null; return target.closest('[role="row"]')?.getAttribute('data-key') ?? null; } export interface TreeNode { id: string; title: string; /** The icon before the title. */ icon?: ReactNode; /** * The right edge of the row: a status mark, a counter, a progress indicator — the caller draws * it. */ trailing?: ReactNode; children?: TreeNode[]; } interface Props { label: string; items: TreeNode[]; selectedId?: string; onSelect: (id: string) => void; /** * A double click or Enter on a row. For us that is «pin the tab» (the VS Code model): a single * click stays a preview. It is not done through the library's `onAction` — with an action set, * a single click starts to mean activation, and the preview disappears as a notion. */ onActivate?: (id: string) => void; /** * The row is about to be opened: the button is down on it. Early enough to read what it needs * before the click completes, and once per intent — not for every row a pointer sweeps over. * * ⚠ NOT on focus. Arrow keys move focus without selecting anything (measured: four presses, the * open tab does not move), so a read per focused row is a read of chapters nobody asked for. */ onPreload?: (id: string) => void; /** * Which branches are open. CONTROLLED, because the tree is not the only thing that opens them: * a chapter chosen in the go-to palette may sit inside a folded group, and an uncontrolled tree * has no way of being told about it. */ expandedIds: Set; onExpandedChange: (ids: Set) => void; } /** * The tree is virtualized by default and not on a measurement: a book has 2284 sections * (measurement D39.84), and a list without virtualization takes the tab down before the screen is * even drawn (Ф-12). */ export function Tree({ label, items, selectedId, onSelect, onActivate, onPreload, expandedIds, onExpandedChange, }: Props) { // The level indent comes off the library's own data-level, so nesting is not counted by hand. const renderNode = (node: TreeNode) => ( {({ hasChildItems, isExpanded }) => ( <> {hasChildItems ? ( ) : ( )} {node.icon} {node.title} {node.trailing} )} {node.children && {renderNode}} ); return ( // ⚠ The double click and Enter are caught by the WRAPPER and not by the row, and that is not // stylistics: rows live in the library's COLLECTION and are rebuilt only when `items` change, // so a closure inside a row freezes on the first render (caught by a scenario, not by // reasoning). The row is taken from the EVENT ITSELF — by the `data-key` the library puts // on the row: taking the «current selection» is not allowed, a double click on the book or on // an empty spot does not move the selection and would pin someone else's tab. // Enter is caught in the CAPTURE phase: the library's `usePress` stops the bubbling of the // synthetic keydown, and an ordinary handler on the wrapper never gets it.
{ const id = rowKeyOf(event.target); // The chevron is «collapse and expand», not «pin». if (id !== null && !(event.target instanceof HTMLElement && event.target.closest('button'))) onActivate?.(id); }} onPointerDown={(event) => { const id = rowKeyOf(event.target); if (id !== null) onPreload?.(id); }} onKeyDownCapture={(event) => { // The same guard as on the double click: Enter on the chevron is «collapse», not «pin». // And the row is taken only from the EVENT ITSELF: an Enter that did not come from a row // pins nothing — otherwise it would pin someone else's, accidentally selected one. if (event.key !== 'Enter') return; if (event.target instanceof HTMLElement && event.target.closest('button')) return; const id = rowKeyOf(event.target); if (id !== null) onActivate?.(id); }} > { if (keys !== 'all') for (const key of keys) onSelect(String(key)); }} expandedKeys={expandedIds} onExpandedChange={(keys) => { onExpandedChange(new Set([...keys].map(String))); }} > {renderNode}
); }