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(), at = new Map(); 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>(() => new Set(nodes.filter((n) => n.depth === 0).map((n) => n.code))); const [focus, setFocus] = useState(null); const refs = useRef(new Map()); // предки выбранной страницы раскрываются при смене 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 (
{visible.map((n, i) => { const kids = hasKids.has(n.code); const sel = n.code === selected; return (
{ 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); }}> {n.title || n.code}
); })}
); }