shadimage
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 · light
iconslucide-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>
  );
}

More from ilinxa

Browse all components in this library →