textmachine/frontend/src/ui/Tree.tsx

139 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
/** Ключ строки, в которой произошло событие. `null` — событие пришло мимо строк. */
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;
/** Иконка перед названием. */
icon?: ReactNode;
/** Правый край строки: метка состояния, счётчик, индикатор выполнения — рисует вызывающий. */
trailing?: ReactNode;
children?: TreeNode[];
}
interface Props {
label: string;
items: TreeNode[];
selectedId?: string;
onSelect: (id: string) => void;
/**
* Двойной клик или Enter по строке. У нас это «закрепить вкладку» (модель VS Code): одиночный
* клик остаётся предпросмотром. Через `onAction` библиотеки это не делается — при заданном
* действии одиночный клик начинает означать активацию, и предпросмотр исчезает как понятие.
*/
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;
defaultExpandedIds?: string[];
}
/**
* Дерево виртуализовано по умолчанию, а не по замеру: у книги 2284 раздела (замер D39.84),
* и список без виртуализации кладёт вкладку ещё до того, как экран нарисован (Ф-12).
*/
export function Tree({
label,
items,
selectedId,
onSelect,
onActivate,
onPreload,
defaultExpandedIds,
}: Props) {
// Отступ уровня даёт сама библиотека через data-level, поэтому вложенность не считается руками.
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 (
// ⚠ Двойной клик и Enter ловит ОБЁРТКА, а не строка, и это не стилистика: строки живут
// в КОЛЛЕКЦИИ библиотеки и пересобираются только при смене `items`, поэтому замыкание
// внутри строки застывает на первом рендере (поймано сценарием, а не рассуждением).
// Строка при этом берётся из САМОГО события — по `data-key`, который библиотека кладёт
// на строку: брать «текущий выбор» нельзя, двойной клик по книге или по пустому месту
// выбор не двигает и закрепил бы чужую вкладку.
// Enter ловится в фазе ПЕРЕХВАТА: `usePress` библиотеки останавливает всплытие
// синтетического keydown, и обычный обработчик на обёртке до него не доходит.
<div
className={styles.frame}
onDoubleClick={(event) => {
const id = rowKeyOf(event.target);
// Шеврон — это «свернуть и развернуть», а не «закрепить».
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) => {
// Тот же гард, что у двойного клика: Enter на шевроне — это «свернуть», а не «закрепить».
// И строка берётся только из САМОГО события: Enter, пришедший не из строки, ничего
// не закрепляет — иначе он закреплял бы чужую, случайно выбранную.
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));
}}
defaultExpandedKeys={defaultExpandedIds}
>
{renderNode}
</AriaTree>
</Virtualizer>
</div>
);
}