@grida/tree-view
Headless, agnostic tree-view controller for editors and IDEs — a pure state machine plus a small set of helpers, rendered with whatever framework you want. The showcases below are the same TreeController wired to different surfaces — canvas, explorer, document, desktop.
The real @grida/svg-editor drives the canvas. The layers panel is a tree-view bound to that editor's tree — select or hover on either side and the other follows.
Click a shape · drag handles · ⌫ to delete
Same editor, same bridge — only the panel chrome and the editor's chrome color change. The layers panel still walks the editor's tree.
Click a shape · drag handles · ⌫ to delete
Filesystem semantics: drops resolve into the nearest folder. Selecting a file opens it — selection is the only wire to the editor pane.
Same controller, lazy children. Expand a folder to fire listChildren; collapse mid-load aborts via the AbortSignal the provider receives. Errors land on a side-channel getLoadState — your meta stays clean.
Same TreeController as the static VS Code section above. Children are listed async via@grida/tree-view/async. Expand a folder → spinner; collapse mid-load → abort; toggle Fail next → error row with retry; press Emit watcher event → tree mutates without re-listing.
hasChildren + listChildren(id, signal) + subscribeChanges?. The same shape works for fs.promises.readdir, an HTTP endpoint, or OPFS.Workspace sidebar with nested pages, emoji affordances, and drag-into-page. Selecting a page swaps the document on the right — one controller, one selection channel.
Notes
Pages live in the sidebar — selecting one opens it here. Drop a page onto another to nest; drag between two pages to reorder. The sidebar and this document are wired to the same TreeController.
Today
- ☐ Triage CodeRabbit comments on PR 719
- ☐ Wire F12 reversed+desiredDepth test
- ☑ Reverse-aware drag math (F10)
- ☑ Tolerant
expandTo(F11.1)
Multi-column rows, zebra striping, double-click to expand — the same core, dressed as macOS and dropped onto the desktop.
Built for editor scale.
The state machine, the math, the intents — packaged so adopters ship a tree in a day, not a quarter.
Canvas layers
Reverse-children flatten, group highlight, selection-aware drag — the controller already knows what a design tool's layers panel needs.
Dark layers panel
Same controller, themed for any palette. Compose constraints to enforce frame / component / instance semantics.
File explorer
Filesystem drag rule — drops resolve into the nearest folder. Drop-target highlight cascades through descendants.
Workspace sidebar
Emoji-prefixed pages, nested toggles, drop-into-page. Selection wires the document body in three lines.
Native window
Multi-column rows, zebra striping, double-click-to-expand — all consumer-side; the package never touches the markup.
Examples
Selection, drag, keyboard, virtualization, custom sources.
1. Plain hierarchy
Expand / collapse + single-select. Click a chevron to toggle, click a row to select.
2. Multi-select
Replace (click), toggle (Cmd/Ctrl + click), range (Shift + click or Shift + ArrowUp/Down).
3. Keyboard navigation
Left panel: defaultKeymap installed (arrows + Home/End + Enter → rename intent + Delete → delete intent). Right panel: the graphics-tool subset — arrow keys are not bound, so they pass through to the host (in a real editor, they would nudge the canvas selection).
4. Move constraints
allOf(onlyIntoContainers(), disallowDescendant()). Drag any row onto a leaf row: the drop is coerced to after. Drag a container onto itself or its descendant: the drop is refused.
5. Move vs. copy drag
Drag a row to reorder. Hold Alt (Option on macOS) to switch the active drag to copy. Both intents are visualized below without mutating the source tree.
6. Virtualized (~10,000 rows)
Demonstrates the recipe documented in the README: the package ships a stable flat row list; the demo wires it into @tanstack/react-virtual. The virtualizer is a consumer choice, not a runtime dependency of @grida/tree-view.
7. Virtualized + deeply nested
100 chains × depth 100 = 10,000 rows, max indent at depth 99 (≈ 1,188 px from the row's left edge). The virtualizer handles row count; horizontal scroll is a pure consumer-side choice — the panel sets a min-width on the inner virtual canvas so the container scrolls both axes. Without that, indented rows would just truncate at the right edge.
position: sticky content cluster — the cluster floats at the right edge of the visible viewport until the indent scrolls far enough that the natural position catches up (Figma layers-panel pattern).8. Custom data source
A JSON tree adapted to TreeSource without copying — proves the package is data-agnostic.
Common features, idiomatic wiring.
Inline rename, focus restoration after delete, type-ahead, reveal in tree, external drag, decoration overlays, persisted expanded state — the patterns every real layer panel or file explorer needs.
Inline rename
Focus a row, press Enter or F2. The package emits a rename intent; you mount the input and commit the new label to your source. Pass keymap={editing ? null : defaultKeymap} while editing so Enter commits the input instead of re-firing rename.
Focus a row, hit Enter (or F2) to rename. The package emits a rename intent; the consumer mounts the input.
Multi-select drag rule
Figma / VS Code / Finder convention: if the grabbed row is part of the current selection, drag the whole selection; otherwise drag just the row. One line in the pointer-down → startDrag bridge: sel.includes(grabbedId) ? sel : [grabbedId].
Cmd/Ctrl-click two or three rows, then drag any of them. The intent below shows items = full selection. Drag an unselected row — items = just that row.
Focus restoration after delete
When you remove the focused row(s), focus should jump to the next visible sibling (or previous, or parent). nextFocusAfterRemove(rows, ids) picks the target from a pre-removal row snapshot — five lines on the consumer side.
Click a row, then press Delete. Focus jumps to the next visible row (or previous if at the end). Multi-select with Shift then Delete to remove a range — focus lands on the row after the range.
Type-ahead search
Type a letter (or a sequence within ~500 ms) to jump focus to the first row whose label starts with the buffer — the WAI-ARIA tree pattern. findByLabelPrefix(rows, prefix, opts) handles the wrap-from-focus search; you keep the buffer (a short-lived string with an inactivity reset).
Focus the panel (click on it), then type he to jump to "Heading", m to jump to "Mask group", etc. Re-typing the same first letter cycles matches.
Reveal-in-tree
"Go to file" / "Find in selection": expand ancestors, focus, select, and scroll into view. controller.reveal(id, opts?) covers the first three; DOM scrollIntoView is yours (the controller has no DOM handle).
Click any button — the panel starts fully collapsed. The recipe is expandTo(id) → focus(id) → scrollIntoView in 4 lines.
Drag from outside (palette → tree)
Drag a chip from a side palette into the tree to create a new node. External payloads don't go through the controller's drag state (today); the consumer runs its own pointer loop and inserts into the source on drop. A first-class startExternalDrag API is on the roadmap.
Drag a chip from the palette into the tree. Drop near the top of a row for before, the bottom for after, the middle of a container for into. The recipe rebuilds hit-test from scratch because startDrag requires existing node ids — that's the SDK gap.
Decoration overlay
Badges (git status, problem counts, dirty markers) come from stores that change independently of the tree. Keep them in consumer-side state and read them in the row renderer — so shuffling badges never bumps source.getVersion() or invalidates the row list.
Decorations live in consumer React state — never touched by TreeSource.getVersion(). Shuffling badges does not invalidate the controller's row list.
Controlled expanded set (persist to localStorage)
Expand / collapse state survives reload — hydrate from storage on mount, persist on every notify. getExpanded() / setExpanded(ids) and the expanded subscription channel are all the controller needs.
Expand / collapse a few rows, then reload the page — state is persisted to localStorage. The controller already exposes getExpanded() /setExpanded() / the expanded channel; the recipe is two effects.
Guides overlay (opt-in)
Default trees have no indent rails. When the consumer wants them — as a continuous rail through descendants of a special container (a mask group, a boolean op, etc.) — the rail is drawn as a single SVG overlay layered over the tree, not as per-row pieces. This keeps the line continuous across any row padding/gap and lets the consumer pick the symbol.