93 lines
6.2 KiB
TypeScript
93 lines
6.2 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||
import { IconChevronDown } from "../../ui/index.ts";
|
||
import type { WikiNode } from "./api/queries.ts";
|
||
|
||
// дерево страниц (WAI-ARIA tree, плоская раскладка с aria-level): стрелки вверх/вниз — по видимым узлам, вправо — раскрыть
|
||
// или войти в узел, влево — свернуть или выйти к родителю, Enter/пробел — открыть страницу. Корень раскрыт, остальные свёрнуты;
|
||
// предки открытой страницы раскрываются сами
|
||
export function Tree({ nodes, selected, onOpen }: { nodes: WikiNode[]; selected: string | null; onOpen(code: string): void }) {
|
||
const byCode = useMemo(() => new Map(nodes.map((n) => [n.code, n])), [nodes]);
|
||
const hasKids = useMemo(() => new Set(nodes.flatMap((n) => (n.parent_code ? [n.parent_code] : []))), [nodes]);
|
||
// место среди братьев одного родителя: aria-setsize / aria-posinset (корни — «братья» без родителя)
|
||
const place = useMemo(() => {
|
||
const kids = new Map<string | null, string[]>(), at = new Map<string, { size: number; pos: number }>();
|
||
for (const n of nodes) kids.set(n.parent_code, [...(kids.get(n.parent_code) ?? []), n.code]);
|
||
for (const list of kids.values()) list.forEach((c, i) => at.set(c, { size: list.length, pos: i + 1 }));
|
||
return at;
|
||
}, [nodes]);
|
||
const [open, setOpen] = useState<Set<string>>(() => new Set(nodes.filter((n) => n.depth === 0).map((n) => n.code)));
|
||
const [focus, setFocus] = useState<string | null>(null);
|
||
const refs = useRef(new Map<string, HTMLDivElement>());
|
||
|
||
// предки выбранной страницы раскрываются при смене selected и когда её цепочка предков до корня стала полной в nodes
|
||
// (дерево из кэша дополнилось свежим набором). Новый массив nodes с теми же узлами, добавление посторонних узлов и ручное
|
||
// сворачивание родителя раскрытия не повторяют: зависимость эффекта — булево selectedKnown, оно сменяется только при
|
||
// появлении или пропаже цепочки. Актуальные узлы читаются через byCodeRef; его эффект объявлен ВЫШЕ эффекта раскрытия,
|
||
// поэтому в одном проходе ref уже свежий
|
||
const selectedKnown = (() => {
|
||
if (!selected) return false;
|
||
let n = byCode.get(selected);
|
||
for (let guard = 0; n && guard <= nodes.length; guard++) {
|
||
if (!n.parent_code) return true; // дошли до корня: цепочка полная
|
||
n = byCode.get(n.parent_code);
|
||
}
|
||
return false; // узла или одного из предков в nodes ещё нет
|
||
})();
|
||
const byCodeRef = useRef(byCode);
|
||
useEffect(() => { byCodeRef.current = byCode; });
|
||
useEffect(() => {
|
||
if (!selected || !selectedKnown) return;
|
||
const map = byCodeRef.current;
|
||
const up: string[] = [];
|
||
for (let n = map.get(selected)?.parent_code; n && !up.includes(n); n = map.get(n)?.parent_code ?? null) up.push(n);
|
||
if (up.length) setOpen((o) => (up.every((c) => o.has(c)) ? o : new Set([...o, ...up])));
|
||
}, [selected, selectedKnown]);
|
||
|
||
const visible = useMemo(() => nodes.filter((n) => {
|
||
for (let p = n.parent_code, guard = 0; p && guard < nodes.length; p = byCode.get(p)?.parent_code ?? null, guard++) {
|
||
if (!open.has(p)) return false;
|
||
}
|
||
return true;
|
||
}), [nodes, byCode, open]);
|
||
|
||
const tabbable = visible.find((n) => n.code === focus)?.code ?? visible.find((n) => n.code === selected)?.code ?? visible[0]?.code;
|
||
const toggle = (code: string) => setOpen((o) => { const n = new Set(o); if (n.has(code)) n.delete(code); else n.add(code); return n; });
|
||
const move = (code: string | undefined) => { if (!code) return; setFocus(code); refs.current.get(code)?.focus(); };
|
||
|
||
const onKey = (e: KeyboardEvent, n: WikiNode, i: number) => {
|
||
const kids = hasKids.has(n.code);
|
||
switch (e.key) {
|
||
case "ArrowDown": move(visible[i + 1]?.code); break;
|
||
case "ArrowUp": move(visible[i - 1]?.code); break;
|
||
case "Home": move(visible[0]?.code); break;
|
||
case "End": move(visible[visible.length - 1]?.code); break;
|
||
case "ArrowRight": if (kids && !open.has(n.code)) toggle(n.code); else if (kids) move(visible[i + 1]?.code); break;
|
||
case "ArrowLeft": if (kids && open.has(n.code)) toggle(n.code); else if (n.parent_code) move(n.parent_code); break;
|
||
case "Enter": case " ": onOpen(n.code); break;
|
||
default: return;
|
||
}
|
||
e.preventDefault();
|
||
};
|
||
|
||
return (
|
||
<div role="tree" aria-label="Страницы вики" className="wiki-tree">
|
||
{visible.map((n, i) => {
|
||
const kids = hasKids.has(n.code);
|
||
const sel = n.code === selected;
|
||
return (
|
||
<div key={n.code} role="treeitem" aria-level={n.depth + 1} aria-setsize={place.get(n.code)?.size} aria-posinset={place.get(n.code)?.pos} aria-selected={sel} aria-expanded={kids ? open.has(n.code) : undefined}
|
||
tabIndex={n.code === tabbable ? 0 : -1} data-code={n.code} className={"wiki-tree__item" + (sel ? " is-selected" : "")}
|
||
style={{ paddingInlineStart: `calc(var(--space-2) + ${n.depth} * var(--space-4))` }}
|
||
ref={(el) => { if (el) refs.current.set(n.code, el); else refs.current.delete(n.code); }}
|
||
onFocus={() => setFocus(n.code)} onKeyDown={(e) => onKey(e, n, i)}
|
||
onClick={() => { onOpen(n.code); if (kids && !open.has(n.code)) toggle(n.code); }}>
|
||
<span className={"wiki-tree__chevron" + (kids ? (open.has(n.code) ? " is-open" : "") : " is-leaf")} aria-hidden="true"
|
||
onClick={kids ? (e) => { e.stopPropagation(); toggle(n.code); } : undefined}>{kids && <IconChevronDown size={16} />}</span>
|
||
<span className="wiki-tree__title">{n.title || n.code}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|