Skip to content

Tree lens on @headless-tree: ARIA tree pattern, keyboard nav, typeahead search#21

Merged
aaltshuler merged 2 commits into
mainfrom
tree-headless
Jul 8, 2026
Merged

Tree lens on @headless-tree: ARIA tree pattern, keyboard nav, typeahead search#21
aaltshuler merged 2 commits into
mainfrom
tree-headless

Conversation

@aaltshuler

@aaltshuler aaltshuler commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Phase 1 of the Tree-lens upgrade researched earlier: swap the web renderer's hand-rolled internals for @headless-tree 1.7 (zero-dep, DOM-free core + React hook, ~14 kB modular) — picked over react-arborist (opinionated react-window layout, react-dnd@14 underneath), react-aria Tree (a second heavyweight headless kit beside Base UI), and MUI X (paid tier + emotion) because it is architecturally identical to the existing lens: flat visible-list render, bring-your-own markup/Tailwind, external state, and a fully happy-dom-testable core.

What changes

  • W3C ARIA tree pattern: role=tree/treeitem, aria-level/setsize/posinset/expanded/selected, roving tabindex; arrow keys / Home / End navigate, Enter activates, type-to-search opens a search box and marks matches.
  • Author surface unchanged — YAML props, buildForest, TUI renderer, registries untouched.
  • Entity-level selection preserved: activation dispatches the node's key to select_state; controlled selectedItems derive from the bound value (every occurrence of the entity highlights — the documented semantics).
  • Re-read-safe disclosure preserved via controlled expandedItems: the expand_depth default overlaid with a user-toggle map diffed from the library's setter. The existing regression scenario (re-read grows the forest → new branch gets the default, user's collapse survives) passes.
  • UX note: row click now both selects and toggles folders (the standard tree pattern); the chevron is a visual indicator.

Verification

  • Web tests 6 → 8, all keyboard/ARIA/search paths now unit-tested under happy-dom (the core's DOM-freeness paying off immediately). Two test-relevant library behaviors encoded in the suite: hotkey matching tracks held keys (a keyup on document must follow every keydown) and typeahead matches the event code (KeyB), not the key.
  • Full suite green: core 122, client 31, tui 1, web 80.
  • Live on the real concepts graph (:4321): 152 nodes render with the pattern, ArrowDown walks focus, typing a opened search and marked 106 matches, click wrote /selected and highlighted the entity.
  • Later phases (not here): async children_query loading, drag-to-reparent mutations, virtualization.

🤖 Generated with Claude Code

https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh

Greptile Summary

This PR replaces the hand-rolled Tree lens internals with @headless-tree 1.7, bringing a W3C ARIA tree pattern (roving tabindex, arrow-key navigation, Home/End, type-to-search) while preserving the author-facing YAML API, the TUI renderer, and the re-read-safe disclosure semantics.

  • Tree.tsx is rewritten around useTree + controlled expandedItems/selectedItems; entity-level selection dispatches via onPrimaryAction only, with a no-op setSelectedItems to avoid double-dispatch; setExpandedItems derives its current snapshot inside the state-updater rather than from the render closure, making batched expansion safe.
  • Tree.test.tsx grows from 6 to 8 tests, covering ARIA structure, roving tabindex, keyboard navigation, typeahead search, entity-level highlighting, and the re-read survival regression.
  • package.json / pnpm-lock.yaml add @headless-tree/core@1.7.0 and @headless-tree/react@1.7.0; the previous review's double-dispatch and stale functional-updater issues are both resolved.

Confidence Score: 5/5

Safe to merge — the rewrite is self-contained to the web Tree component; the author-facing YAML API, TUI renderer, and core catalog are untouched, and the test suite green-lights all eight paths including the re-read regression.

The implementation is well-contained: controlled state is threaded correctly through useTree, onPrimaryAction is the single dispatch site, and setExpandedItems derives its diff from inside the state updater rather than from a render-time snapshot. All three previously flagged issues are resolved. The new test cases cover ARIA structure, keyboard navigation, typeahead, entity-level selection, and re-read survival in a happy-dom environment.

No files require special attention.

Important Files Changed

Filename Overview
packages/web/src/components/Tree.tsx Major rewrite around @headless-tree; controlled expansion and selection are correctly implemented; onPrimaryAction is the single dispatch site; setExpandedItems derives current state inside the updater to avoid stale-snapshot issues.
packages/web/src/components/Tree.test.tsx Test suite expanded to cover ARIA pattern, keyboard navigation, typeahead, entity-level selection, and re-read regression; itemByLabel now throws a labeled diagnostic; all non-null assertions removed.
packages/web/package.json Adds @headless-tree/core and @headless-tree/react at ^1.7.0; no other dependency changes.
packages/core/src/catalog/lenses/tree.ts Binary change (compiled artifact); author-facing prop schemas and buildForest are described as unchanged by the PR.
pnpm-lock.yaml Lock file updated with @headless-tree 1.7.0 snapshots; peer dependencies resolved correctly against react@19.2.6 and react-dom@19.2.6.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant TreeDOM as Tree DOM
    participant HT as headless-tree
    participant Notebook as Notebook State

    User->>TreeDOM: click or Enter on item
    TreeDOM->>HT: item onClick via getProps
    HT->>Notebook: onPrimaryAction calls dispatchSelect with node key
    Notebook-->>TreeDOM: useStateValue returns updated selected
    TreeDOM->>HT: useTree receives new selectedItems derived from selected
    HT-->>TreeDOM: all matching entity paths rendered with bg-accent

    User->>TreeDOM: click folder row
    TreeDOM->>HT: setExpandedItems updater called
    HT->>HT: setUserToggled derives current inside updater
    HT-->>TreeDOM: expandedItems recomputed and tree re-renders

    User->>TreeDOM: type a letter for typeahead
    TreeDOM->>HT: searchFeature opens search input
    HT-->>TreeDOM: matching treeitems rendered with ring-primary

    User->>TreeDOM: query re-read changes p rows
    TreeDOM->>TreeDOM: byPath useMemo rebuilds
    TreeDOM->>HT: useEffect calls rebuildTree
    HT-->>TreeDOM: new branches appear with default policy, user toggles preserved
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant TreeDOM as Tree DOM
    participant HT as headless-tree
    participant Notebook as Notebook State

    User->>TreeDOM: click or Enter on item
    TreeDOM->>HT: item onClick via getProps
    HT->>Notebook: onPrimaryAction calls dispatchSelect with node key
    Notebook-->>TreeDOM: useStateValue returns updated selected
    TreeDOM->>HT: useTree receives new selectedItems derived from selected
    HT-->>TreeDOM: all matching entity paths rendered with bg-accent

    User->>TreeDOM: click folder row
    TreeDOM->>HT: setExpandedItems updater called
    HT->>HT: setUserToggled derives current inside updater
    HT-->>TreeDOM: expandedItems recomputed and tree re-renders

    User->>TreeDOM: type a letter for typeahead
    TreeDOM->>HT: searchFeature opens search input
    HT-->>TreeDOM: matching treeitems rendered with ring-primary

    User->>TreeDOM: query re-read changes p rows
    TreeDOM->>TreeDOM: byPath useMemo rebuilds
    TreeDOM->>HT: useEffect calls rebuildTree
    HT-->>TreeDOM: new branches appear with default policy, user toggles preserved
Loading

Reviews (2): Last reviewed commit: "Single dispatch site, updater-safe expan..." | Re-trigger Greptile

Swaps the web Tree's hand-rolled internals for @headless-tree 1.7
(zero-dep DOM-free core + react hook, ~14 kB) — chosen over
react-arborist / react-aria Tree / MUI X after a landscape survey:
it is architecturally identical to the lens (flat visible-list render,
bring-your-own markup, external state) and fully happy-dom-testable.

- W3C ARIA tree pattern out of the box: role=tree/treeitem,
  aria-level/setsize/posinset/expanded/selected, roving tabindex;
  arrow keys / Home / End navigate, Enter activates, type-to-search
  opens a search box and marks matches (styled ring).
- Author surface, buildForest, TUI renderer, registries: UNCHANGED.
- Entity-level selection preserved: activation dispatches the node's
  KEY to select_state; controlled selectedItems derive from the bound
  value, so every occurrence of the selected entity highlights.
- Re-read-safe disclosure preserved via CONTROLLED expandedItems:
  expand_depth default overlaid with a user-toggle map (diffed from
  the lib's setter) — the existing regression scenario passes.
- UX change (standard tree pattern): row click both selects and
  toggles folders; the chevron is now a visual indicator.

Tests 8 (was 6): + ARIA attributes/roving tabindex, ArrowDown/
ArrowRight keyboard nav, typeahead search (matches marked), duplicate-
entity highlight. Test gotchas encoded: hotkey matching tracks HELD
keys (keyup on document required) and typeahead matches event CODE
(KeyB), not key. Verified live on the concepts graph: 152 nodes,
arrow-key walk, "a" → search with 106 matches, click → /selected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh
Comment thread packages/web/src/components/Tree.tsx Outdated
Comment thread packages/web/src/components/Tree.tsx
Comment thread packages/web/src/components/Tree.test.tsx Outdated
Greptile x3: (1) click fired dispatchSelect twice (selectionFeature's
setSelectedItems AND onPrimaryAction) — two state patches per click
meant double invalidation cycles for dependent cells; onPrimaryAction
(which both click and Enter route through) is now the only dispatch
site and the controlled-selection setter is an intentional no-op.
(2) The expansion setter's functional-updater path computed against a
render-time snapshot; "current" is now derived inside the state updater
from the accumulated toggle map, so batched sequential updates see the
true latest state. (3) itemByLabel throws a labeled error listing the
tree's contents instead of tests dying on undefined.click.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh
@aaltshuler aaltshuler merged commit 71e46f4 into main Jul 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant