Task Tree
alphav0.4.1Hierarchical 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 initpnpm dlx shadcn@latest add @ilinxa/task-treeAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/task-tree-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
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
"use client"; import { useRef, useState } from "react";import { Button } from "@/components/ui/button";import { TaskTree } from "./task-tree";import { TaskTreeWithEditor } from "./task-tree-with-editor";import { TASK_TREE_DEMO_ITEMS, TASK_TREE_DEMO_STATUS_OPTIONS,} from "./dummy-data";import type { TaskItem } from "../task-card/types";import type { TaskTreeHandle } from "./types"; export default function TaskTreeDemo() { return ( <div className="space-y-10"> <DemoSection title="1 — Default tree" description="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." > <div className="h-105 overflow-hidden rounded-md border border-border bg-card"> <TaskTree defaultValue={TASK_TREE_DEMO_ITEMS} statusOptions={TASK_TREE_DEMO_STATUS_OPTIONS} aria-label="Q3 planning tasks" /> </div> </DemoSection> <DemoSection title="2 — With editor (TaskTreeWithEditor)" description="Convenience wrapper. Clicking a row opens a Dialog containing the matching TaskCard in editable mode; live-saves propagate back into the tree." > <div className="h-105 overflow-hidden rounded-md border border-border bg-card"> <TaskTreeWithEditor defaultValue={TASK_TREE_DEMO_ITEMS} statusOptions={TASK_TREE_DEMO_STATUS_OPTIONS} aria-label="Q3 planning tasks (editor)" /> </div> </DemoSection> <DemoSection title="3 — Strip status indicator + larger indent" description="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." > <div className="h-105 overflow-hidden rounded-md border border-border bg-card"> <TaskTree defaultValue={TASK_TREE_DEMO_ITEMS} statusOptions={TASK_TREE_DEMO_STATUS_OPTIONS} statusIndicator="strip" indentSize={28} /> </div> </DemoSection> <DemoSection title="4 — Filter mode: hide (VSCode-style)" description="When filterMode='hide', non-matching rows are omitted entirely; ancestors-of-match still render so the result keeps tree context. Try the search input." > <div className="h-105 overflow-hidden rounded-md border border-border bg-card"> <TaskTree defaultValue={TASK_TREE_DEMO_ITEMS} statusOptions={TASK_TREE_DEMO_STATUS_OPTIONS} filterMode="hide" /> </div> </DemoSection> <DemoSection title="5 — Controlled + onChange logger" description="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." > <ControlledLoggerDemo /> </DemoSection> <DemoSection title="6 — Imperative handle" description="Programmatic access via ref. Buttons drive the tree from outside its UI." > <ImperativeHandleDemo /> </DemoSection> <DemoSection title="7 — Custom row renderer" description="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." > <div className="h-105 overflow-hidden rounded-md border border-border bg-card"> <TaskTree defaultValue={TASK_TREE_DEMO_ITEMS} statusOptions={TASK_TREE_DEMO_STATUS_OPTIONS} renderRow={({ item, level, isSelected }) => ( <div className={`flex items-center gap-2 px-3 py-2 text-sm ${ isSelected ? "bg-primary/10" : "" }`} style={{ paddingInlineStart: 8 + level * 20 }} > <span className="font-mono text-xs text-muted-foreground"> #{level} </span> <span className="font-medium">{item.name}</span> {item.targetPerson && ( <span className="ml-auto text-xs text-muted-foreground"> {item.targetPerson.name} </span> )} </div> )} /> </div> </DemoSection> </div> );} function DemoSection({ title, description, children,}: { title: string; description: string; children: React.ReactNode;}) { return ( <section className="space-y-2"> <h3 className="text-sm font-semibold uppercase tracking-wide text-foreground"> {title} </h3> <p className="text-sm text-muted-foreground">{description}</p> {children} </section> );} function ControlledLoggerDemo() { const [items, setItems] = useState<TaskItem[]>(TASK_TREE_DEMO_ITEMS); const [log, setLog] = useState<string[]>([]); const append = (line: string) => { setLog((prev) => [line, ...prev].slice(0, 8)); }; return ( <div className="grid gap-3 md:grid-cols-[1fr_280px]"> <div className="h-105 overflow-hidden rounded-md border border-border bg-card"> <TaskTree value={items} onChange={(args) => { setItems(args.items); append(`onChange · reason=${args.reason}`); }} statusOptions={TASK_TREE_DEMO_STATUS_OPTIONS} onActiveToggled={({ item, nextActive }) => append(`active · ${item.name} → ${nextActive ? "on" : "off"}`) } onItemMoved={({ item, to }) => append(`moved · ${item.name} → ${to.parentId ?? "root"}/${to.index}`) } /> </div> <div className="rounded-md border border-border bg-card p-3 text-xs"> <div className="mb-2 font-semibold text-foreground">Event log</div> {log.length === 0 ? ( <div className="text-muted-foreground"> Interact with the tree to see events here. </div> ) : ( <ul className="space-y-1 font-mono text-[11px]"> {log.map((line, i) => ( <li key={i} className="text-muted-foreground"> {line} </li> ))} </ul> )} </div> </div> );} function ImperativeHandleDemo() { const ref = useRef<TaskTreeHandle>(null); return ( <div className="space-y-2"> <div className="flex flex-wrap gap-2"> <Button size="sm" onClick={() => ref.current?.expandAll()}> Expand all </Button> <Button size="sm" onClick={() => ref.current?.collapseAll()}> Collapse all </Button> <Button size="sm" onClick={() => ref.current?.selectAll()}> Select all visible </Button> <Button size="sm" onClick={() => ref.current?.clearSelection()}> Clear selection </Button> <Button size="sm" variant="outline" onClick={() => ref.current?.setQuery("review")} > Search “review” </Button> <Button size="sm" variant="outline" onClick={() => ref.current?.clearAllFilters()} > Clear search + filter </Button> </div> <div className="h-105 overflow-hidden rounded-md border border-border bg-card"> <TaskTree ref={ref} defaultValue={TASK_TREE_DEMO_ITEMS} statusOptions={TASK_TREE_DEMO_STATUS_OPTIONS} /> </div> </div> );} 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)