153 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|