139 lines
6.3 KiB
TypeScript
139 lines
6.3 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';
|
||
|
||
/** Ключ строки, в которой произошло событие. `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>
|
||
);
|
||
}
|