Task Tree
alphav0.4.0Hierarchical task outline with multi-select, bulk operations, search and filter toolbar, dual drag-and-drop, and virtualization.
Context
Task Tree is the lightweight cousin to task-card — same fixed TaskItem schema (cross-procomp type-only dep), thin two-line row instead of the time-aware card chrome. Use it for sub-issue lists, side-panel outlines, hierarchical task pickers, and bulk-management screens. Clicking a row opens task-card's edit popup (consumer-owned or via the TaskTreeWithEditor convenience wrapper). Shared DnD payload (application/x-ilinxa-task+json) lets drags cross between task-tree and task-card in both directions on pointer; touch DnD is internal-only by design. v0.1 ships feature-complete (no scheduled v0.2/v0.3 — see procomp plan).
Installation
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/task-treeAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/task-tree-fixturesPreview
1 — Default tree
Out-of-the-box behaviour: toolbar (search + sort + filter + bulk), recursive children, dot status indicator, click to select, Cmd-A to select all visible, Cmd/Ctrl-click to toggle, Shift-click to range-select, drag-from-grip to reorder, Delete to remove the focused row.
2 — With editor (TaskTreeWithEditor)
Convenience wrapper. Clicking a row opens a Dialog containing the matching TaskCard in editable mode; live-saves propagate back into the tree.
3 — Strip status indicator + larger indent
The status indicator can render as a left-edge color strip (variant=strip) instead of the default dot. Combined with a wider indent for hierarchical scanning.
4 — Filter mode: hide (VSCode-style)
When filterMode='hide', non-matching rows are omitted entirely; ancestors-of-match still render so the result keeps tree context. Try the search input.
5 — Controlled + onChange logger
The tree is controlled via `value` + `onChange`. Every mutation routes through the consumer's reducer. Watch the live log for each event's `reason` field.
6 — Imperative handle
Programmatic access via ref. Buttons drive the tree from outside its UI.
7 — Custom row renderer
Slot prop replaces the default row paint while keeping all DnD + click + drop-indicator wiring intact. `defaultRender` is available if you want to wrap rather than replace.
Demo source
Usage
When to use
TaskTree is the lightweight sibling to @ilinxa/task-card. Same fixed TaskItem schema, but renders a thin two-line row (bold name + truncated description) instead of the time-driven card chrome.
- Sub-issue / outline lists where dozens to hundreds of rows must scan quickly.
- Side panels next to a primary editor (file-tree-style layout).
- Hierarchical task pickers, often with a rich editor opening on row click.
- Bulk management screens (multi-select + bulk-toggle / bulk-remove).
For time-aware urgency coloring or the full edit popup, use task-card. For kanban boards, compose @ilinxa/kanban-board with taskCardKanbanRenderer.
Quick start
import { TaskTree } from "@ilinxa/task-tree";
import type { TaskItem } from "@ilinxa/task-card";
const items: TaskItem[] = [
{
id: "t-1",
name: "Ship Q3 plan",
status: "in-progress",
active: true,
setAt: "2026-05-18T09:00:00Z",
children: [
{ id: "t-1a", name: "Draft outline", status: "done", active: true, setAt: "2026-05-18T09:00:00Z" },
],
},
];
<TaskTree
defaultValue={items}
statusOptions={[
{ value: "todo", label: "To do", variant: "outline" },
{ value: "in-progress", label: "In progress", variant: "secondary" },
{ value: "done", label: "Done", variant: "default" },
]}
onChange={({ items, reason }) => save(items)}
/>Controlled vs uncontrolled
Both modes work; the three-defenses pattern protects controlled consumers from echo storms and mid-drag setState races (microtask-defer + structural resync guard + drag-active notification suppression).
// Uncontrolled
<TaskTree defaultValue={items} onChange={({ items }) => save(items)} />
// Controlled — value wins, defaultValue ignored
<TaskTree value={items} onChange={({ items }) => setItems(items)} />With editor: TaskTreeWithEditor
Pair the tree with a Dialog-mounted TaskCard in one line. Clicking a row opens the matching card editable; live-saves propagate back into the tree (Q-P1 auto-persistence).
import { TaskTreeWithEditor } from "@ilinxa/task-tree";
<TaskTreeWithEditor
defaultValue={items}
statusOptions={statusOptions}
onChange={({ items }) => save(items)}
/>For stricter integrations (confirm dialog before edit, custom editor surface) compose <TaskTree> + your own dialog using the onItemClick callback.
Imperative handle (26 methods)
const ref = useRef<TaskTreeHandle>(null);
// Tree state
ref.current?.getValue();
ref.current?.setValue(newItems);
// Item ops
ref.current?.addItem(item, { parentId: "t-1", index: 0 });
ref.current?.addChild("t-1", item);
ref.current?.removeItem("t-1a");
ref.current?.removeItems(["t-1a", "t-1b"]);
ref.current?.toggleActive("t-1", false);
ref.current?.toggleActiveBulk(["t-1", "t-2"], true);
// Focus + lookup
ref.current?.focusItem("t-1");
ref.current?.getItemById("t-1");
// Collapse
ref.current?.expandItem("t-1");
ref.current?.collapseItem("t-1");
ref.current?.toggleCollapse("t-1");
ref.current?.expandAll();
ref.current?.collapseAll();
ref.current?.isCollapsed("t-1");
// Selection
ref.current?.selectItem("t-1");
ref.current?.deselectItem("t-1");
ref.current?.selectRange("t-1", "t-3");
ref.current?.selectAll(); // visible only
ref.current?.clearSelection();
ref.current?.getSelectedIds();
// Toolbar state
ref.current?.setQuery("review");
ref.current?.setSort({ kind: "name", direction: "asc" });
ref.current?.setFilter({ statuses: ["done"] });
ref.current?.clearAllFilters();Headless mode: useTaskTreeState
The same engine that powers <TaskTree> is also a hook. Drive your own toolbar / row layout / external state manager off the returned value, or feed it back into the default shell via the state prop.
import { useTaskTreeState, TaskTree } from "@ilinxa/task-tree";
function MyTree() {
const state = useTaskTreeState({
defaultValue: items,
onChange: ({ items }) => save(items),
});
// Drive a custom search input:
// <input value={state.query} onChange={(e) => state.setQuery(e.target.value)} />
// OR feed back into the default shell:
return <TaskTree state={state} />;
}Slot props (8)
renderRow— full row paint; receivesdefaultRender.renderName/renderDescription/renderPerson— per-field overrides.renderStatusIndicator— receives the matchedstatusOption.renderToolbar— wraps or replaces the toolbar; receivesdefaultToolbar+state.renderEmptyState— replaces the default placeholder; receiveshasFilter.renderDragOverlay— replaces the cursor-follow visual; receives the draggeditem.
Keyboard map
- ↑ / ↓ — previous / next visible row
- → — expand collapsed row OR move to first child
- ← — collapse expanded row OR move to parent
- Home / End — first / last visible row
- Space — toggle active state of focused row
- Enter — select + fire
onItemClick - Delete / Backspace — remove focused row
- Cmd/Ctrl + A — select all visible
- Cmd/Ctrl + Click — toggle row in selection
- Shift + Click — range select from anchor
- Escape — clear selection
Features
- Two-line row: chevron + status-indicator + checkbox + bold name + person label (top); thin truncated description (bottom)
- Per-row collapsibility (chevron); UI-only state (collapsedIds), not in TaskItem; default expanded
- Recursive children with infinite nesting
- Multi-select: Shift-click range + Cmd/Ctrl-click toggle + Cmd-A select all; bulk-toggle-active / bulk-remove / bulk-edit callbacks
- Default toolbar with search (200ms debounce) + sort (5 kinds: name/setAt/expireAt/status + custom) + filter (status/person/active)
- Filter mode: 'fade' (dim non-matches) or 'hide' (omit but ancestors-of-match render — VSCode style)
- Dual DnD: @dnd-kit (Mouse + Touch + Keyboard sensors) for internal drag; native HTML5 dataTransfer for cross-procomp drag with task-card
- Edge-zone drops: top 25% / middle 50% / bottom 25% (capped 8px); top/bottom = sibling adjacent; middle = reparent as last child + auto-expand target
- Circular-drop prevention (hit-test ban; onPermissionDenied fires with reason 'circular-drop')
- Virtualization via @tanstack/react-virtual; auto-enables at ≥200 total items; suspends during drag
- Permission matrix mirroring task-card (the `permissions` prop: default / byLevel / byItem with inherit cascade, + onPermissionDenied) gating 6 actions (edit / toggleActive / drag / dropAsSibling / dropIntoChildren / remove) — honored on BOTH the keyboard AND mouse/DnD paths (grip, active checkbox, drop targets, root-create)
- 8 slot props: renderRow / renderName / renderDescription / renderPerson / renderStatusIndicator / renderToolbar / renderEmptyState / renderDragOverlay (slot wins over prop variant)
- Headless useTaskTreeState hook — superset of TaskTreeHandle plus live state values + dispatch escape hatch
- Controlled (value + onChange) and uncontrolled (defaultValue) modes; controlled mode uses the three-defenses pattern (microtask-defer + full-field resync guard + suppress mid-drag onChange)
- 29-method imperative handle: tree state / item ops / single + bulk active-toggle + remove / focus / collapse / selection / query/sort/filter + v0.3 copy/cut/paste
- v0.3 cross-surface clipboard: copy/cut/paste TaskItems through the shared `ilinxa/task` envelope (task-card/lib/clipboard) — ⌘/Ctrl+C·X·V (document-level, gated on focus + skipped over inputs, operates on the selection or focused row) + imperative copyItems/cutItems/pasteItems; paste re-ids each subtree under the focused row; interops with card-tree / gantt / calendar
- v0.3 priorityOptions prop — threaded to the TaskTreeWithEditor edit card (parity with card-tree / gantt / calendar)
- 17 object-args events (post-F-cross-12 convention)
- Full WAI-ARIA tree pattern: role=tree + role=treeitem + aria-level + aria-expanded + aria-selected; arrow nav + Home/End + Space + Enter + Delete/Backspace + Cmd-A + Escape
- Companion: <TaskTreeWithEditor> convenience export wires task-card edit popup inside a Dialog automatically
- Toolbar '+ New' button (createItem factory + statusOptions[0] fallback); gated behind editable + !readOnly + the matrix's level-0 addChildren rule. Wrapper opens the edit panel on a pending item; commit deferred until Submit (onCreateRequest hook)
- Keyboard Space + Delete honor the permissions matrix + item.locked + readOnly; fire onPermissionDenied on denial (F-perm closed)