@grida/tree-view
Headless, agnostic tree-view controller for editors and IDEs. Zero runtime dependencies, no DOM coupling in the core, no widget library on top. React is the only optional peer.
Same controller. Wildly different trees.
The package never touches your DOM. Each panel below is identical wiring — same TreeController, same drag / keyboard / hit-test loop — composed against a different fixture, row renderer, indent geometry, and constraint stack. The result: a layers panel, a sidebar, a file explorer, and a double-click-to-open list view, all from one ~500-line core.
Grida
Monochrome zinc accent, eye + lock per row, full reorder drag.
Figma
Dark layers panel, components in purple, hidden layers dim, full reorder drag.
VS Code
Filesystem semantics — drop is always *into* the nearest folder, target highlights in blue. No reordering.
Notion
Cream sidebar, emoji-prefixed pages, chevron on hover only. Drop into a page nests it; drag between pages to reorder.
Finder (macOS)
Multi-column grid, zebra rows, double-click to expand. Same FS drag rule as VS Code.
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. Each panel below shows how to wire the feature with the primitives the package ships.
10. 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.
11. 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.
12. 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.
13. 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.
14. 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.
15. 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.
16. 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.
17. 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.
18. 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 (vertical bar, ┌/└ corners, arrow markers, anything).