textmachine/frontend/src/ui/Tree.tsx

153 lines
5.7 KiB
TypeScript

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<string>;
onExpandedChange: (ids: Set<string>) => 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) => (
<TreeItem className={styles.item} textValue={node.title}>
<TreeItemContent>
{({ hasChildItems, isExpanded }) => (
<>
{hasChildItems ? (
<Button className={styles.chevron} slot="chevron">
{isExpanded ? <ChevronDown {...icon} /> : <ChevronRight {...icon} />}
</Button>
) : (
<span className={styles.chevron} />
)}
{node.icon}
<span className={styles.title}>{node.title}</span>
{node.trailing}
</>
)}
</TreeItemContent>
{node.children && <Collection items={node.children}>{renderNode}</Collection>}
</TreeItem>
);
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.
<div
className={styles.frame}
onDoubleClick={(event) => {
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);
}}
>
<Virtualizer layout={ListLayout} layoutOptions={{ rowSize: measures['--row-height'] }}>
<AriaTree
className={styles.tree}
aria-label={label}
items={items}
selectionMode="single"
selectedKeys={selectedId === undefined ? [] : [selectedId]}
onSelectionChange={(keys) => {
if (keys !== 'all') for (const key of keys) onSelect(String(key));
}}
expandedKeys={expandedIds}
onExpandedChange={(keys) => {
onExpandedChange(new Set([...keys].map(String)));
}}
>
{renderNode}
</AriaTree>
</Virtualizer>
</div>
);
}