ilinxa
blocktask-treesource: ok · refreshed
Task Tree
Hierarchical task outline with multi-select, bulk operations, search and filter toolbar, dual drag-and-drop, and virtualization.
npx shadcn add "https://ui.ilinxa.com/r/task-tree.json"free
Interaction
dragdropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
avatarbadgebuttoncheckboxdialoginputpopoverselectseparator@ilinxa/task-card
Files48 · source cached 10/8/2026, 8:09:38 AM
src/registry/components/data/task-tree/task-tree.tsx
"use client";
import {
forwardRef,
useCallback,
useEffect,
useId,
useImperativeHandle,
useMemo,
useRef,
} from "react";
import {
DndContext,
DragOverlay,
type DndContextProps,
} from "@dnd-kit/core";
import { cn } from "@/lib/utils";
import type { TaskItem, TaskStatusOption } from "../task-card/types";
import {
readTasksFromClipboardEvent,
writeTasksToClipboardEvent,
reassignTaskIds,
} from "../task-card/lib/clipboard";
import type {
TaskTreeHandle,
TaskTreePermissionDeniedEvent,
TaskTreeProps,
} from "./types";
import { useTaskTreeState } from "./hooks/use-task-tree-state";
import {
TaskTreeDndContext,
TaskTreeRenderContext,
TaskTreeStateContext,
type TaskTreeDndContextValue,
type TaskTreeRenderContextValue,
} from "./hooks/use-task-tree-context";
import { useTreeDndInternal } from "./hooks/use-tree-dnd-internal";
import { useTreeDndHtml5 } from "./hooks/use-tree-dnd-html5";
import { evalPermission } from "./lib/permissions";
import {
findItemWithLevel,
forEachItem,
pruneNestedIds,
} from "./lib/tree-walker";
import { TaskTreeList } from "./parts/task-tree-list";
import { TaskTreeDragOverlay } from "./parts/task-tree-drag-overlay";
import { TaskTreeToolbar } from "./parts/task-tree-toolbar";
import { isValidElement, type ReactNode } from "react";
/**
* Tree-row renderer for TaskItem outlines. Sibling to `<TaskCard>` —
* same `TaskItem` schema, lightweight two-line row instead of the rich
* card chrome.
*
* Status across the C1–C11 commit chain:
* C1–C6 ✓ — scaffold, lib, hooks, row primitives, list, DnD
* C7 ◌ — toolbar
* C8 ◌ — keyboard + a11y + empty state
* C9+ ◌ — wrapper + demo + usage + meta sync + ship
*
* GATE 1: docs/procomps/task-tree-procomp/task-tree-procomp-description.md
* GATE 2: docs/procomps/task-tree-procomp/task-tree-procomp-plan.md
*/
export const TaskTree = forwardRef<TaskTreeHandle, TaskTreeProps>(
function TaskTree(props, ref) {
const {
defaultValue,
value,
onChange,
state: externalState,
statusOptions,
defaultCollapsedIds,
defaultSelectedIds,
indentSize = 20,
filterMode = "fade",
statusIndicator = "dot",
virtualize,
toolbar = "default",
dndContext = "internal",
readOnly,
editable = false,
createItem,
onCreateRequest,
renderRow,
renderName,
renderDescription,
renderPerson,
renderStatusIndicator,
renderToolbar,
renderEmptyState,
renderDragOverlay,
onItemClick,
onItemContextMenu,
onActiveToggled,
onCollapseToggled,
onItemMoved,
onItemDropped,
onItemAdded,
onItemRemoved,
onBulkToggleActive,
onBulkRemove,
onBulkEdit,
onSelectionChanged,
onSearchChanged,
onSortChanged,
onFilterChanged,
permissions,
onPermissionDenied,
className,
"aria-label": ariaLabel,
} = props;
// Drag flags shared between useControlledMode (defense 3) and the DnD
// hooks (set/unset on dragStart/dragEnd). isInternalDragRef is the
// mutual-exclusion gate between @dnd-kit grip and native HTML5 drag.
const isDraggingRef = useRef(false);
const isInternalDragRef = useRef(false);
// Stable id for <DndContext>. Without an explicit id, @dnd-kit allocates
// `DndDescribedBy-N` from a module-level counter that diverges between
// SSR (single pass) and CSR (StrictMode double-pass) — that triggers a
// hydration mismatch on every grip button's `aria-describedby`. React's
// useId is stable across SSR + CSR so we use it as the deterministic seed.
const dndContextId = useId();
const internalState = useTaskTreeState({
defaultValue,
value,
onChange,
defaultCollapsedIds,
defaultSelectedIds,
filterMode,
isDraggingRef,
onItemClick,
onItemContextMenu,
onActiveToggled,
onCollapseToggled,
onItemMoved,
onItemDropped,
onItemAdded,
onItemRemoved,
onBulkToggleActive,
onBulkRemove,
onBulkEdit,
onSelectionChanged,
onSearchChanged,
onSortChanged,
onFilterChanged,
onPermissionDenied,
permissions,
});
const stateValue = externalState ?? internalState;
useImperativeHandle(ref, () => stateValue, [stateValue]);
// Latest state for the document clipboard listeners (kept in a ref so the
// listeners attach once, not on every tree mutation).
const treeRef = useRef<HTMLDivElement | null>(null);
const stateValueRef = useRef(stateValue);
useEffect(() => {
stateValueRef.current = stateValue;
}, [stateValue]);
// Permission matrix + denial callback mirrored to refs so the document
// clipboard listeners (attach-once) always gate against the live values.
const permissionsRef = useRef(permissions);
const onPermissionDeniedRef = useRef(onPermissionDenied);
useEffect(() => {
permissionsRef.current = permissions;
onPermissionDeniedRef.current = onPermissionDenied;
}, [permissions, onPermissionDenied]);
/* ── cross-surface clipboard (v0.3.0) — copy/cut/paste TaskItems through the
OS clipboard (shared ilinxa/task envelope). Document-level, gated on the
tree containing focus + skipped over text inputs; mirrors gantt/calendar.
Operates on the current selection (or the focused row). ── */
useEffect(() => {
if (!editable || readOnly) return;
const owns = () => {
const active = document.activeElement;
return !!treeRef.current && !!active && treeRef.current.contains(active);
};
const overText = () => {
const el = document.activeElement as HTMLElement | null;
return (
el?.tagName === "INPUT" ||
el?.tagName === "TEXTAREA" ||
el?.tagName === "SELECT" ||
el?.isContentEditable === true
);
};
const targetIds = (): string[] => {
const s = stateValueRef.current;
const sel = s.getSelectedIds();
// Prune ids whose ancestor is also selected — a parent's payload
// already carries the subtree; keeping both duplicates it on paste.
if (sel.size > 0) return pruneNestedIds(s.items, [...sel]);
return s.focusedItemId ? [s.focusedItemId] : [];
};
const collect = (ids: string[]): TaskItem[] =>
ids
.map((id) => stateValueRef.current.getItemById(id))
.filter((it): it is TaskItem => !!it);
const onCopy = (e: ClipboardEvent) => {
if (!owns() || overText()) return;
const items = collect(targetIds());
if (items.length === 0) return;
writeTasksToClipboardEvent(e, items, "task-tree");
e.preventDefault();
};
const onCut = (e: ClipboardEvent) => {
if (!owns() || overText()) return;
const ids = targetIds();
if (ids.length === 0) return;
// Gate each id on the remove rule + `locked`, mirroring keyboard
// Delete: blocked ids are reported + skipped, allowed ids are cut.
const allowed: string[] = [];
for (const id of ids) {
const found = findItemWithLevel(stateValueRef.current.items, id);
if (!found) continue;
if (
evalPermission(permissionsRef.current, "remove", found.item, found.level)
) {
allowed.push(id);
} else {
onPermissionDeniedRef.current?.({
action: "remove",
itemId: id,
reason:
found.item.locked === true ? "denied-by-lock" : "denied-by-rule",
});
}
}
const items = collect(allowed);
if (items.length === 0) return;
writeTasksToClipboardEvent(e, items, "task-tree");
stateValueRef.current.removeItems(allowed);
e.preventDefault();
};
const onPaste = (e: ClipboardEvent) => {
if (!owns() || overText()) return;
const items = readTasksFromClipboardEvent(e);
if (!items) return;
const s = stateValueRef.current;
const parentId = s.focusedItemId;
if (parentId) {
// Gate the graft target on its dropIntoChildren (addChildren) rule +
// `locked` — same rule the DnD drop path enforces (TT1 parity).
const target = findItemWithLevel(s.items, parentId);
if (
target &&
!evalPermission(
permissionsRef.current,
"dropIntoChildren",
target.item,
target.level,
)
) {
onPermissionDeniedRef.current?.({
action: "dropIntoChildren",
itemId: parentId,
reason:
target.item.locked === true ? "denied-by-lock" : "denied-by-rule",
});
// The payload was ours — consume the event, deny the graft.
e.preventDefault();
return;
}
}
for (const raw of items) {
const fresh = reassignTaskIds(raw);
if (parentId) s.addChild(parentId, fresh);
else s.addItem(fresh);
}
e.preventDefault();
};
document.addEventListener("copy", onCopy);
document.addEventListener("cut", onCut);
document.addEventListener("paste", onPaste);
return () => {
document.removeEventListener("copy", onCopy);
document.removeEventListener("cut", onCut);
document.removeEventListener("paste", onPaste);
};
}, [editable, readOnly]);
const statusOptionMap = useMemo(() => {
const map = new Map<string, TaskStatusOption>();
if (statusOptions) {
for (const o of statusOptions) map.set(o.value, o);
}
return map;
}, [statusOptions]);
const renderContextValue = useMemo<TaskTreeRenderContextValue>(
() => ({
statusIndicator,
statusOptionMap,
indentSize,
renderRow,
renderName,
renderDescription,
renderPerson,
renderStatusIndicator,
renderEmptyState,
}),
[
statusIndicator,
statusOptionMap,
indentSize,
renderRow,
renderName,
renderDescription,
renderPerson,
renderStatusIndicator,
renderEmptyState,
],
);
// Resolve a drop-target's permission against the live tree, mirroring the
// keyboard path's evalPermission gate. Predicates are target-keyed to match
// the DnD hook's enforcement signature (TT1 — previously left undefined so
// the matrix only applied on the keyboard path).
const resolveDropPermission = useCallback(
(targetId: string, action: "dropIntoChildren" | "dropAsSibling"): boolean => {
let allowed = true;
forEachItem(stateValue.items, (item, level) => {
if (item.id === targetId) {
allowed = evalPermission(permissions, action, item, level);
return false;
}
return true;
});
return allowed;
},
[permissions, stateValue.items],
);
const canDropIntoChildren = useCallback(
(targetId: string) => resolveDropPermission(targetId, "dropIntoChildren"),
[resolveDropPermission],
);
const canDropAsSibling = useCallback(
(targetId: string) => resolveDropPermission(targetId, "dropAsSibling"),
[resolveDropPermission],
);
// DnD wiring.
const dndInternal = useTreeDndInternal({
items: stateValue.items,
dispatch: stateValue.dispatch,
fireMoved: (args) => onItemMoved?.(args),
firePermissionDenied: (args: TaskTreePermissionDeniedEvent) =>
onPermissionDenied?.(args),
isDraggingRef,
isInternalDragRef,
canDropIntoChildren,
canDropAsSibling,
});
const dndHtml5 = useTreeDndHtml5({
items: stateValue.items,
dispatch: stateValue.dispatch,
fireAdded: (args) => onItemAdded?.(args),
fireDropped: (args) => onItemDropped?.(args),
isInternalDragRef,
});
const dndContextValue = useMemo<TaskTreeDndContextValue>(
() => ({
activeItemId: dndInternal.activeItem?.id ?? null,
overId: dndInternal.over?.overId ?? null,
overZone: dndInternal.over?.zone ?? null,
overCircular: dndInternal.over?.circular ?? false,
handleRowClick: stateValue.handleRowClick,
getRowHandlers: dndHtml5.getRowHandlers,
}),
[
dndInternal.activeItem,
dndInternal.over,
stateValue.handleRowClick,
dndHtml5.getRowHandlers,
],
);
// Virtualization config from prop variants.
const virtualizeMode: "auto" | "always" | "never" =
virtualize === true
? "always"
: virtualize === false
? "never"
: "auto";
const virtualizeThreshold =
typeof virtualize === "object" && virtualize !== null
? (virtualize.threshold ?? 200)
: 200;
const defaultToolbar: ReactNode =
toolbar === "default" ? (
<TaskTreeToolbar
statusOptions={statusOptions}
onBulkEdit={onBulkEdit}
createItem={createItem}
onCreateRequest={onCreateRequest}
readOnly={readOnly}
editable={editable}
permissions={permissions}
/>
) : null;
const customNodeToolbar: ReactNode =
toolbar !== "default" && toolbar !== "none" && isValidElement(toolbar)
? (toolbar as ReactNode)
: null;
const toolbarRendered: ReactNode = renderToolbar
? renderToolbar({ defaultToolbar, state: stateValue })
: (customNodeToolbar ?? (toolbar === "none" ? null : defaultToolbar));
const treeBody = (
<TaskTreeStateContext.Provider value={stateValue}>
<TaskTreeRenderContext.Provider value={renderContextValue}>
<TaskTreeDndContext.Provider value={dndContextValue}>
<div
ref={treeRef}
aria-label={ariaLabel ?? "Task tree"}
className={cn("flex h-full flex-col", className)}
>
{toolbarRendered}
<TaskTreeList
virtualize={virtualizeMode}
virtualizeThreshold={virtualizeThreshold}
suspended={dndInternal.activeItem !== null}
permissions={permissions}
onPermissionDenied={onPermissionDenied}
readOnly={readOnly}
/>
</div>
</TaskTreeDndContext.Provider>
</TaskTreeRenderContext.Provider>
</TaskTreeStateContext.Provider>
);
const dndProps: DndContextProps = {
id: dndContextId,
sensors: dndInternal.sensors,
onDragStart: dndInternal.onDragStart,
onDragOver: dndInternal.onDragOver,
onDragEnd: dndInternal.onDragEnd,
onDragCancel: dndInternal.onDragCancel,
};
if (dndContext === "external") {
return treeBody;
}
return (
<DndContext {...dndProps}>
{treeBody}
<DragOverlay>
{dndInternal.activeItem &&
(renderDragOverlay
? renderDragOverlay({
item: dndInternal.activeItem,
level: dndInternal.activeLevel,
})
: (
<TaskTreeDragOverlay
item={dndInternal.activeItem}
level={dndInternal.activeLevel}
/>
))}
</DragOverlay>
</DndContext>
);
},
);
src/registry/components/data/task-tree/task-tree-with-editor.tsx
"use client";
import {
forwardRef,
useCallback,
useRef,
useState,
type Ref,
} from "react";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { TaskCard } from "../task-card/task-card";
import type { TaskItem } from "../task-card/types";
import { TaskTree } from "./task-tree";
import type {
TaskTreeHandle,
TaskTreeItemEvent,
TaskTreeProps,
} from "./types";
import { updateById } from "./lib/tree-mutators";
/**
* Convenience wrapper that pairs `<TaskTree>` with a `<TaskCard>` edit
* dialog. Two modes the dialog enters:
*
* - EDIT existing item — fired by row click. Live-edit semantics: each
* keystroke / field change propagates straight back into the tree via
* `handle.setValue`. Closing the dialog (X, overlay, Escape) just
* dismisses; changes are already saved.
*
* - CREATE new item — fired by the toolbar "+ New" button (via the
* deferred-commit `onCreateRequest` hook). The dialog opens on a
* PENDING item that is NOT yet in the tree. Edits inside the dialog
* mutate only the local pending state. Submit commits via
* `handle.addItem` and closes; Cancel / Escape / overlay discards the
* pending item entirely.
*
* Architecture:
* - Wrapper is STATE-TRANSPARENT. Consumer's value / defaultValue /
* onChange flow through to TaskTree unmodified. The wrapper owns only
* the dialog's open + pending state.
* - The two dialog modes are distinguished by `pending` state. When
* `pending` is true, onChange does NOT touch the tree — it only
* updates the local `editTarget`. The footer's Submit button is the
* single commit point.
* - Outer ref is forwarded via a callback ref so the consumer's
* ref.current stays in sync as the inner handle identity changes
* across renders (it changes whenever state.items mutates because
* TaskTreeStateValue is rebuilt). useImperativeHandle would freeze
* to the initial handle.
*
* Consumers that want a stricter integration (confirm dialog before edit,
* custom editor surface) should compose <TaskTree> + their own dialog
* using the onItemClick / onCreateRequest callbacks.
*/
export const TaskTreeWithEditor = forwardRef<TaskTreeHandle, TaskTreeProps>(
function TaskTreeWithEditor(props, ref) {
const { onItemClick: onItemClickProp, ...rest } = props;
const treeRef = useRef<TaskTreeHandle | null>(null);
const [editTarget, setEditTarget] = useState<TaskItem | null>(null);
// True when editTarget represents a not-yet-committed new row.
const [pending, setPending] = useState(false);
// Callback ref — fires whenever the inner TaskTree's handle reference
// changes. Forwards to both internal treeRef and the outer consumer ref.
const setTreeRef = useCallback(
(handle: TaskTreeHandle | null) => {
treeRef.current = handle;
forwardRefValue(ref, handle);
},
[ref],
);
// Radix Dialog flips `aria-hidden="true"` on the surrounding tree
// wrapper the instant `open` becomes truthy. If a focused element
// (the clicked row, the "+ New" button, etc.) is still inside that
// wrapper at that moment, the browser logs an a11y warning because
// a focused descendant of an aria-hidden ancestor is illegal. Blur
// the active element before flipping our open state; Radix's
// dialog auto-focus then takes over on the next tick.
const blurActiveElement = useCallback(() => {
if (typeof document === "undefined") return;
const el = document.activeElement;
if (el instanceof HTMLElement) el.blur();
}, []);
const handleRowClick = useCallback(
(args: TaskTreeItemEvent) => {
blurActiveElement();
setEditTarget(args.item);
setPending(false);
onItemClickProp?.(args);
},
[onItemClickProp, blurActiveElement],
);
const handleCreateRequest = useCallback(
(draft: TaskItem) => {
blurActiveElement();
// Open the dialog on the pending item; do NOT add to the tree yet —
// commit happens on Submit.
setEditTarget(draft);
setPending(true);
},
[blurActiveElement],
);
const handleEditedItemChange = useCallback(
(updated: TaskItem) => {
// Always track the latest in-dialog state so the title + Submit
// payload reflect user edits.
setEditTarget(updated);
if (pending) {
// Pending mode — defer tree mutation until Submit.
return;
}
// Edit-existing mode — propagate every change to the tree.
const handle = treeRef.current;
if (!handle) return;
const current = handle.getValue();
const next = updateById(current, updated.id, () => updated);
handle.setValue(next);
},
[pending],
);
const closeDialog = useCallback(() => {
setEditTarget(null);
setPending(false);
}, []);
const handleSubmitPending = useCallback(() => {
if (!pending || !editTarget) return;
const handle = treeRef.current;
if (!handle) {
closeDialog();
return;
}
handle.addItem(editTarget);
// Focus the newly-committed row so arrow-keys continue from it.
handle.focusItem(editTarget.id);
closeDialog();
}, [pending, editTarget, closeDialog]);
return (
<>
<TaskTree
editable
{...rest}
ref={setTreeRef}
onItemClick={handleRowClick}
onCreateRequest={handleCreateRequest}
/>
<Dialog
open={!!editTarget}
onOpenChange={(open) => {
// Any close path (X, Escape, overlay) in pending mode = discard.
if (!open) closeDialog();
}}
>
<DialogContent
aria-describedby={undefined}
className="flex max-h-[calc(100dvh-2rem)] max-w-252 flex-col overflow-hidden"
>
<DialogTitle className="sr-only">
{pending
? "New task"
: editTarget
? `Edit ${editTarget.name}`
: "Edit task"}
</DialogTitle>
{editTarget && (
<>
<div className="-mx-4 -mt-4 min-h-0 flex-1 overflow-y-auto p-4">
<TaskCard
// Remount when the target id changes so TaskCard's
// uncontrolled defaultValue picks up the new payload.
key={editTarget.id}
editable
defaultValue={editTarget}
onChange={handleEditedItemChange}
statusOptions={props.statusOptions}
priorityOptions={props.priorityOptions}
/>
</div>
{pending && (
<div className="-mx-4 -mb-4 flex items-center justify-end gap-2 border-t border-border bg-muted/40 px-4 py-3">
<Button
type="button"
variant="ghost"
size="sm"
onClick={closeDialog}
>
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={handleSubmitPending}
>
Add task
</Button>
</div>
)}
</>
)}
</DialogContent>
</Dialog>
</>
);
},
);
function forwardRefValue<T>(ref: Ref<T> | undefined, value: T | null): void {
if (!ref) return;
if (typeof ref === "function") {
ref(value);
return;
}
// RefObject — assign current.
(ref as { current: T | null }).current = value;
}
src/registry/components/data/task-tree/index.ts
export { TaskTree } from "./task-tree";
export { TaskTreeWithEditor } from "./task-tree-with-editor";
// Headless state hook
export { useTaskTreeState } from "./hooks/use-task-tree-state";
export type {
TaskTreeProps,
TaskTreeHandle,
TaskTreeStateValue,
TaskTreeChangeReason,
TaskTreeChangeArgs,
TaskTreeSort,
TaskTreeFilter,
TaskTreePermissionAction,
TaskTreePermissionDenialReason,
TaskTreeAction,
TreeLocation,
TaskTreeVisibleRow,
TaskTreeItemEvent,
TaskTreeMoveEvent,
TaskTreeDropEvent,
TaskTreeAddEvent,
TaskTreeRemoveEvent,
TaskTreePermissionDeniedEvent,
TaskTreeRowRenderArgs,
TaskTreeFieldRenderArgs,
TaskTreeStatusRenderArgs,
TaskTreeToolbarRenderArgs,
TaskTreeEmptyRenderArgs,
TaskTreeDragOverlayArgs,
} from "./types";
src/registry/components/data/task-tree/types.ts
import type { ReactNode, MouseEvent, KeyboardEvent } from "react";
import type {
TaskItem,
TaskStatusOption,
TaskPriorityOption,
TaskPermissions,
} from "../task-card/types";
/**
* Tree-position pointer used by onItemMoved.from / .to.
* parentId === null indicates top-level (sibling of root items).
*/
export interface TreeLocation {
parentId: string | null;
index: number;
}
/**
* Mutation reason tag — fires with every onChange. One code per
* public mutation pathway so consumers can route post-change effects.
*/
export type TaskTreeChangeReason =
| "reorder"
| "reparent"
| "toggle-active"
| "imperative-set"
| "drop-from-external"
| "add-item"
| "remove-item"
| "add-child"
| "bulk-toggle-active"
| "bulk-remove";
/** Sort specification. */
export type TaskTreeSort =
| { kind: "name" | "setAt" | "expireAt" | "status"; direction: "asc" | "desc" }
| { kind: "custom"; compare: (a: TaskItem, b: TaskItem) => number };
/** Filter specification (combined with the search query). */
export interface TaskTreeFilter {
statuses?: ReadonlyArray<string>;
personIds?: ReadonlyArray<string>;
active?: "all" | "active" | "inactive";
}
/** Permission action codes (tree-specific; distinct from task-card's set). */
export type TaskTreePermissionAction =
| "edit"
| "toggleActive"
| "drag"
| "dropAsSibling"
| "dropIntoChildren"
| "remove";
/** Denial reasons fired with onPermissionDenied. */
export type TaskTreePermissionDenialReason =
| "denied-by-rule"
| "denied-by-readOnly"
| "denied-by-lock"
| "circular-drop";
/**
* Reducer action union. Private engine; surfaced via the `dispatch`
* escape hatch on TaskTreeStateValue for advanced consumers.
*/
export type TaskTreeAction =
| { type: "SET_ITEMS"; items: TaskItem[]; reason: TaskTreeChangeReason }
| {
type: "ADD_ITEM";
item: TaskItem;
parentId: string | null;
index?: number;
via: "imperative" | "drop-from-external";
}
| { type: "REMOVE_ITEM"; id: string; via: "imperative" | "keyboard" | "bulk" }
| { type: "REMOVE_ITEMS"; ids: ReadonlyArray<string> }
| { type: "ADD_CHILD"; parentId: string; item: TaskItem; index?: number }
| {
type: "MOVE_ITEM";
itemId: string;
to: TreeLocation;
reason: "reorder" | "reparent";
}
| { type: "TOGGLE_ACTIVE"; id: string; nextActive: boolean }
| {
type: "TOGGLE_ACTIVE_BULK";
ids: ReadonlyArray<string>;
nextActive: boolean;
}
| { type: "TOGGLE_COLLAPSE"; id: string }
| { type: "EXPAND_ALL" }
| { type: "COLLAPSE_ALL" }
| { type: "SET_COLLAPSED"; ids: ReadonlyArray<string> }
| {
type: "SELECT_ONE";
id: string;
mode: "replace" | "toggle" | "range";
}
| { type: "SELECT_ALL" }
| {
type: "SELECT_REPLACE";
ids: ReadonlyArray<string>;
anchorId?: string | null;
}
| { type: "CLEAR_SELECTION" }
| { type: "SET_FOCUS"; id: string | null }
| { type: "SET_QUERY"; query: string }
| { type: "SET_SORT"; sort: TaskTreeSort }
| { type: "SET_FILTER"; filter: TaskTreeFilter }
| { type: "CLEAR_FILTERS" };
/* ---------------------------------- Event arg shapes ---------------------------------- */
export interface TaskTreeChangeArgs {
items: TaskItem[];
reason: TaskTreeChangeReason;
}
export interface TaskTreeItemEvent {
item: TaskItem;
level: number;
/**
* The originating event. MouseEvent for click + context-menu, KeyboardEvent
* when the row is activated via Enter from the keyboard handler. Consumers
* narrow with `"clientX" in event` or instanceof checks when they need
* mouse-specific properties.
*/
event: MouseEvent | KeyboardEvent;
}
export interface TaskTreeMoveEvent {
item: TaskItem;
from: TreeLocation;
to: TreeLocation;
via: "drag" | "imperative";
}
export interface TaskTreeDropEvent {
item: TaskItem;
from: "internal" | "external";
targetParentId: string | null;
targetIndex: number;
}
export interface TaskTreeAddEvent {
item: TaskItem;
parentId: string | null;
index: number;
via: "imperative" | "drop-from-external";
}
export interface TaskTreeRemoveEvent {
item: TaskItem;
via: "imperative" | "keyboard" | "bulk";
}
export interface TaskTreePermissionDeniedEvent {
action: TaskTreePermissionAction;
itemId: string;
reason: TaskTreePermissionDenialReason;
}
/* ---------------------------------- Visible item snapshot ---------------------------------- */
/**
* Renderer-ready flat row produced by the filter → sort → flatten pipeline.
* level + parentId + index are needed for indent, ARIA, and reparent target
* detection without re-walking the tree per render.
*/
export interface TaskTreeVisibleRow {
item: TaskItem;
level: number;
parentId: string | null;
index: number;
/**
* Set by the filter pipeline in `filterMode: "fade"` when this row's item
* doesn't match the active query/filter. Renderers apply reduced opacity
* while keeping interactive affordances reachable. Absent in "hide" mode
* because non-matching rows aren't emitted at all.
*/
dimmed?: boolean;
}
/* ---------------------------------- Imperative handle ---------------------------------- */
/**
* The full imperative handle exposed via ref. 29 methods (26 base + 3 v0.3
* clipboard) covering tree state, item ops, single + bulk active-toggle +
* remove, focus, collapse, selection, query/sort/filter, and copy/cut/paste.
*/
export interface TaskTreeHandle {
// Tree state (2)
getValue(): TaskItem[];
setValue(next: TaskItem[]): void;
// Item ops (6)
addItem(item: TaskItem, opts?: { parentId?: string; index?: number }): void;
removeItem(id: string): void;
addChild(parentId: string, item: TaskItem, index?: number): void;
removeItems(ids: ReadonlyArray<string>): void;
toggleActive(id: string, nextActive: boolean): void;
toggleActiveBulk(ids: ReadonlyArray<string>, nextActive: boolean): void;
// Focus / lookup (2)
focusItem(id: string): void;
getItemById(id: string): TaskItem | undefined;
// Collapse (6)
expandItem(id: string): void;
collapseItem(id: string): void;
toggleCollapse(id: string): void;
expandAll(): void;
collapseAll(): void;
isCollapsed(id: string): boolean;
// Selection (6)
selectItem(id: string): void;
deselectItem(id: string): void;
selectRange(idA: string, idB: string): void;
selectAll(): void;
clearSelection(): void;
getSelectedIds(): ReadonlySet<string>;
// Query / sort / filter (4)
setQuery(query: string): void;
setSort(sort: TaskTreeSort): void;
setFilter(filter: TaskTreeFilter): void;
clearAllFilters(): void;
// Cross-surface clipboard (v0.3.0; shared ilinxa/task envelope) (3)
/** Copy items to the OS clipboard. Defaults to the current selection (or focus). */
copyItems(ids?: ReadonlyArray<string>): void;
/** Copy then remove. Defaults to the current selection (or focus). */
cutItems(ids?: ReadonlyArray<string>): void;
/** Paste clipboard tasks (re-id'd) under `parentId` (null/omitted = the focused row; root if none focused). */
pasteItems(parentId?: string | null): void;
}
/**
* Headless state value returned by useTaskTreeState. Superset of
* TaskTreeHandle plus the live state values + reducer escape hatch.
* Consumers can pass this to <TaskTree state={state} /> to host their
* own toolbar / row layout / virtualization.
*/
export interface TaskTreeStateValue extends TaskTreeHandle {
items: TaskItem[];
visibleItems: ReadonlyArray<TaskTreeVisibleRow>;
collapsedIds: ReadonlySet<string>;
selectedIds: ReadonlySet<string>;
query: string;
sort: TaskTreeSort;
filter: TaskTreeFilter;
dispatch: (action: TaskTreeAction) => void;
/**
* Click handler that owns selection-mode resolution (plain / cmd / shift),
* range resolution over `visibleItems`, and `onItemClick` firing. The
* default `<TaskTreeRow>` wires it; custom rows / slot consumers call it
* from their own row click target.
*/
handleRowClick: (
item: TaskItem,
level: number,
event: MouseEvent | KeyboardEvent,
) => void;
/**
* Currently focused row id — drives the row's tabindex and arrow-key
* navigation. Null until the user interacts; first visible row becomes
* tabbable by default.
*/
focusedItemId: string | null;
}
/* ---------------------------------- Slot render-prop args ---------------------------------- */
export interface TaskTreeRowRenderArgs {
item: TaskItem;
level: number;
isSelected: boolean;
isCollapsed: boolean;
isExpanded: boolean;
defaultRender: ReactNode;
}
export interface TaskTreeFieldRenderArgs {
item: TaskItem;
level: number;
}
export interface TaskTreeStatusRenderArgs extends TaskTreeFieldRenderArgs {
statusOption?: TaskStatusOption;
}
export interface TaskTreeToolbarRenderArgs {
defaultToolbar: ReactNode;
state: TaskTreeStateValue;
}
export interface TaskTreeEmptyRenderArgs {
hasFilter: boolean;
}
export interface TaskTreeDragOverlayArgs {
item: TaskItem;
level: number;
}
/* ---------------------------------- Public props ---------------------------------- */
export interface TaskTreeProps {
// Data
defaultValue?: TaskItem[];
value?: TaskItem[];
onChange?: (args: TaskTreeChangeArgs) => void;
/** Lifted state from useTaskTreeState(); supersedes value/defaultValue when provided. */
state?: TaskTreeStateValue;
// Status enum
statusOptions?: TaskStatusOption[];
/** Priority enum — threaded to the edit surface (TaskTreeWithEditor's card). */
priorityOptions?: TaskPriorityOption[];
// Permissions
permissions?: TaskPermissions;
onPermissionDenied?: (args: TaskTreePermissionDeniedEvent) => void;
// Behavior
readOnly?: boolean;
/**
* Signals that the tree has an editing affordance (an edit panel,
* inline editor, or similar consumer-provided surface). Default
* `false`. When false, the default toolbar's "+ New" button and the
* bulk action bar's "Delete" button are hidden — without an editor a
* newly-added row is just an unrenameable "Untitled" stub and a delete
* affordance is pointing at a row the user never got to interact with.
* `<TaskTreeWithEditor>` sets this to `true` automatically. Consumers
* wiring their own editor (via `onItemClick` opening a dialog, an
* inline rename, etc.) should set `editable` themselves.
*/
editable?: boolean;
/**
* Factory for the default toolbar's "+ New" button. When provided, the
* toolbar renders the button and calls this on click; the returned item
* is appended at root via `state.addItem`. When absent, the toolbar
* falls back to a built-in factory IF `statusOptions[0]` is available
* (needed to satisfy the required `status` field). When neither path
* yields a factory (no `createItem` + no `statusOptions`), the button
* is hidden. Also hidden when `readOnly`, `editable === false`, or
* `toolbar` isn't the built-in default. Consumers wanting fancier
* behaviour (add-as-child, add-as-sibling, custom inline editor) should
* replace the toolbar via `renderToolbar` and call `state.addItem` /
* `state.addChild` themselves.
*/
createItem?: () => TaskItem;
/**
* Intercepts the default toolbar's "+ New" button click. When provided,
* the toolbar calls this with a freshly-built item (via `createItem` or
* the built-in fallback) and DOES NOT add it to the tree itself —
* commit is the consumer's responsibility (call `state.addItem` /
* `handle.addItem` when ready). Used by `<TaskTreeWithEditor>` to open
* the edit dialog on a pending item and commit only after the user
* submits, so a new row never appears in the tree until its values are
* filled in. When omitted, the toolbar adds the item synchronously and
* focuses it (current behaviour).
*/
onCreateRequest?: (item: TaskItem) => void;
defaultCollapsedIds?: ReadonlyArray<string>;
defaultSelectedIds?: ReadonlyArray<string>;
/** Pixels per nesting level (default 20). */
indentSize?: number;
/** Default "fade" (non-matching rows shown dimmed); "hide" omits non-matching + non-ancestor rows. */
filterMode?: "fade" | "hide";
/** Default "dot"; "strip" renders a left-edge color strip; "none" hides the indicator. */
statusIndicator?: "dot" | "strip" | "none";
/** Default auto-enable when total tree size ≥ 200 rows. */
virtualize?: boolean | { threshold?: number };
/** Default "default" mounts the built-in toolbar; "none" hides; ReactNode replaces. */
toolbar?: "default" | "none" | ReactNode;
/**
* "internal" (default) mounts a <DndContext> wrapping the tree.
* "external" skips internal mounting — use when the consumer already
* provides a <DndContext> higher up (e.g., nested inside kanban-board).
* Note: when external, the consumer is responsible for matching the
* MouseSensor (distance:5) + TouchSensor (delay:300) sensor config or
* accepting whatever activation rules their outer context provides.
*/
dndContext?: "internal" | "external";
// Slot props (priority rule: slot wins over prop variant)
renderRow?: (args: TaskTreeRowRenderArgs) => ReactNode;
renderName?: (args: TaskTreeFieldRenderArgs) => ReactNode;
renderDescription?: (args: TaskTreeFieldRenderArgs) => ReactNode;
renderPerson?: (args: TaskTreeFieldRenderArgs) => ReactNode;
renderStatusIndicator?: (args: TaskTreeStatusRenderArgs) => ReactNode;
renderToolbar?: (args: TaskTreeToolbarRenderArgs) => ReactNode;
renderEmptyState?: (args: TaskTreeEmptyRenderArgs) => ReactNode;
renderDragOverlay?: (args: TaskTreeDragOverlayArgs) => ReactNode;
// Events
onItemClick?: (args: TaskTreeItemEvent) => void;
onItemContextMenu?: (args: TaskTreeItemEvent) => void;
onActiveToggled?: (args: { item: TaskItem; nextActive: boolean }) => void;
onCollapseToggled?: (args: { item: TaskItem; collapsed: boolean }) => void;
onItemMoved?: (args: TaskTreeMoveEvent) => void;
onItemDropped?: (args: TaskTreeDropEvent) => void;
onItemAdded?: (args: TaskTreeAddEvent) => void;
onItemRemoved?: (args: TaskTreeRemoveEvent) => void;
onBulkToggleActive?: (args: {
ids: ReadonlyArray<string>;
nextActive: boolean;
}) => void;
onBulkRemove?: (args: { ids: ReadonlyArray<string> }) => void;
onBulkEdit?: (args: { ids: ReadonlyArray<string> }) => void;
onSelectionChanged?: (args: { selectedIds: ReadonlySet<string> }) => void;
onSearchChanged?: (args: { query: string }) => void;
onSortChanged?: (args: { sort: TaskTreeSort }) => void;
onFilterChanged?: (args: { filter: TaskTreeFilter }) => void;
// Standard
className?: string;
"aria-label"?: string;
}
src/registry/components/data/task-tree/lib/reducer.ts
import type { TaskItem } from "../../task-card/types";
import type {
TaskTreeAction,
TaskTreeChangeReason,
TaskTreeFilter,
TaskTreeSort,
} from "../types";
import {
insertAt,
moveItem,
removeById,
updateById,
} from "./tree-mutators";
import { forEachItem } from "./tree-walker";
/**
* Internal reducer state. Not exported from the package barrel — only
* `useTaskTreeState` (C3) consumes it. Mutations are reference-equal on
* no-ops so downstream selectors can memoize off identity.
*/
export interface State {
items: TaskItem[];
collapsedIds: ReadonlySet<string>;
selectedIds: ReadonlySet<string>;
selectionAnchorId: string | null;
query: string;
sort: TaskTreeSort;
filter: TaskTreeFilter;
focusedItemId: string | null;
/**
* Set by the reducer on every items-changing action; consumed by the
* hook's items-change effect to route the correct reason to onChange.
* Lives in state (vs an external ref) so raw-dispatch consumers route
* correctly too. Non-items actions leave it untouched.
*/
lastChangeReason: TaskTreeChangeReason | null;
}
export interface CreateInitialStateInput {
items: TaskItem[];
defaultCollapsedIds?: ReadonlyArray<string>;
defaultSelectedIds?: ReadonlyArray<string>;
defaultSort?: TaskTreeSort;
defaultFilter?: TaskTreeFilter;
}
const DEFAULT_SORT: TaskTreeSort = { kind: "name", direction: "asc" };
const DEFAULT_FILTER: TaskTreeFilter = {
statuses: [],
personIds: [],
active: "all",
};
export function createInitialState(input: CreateInitialStateInput): State {
return {
items: input.items,
collapsedIds: new Set(input.defaultCollapsedIds ?? []),
selectedIds: new Set(input.defaultSelectedIds ?? []),
selectionAnchorId: null,
query: "",
sort: input.defaultSort ?? DEFAULT_SORT,
filter: input.defaultFilter ?? DEFAULT_FILTER,
focusedItemId: null,
lastChangeReason: null,
};
}
/**
* Derive the public change reason from any items-changing action. Returns
* null for non-items actions so the hook's effect can skip firing onChange.
*/
function reasonFromAction(
action: TaskTreeAction,
): TaskTreeChangeReason | null {
switch (action.type) {
case "SET_ITEMS":
return action.reason;
case "ADD_ITEM":
return action.via === "drop-from-external"
? "drop-from-external"
: "add-item";
case "REMOVE_ITEM":
return "remove-item";
case "REMOVE_ITEMS":
return "bulk-remove";
case "ADD_CHILD":
return "add-child";
case "MOVE_ITEM":
return action.reason;
case "TOGGLE_ACTIVE":
return "toggle-active";
case "TOGGLE_ACTIVE_BULK":
return "bulk-toggle-active";
default:
return null;
}
}
export function reducer(state: State, action: TaskTreeAction): State {
const next = reducerInner(state, action);
if (next === state) return state;
if (next.items !== state.items) {
const reason = reasonFromAction(action);
return { ...next, lastChangeReason: reason };
}
return next;
}
function reducerInner(state: State, action: TaskTreeAction): State {
switch (action.type) {
case "SET_ITEMS": {
if (action.items === state.items) return state;
return pruneOrphanedIds({ ...state, items: action.items });
}
case "ADD_ITEM": {
const items = insertAt(
state.items,
action.parentId,
action.index,
action.item,
);
if (items === state.items) return state;
return { ...state, items };
}
case "REMOVE_ITEM": {
const items = removeById(state.items, action.id);
if (items === state.items) return state;
return pruneOrphanedIds({ ...state, items });
}
case "REMOVE_ITEMS": {
if (action.ids.length === 0) return state;
let items = state.items;
for (const id of action.ids) {
items = removeById(items, id);
}
if (items === state.items) return state;
return pruneOrphanedIds({ ...state, items });
}
case "ADD_CHILD": {
const items = insertAt(
state.items,
action.parentId,
action.index,
action.item,
);
if (items === state.items) return state;
const collapsedIds = withoutId(state.collapsedIds, action.parentId);
return { ...state, items, collapsedIds };
}
case "MOVE_ITEM": {
const items = moveItem(state.items, action.itemId, action.to);
if (items === state.items) return state;
const collapsedIds =
action.to.parentId !== null
? withoutId(state.collapsedIds, action.to.parentId)
: state.collapsedIds;
return { ...state, items, collapsedIds };
}
case "TOGGLE_ACTIVE": {
const items = updateById(state.items, action.id, (item) =>
item.active === action.nextActive
? item
: { ...item, active: action.nextActive },
);
if (items === state.items) return state;
return { ...state, items };
}
case "TOGGLE_ACTIVE_BULK": {
if (action.ids.length === 0) return state;
const target = new Set(action.ids);
let items = state.items;
for (const id of target) {
items = updateById(items, id, (item) =>
item.active === action.nextActive
? item
: { ...item, active: action.nextActive },
);
}
if (items === state.items) return state;
return { ...state, items };
}
case "TOGGLE_COLLAPSE": {
const next = new Set(state.collapsedIds);
if (next.has(action.id)) next.delete(action.id);
else next.add(action.id);
return { ...state, collapsedIds: next };
}
case "EXPAND_ALL": {
if (state.collapsedIds.size === 0) return state;
return { ...state, collapsedIds: new Set<string>() };
}
case "COLLAPSE_ALL": {
const next = new Set<string>();
forEachItem(state.items, (item) => {
if (item.children && item.children.length > 0) next.add(item.id);
});
if (sameSet(next, state.collapsedIds)) return state;
return { ...state, collapsedIds: next };
}
case "SET_COLLAPSED": {
const next = new Set(action.ids);
if (sameSet(next, state.collapsedIds)) return state;
return { ...state, collapsedIds: next };
}
case "SELECT_ONE": {
const { id, mode } = action;
if (mode === "replace") {
if (state.selectedIds.size === 1 && state.selectedIds.has(id)) {
if (state.selectionAnchorId === id) return state;
return { ...state, selectionAnchorId: id };
}
return {
...state,
selectedIds: new Set([id]),
selectionAnchorId: id,
};
}
if (mode === "toggle") {
const next = new Set(state.selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
return { ...state, selectedIds: next, selectionAnchorId: id };
}
// mode === "range" — DFS over the (unfiltered, uncollapsed) tree.
// Visibility-aware range select is the hook's job: the hook computes the
// visible-row id list using the consumer's filterMode and dispatches
// SELECT_REPLACE with the resolved ids. This branch is the fallback for
// raw `dispatch(SELECT_ONE/range)` users and works correctly when the
// filter is inactive and nothing is collapsed.
const anchor = state.selectionAnchorId;
const rangeIds = computeUnfilteredRangeIds(state.items, anchor, id);
if (rangeIds.length === 0) return state;
const next = new Set(state.selectedIds);
for (const r of rangeIds) next.add(r);
return { ...state, selectedIds: next };
}
case "SELECT_ALL": {
// Unfiltered: select every id in the tree. Visibility-aware select-all
// is the hook's job; it uses SELECT_REPLACE with the visible-id list.
const next = new Set<string>();
forEachItem(state.items, (item) => {
next.add(item.id);
});
if (sameSet(next, state.selectedIds)) return state;
return { ...state, selectedIds: next };
}
case "SELECT_REPLACE": {
const next = new Set(action.ids);
const anchor =
action.anchorId !== undefined
? action.anchorId
: state.selectionAnchorId;
if (
sameSet(next, state.selectedIds) &&
anchor === state.selectionAnchorId
) {
return state;
}
return { ...state, selectedIds: next, selectionAnchorId: anchor };
}
case "CLEAR_SELECTION": {
if (state.selectedIds.size === 0 && state.selectionAnchorId === null) {
return state;
}
return {
...state,
selectedIds: new Set<string>(),
selectionAnchorId: null,
};
}
case "SET_FOCUS": {
if (state.focusedItemId === action.id) return state;
return { ...state, focusedItemId: action.id };
}
case "SET_QUERY": {
if (state.query === action.query) return state;
return { ...state, query: action.query };
}
case "SET_SORT": {
if (sortsEqual(state.sort, action.sort)) return state;
return { ...state, sort: action.sort };
}
case "SET_FILTER": {
if (filtersEqual(state.filter, action.filter)) return state;
return { ...state, filter: action.filter };
}
case "CLEAR_FILTERS": {
if (
state.query === "" &&
filtersEqual(state.filter, DEFAULT_FILTER)
) {
return state;
}
return { ...state, query: "", filter: DEFAULT_FILTER };
}
}
}
/**
* After a tree mutation, drop dangling ids from collapsedIds + selectedIds +
* the selection anchor. Cheap (DFS over remaining items) and keeps Q7's "drop
* silently" lock honored for selection.
*/
function pruneOrphanedIds(state: State): State {
const live = new Set<string>();
forEachItem(state.items, (item) => {
live.add(item.id);
});
let collapsedIds = state.collapsedIds;
let selectedIds = state.selectedIds;
let anchor = state.selectionAnchorId;
let focus = state.focusedItemId;
if (hasOrphan(collapsedIds, live)) {
const next = new Set<string>();
for (const id of collapsedIds) if (live.has(id)) next.add(id);
collapsedIds = next;
}
if (hasOrphan(selectedIds, live)) {
const next = new Set<string>();
for (const id of selectedIds) if (live.has(id)) next.add(id);
selectedIds = next;
}
if (anchor !== null && !live.has(anchor)) anchor = null;
if (focus !== null && !live.has(focus)) focus = null;
if (
collapsedIds === state.collapsedIds &&
selectedIds === state.selectedIds &&
anchor === state.selectionAnchorId &&
focus === state.focusedItemId
) {
return state;
}
return {
...state,
collapsedIds,
selectedIds,
selectionAnchorId: anchor,
focusedItemId: focus,
};
}
function hasOrphan(ids: ReadonlySet<string>, live: ReadonlySet<string>): boolean {
for (const id of ids) if (!live.has(id)) return true;
return false;
}
function withoutId(
ids: ReadonlySet<string>,
id: string,
): ReadonlySet<string> {
if (!ids.has(id)) return ids;
const next = new Set(ids);
next.delete(id);
return next;
}
function sameSet<T>(a: ReadonlySet<T>, b: ReadonlySet<T>): boolean {
if (a === b) return true;
if (a.size !== b.size) return false;
for (const v of a) if (!b.has(v)) return false;
return true;
}
function sortsEqual(a: TaskTreeSort, b: TaskTreeSort): boolean {
if (a === b) return true;
if (a.kind !== b.kind) return false;
if (a.kind === "custom" && b.kind === "custom") return a.compare === b.compare;
if (a.kind !== "custom" && b.kind !== "custom") {
return a.direction === b.direction;
}
return false;
}
function filtersEqual(a: TaskTreeFilter, b: TaskTreeFilter): boolean {
if (a === b) return true;
if ((a.active ?? "all") !== (b.active ?? "all")) return false;
if (!arrEq(a.statuses ?? [], b.statuses ?? [])) return false;
if (!arrEq(a.personIds ?? [], b.personIds ?? [])) return false;
return true;
}
function arrEq<T>(a: ReadonlyArray<T>, b: ReadonlyArray<T>): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
/**
* Range select fallback over the UNFILTERED tree in DFS order. The hook
* supersedes this for visibility-aware ranges by dispatching SELECT_REPLACE
* with pre-resolved ids; this only fires for raw `dispatch(SELECT_ONE/range)`.
*/
function computeUnfilteredRangeIds(
items: ReadonlyArray<TaskItem>,
anchor: string | null,
target: string,
): string[] {
const order: string[] = [];
forEachItem(items, (item) => {
order.push(item.id);
});
const targetIdx = order.indexOf(target);
if (targetIdx === -1) return [];
const anchorIdx = anchor === null ? 0 : order.indexOf(anchor);
if (anchorIdx === -1) return [target];
const [lo, hi] =
anchorIdx <= targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx];
return order.slice(lo, hi + 1);
}
src/registry/components/data/task-tree/lib/tree-walker.ts
import type { TaskItem } from "../../task-card/types";
/** Depth-first search for an item by id. Returns undefined when not found. */
export function findItemById(
items: ReadonlyArray<TaskItem>,
id: string,
): TaskItem | undefined {
for (const item of items) {
if (item.id === id) return item;
if (item.children && item.children.length > 0) {
const hit = findItemById(item.children, id);
if (hit) return hit;
}
}
return undefined;
}
/**
* Find the parent id of `id`. Returns null when `id` is a top-level item OR
* when `id` is not in the tree. Use `findItemById` first if you need to
* disambiguate those two cases.
*/
export function findParentId(
items: ReadonlyArray<TaskItem>,
id: string,
): string | null {
for (const item of items) {
if (item.children) {
for (const child of item.children) {
if (child.id === id) return item.id;
}
const deeper = findParentId(item.children, id);
if (deeper !== null) return deeper;
}
}
return null;
}
/**
* Return the ancestor chain (root-first) leading to `id`, excluding `id`
* itself. Empty when `id` is top-level or missing.
*/
export function findAncestors(
items: ReadonlyArray<TaskItem>,
id: string,
): TaskItem[] {
const path: TaskItem[] = [];
if (walkPath(items, id, path)) return path;
return [];
}
function walkPath(
items: ReadonlyArray<TaskItem>,
id: string,
acc: TaskItem[],
): boolean {
for (const item of items) {
if (item.id === id) return true;
if (item.children && item.children.length > 0) {
acc.push(item);
if (walkPath(item.children, id, acc)) return true;
acc.pop();
}
}
return false;
}
/** Sibling index of `id` under `parentId`. Returns -1 when not found. */
export function findIndexUnder(
items: ReadonlyArray<TaskItem>,
parentId: string | null,
id: string,
): number {
if (parentId === null) {
return items.findIndex((i) => i.id === id);
}
const parent = findItemById(items, parentId);
if (!parent || !parent.children) return -1;
return parent.children.findIndex((i) => i.id === id);
}
/**
* Locate an item AND its depth (0-based, matching the visible-row `level`
* convention). Returns null when `id` is not in the tree. Used by the
* clipboard paths to evaluate level-keyed permission rules.
*/
export function findItemWithLevel(
items: ReadonlyArray<TaskItem>,
id: string,
): { item: TaskItem; level: number } | null {
let out: { item: TaskItem; level: number } | null = null;
forEachItem(items, (item, level) => {
if (item.id === id) {
out = { item, level };
return false;
}
return true;
});
return out;
}
/**
* Prune ids whose ANCESTOR is also in the set. A copy/cut of a parent already
* carries its whole subtree, so serializing a selected descendant a second
* time would duplicate it on paste.
*/
export function pruneNestedIds(
items: ReadonlyArray<TaskItem>,
ids: ReadonlyArray<string>,
): string[] {
if (ids.length <= 1) return [...ids];
const idSet = new Set(ids);
return ids.filter(
(id) => !findAncestors(items, id).some((a) => idSet.has(a.id)),
);
}
/** Visit every item depth-first. Stops when visitor returns false. */
export function forEachItem(
items: ReadonlyArray<TaskItem>,
visit: (item: TaskItem, level: number, parentId: string | null) => boolean | void,
level = 0,
parentId: string | null = null,
): boolean {
for (const item of items) {
if (visit(item, level, parentId) === false) return false;
if (item.children && item.children.length > 0) {
if (!forEachItem(item.children, visit, level + 1, item.id)) return false;
}
}
return true;
}
src/registry/components/data/task-tree/lib/tree-mutators.ts
import type { TaskItem } from "../../task-card/types";
import type { TreeLocation } from "../types";
import { findItemById, findParentId } from "./tree-walker";
import { isAncestor } from "./circular-drop";
/**
* Insert `item` as a child of `parentId` at `index`. parentId=null inserts at
* the top level. index=undefined appends. Returns a new top-level array; the
* spine from root to mutation point is copied; untouched subtrees are reused.
*/
export function insertAt(
items: ReadonlyArray<TaskItem>,
parentId: string | null,
index: number | undefined,
item: TaskItem,
): TaskItem[] {
if (parentId === null) {
const next = items.slice();
const at = clampIndex(index, next.length);
next.splice(at, 0, item);
return next;
}
let mutated = false;
const next = items.map((node) => {
if (node.id === parentId) {
mutated = true;
const children = node.children ? node.children.slice() : [];
const at = clampIndex(index, children.length);
children.splice(at, 0, item);
return { ...node, children };
}
if (node.children && node.children.length > 0) {
const childUpdated = insertAt(node.children, parentId, index, item);
if (childUpdated !== node.children) {
mutated = true;
return { ...node, children: childUpdated };
}
}
return node;
});
return mutated ? next : (items as TaskItem[]);
}
/**
* Remove the item with `id` and return the new top-level array. Returns the
* input by reference when the id is not in the tree.
*/
export function removeById(
items: ReadonlyArray<TaskItem>,
id: string,
): TaskItem[] {
let mutated = false;
const next = items
.map((node) => {
if (node.id === id) {
mutated = true;
return null;
}
if (node.children && node.children.length > 0) {
const childUpdated = removeById(node.children, id);
if (childUpdated !== node.children) {
mutated = true;
return { ...node, children: childUpdated };
}
}
return node;
})
.filter((n): n is TaskItem => n !== null);
return mutated ? next : (items as TaskItem[]);
}
/**
* Apply an updater to the item with `id`. The updater receives the current
* item and returns the next one (it may return the same reference to signal
* no-op). Returns the input by reference when nothing changed.
*/
export function updateById(
items: ReadonlyArray<TaskItem>,
id: string,
updater: (item: TaskItem) => TaskItem,
): TaskItem[] {
let mutated = false;
const next = items.map((node) => {
if (node.id === id) {
const replaced = updater(node);
if (replaced !== node) mutated = true;
return replaced;
}
if (node.children && node.children.length > 0) {
const childUpdated = updateById(node.children, id, updater);
if (childUpdated !== node.children) {
mutated = true;
return { ...node, children: childUpdated };
}
}
return node;
});
return mutated ? next : (items as TaskItem[]);
}
/**
* Atomic remove + insert. Bans circular drops (target inside source's subtree)
* by returning the input unchanged. Also a no-op when the source can't be
* found. Auto-expands behavior is handled in the reducer (collapsedIds set),
* not here — this stays a pure tree transform.
*/
export function moveItem(
items: ReadonlyArray<TaskItem>,
sourceId: string,
to: TreeLocation,
): TaskItem[] {
const source = findItemById(items, sourceId);
if (!source) return items as TaskItem[];
if (to.parentId && isAncestor(items, sourceId, to.parentId)) {
return items as TaskItem[];
}
const oldParentId = findParentId(items, sourceId);
const sameParent = oldParentId === to.parentId;
// Compute the destination index BEFORE removal when the source and target
// share a parent — the removal shifts later siblings up by one.
let targetIndex = to.index;
if (sameParent) {
const oldIndex = siblingIndex(items, oldParentId, sourceId);
if (oldIndex !== -1 && oldIndex < targetIndex) {
targetIndex = targetIndex - 1;
}
}
const withoutSource = removeById(items, sourceId);
return insertAt(withoutSource, to.parentId, targetIndex, source);
}
function siblingIndex(
items: ReadonlyArray<TaskItem>,
parentId: string | null,
id: string,
): number {
if (parentId === null) {
return items.findIndex((i) => i.id === id);
}
const parent = findItemById(items, parentId);
if (!parent || !parent.children) return -1;
return parent.children.findIndex((i) => i.id === id);
}
function clampIndex(index: number | undefined, len: number): number {
if (index === undefined) return len;
if (index < 0) return 0;
if (index > len) return len;
return index;
}
src/registry/components/data/task-tree/lib/filter-items.ts
import type { TaskItem } from "../../task-card/types";
import type { TaskTreeFilter } from "../types";
import { forEachItem } from "./tree-walker";
import { itemMatchesQuery } from "./search-items";
/**
* Build a match-flag map: `true` when the item matches BOTH the filter spec
* AND the search query, after normalization. The flatten pass uses this to
* decide what to dim (fade mode) or hide (hide mode, with ancestor-of-match
* still rendered).
*
* Filter spec interpretation:
* - statuses: empty array OR undefined ⇒ all statuses pass
* - personIds: empty array OR undefined ⇒ all persons pass; matches against
* both targetPerson.id AND creatorPerson.id (either hits qualifies)
* - active: "all" (default) | "active" | "inactive"
*/
export function buildMatchMap(
items: ReadonlyArray<TaskItem>,
filter: TaskTreeFilter,
query: string,
): ReadonlyMap<string, boolean> {
const map = new Map<string, boolean>();
const statuses = filter.statuses;
const personIds = filter.personIds;
const active = filter.active ?? "all";
const hasStatusFilter = statuses && statuses.length > 0;
const hasPersonFilter = personIds && personIds.length > 0;
const trimmed = query.trim();
const hasQuery = trimmed.length > 0;
forEachItem(items, (item) => {
let pass = true;
if (hasStatusFilter && !statuses.includes(item.status)) pass = false;
if (pass && hasPersonFilter) {
const targetId = item.targetPerson?.id;
const creatorId = item.creatorPerson?.id;
const ok =
(targetId !== undefined && personIds.includes(targetId)) ||
(creatorId !== undefined && personIds.includes(creatorId));
if (!ok) pass = false;
}
if (pass && active === "active" && !item.active) pass = false;
if (pass && active === "inactive" && item.active) pass = false;
if (pass && hasQuery && !itemMatchesQuery(item, trimmed)) pass = false;
map.set(item.id, pass);
});
return map;
}
/**
* Build the ancestor-of-match set used by `filterMode: "hide"` to ensure that
* even non-matching ancestors render so the matched descendants stay visible
* in tree context (VSCode-style — Q6 lock).
*/
export function buildAncestorOfMatchSet(
items: ReadonlyArray<TaskItem>,
matchMap: ReadonlyMap<string, boolean>,
): ReadonlySet<string> {
const out = new Set<string>();
function walk(nodes: ReadonlyArray<TaskItem>): boolean {
let any = false;
for (const node of nodes) {
const selfMatch = matchMap.get(node.id) === true;
let descendantMatch = false;
if (node.children && node.children.length > 0) {
descendantMatch = walk(node.children);
}
if (descendantMatch && !selfMatch) {
out.add(node.id);
}
if (selfMatch || descendantMatch) any = true;
}
return any;
}
walk(items);
return out;
}
/**
* Convenience: true when ANY entry in the filter spec or query is non-empty.
* Used by the empty state ("no results for filter X") and toolbar
* clear-filters affordance gating.
*/
export function isFilterActive(filter: TaskTreeFilter, query: string): boolean {
if (query.trim().length > 0) return true;
if (filter.statuses && filter.statuses.length > 0) return true;
if (filter.personIds && filter.personIds.length > 0) return true;
if (filter.active && filter.active !== "all") return true;
return false;
}
src/registry/components/data/task-tree/lib/search-items.ts
import type { TaskItem } from "../../task-card/types";
/**
* Plain case-insensitive `.includes()` over `name` + `description` per
* Q5 lock. Empty/whitespace-only queries match everything (returns true for
* every item; callers don't need to special-case).
*/
export function itemMatchesQuery(item: TaskItem, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
if (item.name.toLowerCase().includes(q)) return true;
if (item.description && item.description.toLowerCase().includes(q)) return true;
return false;
}
src/registry/components/data/task-tree/lib/sort-items.ts
import type { TaskItem } from "../../task-card/types";
import type { TaskTreeSort } from "../types";
/**
* Apply `sort` to the tree recursively. Children sort independently under
* their parent. Pure: returns a new top-level array; subtrees with empty or
* single-element children are returned by reference when stable. Sibling
* order is stable (Array.prototype.sort is stable as of ES2019).
*/
export function applySort(
items: ReadonlyArray<TaskItem>,
sort: TaskTreeSort,
): TaskItem[] {
const compare = makeComparator(sort);
return sortRecursive(items, compare);
}
function sortRecursive(
items: ReadonlyArray<TaskItem>,
compare: (a: TaskItem, b: TaskItem) => number,
): TaskItem[] {
const next = items
.map((item) => {
if (item.children && item.children.length > 0) {
const sortedChildren = sortRecursive(item.children, compare);
if (sortedChildren !== item.children) {
return { ...item, children: sortedChildren };
}
}
return item;
})
.slice();
next.sort(compare);
return next;
}
function makeComparator(
sort: TaskTreeSort,
): (a: TaskItem, b: TaskItem) => number {
if (sort.kind === "custom") return sort.compare;
const dir = sort.direction === "desc" ? -1 : 1;
switch (sort.kind) {
case "name":
return (a, b) => dir * a.name.localeCompare(b.name);
case "setAt":
return (a, b) => dir * compareIsoStrings(a.setAt, b.setAt);
case "expireAt":
return (a, b) => dir * compareIsoStrings(a.expireAt, b.expireAt);
case "status":
return (a, b) => dir * a.status.localeCompare(b.status);
}
}
function compareIsoStrings(a: string | undefined, b: string | undefined): number {
// Items without the timestamp sort last regardless of direction so the
// direction flip only affects rows that actually have the field.
if (a === undefined && b === undefined) return 0;
if (a === undefined) return 1;
if (b === undefined) return -1;
if (a < b) return -1;
if (a > b) return 1;
return 0;
}
src/registry/components/data/task-tree/lib/flatten-tree.ts
import type { TaskItem } from "../../task-card/types";
import type { TaskTreeVisibleRow } from "../types";
/**
* Renderer-ready row variant. The optional `dimmed` flag is set by the
* filter-fade path; the default renderer applies opacity-30 when true.
*/
export interface FlattenedRow extends TaskTreeVisibleRow {
dimmed?: boolean;
}
export interface FlattenOptions {
collapsedIds: ReadonlySet<string>;
/** "fade" emits all rows with dimmed flag; "hide" omits non-matching + non-ancestor rows. */
mode: "fade" | "hide";
/** When mode === "hide" OR mode === "fade", drives the dim / hide decision. */
matchMap?: ReadonlyMap<string, boolean>;
/** When mode === "hide", ancestors-of-match still render. */
ancestorOfMatch?: ReadonlySet<string>;
}
/**
* DFS flatten respecting `collapsedIds`. Emits one row per visible item.
*
* When a row is collapsed, its descendants are skipped entirely (no rows
* emitted for them) regardless of mode.
*
* Mode "fade":
* - every non-collapsed row emits
* - matchMap === false ⇒ row.dimmed = true
*
* Mode "hide":
* - rows where matchMap === true emit normally
* - rows in ancestorOfMatch emit normally (so context survives)
* - everything else is skipped
*
* When no matchMap is supplied (filter inactive), every non-collapsed row
* emits without the dimmed flag in either mode.
*/
export function flattenTree(
items: ReadonlyArray<TaskItem>,
options: FlattenOptions,
): FlattenedRow[] {
const out: FlattenedRow[] = [];
walk(items, 0, null, options, out);
return out;
}
function walk(
items: ReadonlyArray<TaskItem>,
level: number,
parentId: string | null,
options: FlattenOptions,
out: FlattenedRow[],
): void {
const { collapsedIds, mode, matchMap, ancestorOfMatch } = options;
for (let i = 0; i < items.length; i++) {
const item = items[i];
const matches = matchMap ? matchMap.get(item.id) === true : true;
const isAncestor = ancestorOfMatch ? ancestorOfMatch.has(item.id) : false;
let emit = true;
let dimmed = false;
if (matchMap) {
if (mode === "hide") {
emit = matches || isAncestor;
} else {
// mode === "fade"
emit = true;
dimmed = !matches;
}
}
if (emit) {
out.push(
dimmed
? { item, level, parentId, index: i, dimmed: true }
: { item, level, parentId, index: i },
);
}
const hasChildren = item.children && item.children.length > 0;
const collapsed = collapsedIds.has(item.id);
if (hasChildren && !collapsed) {
walk(item.children!, level + 1, item.id, options, out);
}
}
}
src/registry/components/data/task-tree/lib/visible-items.ts
import type { TaskItem } from "../../task-card/types";
import type { TaskTreeFilter, TaskTreeSort } from "../types";
import {
buildAncestorOfMatchSet,
buildMatchMap,
isFilterActive,
} from "./filter-items";
import { applySort } from "./sort-items";
import { flattenTree, type FlattenedRow } from "./flatten-tree";
export interface VisibleItemsInput {
items: ReadonlyArray<TaskItem>;
query: string;
sort: TaskTreeSort;
filter: TaskTreeFilter;
collapsedIds: ReadonlySet<string>;
/** Default "fade" per description doc; "hide" omits non-matching + non-ancestor rows. */
filterMode: "fade" | "hide";
}
/**
* Top-level pipeline orchestrator: items → filter → sort → flatten →
* TaskTreeVisibleRow[] (with optional `dimmed` flag in fade mode).
*
* Order locked at Q-P5 (filter then sort then flatten). Memoize-friendly:
* caller passes stable refs and the pipeline returns a new array only when
* inputs actually change.
*/
export function computeVisibleItems(input: VisibleItemsInput): FlattenedRow[] {
const { items, query, sort, filter, collapsedIds, filterMode } = input;
const filterOn = isFilterActive(filter, query);
const matchMap = filterOn ? buildMatchMap(items, filter, query) : undefined;
const ancestorSet =
filterOn && filterMode === "hide" && matchMap
? buildAncestorOfMatchSet(items, matchMap)
: undefined;
const sorted = applySort(items, sort);
return flattenTree(sorted, {
collapsedIds,
mode: filterMode,
matchMap,
ancestorOfMatch: ancestorSet,
});
}
src/registry/components/data/task-tree/lib/circular-drop.ts
import type { TaskItem } from "../../task-card/types";
import { findItemById } from "./tree-walker";
/**
* True when `targetId` lies within the subtree rooted at `sourceId` —
* i.e., reparenting `sourceId` under `targetId` would form a cycle.
* Also true when source === target (item dropped into itself).
*/
export function isAncestor(
items: ReadonlyArray<TaskItem>,
sourceId: string,
targetId: string,
): boolean {
if (sourceId === targetId) return true;
const source = findItemById(items, sourceId);
if (!source || !source.children || source.children.length === 0) return false;
return findItemById(source.children, targetId) !== undefined;
}
src/registry/components/data/task-tree/lib/dnd-payload.ts
import type { TaskItem } from "../../task-card/types";
/**
* Cross-procomp MIME — identical to task-card's TASK_CLIPBOARD_MIME so
* payloads round-trip between tree rows, rich-cards, and the clipboard.
*/
export const TASK_TREE_MIME = "application/x-ilinxa-task+json" as const;
export function serializeForDataTransfer(item: TaskItem): string {
return JSON.stringify(item);
}
/**
* Parse a payload off a DataTransfer object. Returns null when the payload is
* missing, malformed, or doesn't look like a TaskItem. Callers decide whether
* to fall back to text/plain (which carries the same JSON body in our setter).
*/
export function parseFromDataTransfer(dt: DataTransfer): TaskItem | null {
const raw =
dt.getData(TASK_TREE_MIME) || dt.getData("text/plain") || "";
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (!isTaskItemShape(parsed)) return null;
return parsed as TaskItem;
} catch {
return null;
}
}
function isTaskItemShape(value: unknown): boolean {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v.id === "string" &&
typeof v.name === "string" &&
typeof v.status === "string" &&
typeof v.active === "boolean" &&
typeof v.setAt === "string"
);
}
src/registry/components/data/task-tree/lib/permissions.ts
import type {
TaskItem,
TaskPermissionRule,
TaskPermissions,
} from "../../task-card/types";
import type { TaskTreePermissionAction } from "../types";
/**
* Map tree-specific actions onto task-card's TaskPermissionRule keys.
* The TaskPermissions prop type comes from task-card to keep the matrix
* shape consistent across sibling procomps, so the two tree-only actions
* piggy-back on the most-natural existing rule key:
* - dropAsSibling → "drag" (the source item must be draggable)
* - dropIntoChildren → "addChildren" (the target must accept new children)
*/
const ACTION_TO_RULE_KEY: Record<
TaskTreePermissionAction,
keyof TaskPermissionRule
> = {
edit: "edit",
toggleActive: "toggleActive",
drag: "drag",
dropAsSibling: "drag",
dropIntoChildren: "addChildren",
remove: "remove",
};
/**
* Evaluate the effective allow/deny for a single action against an item at
* `level`. Hard locks (`item.locked === true`) deny everything. The lookup
* order is byItem → byLevel → default, then a true fallback when nothing
* matches.
*
* `permissions.inherit` is honored by ascending the chain on undefined keys;
* when inherit is false the first match wins outright (no fallthrough).
*/
export function evalPermission(
permissions: TaskPermissions | undefined,
action: TaskTreePermissionAction,
item: TaskItem,
level: number,
): boolean {
if (item.locked) return false;
if (!permissions) return true;
const ruleKey = ACTION_TO_RULE_KEY[action];
const inherit = permissions.inherit !== false;
const byItemValue = permissions.byItem?.[item.id]?.[ruleKey];
if (byItemValue !== undefined) return byItemValue;
if (!inherit && permissions.byItem?.[item.id]) return true;
const byLevelValue = permissions.byLevel?.[level]?.[ruleKey];
if (byLevelValue !== undefined) return byLevelValue;
if (!inherit && permissions.byLevel?.[level]) return true;
const defaultValue = permissions.default?.[ruleKey];
if (defaultValue !== undefined) return defaultValue;
return true;
}
src/registry/components/data/task-tree/lib/edge-zone.ts
/**
* Compute the drop zone for a pointer over a row's bounding box.
*
* Top / bottom 8px → sibling drop indicator (reorder).
* Middle → reparent drop indicator (drop into children).
*
* Cap from description doc L6: top + bottom bands are capped at 8px so the
* "middle" zone always wins for typical 52px rows, even at coarse coordinates.
*/
export type EdgeZone = "top" | "middle" | "bottom";
export const EDGE_BAND_PX = 8;
export function computeEdgeZone(
pointerY: number,
rowTop: number,
rowHeight: number,
): EdgeZone {
const relative = pointerY - rowTop;
if (relative < EDGE_BAND_PX) return "top";
if (relative > rowHeight - EDGE_BAND_PX) return "bottom";
return "middle";
}
src/registry/components/data/task-tree/lib/shallow.ts
/**
* Shallow-equality helpers used by the controlled-mode structural resync guard
* (lands in C3 `use-controlled-mode.ts`). Kept here as a sealed-folder utility
* to avoid leaking a third-party shallow dep to consumers.
*/
export function shallowEqualArray<T>(
a: ReadonlyArray<T>,
b: ReadonlyArray<T>,
): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
export function shallowEqualSet<T>(
a: ReadonlySet<T>,
b: ReadonlySet<T>,
): boolean {
if (a === b) return true;
if (a.size !== b.size) return false;
for (const v of a) {
if (!b.has(v)) return false;
}
return true;
}
export function shallowEqualObject<T extends Record<string, unknown>>(
a: T,
b: T,
): boolean {
if (a === b) return true;
const ak = Object.keys(a);
const bk = Object.keys(b);
if (ak.length !== bk.length) return false;
for (const k of ak) {
if (a[k] !== b[k]) return false;
}
return true;
}
src/registry/components/data/task-tree/lib/default-status-options.ts
import type { TaskStatusOption } from "../../task-card/types";
/**
* Fallback status enum when the consumer passes no `statusOptions` prop.
* Mirrors the demo set in dummy-data.ts so out-of-the-box rendering is
* predictable for both demos and ad-hoc usage.
*/
export const DEFAULT_STATUS_OPTIONS: ReadonlyArray<TaskStatusOption> = [
{ value: "todo", label: "To do", variant: "outline" },
{ value: "in-progress", label: "In progress", variant: "secondary" },
{ value: "done", label: "Done", variant: "default" },
{ value: "blocked", label: "Blocked", variant: "destructive" },
];
src/registry/components/data/task-tree/hooks/use-task-tree-state.ts
"use client";
import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
} from "react";
import type { TaskItem, TaskPermissions } from "../../task-card/types";
import type {
TaskTreeChangeArgs,
TaskTreeFilter,
TaskTreeHandle,
TaskTreeSort,
TaskTreeStateValue,
TaskTreeVisibleRow,
} from "../types";
import {
createInitialState,
reducer,
type State,
} from "../lib/reducer";
import { computeVisibleItems } from "../lib/visible-items";
import { evalPermission } from "../lib/permissions";
import {
findItemById,
findItemWithLevel,
pruneNestedIds,
} from "../lib/tree-walker";
import {
copyTasksToClipboard,
readTasksFromClipboard,
reassignTaskIds,
} from "../../task-card/lib/clipboard";
import { useControlledMode } from "./use-controlled-mode";
import { useTreeEvents, type TreeEventCallbacks } from "./use-tree-events";
import { useSelection } from "./use-selection";
export interface UseTaskTreeStateArgs extends TreeEventCallbacks {
defaultValue?: TaskItem[];
value?: TaskItem[];
onChange?: (args: TaskTreeChangeArgs) => void;
defaultCollapsedIds?: ReadonlyArray<string>;
defaultSelectedIds?: ReadonlyArray<string>;
defaultSort?: TaskTreeSort;
defaultFilter?: TaskTreeFilter;
/** Default "fade"; "hide" omits non-matching + non-ancestor-of-match rows. */
filterMode?: "fade" | "hide";
/**
* Optional drag-flag ref. Forwarded to useControlledMode so defense 3
* (suppress mid-drag onChange) reads from the same ref the host's DnD
* hooks mutate. When omitted, controlled-mode allocates its own inert ref.
*/
isDraggingRef?: React.MutableRefObject<boolean>;
/**
* Declarative permission matrix (defaults to allow-all when omitted).
* Gates the imperative clipboard ops — `cutItems` on the remove rule +
* `locked`, `pasteItems` on the target's addChildren rule — mirroring the
* keyboard / DnD paths. Denials fire `onPermissionDenied`.
*/
permissions?: TaskPermissions;
}
/**
* Headless state hook. Owns the reducer + visible-item pipeline + event
* dispatch + controlled-mode wiring. Returns a TaskTreeStateValue that lifts
* cleanly into custom toolbars / virtualization layers / external state
* managers, OR can be fed back into `<TaskTree state={state} />` so the
* default tree shell drives the same engine.
*
* Visibility-aware ops (select-all, range-select via the handle) are
* resolved here against the current visibleItems snapshot using the
* consumer's filterMode, then dispatched as SELECT_REPLACE.
*/
export function useTaskTreeState(
args: UseTaskTreeStateArgs,
): TaskTreeStateValue {
const {
defaultValue,
value,
onChange,
defaultCollapsedIds,
defaultSelectedIds,
defaultSort,
defaultFilter,
filterMode = "fade",
isDraggingRef,
permissions,
...eventCallbacks
} = args;
// Initializer fires once at mount; controlled `value` wins over defaultValue.
type InitArg = {
initial: TaskItem[];
collapsed: ReadonlyArray<string> | undefined;
selected: ReadonlyArray<string> | undefined;
sort: TaskTreeSort | undefined;
filter: TaskTreeFilter | undefined;
};
const initArg: InitArg = {
initial: value ?? defaultValue ?? [],
collapsed: defaultCollapsedIds,
selected: defaultSelectedIds,
sort: defaultSort,
filter: defaultFilter,
};
const [state, dispatch] = useReducer(
reducer,
initArg,
(init: InitArg): State =>
createInitialState({
items: init.initial,
defaultCollapsedIds: init.collapsed,
defaultSelectedIds: init.selected,
defaultSort: init.sort,
defaultFilter: init.filter,
}),
);
const visibleItems = useMemo<ReadonlyArray<TaskTreeVisibleRow>>(
() =>
computeVisibleItems({
items: state.items,
query: state.query,
sort: state.sort,
filter: state.filter,
collapsedIds: state.collapsedIds,
filterMode,
}),
[
state.items,
state.query,
state.sort,
state.filter,
state.collapsedIds,
filterMode,
],
);
// Skip-onChange flag flipped before applyExternalItems so the resulting
// SET_ITEMS dispatch doesn't echo the value-prop change back to the consumer.
const skipNextOnChangeRef = useRef(false);
const applyExternalItems = useCallback(
(next: TaskItem[]) => {
skipNextOnChangeRef.current = true;
dispatch({ type: "SET_ITEMS", items: next, reason: "imperative-set" });
},
[dispatch],
);
const { fireOnChange } = useControlledMode({
value,
internalItems: state.items,
onChange,
applyExternalItems,
isDraggingRef,
});
const { fire } = useTreeEvents(eventCallbacks);
const fireRef = useRef(fire);
useEffect(() => {
fireRef.current = fire;
}, [fire]);
// Track previous state to wire selection / search / sort / filter events.
const prevSelectedIdsRef = useRef(state.selectedIds);
const prevQueryRef = useRef(state.query);
const prevSortRef = useRef(state.sort);
const prevFilterRef = useRef(state.filter);
const prevItemsRef = useRef(state.items);
const isFirstRunRef = useRef(true);
useEffect(() => {
if (isFirstRunRef.current) {
isFirstRunRef.current = false;
prevSelectedIdsRef.current = state.selectedIds;
prevQueryRef.current = state.query;
prevSortRef.current = state.sort;
prevFilterRef.current = state.filter;
prevItemsRef.current = state.items;
return;
}
if (state.selectedIds !== prevSelectedIdsRef.current) {
prevSelectedIdsRef.current = state.selectedIds;
fireRef.current("selectionChanged", { selectedIds: state.selectedIds });
}
if (state.query !== prevQueryRef.current) {
prevQueryRef.current = state.query;
fireRef.current("searchChanged", { query: state.query });
}
if (state.sort !== prevSortRef.current) {
prevSortRef.current = state.sort;
fireRef.current("sortChanged", { sort: state.sort });
}
if (state.filter !== prevFilterRef.current) {
prevFilterRef.current = state.filter;
fireRef.current("filterChanged", { filter: state.filter });
}
if (state.items !== prevItemsRef.current) {
prevItemsRef.current = state.items;
const skip = skipNextOnChangeRef.current;
const reason = state.lastChangeReason ?? "imperative-set";
skipNextOnChangeRef.current = false;
// Controlled-mode echo: items came from the value prop's sync, so the
// consumer already knows. Don't fire onChange back at them.
if (skip) return;
fireOnChange(state.items, reason);
}
// `state.lastChangeReason` is intentionally omitted — it changes lockstep
// with `state.items` and is read only in the items branch. Adding it as
// a dep would fire onChange a second time when the reducer cleared the
// reason without items having changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
state.selectedIds,
state.query,
state.sort,
state.filter,
state.items,
fireOnChange,
]);
const selection = useSelection({
visibleItems,
selectionAnchorId: state.selectionAnchorId,
selectedIds: state.selectedIds,
dispatch,
fire,
});
// Build the imperative handle (26 base methods + 3 clipboard ops).
const handle = useMemo<TaskTreeHandle>(() => {
// Resolve the operand id set: explicit ids → current selection → focused row.
const resolveTargetIds = (ids?: ReadonlyArray<string>): string[] => {
if (ids) return [...ids];
if (state.selectedIds.size > 0) return [...state.selectedIds];
return state.focusedItemId ? [state.focusedItemId] : [];
};
const collectItems = (ids: ReadonlyArray<string>): TaskItem[] =>
ids
.map((id) => findItemById(state.items, id))
.filter((it): it is TaskItem => !!it);
return {
getValue: () => state.items,
setValue: (next) => {
dispatch({ type: "SET_ITEMS", items: next, reason: "imperative-set" });
},
addItem: (item, opts) => {
const parentId = opts?.parentId ?? null;
dispatch({
type: "ADD_ITEM",
item,
parentId,
index: opts?.index,
via: "imperative",
});
fireRef.current("itemAdded", {
item,
parentId,
index: opts?.index ?? -1,
via: "imperative",
});
},
removeItem: (id) => {
const item = findItemById(state.items, id);
if (!item) return;
dispatch({ type: "REMOVE_ITEM", id, via: "imperative" });
fireRef.current("itemRemoved", { item, via: "imperative" });
},
addChild: (parentId, item, index) => {
dispatch({ type: "ADD_CHILD", parentId, item, index });
fireRef.current("itemAdded", {
item,
parentId,
index: index ?? -1,
via: "imperative",
});
},
removeItems: (ids) => {
if (ids.length === 0) return;
dispatch({ type: "REMOVE_ITEMS", ids });
fireRef.current("bulkRemove", { ids });
},
toggleActive: (id, nextActive) => {
const item = findItemById(state.items, id);
if (!item) return;
dispatch({ type: "TOGGLE_ACTIVE", id, nextActive });
fireRef.current("activeToggled", { item, nextActive });
},
toggleActiveBulk: (ids, nextActive) => {
if (ids.length === 0) return;
dispatch({ type: "TOGGLE_ACTIVE_BULK", ids, nextActive });
fireRef.current("bulkToggleActive", { ids, nextActive });
},
focusItem: (id) => {
dispatch({ type: "SET_FOCUS", id });
},
getItemById: (id) => findItemById(state.items, id),
expandItem: (id) => {
if (!state.collapsedIds.has(id)) return;
dispatch({ type: "TOGGLE_COLLAPSE", id });
const item = findItemById(state.items, id);
if (item) {
fireRef.current("collapseToggled", { item, collapsed: false });
}
},
collapseItem: (id) => {
if (state.collapsedIds.has(id)) return;
dispatch({ type: "TOGGLE_COLLAPSE", id });
const item = findItemById(state.items, id);
if (item) {
fireRef.current("collapseToggled", { item, collapsed: true });
}
},
toggleCollapse: (id) => {
const wasCollapsed = state.collapsedIds.has(id);
dispatch({ type: "TOGGLE_COLLAPSE", id });
const item = findItemById(state.items, id);
if (item) {
fireRef.current("collapseToggled", {
item,
collapsed: !wasCollapsed,
});
}
},
expandAll: () => {
dispatch({ type: "EXPAND_ALL" });
},
collapseAll: () => {
dispatch({ type: "COLLAPSE_ALL" });
},
isCollapsed: (id) => state.collapsedIds.has(id),
selectItem: (id) => {
// Idempotent ADD to selection. Toggle-mode would deselect when id is
// already selected — wrong semantics for an explicit "select".
if (state.selectedIds.has(id)) return;
const next: string[] = [];
for (const v of state.selectedIds) next.push(v);
next.push(id);
dispatch({ type: "SELECT_REPLACE", ids: next, anchorId: id });
},
deselectItem: (id) => {
if (!state.selectedIds.has(id)) return;
const next: string[] = [];
for (const v of state.selectedIds) if (v !== id) next.push(v);
dispatch({ type: "SELECT_REPLACE", ids: next });
},
selectRange: selection.selectRange,
selectAll: selection.selectAllVisible,
clearSelection: () => {
dispatch({ type: "CLEAR_SELECTION" });
},
getSelectedIds: () => state.selectedIds,
setQuery: (query) => {
dispatch({ type: "SET_QUERY", query });
},
setSort: (sort) => {
dispatch({ type: "SET_SORT", sort });
},
setFilter: (filter) => {
dispatch({ type: "SET_FILTER", filter });
},
clearAllFilters: () => {
dispatch({ type: "CLEAR_FILTERS" });
},
copyItems: (ids) => {
// Prune ids whose ancestor is also targeted — the ancestor's payload
// already carries the subtree (a duplicate would re-paste it twice).
const items = collectItems(
pruneNestedIds(state.items, resolveTargetIds(ids)),
);
if (items.length === 0) return;
copyTasksToClipboard(items, "task-tree").catch(() => {
// Clipboard unavailable / write denied — nothing to roll back.
if (process.env.NODE_ENV !== "production") {
console.warn("[task-tree] copyItems: clipboard write failed");
}
});
},
cutItems: (ids) => {
const targetIds = pruneNestedIds(state.items, resolveTargetIds(ids));
// Gate each id on the remove rule + `locked`, mirroring keyboard
// Delete: blocked ids are reported + skipped, allowed ids are cut.
const allowed: string[] = [];
for (const id of targetIds) {
const found = findItemWithLevel(state.items, id);
if (!found) continue;
if (evalPermission(permissions, "remove", found.item, found.level)) {
allowed.push(id);
} else {
fireRef.current("permissionDenied", {
action: "remove",
itemId: id,
reason:
found.item.locked === true ? "denied-by-lock" : "denied-by-rule",
});
}
}
const items = collectItems(allowed);
if (items.length === 0) return;
// Await the async clipboard write; remove ONLY on fulfillment so a
// denied/unavailable clipboard can't destroy items with nothing copied.
copyTasksToClipboard(items, "task-tree").then(
() => {
dispatch({ type: "REMOVE_ITEMS", ids: allowed });
fireRef.current("bulkRemove", { ids: allowed });
},
() => {
// Report, don't throw — the cut is aborted, items preserved.
if (process.env.NODE_ENV !== "production") {
console.warn(
"[task-tree] cutItems: clipboard write failed — cut aborted, items were NOT removed",
);
}
},
);
},
pasteItems: (parentId) => {
// Match the keyboard path: omitted → paste under the focused row (root if none).
const target = parentId ?? state.focusedItemId ?? null;
if (target) {
// Gate the graft target on its addChildren (dropIntoChildren) rule +
// `locked` — same rule the DnD drop path enforces (TT1 parity).
const found = findItemWithLevel(state.items, target);
if (
found &&
!evalPermission(permissions, "dropIntoChildren", found.item, found.level)
) {
fireRef.current("permissionDenied", {
action: "dropIntoChildren",
itemId: target,
reason:
found.item.locked === true ? "denied-by-lock" : "denied-by-rule",
});
return;
}
}
void readTasksFromClipboard().then((items) => {
if (!items || items.length === 0) return;
// Re-id every grafted subtree so a paste can't collide.
for (const raw of items) {
const fresh = reassignTaskIds(raw);
if (target) {
dispatch({ type: "ADD_CHILD", parentId: target, item: fresh });
} else {
dispatch({
type: "ADD_ITEM",
item: fresh,
parentId: null,
via: "imperative",
});
}
fireRef.current("itemAdded", {
item: fresh,
parentId: target,
index: -1,
via: "imperative",
});
}
});
},
};
}, [
state.items,
state.collapsedIds,
state.selectedIds,
state.focusedItemId,
dispatch,
selection.selectRange,
selection.selectAllVisible,
permissions,
]);
// Compose the public state value. Spread the handle so all 26 methods land
// on the result. State fields layer on top.
return useMemo<TaskTreeStateValue>(
() => ({
...handle,
items: state.items,
visibleItems,
collapsedIds: state.collapsedIds,
selectedIds: state.selectedIds,
query: state.query,
sort: state.sort,
filter: state.filter,
focusedItemId: state.focusedItemId,
dispatch,
handleRowClick: selection.handleRowClick,
}),
[
handle,
state.items,
visibleItems,
state.collapsedIds,
state.selectedIds,
state.query,
state.sort,
state.filter,
state.focusedItemId,
dispatch,
selection.handleRowClick,
],
);
}
src/registry/components/data/task-tree/hooks/use-task-tree-context.ts
"use client";
import { createContext, useContext } from "react";
import type {
DragEvent as ReactDragEvent,
MouseEvent as ReactMouseEvent,
ReactNode,
} from "react";
import type { TaskItem, TaskStatusOption } from "../../task-card/types";
import type {
TaskTreeEmptyRenderArgs,
TaskTreeFieldRenderArgs,
TaskTreeRowRenderArgs,
TaskTreeStateValue,
TaskTreeStatusRenderArgs,
} from "../types";
import type { EdgeZone } from "../lib/edge-zone";
/**
* State context — populated by the host `<TaskTree>` so parts/ can read the
* live state value (visibleItems, selectedIds, etc.) and dispatch actions
* without re-walking the reducer's effective state. Separate from the
* render-config context (slot props, indentSize, statusOptions) that lives
* alongside parts/ in C4 — keeping state pure here lets the headless
* useTaskTreeState() consumer pass a state ref to other surfaces.
*/
export const TaskTreeStateContext = createContext<TaskTreeStateValue | null>(
null,
);
/**
* Read the state context. Throws when called outside `<TaskTree>` because
* parts/ relying on this context cannot function without it — a silent null
* would mask the bug.
*/
export function useTaskTreeStateContext(): TaskTreeStateValue {
const ctx = useContext(TaskTreeStateContext);
if (ctx === null) {
throw new Error(
"useTaskTreeStateContext must be called inside <TaskTree>. " +
"If composing parts/* manually, wrap them in <TaskTreeStateContext.Provider value={state}>.",
);
}
return ctx;
}
/**
* Render-config context — display config + slot overrides shared by parts/.
* Kept separate from the state context so headless consumers can drive their
* own render layer without inheriting the default chrome's config.
*
* Interactive callbacks (onToggleActive, onRowClick, etc.) are passed as
* props from `<TaskTreeRow>` down to the leaves, NOT through this context,
* to keep the leaves trivially testable.
*/
export interface TaskTreeRenderContextValue {
/** Default "dot"; "strip" renders a left-edge color strip; "none" hides. */
statusIndicator: "dot" | "strip" | "none";
/** Memoized lookup map; empty when the consumer passes no statusOptions. */
statusOptionMap: ReadonlyMap<string, TaskStatusOption>;
/** Pixels per nesting level. */
indentSize: number;
// Slot overrides — undefined => use the default leaf paint.
renderRow?: (args: TaskTreeRowRenderArgs) => ReactNode;
renderName?: (args: TaskTreeFieldRenderArgs) => ReactNode;
renderDescription?: (args: TaskTreeFieldRenderArgs) => ReactNode;
renderPerson?: (args: TaskTreeFieldRenderArgs) => ReactNode;
renderStatusIndicator?: (args: TaskTreeStatusRenderArgs) => ReactNode;
renderEmptyState?: (args: TaskTreeEmptyRenderArgs) => ReactNode;
}
export const TaskTreeRenderContext =
createContext<TaskTreeRenderContextValue | null>(null);
export function useTaskTreeRenderContext(): TaskTreeRenderContextValue {
const ctx = useContext(TaskTreeRenderContext);
if (ctx === null) {
throw new Error(
"useTaskTreeRenderContext must be called inside <TaskTree>. " +
"If composing parts/* manually, wrap them in <TaskTreeRenderContext.Provider value={config}>.",
);
}
return ctx;
}
/**
* DnD context — drag-time state surfaced to rows so they can decorate
* themselves (drop indicator visual + dragging-source dimming) without
* needing to subscribe to the @dnd-kit store directly.
*/
export interface TaskTreeDndContextValue {
activeItemId: string | null;
overId: string | null;
overZone: EdgeZone | null;
/** True when the over zone would form a cycle; row should suppress the indicator. */
overCircular: boolean;
/** Click handler for plain / cmd / shift row click. */
handleRowClick: (
item: TaskItem,
level: number,
event: ReactMouseEvent,
) => void;
/** Native HTML5 drag handlers; spread onto the row's outer div. */
getRowHandlers: (item: TaskItem) => {
draggable: boolean;
onDragStart: (e: ReactDragEvent<HTMLDivElement>) => void;
onDragOver: (e: ReactDragEvent<HTMLDivElement>) => void;
onDrop: (e: ReactDragEvent<HTMLDivElement>) => void;
};
}
/**
* `null` value indicates DnD is not mounted in this tree (e.g.,
* `dndContext="external"` is set AND the consumer's outer DndContext
* doesn't expose its over state to us). Rows degrade gracefully — no
* drop indicator, no row-click handler.
*/
export const TaskTreeDndContext =
createContext<TaskTreeDndContextValue | null>(null);
export function useTaskTreeDndContext(): TaskTreeDndContextValue | null {
return useContext(TaskTreeDndContext);
}
src/registry/components/data/task-tree/hooks/use-controlled-mode.ts
import { useCallback, useEffect, useRef } from "react";
import type { TaskItem } from "../../task-card/types";
import type {
TaskTreeChangeArgs,
TaskTreeChangeReason,
} from "../types";
/**
* Three-defenses controlled-mode wiring. Mirrors flow-canvas v0.2.4's
* deferred-notify + structural resync + mid-flow suppress pattern. Without
* all three the round-trip cycle between `value` prop and `onChange`
* notification cascades into reducer-mid-render warnings + spurious wholesale
* state replacement during drag.
*
* Defense 1 — microtask-defer consumer notify: fireOnChange queues a
* microtask before invoking the consumer callback. Lets the host's reducer
* commit AND React flush before the consumer's setState fires.
*
* Defense 2 — structural resync guard: when `value` updates, compare against
* the current internal state by structure. If equal (round-trip echo), skip
* the dispatch. Avoids the infinite resync cascade.
*
* Defense 3 — drop notifications fired during a continuous flow (drag).
* Drag tick callbacks queue microtasks that fire AFTER the drag started,
* leaving the snapshot stale. Bail at fire time when isDraggingRef is set.
*/
export interface UseControlledModeArgs {
value: TaskItem[] | undefined;
internalItems: TaskItem[];
onChange: ((args: TaskTreeChangeArgs) => void) | undefined;
applyExternalItems: (items: TaskItem[]) => void;
/**
* Optional external drag-flag ref. When provided, the host (task-tree.tsx)
* controls drag state from C6's DnD hooks; defense 3 reads from it at
* microtask-fire time. When omitted, an internal ref is used (no caller
* ever flips it, so drag suppression is inert — fine for pure controlled-
* mode users with no drag).
*/
isDraggingRef?: React.MutableRefObject<boolean>;
}
export interface UseControlledModeResult {
isControlled: boolean;
isDraggingRef: React.MutableRefObject<boolean>;
fireOnChange: (next: TaskItem[], reason: TaskTreeChangeReason) => void;
}
export function useControlledMode(
args: UseControlledModeArgs,
): UseControlledModeResult {
const { value, internalItems, onChange, applyExternalItems } = args;
const isControlled = value !== undefined;
const internalDraggingRef = useRef(false);
const isDraggingRef = args.isDraggingRef ?? internalDraggingRef;
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
// Defense 1 + 3. `isDraggingRef` is intentionally omitted from the dep
// array — refs have stable identity and the callback reads `.current` at
// microtask-fire time. Including it would force fireOnChange's identity to
// change whenever the host swapped the ref (it never should).
const fireOnChange = useCallback(
(next: TaskItem[], reason: TaskTreeChangeReason) => {
const cb = onChangeRef.current;
if (!cb) return;
queueMicrotask(() => {
if (isDraggingRef.current) return;
const live = onChangeRef.current;
live?.({ items: next, reason });
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
// Defense 2 — full-field echo guard (TT2/TT11). Serialize the incoming value
// once and compare to the last applied snapshot + the current internal state.
// Replaces the partial 5-field structural walk (id/name/status/active/children
// only), which silently DROPPED external changes to assignee / description /
// dates, and removes the unbounded recursive compare on every value change.
const lastAppliedRef = useRef<string | null>(null);
useEffect(() => {
if (!isControlled) return;
if (value === internalItems) return;
const incoming = JSON.stringify(value);
if (incoming === lastAppliedRef.current) return;
if (incoming === JSON.stringify(internalItems)) {
lastAppliedRef.current = incoming;
return;
}
lastAppliedRef.current = incoming;
applyExternalItems(value!);
// applyExternalItems' identity is stable; `internalItems` is read for the
// echo compare but intentionally NOT a dep (only react to `value` changes).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, isControlled]);
return { isControlled, isDraggingRef, fireOnChange };
}
src/registry/components/data/task-tree/hooks/use-tree-events.ts
import { useCallback, useEffect, useRef } from "react";
import type { TaskItem } from "../../task-card/types";
import type {
TaskTreeAddEvent,
TaskTreeDropEvent,
TaskTreeFilter,
TaskTreeItemEvent,
TaskTreeMoveEvent,
TaskTreePermissionDeniedEvent,
TaskTreeRemoveEvent,
TaskTreeSort,
} from "../types";
/**
* Map of event names → payload shapes. Lets `fire` be typed without resorting
* to a giant prop-name union switch.
*/
export interface TreeEventMap {
itemMoved: TaskTreeMoveEvent;
itemDropped: TaskTreeDropEvent;
itemAdded: TaskTreeAddEvent;
itemRemoved: TaskTreeRemoveEvent;
activeToggled: { item: TaskItem; nextActive: boolean };
collapseToggled: { item: TaskItem; collapsed: boolean };
bulkToggleActive: { ids: ReadonlyArray<string>; nextActive: boolean };
bulkRemove: { ids: ReadonlyArray<string> };
bulkEdit: { ids: ReadonlyArray<string> };
selectionChanged: { selectedIds: ReadonlySet<string> };
searchChanged: { query: string };
sortChanged: { sort: TaskTreeSort };
filterChanged: { filter: TaskTreeFilter };
itemClick: TaskTreeItemEvent;
itemContextMenu: TaskTreeItemEvent;
permissionDenied: TaskTreePermissionDeniedEvent;
}
/**
* Consumer callbacks. Names mirror TreeEventMap; each is `on<Name>` with the
* first letter upper-cased. Keeping callbacks in a single bag lets us
* ref-mirror them to one stable identity for the lifetime of the host.
*/
export interface TreeEventCallbacks {
onItemMoved?: (args: TaskTreeMoveEvent) => void;
onItemDropped?: (args: TaskTreeDropEvent) => void;
onItemAdded?: (args: TaskTreeAddEvent) => void;
onItemRemoved?: (args: TaskTreeRemoveEvent) => void;
onActiveToggled?: (args: { item: TaskItem; nextActive: boolean }) => void;
onCollapseToggled?: (args: { item: TaskItem; collapsed: boolean }) => void;
onBulkToggleActive?: (args: {
ids: ReadonlyArray<string>;
nextActive: boolean;
}) => void;
onBulkRemove?: (args: { ids: ReadonlyArray<string> }) => void;
onBulkEdit?: (args: { ids: ReadonlyArray<string> }) => void;
onSelectionChanged?: (args: { selectedIds: ReadonlySet<string> }) => void;
onSearchChanged?: (args: { query: string }) => void;
onSortChanged?: (args: { sort: TaskTreeSort }) => void;
onFilterChanged?: (args: { filter: TaskTreeFilter }) => void;
onItemClick?: (args: TaskTreeItemEvent) => void;
onItemContextMenu?: (args: TaskTreeItemEvent) => void;
onPermissionDenied?: (args: TaskTreePermissionDeniedEvent) => void;
}
const NAME_TO_PROP: { [K in keyof TreeEventMap]: keyof TreeEventCallbacks } = {
itemMoved: "onItemMoved",
itemDropped: "onItemDropped",
itemAdded: "onItemAdded",
itemRemoved: "onItemRemoved",
activeToggled: "onActiveToggled",
collapseToggled: "onCollapseToggled",
bulkToggleActive: "onBulkToggleActive",
bulkRemove: "onBulkRemove",
bulkEdit: "onBulkEdit",
selectionChanged: "onSelectionChanged",
searchChanged: "onSearchChanged",
sortChanged: "onSortChanged",
filterChanged: "onFilterChanged",
itemClick: "onItemClick",
itemContextMenu: "onItemContextMenu",
permissionDenied: "onPermissionDenied",
};
export interface TreeEventDispatcher {
fire: <K extends keyof TreeEventMap>(name: K, args: TreeEventMap[K]) => void;
}
/**
* Stable-identity event dispatcher. Microtask-defers each invocation to avoid
* setState-during-render — same defense pattern as flow-canvas v0.2.4's
* fireOnChange. Callbacks are read from a ref at fire-time, so consumers can
* pass inline event handlers without re-rendering the tree.
*
* `onItemClick` / `onItemContextMenu` fire SYNCHRONOUSLY (no microtask defer):
* these are user-initiated and consumers commonly call `event.preventDefault()`
* or navigate from within the handler. Deferring one tick would push focus /
* scroll / navigation out of the same task as the original click event and
* break the synchronous expectation. (React 17+ disabled synthetic-event
* pooling, so deferring is technically safe — this is a UX choice, not a
* correctness one.)
*/
export function useTreeEvents(
callbacks: TreeEventCallbacks,
): TreeEventDispatcher {
const callbacksRef = useRef(callbacks);
useEffect(() => {
callbacksRef.current = callbacks;
}, [callbacks]);
const fire = useCallback(
<K extends keyof TreeEventMap>(name: K, args: TreeEventMap[K]) => {
const propName = NAME_TO_PROP[name];
const cb = callbacksRef.current[propName] as
| ((args: TreeEventMap[K]) => void)
| undefined;
if (!cb) return;
if (name === "itemClick" || name === "itemContextMenu") {
cb(args);
return;
}
queueMicrotask(() => {
// Re-read the callback so a consumer mid-flight swap still routes.
const live = callbacksRef.current[propName] as
| ((args: TreeEventMap[K]) => void)
| undefined;
live?.(args);
});
},
[],
);
return { fire };
}
src/registry/components/data/task-tree/hooks/use-selection.ts
import { useCallback } from "react";
import type {
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
} from "react";
import type { TaskItem } from "../../task-card/types";
import type { TaskTreeAction, TaskTreeVisibleRow } from "../types";
import type { TreeEventDispatcher } from "./use-tree-events";
type RowEvent = ReactMouseEvent | ReactKeyboardEvent;
export interface UseSelectionArgs {
visibleItems: ReadonlyArray<TaskTreeVisibleRow>;
selectionAnchorId: string | null;
selectedIds: ReadonlySet<string>;
dispatch: (action: TaskTreeAction) => void;
/** Event dispatcher; the click handler fires itemClick through here. */
fire: TreeEventDispatcher["fire"];
}
export interface UseSelectionResult {
/** Plain / cmd-click / shift-click handler. Visibility-aware. */
handleRowClick: (item: TaskItem, level: number, event: RowEvent) => void;
/** Imperative range — replaces selection with the visible-row sequence between idA and idB. */
selectRange: (idA: string, idB: string) => void;
/** Imperative — replaces selection with every currently-visible row id. */
selectAllVisible: () => void;
}
/**
* Multi-select math. Resolves ranges + "all" over the live `visibleItems`
* snapshot so the consumer's filterMode + collapse state are respected
* (per plan §8.3 — Cmd+A "selects all VISIBLE items only").
*
* Shift-click ADDS the resolved range to existing selection (matches the
* reducer's SELECT_ONE/range additive semantics — Shift+Cmd-click parity).
* Imperative selectRange replaces the selection (set-selection-to-range
* semantics for direct caller use).
*/
export function useSelection(args: UseSelectionArgs): UseSelectionResult {
const { visibleItems, selectionAnchorId, selectedIds, dispatch, fire } = args;
const handleRowClick = useCallback(
(item: TaskItem, level: number, event: RowEvent) => {
const isShift = event.shiftKey;
const isCmd = event.metaKey || event.ctrlKey;
if (isShift) {
const rangeIds = resolveRangeOverVisible(
visibleItems,
selectionAnchorId,
item.id,
);
if (rangeIds.length === 0) {
// No anchor / target not in visible — fall back to replace.
dispatch({ type: "SELECT_ONE", id: item.id, mode: "replace" });
return;
}
const merged = new Set(selectedIds);
for (const r of rangeIds) merged.add(r);
dispatch({
type: "SELECT_REPLACE",
ids: Array.from(merged),
anchorId: selectionAnchorId ?? item.id,
});
return;
}
if (isCmd) {
dispatch({ type: "SELECT_ONE", id: item.id, mode: "toggle" });
return;
}
// Plain click: replace selection AND fire itemClick. Modifier-click
// does not fire itemClick (matches plan §8.1).
dispatch({ type: "SELECT_ONE", id: item.id, mode: "replace" });
fire("itemClick", { item, level, event });
},
[visibleItems, selectionAnchorId, selectedIds, dispatch, fire],
);
const selectRange = useCallback(
(idA: string, idB: string) => {
const rangeIds = resolveRangeOverVisible(visibleItems, idA, idB);
if (rangeIds.length === 0) return;
dispatch({
type: "SELECT_REPLACE",
ids: rangeIds,
anchorId: idA,
});
},
[visibleItems, dispatch],
);
const selectAllVisible = useCallback(() => {
const ids: string[] = [];
for (const row of visibleItems) ids.push(row.item.id);
dispatch({ type: "SELECT_REPLACE", ids });
}, [visibleItems, dispatch]);
return { handleRowClick, selectRange, selectAllVisible };
}
function resolveRangeOverVisible(
visibleItems: ReadonlyArray<TaskTreeVisibleRow>,
anchorId: string | null,
targetId: string,
): string[] {
if (visibleItems.length === 0) return [];
const targetIdx = visibleItems.findIndex((r) => r.item.id === targetId);
if (targetIdx === -1) return [];
const anchorIdx =
anchorId === null
? 0
: visibleItems.findIndex((r) => r.item.id === anchorId);
if (anchorIdx === -1) return [visibleItems[targetIdx].item.id];
const [lo, hi] =
anchorIdx <= targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx];
const out: string[] = [];
for (let i = lo; i <= hi; i++) out.push(visibleItems[i].item.id);
return out;
}
src/registry/components/data/task-tree/hooks/use-debounced-callback.ts
import { useCallback, useEffect, useRef } from "react";
/**
* Returns a stable-identity debounced wrapper around `fn`. The wrapper
* collapses bursts of calls into one trailing-edge invocation after `ms`
* milliseconds of silence. Cancels any pending invocation on unmount.
*
* Used by the search input (200ms per Q-P4).
*/
export function useDebouncedCallback<TArgs extends unknown[]>(
fn: (...args: TArgs) => void,
ms: number,
): (...args: TArgs) => void {
const fnRef = useRef(fn);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
fnRef.current = fn;
}, [fn]);
useEffect(() => {
return () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
};
}, []);
return useCallback(
(...args: TArgs) => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
timerRef.current = null;
fnRef.current(...args);
}, ms);
},
[ms],
);
}
src/registry/components/data/task-tree/hooks/use-status-option-by-value.ts
import { useMemo } from "react";
import type { TaskStatusOption } from "../../task-card/types";
/**
* Memoized lookup of a status option by `value`. Returns undefined when no
* match is found. Used by the row's status indicator + sort dropdown labels.
*/
export function useStatusOptionByValue(
statusOptions: ReadonlyArray<TaskStatusOption> | undefined,
value: string,
): TaskStatusOption | undefined {
return useMemo(() => {
if (!statusOptions || statusOptions.length === 0) return undefined;
return statusOptions.find((o) => o.value === value);
}, [statusOptions, value]);
}
/**
* Memoized id → option map. Cheap O(N) once per statusOptions change; lookups
* are O(1) thereafter. The hook is useful when many rows render concurrently.
*/
export function useStatusOptionMap(
statusOptions: ReadonlyArray<TaskStatusOption> | undefined,
): ReadonlyMap<string, TaskStatusOption> {
return useMemo(() => {
const map = new Map<string, TaskStatusOption>();
if (!statusOptions) return map;
for (const o of statusOptions) map.set(o.value, o);
return map;
}, [statusOptions]);
}
src/registry/components/data/task-tree/hooks/use-tree-virtual.ts
"use client";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { RefObject } from "react";
import type { TaskTreeVisibleRow } from "../types";
export type TaskTreeVirtualizeMode = "auto" | "always" | "never";
interface UseTreeVirtualArgs {
rows: ReadonlyArray<TaskTreeVisibleRow>;
scrollRef: RefObject<HTMLDivElement | null>;
mode: TaskTreeVirtualizeMode;
/** Threshold for "auto" mode — count of TOTAL rows (not visible) per R9. */
threshold: number;
/** Fixed row height. Default 52px for the 2-line layout (Q-P2). */
rowHeight: number;
/**
* When true, virtualization auto-suspends regardless of mode — drag is the
* canonical use case (R7 mitigation: virtualizer + DnD scroll snap fights).
*/
suspended?: boolean;
}
export interface UseTreeVirtualResult {
/** True when virtualization is active for this render. */
active: boolean;
virtualizer: ReturnType<typeof useVirtualizer<HTMLDivElement, HTMLDivElement>>;
}
/**
* Virtualizer wrapper around `@tanstack/react-virtual`. Threshold check uses
* TOTAL items count (not visible) per R9 so the filter doesn't defeat the
* optimization — a filter that hides 90% of rows on a 1000-item tree still
* benefits from virtualization on the unfiltered sibling.
*/
export function useTreeVirtual(args: UseTreeVirtualArgs): UseTreeVirtualResult {
const { rows, scrollRef, mode, threshold, rowHeight, suspended } = args;
const active =
!suspended &&
(mode === "always" || (mode === "auto" && rows.length >= threshold));
const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => rowHeight,
overscan: 8,
enabled: active,
});
return { active, virtualizer };
}
src/registry/components/data/task-tree/hooks/use-tree-dnd-internal.ts
"use client";
import {
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragCancelEvent,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { useCallback, useEffect, useRef, useState } from "react";
import type { TaskItem } from "../../task-card/types";
import type {
TaskTreeAction,
TaskTreePermissionDeniedEvent,
TreeLocation,
} from "../types";
import { findItemById, findParentId } from "../lib/tree-walker";
import { isAncestor } from "../lib/circular-drop";
import { computeEdgeZone, type EdgeZone } from "../lib/edge-zone";
export interface UseTreeDndInternalArgs {
items: TaskItem[];
dispatch: (action: TaskTreeAction) => void;
fireMoved: (args: {
item: TaskItem;
from: TreeLocation;
to: TreeLocation;
via: "drag" | "imperative";
}) => void;
firePermissionDenied?: (args: TaskTreePermissionDeniedEvent) => void;
isDraggingRef: React.MutableRefObject<boolean>;
/** Mutual-exclusion gate; set by the grip's onDragStart, cleared on end. */
isInternalDragRef: React.MutableRefObject<boolean>;
/** Permission predicate for reparent target. Default allow. */
canDropIntoChildren?: (targetId: string) => boolean;
/** Permission predicate for sibling adjacency. Default allow. */
canDropAsSibling?: (targetId: string) => boolean;
}
export interface TreeDndOverInfo {
overId: string;
zone: EdgeZone;
/** True when the current pointer over the row would form a cycle. */
circular: boolean;
}
export interface UseTreeDndInternalResult {
sensors: ReturnType<typeof useSensors>;
activeItem: TaskItem | null;
activeLevel: number;
over: TreeDndOverInfo | null;
onDragStart: (event: DragStartEvent) => void;
onDragOver: (event: DragOverEvent) => void;
onDragEnd: (event: DragEndEvent) => void;
onDragCancel: (event: DragCancelEvent) => void;
}
/**
* @dnd-kit drag wiring for internal (within-tree) drags. The host mounts a
* `<DndContext>` with these handlers + sensors; rows register as droppables
* with id = item.id. Edge zone is computed from a globally tracked pointer
* Y + the over rect's bounding box (the dragOver event doesn't carry the
* current pointer, only the active's delta).
*
* Sensor split (Mouse + Touch separate, not unified Pointer) per L17 +
* plan §6.1: mouse activates at 5px, touch needs 300ms long-press. Unified
* PointerSensor can't deliver divergent activation timings.
*/
export function useTreeDndInternal(
args: UseTreeDndInternalArgs,
): UseTreeDndInternalResult {
const {
items,
dispatch,
fireMoved,
firePermissionDenied,
isDraggingRef,
isInternalDragRef,
canDropIntoChildren,
canDropAsSibling,
} = args;
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 300, tolerance: 5 },
}),
useSensor(KeyboardSensor),
);
const [activeItem, setActiveItem] = useState<TaskItem | null>(null);
const [activeLevel, setActiveLevel] = useState<number>(0);
const [over, setOver] = useState<TreeDndOverInfo | null>(null);
// Pointer tracker — @dnd-kit's onDragOver provides over.rect but not the
// current pointer; install a global mousemove/touchmove listener while drag
// is active to read the pointer position (viewport coords).
const pointerYRef = useRef<number>(0);
const pointerXRef = useRef<number>(0);
// Keep activeItem + items reachable from the scroll/wheel listener without
// re-subscribing on every change. Synced in an effect (refs must not be
// written during render).
const activeItemRef = useRef<TaskItem | null>(null);
const itemsRef = useRef<TaskItem[]>(items);
useEffect(() => {
activeItemRef.current = activeItem;
itemsRef.current = items;
});
// Recompute the over-target + zone from a pointer position, using
// getBoundingClientRect (viewport coords, consistent with clientY). Shared by
// the scroll/wheel handler so the indicator stays correct when the list
// scrolls under a stationary pointer (TT7).
const applyOverAt = useCallback((clientX: number, clientY: number) => {
const active = activeItemRef.current;
if (!active) return;
const el = document.elementFromPoint(clientX, clientY);
const rowEl = el?.closest<HTMLElement>("[data-task-tree-row]");
const targetId = rowEl?.getAttribute("data-task-tree-row");
if (!rowEl || !targetId || targetId === active.id) {
setOver(null);
return;
}
const rect = rowEl.getBoundingClientRect();
const zone = computeEdgeZone(clientY, rect.top, rect.height);
const circular =
zone === "middle" && isAncestor(itemsRef.current, active.id, targetId);
setOver({ overId: targetId, zone, circular });
}, []);
useEffect(() => {
if (!activeItem) return;
const onMove = (e: MouseEvent | TouchEvent) => {
if ("clientY" in e) {
pointerYRef.current = e.clientY;
pointerXRef.current = e.clientX;
} else if (e.touches && e.touches.length > 0) {
pointerYRef.current = e.touches[0].clientY;
pointerXRef.current = e.touches[0].clientX;
}
};
// On scroll/wheel without pointer movement, a different row sits under the
// stationary pointer — recompute from the cached pointer so the zone +
// indicator don't go stale (TT7).
const onScroll = () => applyOverAt(pointerXRef.current, pointerYRef.current);
window.addEventListener("mousemove", onMove);
window.addEventListener("touchmove", onMove, { passive: true });
window.addEventListener("scroll", onScroll, { capture: true, passive: true });
window.addEventListener("wheel", onScroll, { passive: true });
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("touchmove", onMove);
window.removeEventListener("scroll", onScroll, { capture: true });
window.removeEventListener("wheel", onScroll);
};
}, [activeItem, applyOverAt]);
const onDragStart = useCallback(
(event: DragStartEvent) => {
const id = String(event.active.id);
const item = findItemById(items, id);
if (!item) return;
const level =
typeof event.active.data.current?.level === "number"
? event.active.data.current.level
: 0;
setActiveItem(item);
setActiveLevel(level);
isDraggingRef.current = true;
isInternalDragRef.current = true;
},
[items, isDraggingRef, isInternalDragRef],
);
const onDragOver = useCallback(
(event: DragOverEvent) => {
const overObj = event.over;
if (!overObj || !activeItem) {
setOver(null);
return;
}
const targetId = String(overObj.id);
if (targetId === activeItem.id) {
setOver(null);
return;
}
const rect = overObj.rect;
const zone = computeEdgeZone(
pointerYRef.current,
rect.top,
rect.height,
);
const circular =
zone === "middle" && isAncestor(items, activeItem.id, targetId);
setOver({ overId: targetId, zone, circular });
},
[activeItem, items],
);
const finishDrag = useCallback(() => {
setActiveItem(null);
setOver(null);
isDraggingRef.current = false;
isInternalDragRef.current = false;
}, [isDraggingRef, isInternalDragRef]);
const onDragEnd = useCallback(
(event: DragEndEvent) => {
const src = activeItem;
const overInfo = over;
try {
if (!src || !overInfo) return;
const targetId = overInfo.overId;
if (targetId === src.id) return;
// Circular ban for reparent.
if (overInfo.zone === "middle" && overInfo.circular) {
firePermissionDenied?.({
action: "dropIntoChildren",
itemId: targetId,
reason: "circular-drop",
});
return;
}
// Permission gates.
if (
overInfo.zone === "middle" &&
canDropIntoChildren &&
!canDropIntoChildren(targetId)
) {
firePermissionDenied?.({
action: "dropIntoChildren",
itemId: targetId,
reason: "denied-by-rule",
});
return;
}
if (
overInfo.zone !== "middle" &&
canDropAsSibling &&
!canDropAsSibling(targetId)
) {
firePermissionDenied?.({
action: "dropAsSibling",
itemId: targetId,
reason: "denied-by-rule",
});
return;
}
// Resolve destination from edge zone.
const fromParentId = findParentId(items, src.id);
const fromIndex = siblingIndex(items, fromParentId, src.id);
const toLoc = resolveDropLocation(items, targetId, overInfo.zone);
if (!toLoc) return;
dispatch({
type: "MOVE_ITEM",
itemId: src.id,
to: toLoc,
reason: overInfo.zone === "middle" ? "reparent" : "reorder",
});
fireMoved({
item: src,
from: { parentId: fromParentId, index: fromIndex },
to: toLoc,
via: "drag",
});
} finally {
finishDrag();
}
// event is read above; silence unused-var lint via reference.
void event;
},
[
activeItem,
over,
items,
dispatch,
fireMoved,
firePermissionDenied,
canDropIntoChildren,
canDropAsSibling,
finishDrag,
],
);
const onDragCancel = useCallback(() => {
finishDrag();
}, [finishDrag]);
return {
sensors,
activeItem,
activeLevel,
over,
onDragStart,
onDragOver,
onDragEnd,
onDragCancel,
};
}
function siblingIndex(
items: ReadonlyArray<TaskItem>,
parentId: string | null,
id: string,
): number {
if (parentId === null) return items.findIndex((i) => i.id === id);
const parent = findItemById(items, parentId);
if (!parent || !parent.children) return -1;
return parent.children.findIndex((i) => i.id === id);
}
/**
* Resolve the drop's TreeLocation from the over row + edge zone. Middle =
* append-as-last-child of the target (auto-expand handled in the reducer).
* Top/bottom = sibling adjacency relative to the target's parent.
*/
function resolveDropLocation(
items: ReadonlyArray<TaskItem>,
targetId: string,
zone: EdgeZone,
): TreeLocation | null {
if (zone === "middle") {
const target = findItemById(items, targetId);
if (!target) return null;
const childCount = target.children?.length ?? 0;
return { parentId: targetId, index: childCount };
}
const parentId = findParentId(items, targetId);
const sibIdx = siblingIndex(items, parentId, targetId);
if (sibIdx === -1) return null;
return {
parentId,
index: zone === "top" ? sibIdx : sibIdx + 1,
};
}
src/registry/components/data/task-tree/hooks/use-tree-dnd-html5.ts
"use client";
import { useCallback } from "react";
import type { DragEvent as ReactDragEvent } from "react";
import type { TaskItem } from "../../task-card/types";
import type { TaskTreeAction } from "../types";
import { findItemById } from "./../lib/tree-walker";
import {
TASK_TREE_MIME,
parseFromDataTransfer,
serializeForDataTransfer,
} from "../lib/dnd-payload";
export interface UseTreeDndHtml5Args {
/** Live items snapshot — needed to detect same-tree drops (which become noops). */
items: TaskItem[];
dispatch: (action: TaskTreeAction) => void;
fireDropped?: (args: {
item: TaskItem;
from: "internal" | "external";
targetParentId: string | null;
targetIndex: number;
}) => void;
fireAdded?: (args: {
item: TaskItem;
parentId: string | null;
index: number;
via: "imperative" | "drop-from-external";
}) => void;
isInternalDragRef: React.MutableRefObject<boolean>;
}
export interface RowDragHandlers {
draggable: boolean;
onDragStart: (e: ReactDragEvent<HTMLDivElement>) => void;
onDragOver: (e: ReactDragEvent<HTMLDivElement>) => void;
onDrop: (e: ReactDragEvent<HTMLDivElement>) => void;
}
export interface UseTreeDndHtml5Result {
/** Factory: per-row HTML5 drag handler set. */
getRowHandlers: (item: TaskItem) => RowDragHandlers;
}
/**
* Native HTML5 dataTransfer drag wiring for cross-procomp drags between
* task-tree and task-card (or any procomp that speaks the shared
* `application/x-ilinxa-task+json` MIME).
*
* Each row is BOTH source AND target:
* - As source: dragstart serializes the item; bails if the grip already
* activated @dnd-kit drag (`isInternalDragRef.current === true`).
* - As target: dragover preventDefault when the MIME matches so the
* browser allows the drop; drop parses the payload and dispatches
* ADD_ITEM as a child of the over row.
*
* For v0.1, external HTML5 drops always land as the LAST child of the over
* row (middle-zone semantics). Edge-zone HTML5 drops (top/bottom sibling)
* are a future refinement — the v0.1 contract is "internal drag = full
* top/middle/bottom zones; external drag = always reparent."
*/
export function useTreeDndHtml5(
args: UseTreeDndHtml5Args,
): UseTreeDndHtml5Result {
const { items, dispatch, fireDropped, fireAdded, isInternalDragRef } = args;
const getRowHandlers = useCallback(
(item: TaskItem): RowDragHandlers => ({
draggable: true,
onDragStart: (e) => {
if (isInternalDragRef.current) {
// @dnd-kit drag took over via the grip; suppress native drag so
// the browser doesn't fire a ghost-image alongside the overlay.
e.preventDefault();
return;
}
try {
const payload = serializeForDataTransfer(item);
e.dataTransfer.setData(TASK_TREE_MIME, payload);
e.dataTransfer.setData("text/plain", payload);
e.dataTransfer.effectAllowed = "copy";
} catch {
// setData can throw in restricted contexts (sandboxed iframes);
// swallow — the drag will simply fail to populate the payload.
}
},
onDragOver: (e) => {
if (!e.dataTransfer.types.includes(TASK_TREE_MIME)) return;
if (isInternalDragRef.current) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
},
onDrop: (e) => {
if (isInternalDragRef.current) return;
const parsed = parseFromDataTransfer(e.dataTransfer);
if (!parsed) return;
if (parsed.id === item.id) return; // self-drop noop
// Same-tree native drop (rare — typically the grip routes through
// @dnd-kit, not native HTML5). Skip silently to avoid creating a
// duplicate row; internal moves come through MOVE_ITEM, not
// ADD_ITEM. External drops are detected by parsed.id NOT existing
// in our items.
if (findItemById(items, parsed.id)) return;
e.preventDefault();
const targetParentId = item.id;
const targetIndex = item.children?.length ?? 0;
dispatch({
type: "ADD_ITEM",
item: parsed,
parentId: targetParentId,
index: targetIndex,
via: "drop-from-external",
});
fireAdded?.({
item: parsed,
parentId: targetParentId,
index: targetIndex,
via: "drop-from-external",
});
fireDropped?.({
item: parsed,
from: "external",
targetParentId,
targetIndex,
});
},
}),
[items, dispatch, fireAdded, fireDropped, isInternalDragRef],
);
return { getRowHandlers };
}
src/registry/components/data/task-tree/hooks/use-tree-keyboard.ts
"use client";
import { useCallback } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import type { TaskPermissions } from "../../task-card/types";
import type {
TaskTreePermissionAction,
TaskTreePermissionDeniedEvent,
TaskTreeStateValue,
} from "../types";
import { evalPermission } from "../lib/permissions";
export interface UseTreeKeyboardArgs {
state: TaskTreeStateValue;
/** Declarative permission matrix (defaults to allow-all when omitted). */
permissions?: TaskPermissions;
/**
* Fired when a Space (toggleActive) or Delete/Backspace (remove)
* shortcut is denied by `permissions` or `item.locked`. Receives the
* resolved action code + the offending item id + a reason tag.
*/
onPermissionDenied?: (args: TaskTreePermissionDeniedEvent) => void;
/**
* When true, suppresses Space + Delete/Backspace shortcuts entirely
* and fires a `denied-by-readOnly` event. Navigation + selection
* shortcuts continue to work.
*/
readOnly?: boolean;
}
export interface UseTreeKeyboardResult {
onKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => void;
}
/**
* Keyboard navigation per plan §16.3. Reads visibleItems + focusedItemId
* from state; routes to the appropriate handle method.
*
* Implemented map:
* ArrowDown / ArrowUp — next / prev visible row
* ArrowRight — expand collapsed; else move to first visible child
* ArrowLeft — collapse expanded; else move to parent
* Home / End — first / last visible row
* Space — toggle the focused row's active state
* Enter — select + fire onItemClick on the focused row
* Delete / Backspace — remove the focused row
* Cmd/Ctrl + A — select all visible
* Escape — clear selection
*
* Bail-out rule: the handler is attached at the tree root and events bubble
* from interactive descendants (chevron buttons, checkboxes, grip, slot
* inputs). When the target is a form input or button, we bail so the
* native handler runs without double-handling (e.g., Space toggling the
* checkbox AND firing toggleActive a second time; Backspace deleting a
* character in a slot input AND removing the row).
*/
export function useTreeKeyboard(
args: UseTreeKeyboardArgs,
): UseTreeKeyboardResult {
const { state, permissions, onPermissionDenied, readOnly } = args;
const denyAndFire = useCallback(
(action: TaskTreePermissionAction, itemId: string, locked: boolean) => {
onPermissionDenied?.({
action,
itemId,
reason: readOnly
? "denied-by-readOnly"
: locked
? "denied-by-lock"
: "denied-by-rule",
});
},
[onPermissionDenied, readOnly],
);
const onKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement | null;
if (
target?.matches(
"input, textarea, select, button, [role='button'], [contenteditable='true']",
)
) {
return;
}
const visible = state.visibleItems;
if (visible.length === 0) return;
// Resolve the active row from focusedItemId; fall back to first
// visible when nothing is focused yet.
const focusIdx = state.focusedItemId
? visible.findIndex((r) => r.item.id === state.focusedItemId)
: -1;
const idx = focusIdx === -1 ? 0 : focusIdx;
const current = visible[idx];
const moveTo = (nextIdx: number) => {
const clamped = Math.max(0, Math.min(visible.length - 1, nextIdx));
const next = visible[clamped];
if (!next) return;
state.focusItem(next.item.id);
};
const meta = event.metaKey || event.ctrlKey;
const key = event.key;
// Cmd/Ctrl-A — select all visible.
if (meta && (key === "a" || key === "A")) {
event.preventDefault();
state.selectAll();
return;
}
switch (key) {
case "ArrowDown":
event.preventDefault();
moveTo(idx + 1);
return;
case "ArrowUp":
event.preventDefault();
moveTo(idx - 1);
return;
case "Home":
event.preventDefault();
moveTo(0);
return;
case "End":
event.preventDefault();
moveTo(visible.length - 1);
return;
case "ArrowRight": {
event.preventDefault();
if (!current) return;
const hasChildren =
!!current.item.children && current.item.children.length > 0;
if (!hasChildren) return;
if (state.collapsedIds.has(current.item.id)) {
state.expandItem(current.item.id);
return;
}
// Move to first visible child (the next row whose parentId matches).
const child = visible[idx + 1];
if (child && child.parentId === current.item.id) {
state.focusItem(child.item.id);
}
return;
}
case "ArrowLeft": {
event.preventDefault();
if (!current) return;
const hasChildren =
!!current.item.children && current.item.children.length > 0;
if (hasChildren && !state.collapsedIds.has(current.item.id)) {
state.collapseItem(current.item.id);
return;
}
// Move to parent if any.
if (current.parentId !== null) {
state.focusItem(current.parentId);
}
return;
}
case " ":
case "Spacebar": {
event.preventDefault();
if (!current) return;
if (readOnly) {
denyAndFire("toggleActive", current.item.id, false);
return;
}
const allowed = evalPermission(
permissions,
"toggleActive",
current.item,
current.level,
);
if (!allowed) {
denyAndFire(
"toggleActive",
current.item.id,
current.item.locked === true,
);
return;
}
state.toggleActive(current.item.id, !current.item.active);
return;
}
case "Enter": {
event.preventDefault();
if (!current) return;
// Route through handleRowClick so selection-mode resolution +
// onItemClick firing match the click path. TaskTreeItemEvent.event
// accepts MouseEvent | KeyboardEvent, so the KeyboardEvent passes
// through to the consumer's onItemClick handler.
state.handleRowClick(current.item, current.level, event);
return;
}
case "Delete":
case "Backspace": {
event.preventDefault();
if (!current) return;
if (readOnly) {
denyAndFire("remove", current.item.id, false);
return;
}
const allowed = evalPermission(
permissions,
"remove",
current.item,
current.level,
);
if (!allowed) {
denyAndFire(
"remove",
current.item.id,
current.item.locked === true,
);
return;
}
// Move focus to the next row BEFORE removal so the user keeps a
// sensible focus target. Fall back to previous if at the end.
const nextFocus =
visible[idx + 1]?.item.id ?? visible[idx - 1]?.item.id ?? null;
state.removeItem(current.item.id);
if (nextFocus) state.focusItem(nextFocus);
return;
}
case "Escape": {
event.preventDefault();
state.clearSelection();
return;
}
}
},
[state, permissions, readOnly, denyAndFire],
);
return { onKeyDown };
}
src/registry/components/data/task-tree/parts/task-tree-chevron.tsx
"use client";
import { ChevronRight } from "lucide-react";
import type { MouseEvent } from "react";
import { cn } from "@/lib/utils";
export interface TaskTreeChevronProps {
collapsed: boolean;
hasChildren: boolean;
onToggle?: (event: MouseEvent<HTMLButtonElement>) => void;
className?: string;
}
/**
* Disclosure triangle for the row. Renders as a hidden 16px slot when the
* item has no children so the row alignment stays consistent across rows
* with and without children.
*/
export function TaskTreeChevron({
collapsed,
hasChildren,
onToggle,
className,
}: TaskTreeChevronProps) {
if (!hasChildren) {
return (
<span
aria-hidden
className={cn("inline-block size-4 shrink-0", className)}
/>
);
}
return (
<button
type="button"
onClick={(e) => {
// Chevron is a leaf interaction — don't bubble to the row's click /
// select handler.
e.stopPropagation();
onToggle?.(e);
}}
aria-label={collapsed ? "Expand" : "Collapse"}
aria-expanded={!collapsed}
className={cn(
"inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className,
)}
>
<ChevronRight
className={cn(
"size-3 transition-transform duration-150",
!collapsed && "rotate-90",
)}
/>
</button>
);
}
src/registry/components/data/task-tree/parts/task-tree-status-indicator.tsx
"use client";
import type { TaskStatusOption } from "../../task-card/types";
import { cn } from "@/lib/utils";
export interface TaskTreeStatusIndicatorProps {
variant: "dot" | "strip" | "none";
statusOption?: TaskStatusOption;
className?: string;
}
/**
* Status badge. Three layouts:
* - "dot" — 8px coloured circle, fits inline with the name row.
* - "strip" — full-height left-edge bar; absolute-positioned by the row.
* - "none" — renders nothing.
*
* Colour comes from the matched statusOption's `variant` field via the
* shadcn token palette. When no option matches, falls back to a muted grey
* so the indicator still emits visual presence (dropping the indicator on a
* fallback case would silently shift row spacing).
*/
export function TaskTreeStatusIndicator({
variant,
statusOption,
className,
}: TaskTreeStatusIndicatorProps) {
if (variant === "none") return null;
const palette = paletteFor(statusOption?.variant);
if (variant === "strip") {
return (
<span
aria-hidden
data-status={statusOption?.value ?? "unknown"}
className={cn(
"pointer-events-none absolute inset-y-0 left-0 w-1 rounded-l",
palette.strip,
className,
)}
/>
);
}
// variant === "dot"
return (
<span
aria-hidden
data-status={statusOption?.value ?? "unknown"}
title={statusOption?.label}
className={cn(
"inline-block size-2 shrink-0 rounded-full",
palette.dot,
className,
)}
/>
);
}
function paletteFor(
variant: TaskStatusOption["variant"] | undefined,
): { dot: string; strip: string } {
switch (variant) {
case "default":
return { dot: "bg-primary", strip: "bg-primary" };
case "secondary":
return { dot: "bg-secondary-foreground/60", strip: "bg-secondary-foreground/60" };
case "destructive":
return { dot: "bg-destructive", strip: "bg-destructive" };
case "outline":
return {
dot: "border border-border bg-background",
strip: "bg-border",
};
default:
return {
dot: "bg-muted-foreground/40",
strip: "bg-muted-foreground/40",
};
}
}
src/registry/components/data/task-tree/parts/task-tree-checkbox.tsx
"use client";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
export interface TaskTreeCheckboxProps {
checked: boolean;
/** Disabled state from permission gate; the click is a no-op when true. */
disabled?: boolean;
/** Next active state — already inverted from `checked`. */
onChange?: (nextActive: boolean) => void;
ariaLabel?: string;
className?: string;
}
/**
* Wrapper around the shadcn `<Checkbox>` primitive that bridges the boolean
* `checked` + `onChange` API the row expects.
*
* This is a PER-ROW boolean toggle bound to a single item's `active` — never a
* tri-state group selector, so `checked` is always a real boolean and the box
* never renders an "indeterminate"/mixed state. The `typeof next === "boolean"`
* guard below exists ONLY to bridge the two checkbox backends (producer's Radix
* passes `boolean | "indeterminate"`; consumer-installed Base UI passes only
* `boolean`); the `: true` fallback is therefore unreachable in practice.
*/
export function TaskTreeCheckbox({
checked,
disabled,
onChange,
ariaLabel,
className,
}: TaskTreeCheckboxProps) {
return (
<Checkbox
checked={checked}
disabled={disabled}
aria-label={ariaLabel ?? (checked ? "Mark inactive" : "Mark active")}
aria-disabled={disabled || undefined}
onCheckedChange={(next) => {
if (disabled) return;
// Cross-backend type guard (F-cross-13): Radix passes
// `boolean | "indeterminate"`, Base UI passes only `boolean`. Since
// `checked` is always a real boolean here, `next` is always a boolean
// and the `: true` branch is unreachable — kept only for type-narrowing.
const value: boolean = typeof next === "boolean" ? next : true;
onChange?.(value);
}}
onClick={(e) => {
// Stop row click from also firing — checkbox is a leaf interaction.
e.stopPropagation();
}}
className={cn("size-4 shrink-0", className)}
/>
);
}
src/registry/components/data/task-tree/parts/task-tree-name.tsx
"use client";
import { cn } from "@/lib/utils";
export interface TaskTreeNameProps {
name: string;
active?: boolean;
className?: string;
}
/**
* Bold name span with single-line truncation. Inactive items render dimmed +
* strikethrough as a visual affordance — the active state is also reflected
* via the row checkbox; the dimming pairs the two so an inactive item reads
* "done" from the row glance alone.
*/
export function TaskTreeName({ name, active, className }: TaskTreeNameProps) {
return (
<span
className={cn(
"flex-1 truncate font-semibold",
active === false && "text-muted-foreground line-through opacity-70",
className,
)}
>
{name}
</span>
);
}
src/registry/components/data/task-tree/parts/task-tree-description.tsx
"use client";
import { cn } from "@/lib/utils";
export interface TaskTreeDescriptionProps {
description: string;
className?: string;
}
/**
* Second-line description preview, single-line truncated. Rendered with the
* row's calculated left padding so the description aligns with the name,
* minus the chevron + checkbox affordances' widths.
*/
export function TaskTreeDescription({
description,
className,
}: TaskTreeDescriptionProps) {
return (
<div
className={cn(
"truncate text-xs text-muted-foreground leading-tight",
className,
)}
>
{description}
</div>
);
}
src/registry/components/data/task-tree/parts/task-tree-person-label.tsx
"use client";
import type { TaskPerson } from "../../task-card/types";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
export interface TaskTreePersonLabelProps {
person: TaskPerson;
/** Default "right" puts the label on the row's right edge with avatar trailing. */
layout?: "right" | "compact";
className?: string;
}
/**
* Avatar + name. `layout="compact"` drops the name text (avatar-only) for
* narrow rows; the full name is preserved as a `title` so hover reveals it.
*/
export function TaskTreePersonLabel({
person,
layout = "right",
className,
}: TaskTreePersonLabelProps) {
const initials = computeInitials(person.name);
return (
<span
title={person.name}
className={cn(
"inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground",
className,
)}
>
{layout === "right" && (
<span className="truncate max-w-[120px]">{person.name}</span>
)}
<Avatar className="size-5">
{person.avatar && (
<AvatarImage src={person.avatar} alt={person.name} />
)}
<AvatarFallback className="text-[10px] font-medium">
{initials}
</AvatarFallback>
</Avatar>
</span>
);
}
function computeInitials(name: string): string {
const trimmed = name.trim();
if (!trimmed) return "?";
const parts = trimmed.split(/\s+/);
if (parts.length === 1) {
return parts[0].slice(0, 2).toUpperCase();
}
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
src/registry/components/data/task-tree/parts/task-tree-row-content.tsx
"use client";
import type { MouseEvent } from "react";
import type { TaskItem } from "../../task-card/types";
import { useTaskTreeRenderContext } from "../hooks/use-task-tree-context";
import { TaskTreeChevron } from "./task-tree-chevron";
import { TaskTreeStatusIndicator } from "./task-tree-status-indicator";
import { TaskTreeCheckbox } from "./task-tree-checkbox";
import { TaskTreeName } from "./task-tree-name";
import { TaskTreeDescription } from "./task-tree-description";
import { TaskTreePersonLabel } from "./task-tree-person-label";
import { cn } from "@/lib/utils";
export interface TaskTreeRowContentProps {
item: TaskItem;
level: number;
isSelected: boolean;
isCollapsed: boolean;
/**
* Visual dimming flag from the filter-fade path. The container also fades;
* the inner controls stay full opacity so checkbox / chevron remain
* affordant. Set by the host based on visibleItems[i].dimmed.
*/
dimmed?: boolean;
/** Read-only when both the row-level + item-level toggle gates deny. */
canToggleActive?: boolean;
onToggleCollapse?: (event: MouseEvent<HTMLButtonElement>) => void;
onToggleActive?: (nextActive: boolean) => void;
className?: string;
}
/**
* Default `renderRow` paint. Composes the leaf primitives with the consumer's
* slot overrides (name / description / person / statusIndicator) pulled from
* the render context. Interactive callbacks pass through from `<TaskTreeRow>`
* (lands in C6) — keeping the content itself props-only makes it testable
* without a full DnD host.
*/
export function TaskTreeRowContent({
item,
level,
isSelected,
isCollapsed,
dimmed,
canToggleActive = true,
onToggleCollapse,
onToggleActive,
className,
}: TaskTreeRowContentProps) {
const {
statusIndicator,
statusOptionMap,
indentSize,
renderName,
renderDescription,
renderPerson,
renderStatusIndicator,
} = useTaskTreeRenderContext();
const statusOption = statusOptionMap.get(item.status);
const hasChildren = !!item.children && item.children.length > 0;
const fieldArgs = { item, level } as const;
const stripIndicator = statusIndicator === "strip";
return (
<div
data-level={level}
data-selected={isSelected || undefined}
data-collapsed={isCollapsed || undefined}
data-dimmed={dimmed || undefined}
data-active={item.active}
className={cn(
"relative flex flex-col justify-center gap-0.5 py-1.5 pr-2",
dimmed && "opacity-50",
className,
)}
style={{
// Indent + 8px gutter for chevron alignment.
paddingInlineStart:
(stripIndicator ? 12 : 8) + level * indentSize,
}}
>
{stripIndicator && (
<TaskTreeStatusIndicator variant="strip" statusOption={statusOption} />
)}
<div className="flex min-w-0 items-center gap-1.5">
<TaskTreeChevron
collapsed={isCollapsed}
hasChildren={hasChildren}
onToggle={onToggleCollapse}
/>
{renderStatusIndicator
? renderStatusIndicator({ ...fieldArgs, statusOption })
: statusIndicator === "dot" && (
<TaskTreeStatusIndicator
variant="dot"
statusOption={statusOption}
/>
)}
<TaskTreeCheckbox
checked={item.active}
disabled={!canToggleActive}
onChange={onToggleActive}
ariaLabel={item.active ? `Mark ${item.name} inactive` : `Mark ${item.name} active`}
/>
{renderName ? (
<span className="min-w-0 flex-1 truncate">
{renderName(fieldArgs)}
</span>
) : (
<TaskTreeName name={item.name} active={item.active} />
)}
{item.targetPerson &&
(renderPerson ? (
<span className="shrink-0">{renderPerson(fieldArgs)}</span>
) : (
<TaskTreePersonLabel person={item.targetPerson} />
))}
</div>
{item.description &&
(renderDescription ? (
<div
className="min-w-0"
style={{
paddingInlineStart: 16,
}}
>
{renderDescription(fieldArgs)}
</div>
) : (
<div
className="min-w-0"
style={{
paddingInlineStart: 16,
}}
>
<TaskTreeDescription description={item.description} />
</div>
))}
</div>
);
}
src/registry/components/data/task-tree/parts/task-tree-list.tsx
"use client";
import { useEffect, useRef } from "react";
import type { TaskPermissions } from "../../task-card/types";
import type { TaskTreePermissionDeniedEvent } from "../types";
import {
useTaskTreeRenderContext,
useTaskTreeStateContext,
} from "../hooks/use-task-tree-context";
import {
useTreeVirtual,
type TaskTreeVirtualizeMode,
} from "../hooks/use-tree-virtual";
import { useTreeKeyboard } from "../hooks/use-tree-keyboard";
import { TaskTreeRow } from "./task-tree-row";
import { TaskTreeEmptyState } from "./task-tree-empty-state";
import { isFilterActive } from "../lib/filter-items";
import { evalPermission } from "../lib/permissions";
import { cn } from "@/lib/utils";
export interface TaskTreeListProps {
/** Default "auto" — virtualize when total rows ≥ threshold (R9 lock). */
virtualize?: TaskTreeVirtualizeMode;
/** Default 200 (plan §10.2). */
virtualizeThreshold?: number;
/** Default 52 (Q-P2 fixed row height). */
rowHeight?: number;
/**
* When true, virtualization auto-suspends (R7 mitigation). Wired by C6's
* DnD hooks so virtualizer + drag don't fight over scroll position.
*/
suspended?: boolean;
/** Forwarded into the keyboard hook so Space + Delete honor permissions. */
permissions?: TaskPermissions;
/** Forwarded into the keyboard hook for permission-denied events. */
onPermissionDenied?: (args: TaskTreePermissionDeniedEvent) => void;
/** When true, keyboard Space + Delete fire `denied-by-readOnly`. */
readOnly?: boolean;
className?: string;
}
/**
* List view over `visibleItems`. Optionally virtualizes via
* `@tanstack/react-virtual` when the total tree exceeds the threshold OR
* when the consumer forces "always". Static list paint otherwise so DnD
* indicators + slot rows with variable heights work without virtualizer
* measurement overrides.
*
* Owns the tree's keydown handler + focus management + empty-state slot.
* One row at a time has tabIndex=0 (the focused row or the first visible
* when nothing is focused); a useEffect programmatically focuses the
* matching DOM element when state.focusedItemId changes.
*/
export function TaskTreeList({
virtualize = "auto",
virtualizeThreshold = 200,
rowHeight = 52,
suspended = false,
permissions,
onPermissionDenied,
readOnly,
className,
}: TaskTreeListProps) {
const state = useTaskTreeStateContext();
const { renderEmptyState } = useTaskTreeRenderContext();
const scrollRef = useRef<HTMLDivElement | null>(null);
const { onKeyDown } = useTreeKeyboard({
state,
permissions,
onPermissionDenied,
readOnly,
});
const { active, virtualizer } = useTreeVirtual({
rows: state.visibleItems,
scrollRef,
mode: virtualize,
threshold: virtualizeThreshold,
rowHeight,
suspended,
});
const rows = state.visibleItems;
// Resolve the tabbable row id: the focused one if it's still visible, else
// the first visible row. Lets Tab into the tree always land on something
// meaningful and keeps the WAI-ARIA "exactly one tabindex=0 treeitem" rule.
const tabbableId =
state.focusedItemId &&
rows.some((r) => r.item.id === state.focusedItemId)
? state.focusedItemId
: (rows[0]?.item.id ?? null);
// Sync DOM focus to focusedItemId. Only fires when the id changes — clicks
// that don't change focus don't re-trigger focus() (which would clobber a
// text input the consumer might've focused from a slot row).
useEffect(() => {
if (!state.focusedItemId || !scrollRef.current) return;
const el = scrollRef.current.querySelector<HTMLElement>(
`[data-treeitem-id="${CSS.escape(state.focusedItemId)}"]`,
);
if (el && document.activeElement !== el) {
el.focus({ preventScroll: false });
}
}, [state.focusedItemId]);
const hasFilter = isFilterActive(state.filter, state.query);
if (rows.length === 0) {
const emptyArgs = { hasFilter };
const emptyContent = renderEmptyState
? renderEmptyState(emptyArgs)
: <TaskTreeEmptyState hasFilter={hasFilter} />;
return (
<div
ref={scrollRef}
className={cn("h-full overflow-auto", className)}
role="tree"
aria-label="Task tree"
aria-multiselectable="true"
onKeyDown={onKeyDown}
>
{emptyContent}
</div>
);
}
if (active) {
const virtualItems = virtualizer.getVirtualItems();
return (
<div
ref={scrollRef}
className={cn("h-full overflow-auto", className)}
role="tree"
aria-label="Task tree"
aria-multiselectable="true"
onKeyDown={onKeyDown}
>
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
width: "100%",
}}
>
{virtualItems.map((vRow) => {
const row = rows[vRow.index];
return (
<div
key={row.item.id}
data-treeitem-id={row.item.id}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${vRow.start}px)`,
height: vRow.size,
}}
role="treeitem"
tabIndex={tabbableId === row.item.id ? 0 : -1}
aria-level={row.level + 1}
aria-expanded={
row.item.children && row.item.children.length > 0
? !state.collapsedIds.has(row.item.id)
: undefined
}
aria-selected={state.selectedIds.has(row.item.id) || undefined}
onFocus={() => {
if (state.focusedItemId !== row.item.id) {
state.focusItem(row.item.id);
}
}}
>
<TaskTreeRow
item={row.item}
level={row.level}
isSelected={state.selectedIds.has(row.item.id)}
isCollapsed={state.collapsedIds.has(row.item.id)}
dimmed={row.dimmed}
canDrag={!readOnly && evalPermission(permissions, "drag", row.item, row.level)}
canToggleActive={
!readOnly &&
evalPermission(permissions, "toggleActive", row.item, row.level)
}
/>
</div>
);
})}
</div>
</div>
);
}
// Static list paint (small trees + dragging).
return (
<div
ref={scrollRef}
className={cn("h-full overflow-auto", className)}
role="tree"
aria-label="Task tree"
aria-multiselectable="true"
onKeyDown={onKeyDown}
>
<div className="flex flex-col">
{rows.map((row) => (
<div
key={row.item.id}
data-treeitem-id={row.item.id}
role="treeitem"
tabIndex={tabbableId === row.item.id ? 0 : -1}
aria-level={row.level + 1}
aria-expanded={
row.item.children && row.item.children.length > 0
? !state.collapsedIds.has(row.item.id)
: undefined
}
aria-selected={state.selectedIds.has(row.item.id) || undefined}
onFocus={() => {
if (state.focusedItemId !== row.item.id) {
state.focusItem(row.item.id);
}
}}
>
<TaskTreeRow
item={row.item}
level={row.level}
isSelected={state.selectedIds.has(row.item.id)}
isCollapsed={state.collapsedIds.has(row.item.id)}
dimmed={row.dimmed}
canDrag={!readOnly && evalPermission(permissions, "drag", row.item, row.level)}
canToggleActive={
!readOnly &&
evalPermission(permissions, "toggleActive", row.item, row.level)
}
/>
</div>
))}
</div>
</div>
);
}
src/registry/components/data/task-tree/parts/task-tree-row.tsx
"use client";
import { useDroppable } from "@dnd-kit/core";
import type { MouseEvent } from "react";
import type { TaskItem } from "../../task-card/types";
import { TaskTreeRowContent } from "./task-tree-row-content";
import { TaskTreeGrip } from "./task-tree-grip";
import { TaskTreeDropIndicator } from "./task-tree-drop-indicator";
import { useTaskTreeStateContext } from "../hooks/use-task-tree-context";
import { useTaskTreeRenderContext } from "../hooks/use-task-tree-context";
import { useTaskTreeDndContext } from "../hooks/use-task-tree-context";
import { cn } from "@/lib/utils";
export interface TaskTreeRowProps {
item: TaskItem;
level: number;
isSelected: boolean;
isCollapsed: boolean;
dimmed?: boolean;
/** Permission for the drag-handle render gate. Default true. */
canDrag?: boolean;
/** Permission for the active-checkbox. Default true. */
canToggleActive?: boolean;
}
/**
* Full row: composes grip + row-content + drop-indicator + click/DnD
* wiring. The row body is a single `<div>` that serves as
* - @dnd-kit droppable (id = item.id) — internal drag drop target.
* - HTML5 drop target (via getRowHandlers from DnD context).
* - HTML5 drag source (the row body itself; grip uses @dnd-kit).
* - click target — row-select via fire("itemClick").
*
* Drop indicator paints when the row is the active over-target AND the
* computed zone isn't circular.
*/
export function TaskTreeRow({
item,
level,
isSelected,
isCollapsed,
dimmed,
canDrag = true,
canToggleActive = true,
}: TaskTreeRowProps) {
const state = useTaskTreeStateContext();
const { indentSize, statusIndicator, renderRow } = useTaskTreeRenderContext();
const dnd = useTaskTreeDndContext();
const { setNodeRef, isOver } = useDroppable({ id: item.id });
const isActiveSource = dnd?.activeItemId === item.id;
const showIndicator =
!!dnd &&
isOver &&
dnd.overId === item.id &&
dnd.overZone !== null &&
!dnd.overCircular;
const rowHandlers = dnd?.getRowHandlers(item);
const indicatorIndentPx =
(statusIndicator === "strip" ? 12 : 8) + level * indentSize;
const hasChildren = !!item.children && item.children.length > 0;
const defaultRender = (
<TaskTreeRowContent
item={item}
level={level}
isSelected={isSelected}
isCollapsed={isCollapsed}
dimmed={dimmed}
canToggleActive={canToggleActive}
onToggleCollapse={() => state.toggleCollapse(item.id)}
onToggleActive={(next) => state.toggleActive(item.id, next)}
/>
);
const body = renderRow
? renderRow({
item,
level,
isSelected,
isCollapsed,
isExpanded: hasChildren && !isCollapsed,
defaultRender,
})
: defaultRender;
return (
<div
ref={setNodeRef}
data-task-tree-row={item.id}
onClick={(e: MouseEvent<HTMLDivElement>) => {
// Defensive: synthetic events from nested buttons should already
// stopPropagation, but a slot row replacement might not.
if (
(e.target as HTMLElement | null)?.closest("button, input, [role='button']")
) {
return;
}
// Move keyboard focus to the clicked row so arrow keys continue
// from here. The treeitem div's onFocus also calls focusItem, but
// a programmatic click from a slot may skip onFocus.
state.focusItem(item.id);
dnd?.handleRowClick(item, level, e);
}}
draggable={rowHandlers?.draggable && canDrag}
onDragStart={rowHandlers?.onDragStart}
onDragOver={rowHandlers?.onDragOver}
onDrop={rowHandlers?.onDrop}
className={cn(
// `group` enables the grip's hover-reveal.
"group relative flex w-full items-stretch",
isSelected && "bg-accent/30",
isActiveSource && "opacity-50",
// Hover lift — applies when not dimmed by filter-fade.
!dimmed && "hover:bg-accent/20",
)}
>
{/* Grip column — leading flex item so it sits inside the row's
bounding box (a previous absolute `-left-4` placement landed
outside the row and was clipped by ancestor overflow:hidden /
overflow:auto, making DnD unreachable). Always rendered
(no `sm:` gate) because @dnd-kit's TouchSensor listeners live on
the grip's button — hiding it on mobile would make long-press
drag unreachable. Desktop UX still hides at rest via the
`group-hover` class inside <TaskTreeGrip>. */}
<span className="flex w-4 shrink-0 items-center justify-center">
<TaskTreeGrip id={item.id} enabled={canDrag} level={level} />
</span>
<div className="min-w-0 flex-1">{body}</div>
{showIndicator && dnd?.overZone && (
<TaskTreeDropIndicator
zone={dnd.overZone}
indentPx={indicatorIndentPx}
/>
)}
</div>
);
}
src/registry/components/data/task-tree/parts/task-tree-grip.tsx
"use client";
import { useDraggable } from "@dnd-kit/core";
import { GripVertical } from "lucide-react";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
export interface TaskTreeGripProps {
/** Stable id used as the @dnd-kit draggable id (= item.id). */
id: string;
/**
* Permission gate. When false the grip renders empty (no drag affordance)
* so the cursor doesn't suggest interaction; @dnd-kit's listeners are
* never wired.
*/
enabled?: boolean;
/** Marker the @dnd-kit handlers can read off `activeId.data.current.level`. */
level?: number;
className?: string;
}
/**
* Hover-revealed drag handle. The grip is invisible at rest and fades in
* on row hover (the row sets `group` so this can use `group-hover`).
*
* Wires `useDraggable` from @dnd-kit/core as the activator — drag begins
* here, NOT on the row body. That's what enables the activator mutual-
* exclusion pattern: row-level native dragstart checks
* `isInternalDragRef.current` and bails when the grip has already taken
* over.
*/
export const TaskTreeGrip = forwardRef<HTMLButtonElement, TaskTreeGripProps>(
function TaskTreeGrip({ id, enabled = true, level, className }, ref) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id,
data: { level },
disabled: !enabled,
});
if (!enabled) return null;
return (
<button
ref={(node) => {
setNodeRef(node);
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
type="button"
aria-label="Drag to move"
// Stop click bubble — the grip click should not also select the row.
onClick={(e) => e.stopPropagation()}
{...attributes}
{...listeners}
className={cn(
"inline-flex size-4 shrink-0 cursor-grab items-center justify-center rounded-sm text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isDragging && "cursor-grabbing opacity-100",
className,
)}
>
<GripVertical className="size-3" />
</button>
);
},
);
src/registry/components/data/task-tree/parts/task-tree-drop-indicator.tsx
"use client";
import { cn } from "@/lib/utils";
import type { EdgeZone } from "../lib/edge-zone";
export interface TaskTreeDropIndicatorProps {
/** Which slot of the over-row the indicator paints in. */
zone: EdgeZone;
/** Indent of the dragged-over row so the line aligns with row content. */
indentPx?: number;
className?: string;
}
/**
* Visual feedback for the active drop target.
*
* - "top" — 2px horizontal line at the row's top edge → sibling-before.
* - "bottom" — 2px horizontal line at the row's bottom edge → sibling-after.
* - "middle" — inner-glow ring around the row → reparent as last child.
*
* The component is absolutely positioned relative to the row's bounding box.
* The row sets `position: relative` (already true on `TaskTreeRowContent`)
* for this to land in the right place. Pointer events are disabled so the
* drop event still hits the row, not this overlay.
*/
export function TaskTreeDropIndicator({
zone,
indentPx = 0,
className,
}: TaskTreeDropIndicatorProps) {
if (zone === "top" || zone === "bottom") {
return (
<span
aria-hidden
className={cn(
"pointer-events-none absolute right-0 z-10 h-0.5 bg-primary",
zone === "top" ? "top-0" : "bottom-0",
className,
)}
style={{ left: indentPx }}
/>
);
}
// zone === "middle"
return (
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 z-10 rounded ring-2 ring-inset ring-primary/70",
className,
)}
/>
);
}
src/registry/components/data/task-tree/parts/task-tree-drag-overlay.tsx
"use client";
import type { TaskItem } from "../../task-card/types";
import { TaskTreeName } from "./task-tree-name";
import { cn } from "@/lib/utils";
export interface TaskTreeDragOverlayProps {
item: TaskItem;
level: number;
className?: string;
}
/**
* Cursor-follow visual mounted inside @dnd-kit's `<DragOverlay>`. Kept
* deliberately spare — name only, with a slight elevation — so it reads as
* a "ghost" and doesn't fight the real row underneath.
*/
export function TaskTreeDragOverlay({
item,
level,
className,
}: TaskTreeDragOverlayProps) {
return (
<div
className={cn(
"flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-sm shadow-md",
"max-w-sm cursor-grabbing",
className,
)}
data-level={level}
>
<TaskTreeName name={item.name} />
</div>
);
}
src/registry/components/data/task-tree/parts/task-tree-toolbar.tsx
"use client";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTaskTreeStateContext } from "../hooks/use-task-tree-context";
import { TaskTreeSearchInput } from "./task-tree-search-input";
import { TaskTreeSortDropdown } from "./task-tree-sort-dropdown";
import { TaskTreeFilterDropdown } from "./task-tree-filter-dropdown";
import { TaskTreeFilterActiveToggle } from "./task-tree-filter-active-toggle";
import { TaskTreeBulkActionBar } from "./task-tree-bulk-action-bar";
import type {
TaskItem,
TaskPermissions,
TaskStatusOption,
} from "../../task-card/types";
import { evalPermission } from "../lib/permissions";
import { cn } from "@/lib/utils";
// Synthetic level-0 item used only to evaluate the root-create gate against
// `permissions.default` / `byLevel[0]` (there's no real root node to attach a
// rule to). Its empty id never matches a `byItem` rule.
const ROOT_CREATE_SENTINEL: TaskItem = {
id: "",
name: "",
status: "",
active: true,
setAt: "",
};
export interface TaskTreeToolbarProps {
statusOptions?: ReadonlyArray<TaskStatusOption>;
/**
* Bulk-edit has no matching handle method (Q4 lock — v0.1 does not bake
* an edit dialog). When provided, the Edit button renders and fires this
* with the current selection; consumer wires their own dialog. When
* omitted, the Edit button is hidden.
*/
onBulkEdit?: (args: { ids: ReadonlyArray<string> }) => void;
/**
* Optional factory for the "+ New" button. Falls back to a built-in
* default item (`name: "Untitled"`, first statusOption, `active: true`,
* `setAt: now`) when omitted but `statusOptions` is non-empty. Button
* hides entirely if neither path can produce a valid TaskItem or if
* `readOnly` is set.
*/
createItem?: () => TaskItem;
/**
* Intercepts the "+ New" click. When provided, the toolbar calls this
* with the factory's output instead of committing to the tree itself —
* the consumer (e.g., `<TaskTreeWithEditor>`) takes over to open an
* editor on a pending item and commit only on user submit.
*/
onCreateRequest?: (item: TaskItem) => void;
/** Suppresses the "+ New" button when true. */
readOnly?: boolean;
/**
* Gates the "+ New" button AND the bulk action bar's Delete button.
* Without an editing affordance there's nothing meaningful to do with
* a freshly-added "Untitled" row or with a row the user can't otherwise
* interact with — so we hide both.
*/
editable?: boolean;
/**
* Permission matrix. The "+ New" (root-create) button also honors
* `default` / `byLevel[0]` `addChildren` so root creation can be denied via
* the matrix (there's no root node to attach a `byItem` rule to).
*/
permissions?: TaskPermissions;
className?: string;
}
/**
* Default toolbar. Reads state from context; the host passes statusOptions +
* onBulkEdit (the only bulk action with no handle method — Q4 lock means
* consumers wire their own edit dialog).
*
* Activate / Deactivate / Delete go through the handle methods
* (state.toggleActiveBulk, state.removeItems) so they actually mutate
* the tree. The matching event (onBulkToggleActive / onBulkRemove) fires
* automatically via the handle's internal event dispatcher.
*
* Layout per plan §7.1:
* [Search input (grows)] [Active toggle] [Sort dropdown] [Filter dropdown]
* [Bulk action bar — when selectedIds.size > 0]
*/
export function TaskTreeToolbar({
statusOptions,
onBulkEdit,
createItem,
onCreateRequest,
readOnly,
editable = false,
permissions,
className,
}: TaskTreeToolbarProps) {
const state = useTaskTreeStateContext();
const selectedCount = state.selectedIds.size;
const selectedIdsArray = (): string[] => Array.from(state.selectedIds);
// Resolve the new-item factory. Explicit prop wins; otherwise build a
// minimal default that satisfies TaskItem's required fields — needs
// statusOptions[0] for `status`. When neither path can produce an item
// we hide the button rather than insert invalid data.
const factory: (() => TaskItem) | null =
createItem ??
(statusOptions && statusOptions.length > 0
? () => ({
id:
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `todo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: "Untitled",
status: statusOptions[0].value,
active: true,
setAt: new Date().toISOString(),
})
: null);
// Root-create gate: honor the matrix's level-0 `addChildren` rule (mapped via
// the dropIntoChildren action) so consumers can deny root creation.
const canCreateRoot = evalPermission(
permissions,
"dropIntoChildren",
ROOT_CREATE_SENTINEL,
0,
);
const canAdd = editable && !readOnly && factory !== null && canCreateRoot;
const canBulkRemove = editable && !readOnly;
return (
<div
role="toolbar"
aria-label="Tree controls"
className={cn(
"flex flex-wrap items-center gap-2 border-b border-border bg-background/50 px-2 py-1.5",
className,
)}
>
<TaskTreeSearchInput value={state.query} onChange={state.setQuery} />
<TaskTreeFilterActiveToggle
value={state.filter.active}
onChange={(active) =>
state.setFilter({ ...state.filter, active })
}
/>
<TaskTreeSortDropdown value={state.sort} onChange={state.setSort} />
<TaskTreeFilterDropdown
value={state.filter}
onChange={state.setFilter}
items={state.items}
statusOptions={statusOptions}
/>
{canAdd && (
<Button
size="sm"
variant="default"
className="ml-auto"
onClick={(e) => {
const next = factory!();
if (onCreateRequest) {
// Blur the trigger before the consumer mounts a Radix Dialog
// — Radix flips aria-hidden on the surrounding tree wrapper
// synchronously on open, and a still-focused button inside
// an aria-hidden ancestor trips the browser's a11y warning.
// Blurring here lets the dialog's auto-focus take over
// cleanly.
(e.currentTarget as HTMLButtonElement).blur();
// Deferred-commit path — consumer opens an editor and commits
// on submit. Toolbar stays out of the tree mutation entirely.
onCreateRequest(next);
return;
}
// Default path — commit immediately + focus.
state.addItem(next);
state.focusItem(next.id);
}}
>
<Plus className="size-3.5" />
New
</Button>
)}
{selectedCount > 0 && (
<TaskTreeBulkActionBar
count={selectedCount}
onToggleActive={(next) =>
state.toggleActiveBulk(selectedIdsArray(), next)
}
onRemove={
canBulkRemove
? () => state.removeItems(selectedIdsArray())
: undefined
}
onEdit={
onBulkEdit
? () => onBulkEdit({ ids: selectedIdsArray() })
: undefined
}
onClear={state.clearSelection}
/>
)}
</div>
);
}
src/registry/components/data/task-tree/parts/task-tree-search-input.tsx
"use client";
import { useState } from "react";
import { Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useDebouncedCallback } from "../hooks/use-debounced-callback";
import { cn } from "@/lib/utils";
export interface TaskTreeSearchInputProps {
/** Current debounced query — used as the canonical source on external changes. */
value: string;
onChange: (next: string) => void;
/** Default 200ms per Q-P4. */
delayMs?: number;
placeholder?: string;
className?: string;
}
/**
* Search input. Raw text lives in a local input ref so each keystroke
* doesn't churn the reducer; a debounced (default 200ms) wrapper publishes
* the trimmed value via `onChange`. External resets (clear-all-filters)
* sync the local input by re-seeding on prop change.
*/
export function TaskTreeSearchInput({
value,
onChange,
delayMs = 200,
placeholder = "Search items...",
className,
}: TaskTreeSearchInputProps) {
const [local, setLocal] = useState(value);
const [lastSeenValue, setLastSeenValue] = useState(value);
// Adjust local-on-prop-change without an effect — React's recommended
// pattern for "derived state that syncs to a prop" (avoids the cascading-
// render warning that fires for setState-in-useEffect on a value dep).
if (value !== lastSeenValue) {
setLastSeenValue(value);
setLocal(value);
}
const debounced = useDebouncedCallback(onChange, delayMs);
return (
<div className={cn("relative flex-1 min-w-0", className)}>
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={local}
placeholder={placeholder}
onChange={(e) => {
setLocal(e.target.value);
debounced(e.target.value);
}}
className="h-8 pl-7 pr-7"
aria-label="Search todo tree"
/>
{local.length > 0 && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Clear search"
onClick={() => {
setLocal("");
onChange("");
}}
className="absolute right-0.5 top-1/2 size-7 -translate-y-1/2 p-0"
>
<X className="size-3" />
</Button>
)}
</div>
);
}
src/registry/components/data/task-tree/parts/task-tree-sort-dropdown.tsx
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ArrowUpDown } from "lucide-react";
import type { TaskTreeSort } from "../types";
import { cn } from "@/lib/utils";
interface SortOption {
value: string;
label: string;
spec: Exclude<TaskTreeSort, { kind: "custom" }>;
}
const SORT_OPTIONS: ReadonlyArray<SortOption> = [
{ value: "name-asc", label: "Name ↑", spec: { kind: "name", direction: "asc" } },
{ value: "name-desc", label: "Name ↓", spec: { kind: "name", direction: "desc" } },
{ value: "setAt-asc", label: "Created ↑", spec: { kind: "setAt", direction: "asc" } },
{ value: "setAt-desc", label: "Created ↓", spec: { kind: "setAt", direction: "desc" } },
{ value: "expireAt-asc", label: "Due ↑", spec: { kind: "expireAt", direction: "asc" } },
{ value: "expireAt-desc", label: "Due ↓", spec: { kind: "expireAt", direction: "desc" } },
{ value: "status-asc", label: "Status ↑", spec: { kind: "status", direction: "asc" } },
{ value: "status-desc", label: "Status ↓", spec: { kind: "status", direction: "desc" } },
];
function sortToValue(sort: TaskTreeSort): string | undefined {
if (sort.kind === "custom") return undefined;
return `${sort.kind}-${sort.direction}`;
}
export interface TaskTreeSortDropdownProps {
value: TaskTreeSort;
onChange: (next: TaskTreeSort) => void;
className?: string;
}
/**
* Sort selector. 4 sort kinds × 2 directions = 8 options + a "Custom" display
* label when the consumer has set a custom comparator (the dropdown reads
* read-only in that case until the consumer flips back to a named kind).
*
* F-cross-13 pre-emption (L11): onValueChange widened to accept `string | null`
* (Base UI's Select signature). Null coalesces to "name-asc".
*/
export function TaskTreeSortDropdown({
value,
onChange,
className,
}: TaskTreeSortDropdownProps) {
const isCustom = value.kind === "custom";
const current = sortToValue(value);
return (
<Select
value={current ?? ""}
disabled={isCustom}
onValueChange={(v: string | null) => {
const choice = v ?? "name-asc";
const match = SORT_OPTIONS.find((o) => o.value === choice);
if (!match) return;
onChange(match.spec);
}}
>
<SelectTrigger
aria-label="Sort"
className={cn("h-8 w-[140px]", className)}
>
<ArrowUpDown className="mr-1 size-3.5 text-muted-foreground" />
<SelectValue placeholder={isCustom ? "Custom" : "Sort"} />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
src/registry/components/data/task-tree/parts/task-tree-filter-active-toggle.tsx
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ActiveMode = "all" | "active" | "inactive";
export interface TaskTreeFilterActiveToggleProps {
value: ActiveMode | undefined;
onChange: (next: ActiveMode) => void;
className?: string;
}
const MODES: ReadonlyArray<{ value: ActiveMode; label: string }> = [
{ value: "all", label: "All" },
{ value: "active", label: "Active" },
{ value: "inactive", label: "Done" },
];
/**
* Segmented control for the active/inactive filter. Three values; uses
* native `<button>` array with `aria-pressed` rather than a Tabs primitive
* to keep the trigger trivially compact.
*/
export function TaskTreeFilterActiveToggle({
value = "all",
onChange,
className,
}: TaskTreeFilterActiveToggleProps) {
return (
<div
role="group"
aria-label="Filter by active state"
className={cn(
"inline-flex h-8 items-center rounded-md border border-input bg-background p-0.5",
className,
)}
>
{MODES.map((mode) => (
<Button
key={mode.value}
type="button"
variant={value === mode.value ? "secondary" : "ghost"}
size="sm"
aria-pressed={value === mode.value}
onClick={() => onChange(mode.value)}
className="h-7 px-2 text-xs"
>
{mode.label}
</Button>
))}
</div>
);
}
src/registry/components/data/task-tree/parts/task-tree-filter-dropdown.tsx
"use client";
import { useMemo, useState } from "react";
import { Filter, X } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button, buttonVariants } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import type {
TaskItem,
TaskPerson,
TaskStatusOption,
} from "../../task-card/types";
import type { TaskTreeFilter } from "../types";
import { forEachItem } from "../lib/tree-walker";
import { cn } from "@/lib/utils";
export interface TaskTreeFilterDropdownProps {
value: TaskTreeFilter;
onChange: (next: TaskTreeFilter) => void;
items: TaskItem[];
statusOptions?: ReadonlyArray<TaskStatusOption>;
className?: string;
}
/**
* Multi-select Popover for status + person + clear. Status chips come from
* the consumer's statusOptions; persons are gathered lazily from the live
* items tree on first open (and recomputed when items change while the
* popover is open). Empty arrays mean "no filter applied" semantically.
*/
export function TaskTreeFilterDropdown({
value,
onChange,
items,
statusOptions,
className,
}: TaskTreeFilterDropdownProps) {
const [open, setOpen] = useState(false);
const persons = useMemo(() => collectPersons(items), [items]);
const activeStatusCount = value.statuses?.length ?? 0;
const activePersonCount = value.personIds?.length ?? 0;
const totalActive = activeStatusCount + activePersonCount;
const toggleStatus = (statusValue: string) => {
const current = value.statuses ?? [];
const next = current.includes(statusValue)
? current.filter((s) => s !== statusValue)
: [...current, statusValue];
onChange({ ...value, statuses: next });
};
const togglePerson = (personId: string) => {
const current = value.personIds ?? [];
const next = current.includes(personId)
? current.filter((p) => p !== personId)
: [...current, personId];
onChange({ ...value, personIds: next });
};
return (
<Popover open={open} onOpenChange={setOpen}>
{/* F-cross-13 path-b: trigger IS the button (native <button> in both
backends) — `asChild` is Radix-only; `buttonVariants` reproduces the
outline/sm look. (v0.3.2) */}
<PopoverTrigger
type="button"
aria-label="Filter"
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"h-8 gap-1",
className,
)}
>
<Filter className="size-3.5" />
<span>Filter</span>
{totalActive > 0 && (
<Badge variant="secondary" className="ml-1 h-4 px-1 text-[10px]">
{totalActive}
</Badge>
)}
</PopoverTrigger>
<PopoverContent
className="w-72 max-h-[60vh] overflow-y-auto p-3"
align="end"
>
<div className="space-y-3">
{statusOptions && statusOptions.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
Status
</div>
<div className="space-y-1">
{statusOptions.map((opt) => {
const checked = value.statuses?.includes(opt.value) ?? false;
return (
<label
key={opt.value}
className="flex items-center gap-2 text-sm cursor-pointer"
>
<Checkbox
checked={checked}
onCheckedChange={() => toggleStatus(opt.value)}
/>
<span>{opt.label}</span>
</label>
);
})}
</div>
</div>
)}
{persons.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
Person
</div>
<div className="space-y-1">
{persons.map((p) => {
const checked = value.personIds?.includes(p.id) ?? false;
return (
<label
key={p.id}
className="flex items-center gap-2 text-sm cursor-pointer"
>
<Checkbox
checked={checked}
onCheckedChange={() => togglePerson(p.id)}
/>
<span>{p.name}</span>
</label>
);
})}
</div>
</div>
)}
{totalActive > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onChange({ ...value, statuses: [], personIds: [] })}
className="w-full justify-start gap-1 px-1 h-7 text-xs"
>
<X className="size-3" /> Clear status + person
</Button>
)}
</div>
</PopoverContent>
</Popover>
);
}
/**
* Lazy person collection: walks the tree once collecting every unique
* person id from both targetPerson and creatorPerson fields. Preserves
* insertion order for stable rendering.
*/
function collectPersons(items: ReadonlyArray<TaskItem>): TaskPerson[] {
const map = new Map<string, TaskPerson>();
forEachItem(items, (item) => {
if (item.targetPerson && !map.has(item.targetPerson.id)) {
map.set(item.targetPerson.id, item.targetPerson);
}
if (item.creatorPerson && !map.has(item.creatorPerson.id)) {
map.set(item.creatorPerson.id, item.creatorPerson);
}
});
return Array.from(map.values());
}
src/registry/components/data/task-tree/parts/task-tree-bulk-action-bar.tsx
"use client";
import { Trash2, Pencil, CheckCheck, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
export interface TaskTreeBulkActionBarProps {
count: number;
/** When omitted the matching button is hidden. */
onToggleActive?: (nextActive: boolean) => void;
onRemove?: () => void;
onEdit?: () => void;
onClear?: () => void;
className?: string;
}
/**
* Bulk action surface — appears in the toolbar when any rows are selected.
* Each action button is gated on the matching callback being provided;
* `onEdit` in particular is consumer-only (bulk-edit popup isn't baked into
* v0.1 per Q4 lock — consumers wire their own dialog).
*/
export function TaskTreeBulkActionBar({
count,
onToggleActive,
onRemove,
onEdit,
onClear,
className,
}: TaskTreeBulkActionBarProps) {
if (count === 0) return null;
return (
<div
role="toolbar"
aria-label={`${count} item${count === 1 ? "" : "s"} selected`}
className={cn(
"inline-flex h-8 items-center gap-1 rounded-md border border-input bg-background px-1.5",
className,
)}
>
<Badge variant="secondary" className="h-5 px-1.5 text-xs">
{count}
</Badge>
<Separator orientation="vertical" className="h-4" />
{onToggleActive && (
<>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onToggleActive(true)}
className="h-6 gap-1 px-2 text-xs"
>
<CheckCheck className="size-3" /> Activate
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onToggleActive(false)}
className="h-6 px-2 text-xs"
>
Deactivate
</Button>
</>
)}
{onEdit && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onEdit}
className="h-6 gap-1 px-2 text-xs"
>
<Pencil className="size-3" /> Edit
</Button>
)}
{onRemove && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onRemove}
className="h-6 gap-1 px-2 text-xs text-destructive hover:text-destructive"
>
<Trash2 className="size-3" /> Delete
</Button>
)}
{onClear && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Clear selection"
onClick={onClear}
className="size-6"
>
<X className="size-3" />
</Button>
)}
</div>
);
}
src/registry/components/data/task-tree/parts/task-tree-empty-state.tsx
"use client";
import { Inbox, SearchX } from "lucide-react";
import { cn } from "@/lib/utils";
export interface TaskTreeEmptyStateProps {
/** True when the empty list is the result of a filter/search; false when the tree is genuinely empty. */
hasFilter: boolean;
className?: string;
}
/**
* Default placeholder rendered when `visibleItems.length === 0`. Two
* variants — "no matches" (filter active) vs "no tasks" (genuinely empty
* tree). Replace via the `renderEmptyState` slot prop.
*/
export function TaskTreeEmptyState({
hasFilter,
className,
}: TaskTreeEmptyStateProps) {
const Icon = hasFilter ? SearchX : Inbox;
return (
<div
role="status"
aria-live="polite"
className={cn(
"flex h-full flex-col items-center justify-center gap-2 px-6 py-12 text-center",
className,
)}
>
<Icon className="size-8 text-muted-foreground/60" aria-hidden />
<div className="text-sm font-medium text-foreground">
{hasFilter ? "No matches" : "No tasks yet"}
</div>
<div className="text-xs text-muted-foreground">
{hasFilter
? "Adjust your search or filter to see more rows."
: "Items added via the tree's imperative API or DnD will appear here."}
</div>
</div>
);
}