feat(primitives): tree — headless tree-widget behavior#1436
Conversation
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>
|
Added the SidebarTree component —
Component test coverage (10 tests, all passing locally)
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}
</>
)}
/> |
|
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. |
Summary
Adds a headless tree primitive at
packages/ui/src/primitives/tree.ts. Sibling layer toroving-focus,keyboard-handler,typeahead,block-handler. Greenlit by rafters team in legion bullpen019df3e9-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
typeaheadprimitive), 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:
drag-dropprimitive)loadChildren?extensionPublic surface
createTree<T>(options): TreeAPI<T>-- framework-agnostic, no DOMattachTree<T>(container, options): { api, cleanup }-- DOM-attached convenience form (parity withcreateRovingFocus/createTypeahead)getVisibleEntries<T>(nodes, expanded): TreeFlatEntry<T>[]-- exported walker for render layersTest coverage (33 tests, all passing locally)
setNodesdrops stale focused/selected/expanded idsattachTreekeydown wiring + cleanupVerification done locally
pnpm vitest run packages/ui/test/primitives/tree.test.ts-- 33/33 passpnpm typecheck-- cleanpnpm lint(biome) -- cleanTest plan
pnpx rafters add primitive treeregisters post-mergeReferences