Skip to content

feat(primitives): tree — headless tree-widget behavior#1436

Open
ssilvius wants to merge 2 commits into
mainfrom
feat/tree-primitive-v1
Open

feat(primitives): tree — headless tree-widget behavior#1436
ssilvius wants to merge 2 commits into
mainfrom
feat/tree-primitive-v1

Conversation

@ssilvius
Copy link
Copy Markdown
Collaborator

@ssilvius ssilvius commented May 4, 2026

Summary

Adds a headless tree primitive at packages/ui/src/primitives/tree.ts. Sibling layer to roving-focus, keyboard-handler, typeahead, block-handler. Greenlit by rafters team in legion bullpen 019df3e9-7c64.

What this is

Behavioral primitive owning hierarchy walk, expand/collapse, single/multiple/range selection, keyboard nav (arrows, Home/End, Enter, type-ahead via existing typeahead primitive), ARIA prop generation per WAI-ARIA tree pattern (role=tree/treeitem/group, aria-expanded, aria-level, aria-setsize, aria-posinset, aria-selected), and roving tabindex.

What this explicitly is not

Per the design discussion and the consumer invariant, the primitive does NOT:

  • Fetch data (no fs, fetch, octokit) -- consumer materializes nodes
  • Render or style -- consumer wires the returned prop objects to JSX
  • Implement domain logic (file icons, status badges)
  • Drag-and-drop (compose with existing drag-drop primitive)
  • Virtualize (compose with a future virtual-list primitive)
  • Async children loading -- deferred to v2 via loadChildren? extension

Public surface

  • createTree<T>(options): TreeAPI<T> -- framework-agnostic, no DOM
  • attachTree<T>(container, options): { api, cleanup } -- DOM-attached convenience form (parity with createRovingFocus / createTypeahead)
  • getVisibleEntries<T>(nodes, expanded): TreeFlatEntry<T>[] -- exported walker for render layers

Test coverage (33 tests, all passing locally)

  • 7 movers (ArrowDown/Up/Right/Left, Home, End, Enter)
  • Selection modes (single / multi-append / range-shift / none)
  • Disabled nodes skipped by initial focus + selection
  • Expand/collapse with focus follow
  • ARIA prop snapshots
  • Multi-root trees
  • 3+-level deep nesting (DFS order)
  • Typeahead jump (with id fallback when data lacks label/name/title)
  • setNodes drops stale focused/selected/expanded ids
  • attachTree keydown wiring + cleanup

Verification done locally

  • pnpm vitest run packages/ui/test/primitives/tree.test.ts -- 33/33 pass
  • pnpm typecheck -- clean
  • pnpm lint (biome) -- clean

Test plan

  • CI full unit + a11y suite green
  • First consumer (gitpress file sidebar gitpress#272) verifies the API in practice
  • pnpx rafters add primitive tree registers post-merge

References

  • Design proposal: rafters bullpen 019df3e7-bb05-7fb1-b42a-b57293122bdd
  • Greenlight: 019df3e9-7c64 + signal 019df3e9-8fde
  • First consumer: gitpress#272

ssilvius and others added 2 commits May 4, 2026 10:13
Adds a tree primitive at packages/ui/src/primitives/tree.ts: hierarchy
walk, expand/collapse, single/multi/range selection, keyboard nav, and
ARIA prop generation per the WAI-ARIA tree pattern.

Composes existing primitives (typeahead). Sibling layer to roving-focus
and keyboard-handler. No DOM in createTree (consumers wire via React /
Vue / vanilla); attachTree provides the DOM-attached convenience form
matching createRovingFocus / createTypeahead.

Sean's invariant for the primitive layer: NO data fetching. The
consumer materializes nodes; the primitive walks them. Async children
loading is deferred to v2 via a loadChildren? extension.

Public surface:
- createTree(options) -> TreeAPI: state, movers, ARIA prop getters
- attachTree(container, options) -> { api, cleanup }
- getVisibleEntries(nodes, expanded) -> walker for render layer

Tests cover all 7 keyboard movers, three selection modes (single,
multi-append, range-shift), expand/collapse with focus follow, ARIA
prop snapshots, multi-root, 3+-level nesting, typeahead jump, disabled
nodes, setNodes drift, and the attachTree DOM form.

Greenlit by rafters team in legion bullpen 019df3e9-7c64. First
consumer is gitpress file sidebar (gitpress#272).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rimitive

Adds a SidebarTree component at packages/ui/src/components/ui/sidebar-tree.tsx
that wraps the tree primitive with a sidebar-themed list rendering, chevron
expanders, and a renderNode prop for consumer-owned per-node content (icons,
status indicators, labels).

This is the consumer-facing UI piece. The primitive (createTree) owns the
behavior; SidebarTree owns the layout, ARIA wiring, and roving tabindex.
Consumers of `pnpx rafters add sidebar-tree` get both layers wired together.

Architecture:
- Root <ul role=tree> with onKeyDown -> primitive.handleKeyDown
- Each <li role=treeitem> with click -> focus + select + activate
- Chevron <button> per parent toggles expansion; tabindex=-1 (root owns focus)
- Children wrapped in <ul role=group> per WAI-ARIA tree spec
- renderNode receives { node, level, isExpanded, hasChildren, isFocused, isSelected, isDisabled }
- Controlled (expanded/selected/focused props) and uncontrolled (defaultExpanded etc) modes

10 tests: rendering, role=group wrapper, click activation + selection,
chevron expansion, ArrowRight expand-then-descend, aria-level, render-state
delivery, multi-select cmd-click append.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ssilvius
Copy link
Copy Markdown
Collaborator Author

ssilvius commented May 4, 2026

Added the SidebarTree component (commit e41b2c99) on top of the primitive. Two layers in one PR — primitive owns behavior, component owns visual rendering + ARIA wiring.

SidebarTree componentpackages/ui/src/components/ui/sidebar-tree.tsx

  • <ul role=tree> / <li role=treeitem> / <ul role=group> per WAI-ARIA spec
  • Chevron <button> per parent (tabindex=-1; root owns focus per roving-tabindex)
  • renderNode prop receives { node, level, isExpanded, hasChildren, isFocused, isSelected, isDisabled } so consumers control icons / dots / labels without re-implementing the wiring
  • Controlled (expanded/selected/focused) and uncontrolled (defaultExpanded etc) modes
  • Single / multi-append / range-shift selection wired to click + keyboard

Component test coverage (10 tests, all passing locally)

  • Rendering top-level nodes as treeitems
  • Children of collapsed parents excluded from DOM
  • Chevron click expansion
  • <ul role=group> wrapper around expanded children
  • Click activates + selects, updates aria-selected
  • Keyboard ArrowRight expand-then-descend
  • aria-level reflects depth
  • renderNode receives correct expansion + selection state
  • Multi-select cmd-click append

Updated totals: 43 tests across primitive + component, biome clean, typecheck clean.

Consumer pattern (gitpress file sidebar example):

<SidebarTree
  nodes={fileNodes}
  selectionMode="single"
  onActivate={(id) => openFile(id)}
  renderNode={({ node, isExpanded }) => (
    <>
      {node.children?.length ? <FolderIcon open={isExpanded} /> : <FileIcon />}
      <span className="truncate">{node.data.label}</span>
      {node.data.status ? <StatusDot status={node.data.status} /> : null}
    </>
  )}
/>

@ssilvius
Copy link
Copy Markdown
Collaborator Author

ssilvius commented May 4, 2026

Linked to issues #1437 (tree primitive) and #1438 (SidebarTree component) so this work sits on the rafters kanban properly. Per Sean: cross-repo work should be filed as issues in the target repo and executed by that repo's agent — gitpress drafting the PR was a workflow drift. Rafters team owns the decision: review + merge as-is, or close this PR and rewrite against the issues.

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