2026-10-24 15:38:46 +04:00

93 lines
6.2 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 { 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>
);
}