'use client'; import * as React from 'react'; import { isBranch, type FlatNode, type TreeNode } from './tree-nodes'; /** * The tree's whole keyboard contract, in one place. * * Arrows that move *and* expand, Home/End, `*` to open a level, type-ahead — * the ARIA tree pattern, and the largest single block in a component that also * owns three controllable states and a virtualised row list. It reads as its * own subject, and it is: every input below is something the component already * computed, and the only thing this returns is the handler. * * Deliberately **not** exported from `index.ts`. `pnpm exports:gen` gives every * `index.ts` a subpath, and a consumer has no business reaching a component's * private navigation. */ export function useTreeKeyboard({ rows, active, byKey, expanded, checkable, focusRow, setExpandedKey, setExpanded, onExpand, selectKey, toggleCheck, }: { rows: FlatNode[]; /** The roving tab stop — the row the keys act on. */ active: React.Key | undefined; byKey: Map; expanded: Set; checkable?: boolean; focusRow: (key: React.Key) => void; setExpandedKey: (key: React.Key, open: boolean) => void; /* `*` opens a whole level in one write, so it needs the setter and the callback rather than the single-key helper above. */ setExpanded: (keys: Set) => void; onExpand?: (keys: React.Key[], info: { node: TreeNode; expanded: boolean }) => void; selectKey: (key: React.Key) => void; toggleCheck: (key: React.Key) => void; }) { /* Type-ahead's buffer. It lives here rather than in the component because nothing else can observe it — it is read and written inside one keypress. */ const typeAhead = React.useRef({ text: '', at: 0 }); const move = (delta: 1 | -1) => { const at = rows.findIndex((row) => row.node.key === active); const next = rows[at + delta]; if (next) focusRow(next.node.key); }; return (event: React.KeyboardEvent) => { if (active === undefined) return; const node = byKey.get(active)!; const row = rows.find((entry) => entry.node.key === active); const branch = isBranch(node); const isOpen = expanded.has(active); switch (event.key) { case 'ArrowDown': event.preventDefault(); move(1); break; case 'ArrowUp': event.preventDefault(); move(-1); break; case 'ArrowRight': event.preventDefault(); /* A closed branch opens; an open one steps into its first child. */ if (branch && !isOpen) setExpandedKey(active, true); else if (branch && isOpen) move(1); break; case 'ArrowLeft': event.preventDefault(); /* An open branch closes; anything else climbs to its parent. */ if (branch && isOpen) setExpandedKey(active, false); else if (row?.parentKey != null) focusRow(row.parentKey); break; case 'Home': event.preventDefault(); if (rows[0]) focusRow(rows[0].node.key); break; case 'End': event.preventDefault(); if (rows.length) focusRow(rows[rows.length - 1].node.key); break; case 'Enter': event.preventDefault(); selectKey(active); break; case ' ': event.preventDefault(); if (checkable) toggleCheck(active); else selectKey(active); break; case '*': { event.preventDefault(); /* Opens every sibling of the focused node, per the ARIA tree pattern. Built as one set rather than a loop of single toggles: each toggle would derive its result from the same pre-update snapshot, so only the last sibling would survive the batch. */ const siblings = rows.filter( (entry) => entry.parentKey === row?.parentKey && isBranch(entry.node) ); if (siblings.length === 0) break; const next = new Set(expanded); for (const sibling of siblings) next.add(sibling.node.key); setExpanded(next); onExpand?.([...next], { node, expanded: true }); break; } default: { if (event.key.length !== 1 || event.metaKey || event.ctrlKey || event.altKey) return; /* Type-ahead: keys pressed within a second build one search string. */ const now = Date.now(); typeAhead.current.text = now - typeAhead.current.at > 1000 ? event.key : typeAhead.current.text + event.key; typeAhead.current.at = now; const needle = typeAhead.current.text.toLowerCase(); const at = rows.findIndex((entry) => entry.node.key === active); /* Search forward from the current row and wrap, so repeated presses cycle through the matches rather than sticking on the first. */ const order = [...rows.slice(at + 1), ...rows.slice(0, at + 1)]; const hit = order.find((entry) => String(entry.node.title).toLowerCase().startsWith(needle) ); if (hit) { event.preventDefault(); focusRow(hit.node.key); } } } }; }