/* The tree as a control. What a node *says* belongs to the caller - symbols, counters, second lines stay possible. What makes it a node belongs to the tree: the row, the indentation, the chevron, the tick and the information for the screen reader. The table keeps it exactly this way with its cells. The accessibility layer is flat (ADR-0004): a sequence of `treeitem`s, each with its own level, its own position and its own sibling count. Nesting and virtualisation rule each other out, and the numbers therefore come from the flattening and never from what happens to stand in the document. The active node is communicated through the focus, not through `aria-selected`: `aria-selected` would again be the conflation of "where am I" and "what have I chosen" that ADR-0003 resolves. The tick speaks through `aria-checked` on the node itself; the visible checkbox is decoration while doing so and hidden from the screen reader, so that nothing is announced twice. */ import { useEffect, useId, useRef } from "react"; import type { HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react"; import { cx } from "../../lib/cx"; import { mergeRefs } from "../../lib/mergeRefs"; import type { RefProp } from "../../lib/refProp"; import { AngleGlyph } from "../../lib/glyphs"; import { Checkbox } from "../Checkbox"; import { Spinner } from "../Spinner"; import { useWording } from "../../lib/language"; import { idPart } from "../../lib/idPart"; import type { FlatteningEntry, Key } from "./treeModel"; import type { Tree } from "./useTree"; import styles from "./TreeView.module.css"; /** After this pause typeahead starts over. */ const TYPEAHEAD_PAUSE = 600; interface TreeViewBase extends Omit, "children">, RefProp { /** The tree from `useTree`. It is the whole state: flattening, active node, checks, anchor. The view holds nothing beside it. */ tree: Tree; /** Show ticks. Without them a tree remains that knows only an active node - and no space is kept free for them. */ checkable?: boolean; /** What the node says. */ children: (entry: FlatteningEntry) => ReactNode; } /** The props of `TreeView`. The tree is a structure of its own and needs a name: `aria-label` is required. */ export type TreeViewProps = TreeViewBase & { /** Accessible name of the tree. */ "aria-label": string; }; /** The tree as a control: draws the nodes of a `Tree` from `useTree` as a `tree` with keyboard navigation, typeahead and, with `checkable`, ticks. What a node says is yours - `children` renders it. */ export function TreeView({ ref, tree, "aria-label": name, checkable = false, children, className, onKeyDown, onFocus, onBlur, onScroll, ...rest }: TreeViewProps) { const wording = useWording(); // Namespace of the box ids: two trees with the same keys must not share one. const idBase = useId(); const rowWindow = tree.virtual; /* Virtualised, the root is at the same time the scroll area, and then its ref carries the window arithmetic - the table keeps it this way too. Otherwise its own. The caller's ref is handed the same element beside it. */ const ownRef = useRef(null); const rootRef = rowWindow?.scrollRef ?? ownRef; /* The focus follows the active node only where it already lies inside the tree - otherwise a state change from outside would tear the focus away. */ const hasFocus = useRef(false); /** The last seen reading of the reveal counter. */ const revealed = useRef(0); /* The typeahead buffer: one string and one timer. Every decision stands in typeaheadTarget. */ const buffer = useRef(""); const bufferTimer = useRef(null); /* Bringing into view and focusing are two things, and only the second hangs on the focus - except on an explicit request. The active node is a key and not an element: if it lies outside the window, there is nothing to focus until the window has followed - and follow it must even when the tree does not have the focus at all, for instance because the active node was set from outside. The effect runs a second time after the window has followed and focuses then. The window follows the active node when the node MOVES (or is asked for again), once. A render that merely finds it outside the window - because the person scrolled away - must leave the scroll position alone, and so must not search the flattening for it. */ const shown = useRef<{ active: Key | null; count: number } | null>(null); const windowFrom = rowWindow?.from; const windowTo = rowWindow?.to; const showRow = rowWindow?.showRow; useEffect(() => { const root = rootRef.current; if (root === null || tree.active === null) return; if (showRow !== undefined && windowFrom !== undefined && windowTo !== undefined) { const moved = shown.current === null || shown.current.active !== tree.active || shown.current.count !== tree.revealCount; if (moved) { shown.current = { active: tree.active, count: tree.revealCount }; const index = tree.flattening.findIndex((e) => e.key === tree.active); if (index >= 0 && (index < windowFrom || index >= windowTo)) { showRow(index); return; } } } // An explicit request "show me this node" is the opposite of an // incidental state change and may take the focus. const requested = tree.revealCount !== revealed.current; revealed.current = tree.revealCount; if (!hasFocus.current && !requested) return; const target = root.querySelector( `[data-key="${CSS.escape(String(tree.active))}"]`, ); if (target !== null && target !== document.activeElement) target.focus(); }, [tree.active, tree.flattening, tree.revealCount, windowFrom, windowTo, showRow, rootRef]); useEffect( () => () => { if (bufferTimer.current !== null) window.clearTimeout(bufferTimer.current); }, [], ); const onKey = (e: ReactKeyboardEvent): void => { onKeyDown?.(e); if (e.defaultPrevented) return; /* The recommended model of the practice guideline, not the alternative one: the alternative reassigns the space key, and here that already checks. */ if (checkable && e.key === "a" && (e.ctrlKey || e.metaKey)) { tree.toggleAll(); e.preventDefault(); return; } if (checkable && e.shiftKey && e.key === " " && tree.active !== null) { tree.checkTo(tree.active); e.preventDefault(); return; } if (checkable && e.shiftKey && (e.key === "ArrowDown" || e.key === "ArrowUp")) { // Only this one call: moveAndCheck moves by itself, and a move() first // would compute twice from the same snapshot. tree.moveAndCheck(e.key === "ArrowDown" ? "down" : "up"); e.preventDefault(); return; } switch (e.key) { case "ArrowDown": tree.move("down"); break; case "ArrowUp": tree.move("up"); break; case "ArrowRight": tree.move("in"); break; case "ArrowLeft": tree.move("out"); break; case "Home": tree.move("start"); break; case "End": tree.move("end"); break; case "Enter": if (tree.active !== null) tree.activate(tree.active); break; case " ": if (checkable && tree.active !== null) tree.toggleCheck(tree.active); break; default: { /* Typeahead: one printable character without a modifier. The space key is excepted - it checks, and a label that begins with a space does not exist. */ if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey || e.key === " ") return; if (bufferTimer.current !== null) window.clearTimeout(bufferTimer.current); buffer.current += e.key; bufferTimer.current = window.setTimeout(() => { buffer.current = ""; bufferTimer.current = null; }, TYPEAHEAD_PAUSE); tree.typeahead(buffer.current); break; } } e.preventDefault(); }; /* Exactly one tab stop for the whole tree. Without an active node the first entry carries it, so that the tree stays reachable at all. */ const rendered = rowWindow === undefined ? tree.flattening : tree.flattening.slice(rowWindow.from, rowWindow.to); const tabStop = tree.active !== null && rendered.some((e) => e.key === tree.active) ? tree.active : rendered[0]?.key; return (
{ onScroll?.(e); rowWindow?.onScroll(); }} onFocus={(e) => { onFocus?.(e); hasFocus.current = true; }} onBlur={(e) => { onBlur?.(e); if (!e.currentTarget.contains(e.relatedTarget as Node | null)) hasFocus.current = false; }} > {/* The root is a flex column: without `flexShrink: 0` the fillers give way to the rows and the scroll area is as high as its window. */} {rowWindow !== undefined && rowWindow.fillerBefore > 0 ? (