diff --git a/packages/core/src/catalog/lenses/tree.ts b/packages/core/src/catalog/lenses/tree.ts index a897c3f..74f8857 100644 Binary files a/packages/core/src/catalog/lenses/tree.ts and b/packages/core/src/catalog/lenses/tree.ts differ diff --git a/packages/web/package.json b/packages/web/package.json index 7490f40..6d6317d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -18,6 +18,8 @@ "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/urbanist": "^5.2.7", + "@headless-tree/core": "^1.7.0", + "@headless-tree/react": "^1.7.0", "@json-render/core": "^0.19.0", "@json-render/react": "^0.19.0", "@modernrelay/notebook-client": "workspace:*", diff --git a/packages/web/src/components/Tree.test.tsx b/packages/web/src/components/Tree.test.tsx index 3f4c406..45766c5 100644 --- a/packages/web/src/components/Tree.test.tsx +++ b/packages/web/src/components/Tree.test.tsx @@ -1,7 +1,8 @@ // @vitest-environment happy-dom // -// Tree lens through the REAL registry: forest grouping renders nested, -// disclosure collapses, node click writes select_state. +// Tree lens over @headless-tree, through the REAL registry: ARIA tree +// pattern, keyboard navigation, typeahead search, entity-level selection, +// and the re-read-safe disclosure policy. import { describe, it, expect, afterEach } from "vitest"; import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -9,6 +10,10 @@ import { JSONUIProvider, Renderer } from "@json-render/react"; import { assembleLensSpec, type QueryResult } from "@modernrelay/notebook-core"; import { webRegistry } from "../registry.js"; +// headless-tree's internal state updates run through React setState in +// effects/handlers; mark the env so act() covers them. +(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; + const ROWS = [ { d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "attr", rn: "Attractors" }, { d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "emer", rn: "Emergence" }, @@ -24,7 +29,7 @@ const result = (rows: Record[]): QueryResult => ({ rows, }); -function treeSpec(extra: Record = {}) { +function treeSpec(extra: Record = {}, rows = ROWS) { return assembleLensSpec( "tree", "Tree", @@ -37,7 +42,7 @@ function treeSpec(extra: Record = {}) { select_state: "/selected", ...extra, }, - result(ROWS), + result(rows), ); } @@ -62,36 +67,69 @@ function mount(spec: ReturnType): { return { host, root, rerender: render }; } +const itemByLabel = (host: HTMLElement, label: string): HTMLElement => { + const el = Array.from( + host.querySelectorAll('[role="treeitem"]'), + ).find((it) => it.textContent?.includes(label)); + if (el === undefined) { + throw new Error(`no treeitem labeled "${label}" — tree has: ${Array.from( + host.querySelectorAll('[role="treeitem"]'), + ) + .map((it) => it.textContent) + .join(" | ")}`); + } + return el; +}; + +const click = (el: HTMLElement): void => { + act(() => { + el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); +}; + +// Hotkey matching tracks HELD keys (keydown on the tree, keyup on document) +// — release after every press or the next hotkey sees a chord. +const key = (el: HTMLElement, k: string, code?: string): void => { + act(() => { + el.dispatchEvent( + new KeyboardEvent("keydown", { key: k, code: code ?? k, bubbles: true }), + ); + document.dispatchEvent( + new KeyboardEvent("keyup", { key: k, code: code ?? k, bubbles: true }), + ); + }); +}; + afterEach(() => { document.body.innerHTML = ""; }); -describe("Tree lens (web)", () => { - it("renders the grouped forest with counts", () => { +describe("Tree lens (web, headless-tree)", () => { + it("renders the grouped forest with the ARIA tree pattern and counts", () => { const { host, root } = mount(treeSpec()); - const text = host.textContent ?? ""; - expect(text).toContain("Systems"); - expect(text).toContain("Cognitive"); - expect(text).toContain("Feedback loops"); - expect(text).toContain("Attractors"); - // Systems has 2 concepts; Feedback loops has 2 related - const badges = Array.from(host.querySelectorAll("li > div > [class*='badge'], li > div > span[class*='tabular']")).map((b) => b.textContent); - expect(badges).toContain("2"); + expect(host.querySelector('[role="tree"]')).toBeTruthy(); + const items = Array.from(host.querySelectorAll('[role="treeitem"]')); + expect(items.length).toBeGreaterThanOrEqual(7); // 2 domains + 3 concepts + leaves + const systems = itemByLabel(host, "Systems"); + expect(systems.getAttribute("aria-level")).toBe("1"); + expect(systems.getAttribute("aria-expanded")).toBe("true"); + const loops = itemByLabel(host, "Feedback loops"); + expect(loops.getAttribute("aria-level")).toBe("2"); + // count badges: Systems (2 concepts), Feedback loops (2 related) + expect(systems.textContent).toContain("2"); + expect(loops.textContent).toContain("2"); + // roving tabindex: exactly one 0 + const tabZero = items.filter((i) => i.getAttribute("tabindex") === "0"); + expect(tabZero.length).toBe(1); act(() => root.unmount()); }); - it("disclosure collapses a branch", () => { + it("row click collapses an expanded branch", () => { const { host, root } = mount(treeSpec()); - const collapseSystems = Array.from(host.querySelectorAll("button")).find( - (b) => b.getAttribute("aria-label") === "Collapse Systems", - )!; - expect(host.textContent).toContain("Feedback loops"); - act(() => { - collapseSystems.click(); - }); - // Systems' subtree hidden; Cognitive's still visible + expect(host.textContent).toContain("Chunking"); + click(itemByLabel(host, "Systems")); expect(host.textContent).not.toContain("Chunking"); - expect(host.textContent).toContain("Bias"); + expect(host.textContent).toContain("Bias"); // Cognitive untouched act(() => root.unmount()); }); @@ -102,52 +140,60 @@ describe("Tree lens (web)", () => { act(() => root.unmount()); }); - it("node click writes its key value to select_state (highlight follows)", () => { + it("node click writes its key to select_state; every occurrence of the entity highlights", () => { const { host, root } = mount(treeSpec()); - const chunking = Array.from(host.querySelectorAll("span")).find( - (s) => s.textContent === "Chunking", - )!; - act(() => { - chunking.click(); - }); - // selection is the KEY value; the highlight class lands on the node - expect(chunking.className).toContain("bg-accent"); - // a different node with the same label elsewhere isn't highlighted - const systems = Array.from(host.querySelectorAll("span")).find( - (s) => s.textContent === "Systems", - )!; - expect(systems.className).not.toContain("bg-accent"); + // duplicate-entity case FIRST (a later Chunking click collapses its + // subtree, hiding one Attractors occurrence): both occurrences highlight + click(itemByLabel(host, "Attractors")); + const attractors = Array.from( + host.querySelectorAll('[role="treeitem"]'), + ).filter((el) => el.textContent?.includes("Attractors")); + expect(attractors.length).toBe(2); + for (const el of attractors) expect(el.className).toContain("bg-accent"); + // single-entity: clicking Chunking moves the selection (and toggles it) + click(itemByLabel(host, "Chunking")); + expect(itemByLabel(host, "Chunking").className).toContain("bg-accent"); + expect(itemByLabel(host, "Systems").className).not.toContain("bg-accent"); + act(() => root.unmount()); + }); + + it("keyboard: ArrowDown moves focus; ArrowRight expands a collapsed folder", () => { + const { host, root } = mount(treeSpec({ expand_depth: 1 })); + const systems = itemByLabel(host, "Systems"); + act(() => systems.focus()); + key(systems, "ArrowDown"); + const loops = itemByLabel(host, "Feedback loops"); + expect(loops.getAttribute("tabindex")).toBe("0"); // roving focus moved + expect(loops.getAttribute("aria-expanded")).toBe("false"); + act(() => loops.focus()); + key(loops, "ArrowRight"); + expect(host.textContent).toContain("Emergence"); // children now visible + act(() => root.unmount()); + }); + + it("typeahead opens search and marks matches", () => { + const { host, root } = mount(treeSpec()); + const systems = itemByLabel(host, "Systems"); + act(() => systems.focus()); + key(systems, "b", "KeyB"); // typeahead matches the event CODE + const input = host.querySelector('input[aria-label="Search tree"]'); + expect(input).toBeTruthy(); + const bias = itemByLabel(host, "Bias"); + expect(bias.className).toContain("ring-primary"); act(() => root.unmount()); }); it("re-read rows keep the default-open policy for NEW branches; user toggles survive", () => { const { host, root, rerender } = mount(treeSpec()); - // user collapses Systems - act(() => { - Array.from(host.querySelectorAll("button")) - .find((b) => b.getAttribute("aria-label") === "Collapse Systems")! - .click(); - }); + click(itemByLabel(host, "Systems")); // user collapses Systems expect(host.textContent).not.toContain("Chunking"); - // a background re-read adds a brand-new domain - const grown = assembleLensSpec( - "tree", - "Tree", - { - levels: [ - { key: "d", label: "dn" }, - { key: "c", label: "cn" }, - { key: "r", label: "rn" }, - ], - select_state: "/selected", - }, - result([ + rerender( + treeSpec({}, [ ...ROWS, { d: "phys", dn: "Physics", c: "entropy", cn: "Entropy", r: "attr", rn: "Attractors" }, ]), ); - rerender(grown); - // the NEW domain follows the default policy (open — no expand_depth set) + // the NEW domain follows the default policy (open — no expand_depth) expect(host.textContent).toContain("Physics"); expect(host.textContent).toContain("Entropy"); // the user's explicit collapse of Systems SURVIVES the re-read diff --git a/packages/web/src/components/Tree.tsx b/packages/web/src/components/Tree.tsx index f6480e3..fc21393 100644 --- a/packages/web/src/components/Tree.tsx +++ b/packages/web/src/components/Tree.tsx @@ -1,5 +1,12 @@ -import React, { useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useActions, useStateValue } from "@json-render/react"; +import { + hotkeysCoreFeature, + searchFeature, + selectionFeature, + syncDataLoaderFeature, +} from "@headless-tree/core"; +import { useTree } from "@headless-tree/react"; import { buildForest, type TreeNode, @@ -12,36 +19,153 @@ interface ComponentCtx

{ props: P; } +const ROOT_ID = "__root__"; + /** - * Tree lens — a collapsible forest grouped from path-shaped rows (one row per - * root→leaf chain; `levels` names the column pairs). Clicking a node label - * writes THAT node's key value to `select_state` (Table's mechanism), so any - * level drives dependent cells. Disclosure state is per-node local UI state. + * Tree lens — a collapsible forest grouped from path-shaped rows, rendered + * over @headless-tree (W3C ARIA tree pattern: roving tabindex, arrow-key + * navigation, Home/End, type-to-search). Selection is ENTITY-level: a node + * click/Enter writes the node's KEY value to `select_state`, and every + * occurrence of the selected entity in the forest highlights together. + * Row click also toggles folders (the standard tree pattern); disclosure + * state = the expand_depth default overlaid with explicit user toggles, so + * a query re-read gives NEW branches the default while user folds survive. */ export function Tree({ props: p, }: ComponentCtx): React.ReactElement { const actions = useActions(); const selected = useStateValue(p.select_state ?? "/__never__"); - const forest = useMemo( - () => buildForest(p.rows, p.levels), - [p.rows, p.levels], + const selectable = Boolean(p.select_state); + + // Forest + path index rebuilt when the query rows change. The synthetic + // root holds the forest roots as children (headless-tree needs one root). + const { byPath, forest } = useMemo(() => { + const forest = buildForest(p.rows, p.levels); + const byPath = new Map(); + const walk = (nodes: TreeNode[]): void => { + for (const node of nodes) { + byPath.set(node.path, node); + walk(node.children); + } + }; + walk(forest); + byPath.set(ROOT_ID, { + key: "", + label: p.title ?? "root", + path: ROOT_ID, + depth: -1, + children: forest, + }); + return { byPath, forest }; + }, [p.rows, p.levels, p.title]); + + const defaultOpen = useCallback( + (node: TreeNode): boolean => + node.children.length > 0 && + (p.expand_depth === undefined || node.depth < p.expand_depth), + [p.expand_depth], ); - // Disclosure = default policy + explicit user toggles. Deriving the default - // per node (instead of materializing a set once) keeps re-read forests - // correct: new paths get the expand_depth default; the user's explicit - // opens/closes survive by path identity. - const defaultOpen = (node: TreeNode): boolean => - node.children.length > 0 && - (p.expand_depth === undefined || node.depth < p.expand_depth); + + // Disclosure = default policy + explicit user toggles (re-read safe: new + // paths aren't in the toggle map, so they get the expand_depth default). const [userToggled, setUserToggled] = useState>( () => new Map(), ); - const isOpen = (node: TreeNode): boolean => - userToggled.get(node.path) ?? defaultOpen(node); - const toggle = (node: TreeNode): void => { - setUserToggled((prev) => new Map(prev).set(node.path, !isOpen(node))); - }; + const computeExpanded = useCallback( + (toggled: ReadonlyMap): string[] => { + const open: string[] = [ROOT_ID]; + for (const node of byPath.values()) { + if (node.path === ROOT_ID) continue; + if (toggled.get(node.path) ?? defaultOpen(node)) open.push(node.path); + } + return open; + }, + [byPath, defaultOpen], + ); + const expandedItems = useMemo( + () => computeExpanded(userToggled), + [computeExpanded, userToggled], + ); + const setExpandedItems = useCallback( + (updater: string[] | ((prev: string[]) => string[])) => { + // "Current" is derived INSIDE the state updater from the accumulated + // toggle map, so a functional updater sees the true latest expansion + // even across batched sequential calls (not a render-time snapshot). + setUserToggled((prevToggled) => { + const currentArr = computeExpanded(prevToggled); + const current = new Set(currentArr); + const nextArr = + typeof updater === "function" ? updater(currentArr) : updater; + const next = new Set(nextArr); + const toggled = new Map(prevToggled); + for (const path of next) { + if (!current.has(path)) toggled.set(path, true); + } + for (const path of current) { + if (!next.has(path) && path !== ROOT_ID) toggled.set(path, false); + } + return toggled; + }); + }, + [computeExpanded], + ); + + // Entity-level selection: every path whose key matches the bound value. + const selectedItems = useMemo(() => { + if (!selectable || selected === undefined || selected === "") return []; + const paths: string[] = []; + for (const node of byPath.values()) { + if (node.path !== ROOT_ID && node.key === selected) paths.push(node.path); + } + return paths; + }, [byPath, selected, selectable]); + + const dispatchSelect = useCallback( + (key: string) => { + if (!selectable || key === "") return; + actions.execute({ + action: "setState", + params: { statePath: p.select_state, value: key }, + }); + }, + [actions, selectable, p.select_state], + ); + + const tree = useTree({ + rootItemId: ROOT_ID, + getItemName: (item) => item.getItemData()?.label ?? "", + isItemFolder: (item) => (item.getItemData()?.children.length ?? 0) > 0, + dataLoader: { + getItem: (id) => byPath.get(id) as TreeNode, + getChildren: (id) => (byPath.get(id)?.children ?? []).map((c) => c.path), + }, + state: { expandedItems, selectedItems }, + setExpandedItems, + // Selection is driven exclusively through the entity key in notebook + // state (dispatch → useStateValue → derived selectedItems). The single + // dispatch site is onPrimaryAction — both click and Enter route through + // primaryAction, and dispatching from setSelectedItems too would double + // the state patch (two invalidation cycles per click). The setter is + // still required for controlled state; it intentionally does nothing. + setSelectedItems: () => {}, + onPrimaryAction: (item) => { + const data = item.getItemData(); + if (data !== undefined) dispatchSelect(data.key); + }, + features: [ + syncDataLoaderFeature, + selectionFeature, + hotkeysCoreFeature, + searchFeature, + ], + }); + + // New rows → new forest → re-flatten from the root. + useEffect(() => { + tree.rebuildTree(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [byPath]); if (forest.length === 0) { return ( @@ -51,69 +175,56 @@ export function Tree({ ); } - const selectable = Boolean(p.select_state); const showCounts = p.counts !== false; - const renderNode = (node: TreeNode): React.ReactElement => { - const hasChildren = node.children.length > 0; - const open = isOpen(node); - const isSelected = selectable && node.key === selected; - return ( -

  • -
    - {hasChildren ? ( - - ) : ( - - )} - - actions.execute({ - action: "setState", - params: { statePath: p.select_state, value: node.key }, - }), - } - : {})} - > - {node.label} - - {showCounts && hasChildren && ( - - {node.children.length} - - )} -
    - {hasChildren && open && ( -
      - {node.children.map(renderNode)} -
    - )} -
  • - ); - }; - return (
    {p.title && (

    {p.title}

    )} -
      {forest.map(renderNode)}
    + {tree.isSearchOpen() && ( + + )} +
    + {tree.getItems().map((item) => { + const node = item.getItemData(); + if (node === undefined) return null; + const level = item.getItemMeta().level; + const isFolder = item.isFolder(); + const isOpen = item.isExpanded(); + return ( +
    + + {isFolder ? (isOpen ? "▾" : "▸") : ""} + + {node.label} + {showCounts && isFolder && ( + + {node.children.length} + + )} +
    + ); + })} +
    ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a3d71d..dcb0439 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,12 @@ importers: '@fontsource-variable/urbanist': specifier: ^5.2.7 version: 5.2.7 + '@headless-tree/core': + specifier: ^1.7.0 + version: 1.7.0 + '@headless-tree/react': + specifier: ^1.7.0 + version: 1.7.0(@headless-tree/core@1.7.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@json-render/core': specifier: ^0.19.0 version: 0.19.0(zod@4.4.3) @@ -438,6 +444,16 @@ packages: '@fontsource-variable/urbanist@5.2.7': resolution: {integrity: sha512-OYeUoBQhwm3pbXG5yUxq1omO9H64Dx1eKxldKYOztrf+8GFG1vu3FP/zZpmFOwtgsPj2h6JYHX9VVWopSxvOyQ==} + '@headless-tree/core@1.7.0': + resolution: {integrity: sha512-LxcX7LNepwfOPrZcs4PNfDwCzbi326uCAX5jYBfw94jI+pZQA7ANaE/No3LW0XFgcBcQtK/G2bInbVEhLF1C/Q==} + + '@headless-tree/react@1.7.0': + resolution: {integrity: sha512-hcnG4mpTP98KVWZYqKsXNOoiv7ZQqZVM2jWwzEWtF+VDSz+1O/juMI+/Q0G4lZM+ez07/tmBBWjvZub/mLAUXQ==} + peerDependencies: + '@headless-tree/core': '*' + react: '*' + react-dom: '*' + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2027,6 +2043,14 @@ snapshots: '@fontsource-variable/urbanist@5.2.7': {} + '@headless-tree/core@1.7.0': {} + + '@headless-tree/react@1.7.0(@headless-tree/core@1.7.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@headless-tree/core': 1.7.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5