Tree viewNew
The sidebar of pages, folders or files: carry a row up, down and sideways to nest it, and folders open as you hold them.
Install with the shadcn CLI
$ npxnpx shadcn@latest add https://hairlineui.com/r/tree-view.jsonOverview
Most tree views pop children in, and their drag and drop shows a blue line that never says whether you are dropping inside a folder or beside it, so rows land one level off. Here a row's children slide out from under it while the rows below make room, and closing folds them back. Pick a row up (with everything inside it) and a copy follows you while the original dims in place, so nothing under the pointer moves. Up and down picks the gap; sideways picks the depth, one level per indent, and the line marking the gap starts exactly where the row will sit. The middle of a row means inside it: the row takes a ring, and a closed one opens itself if you hold it there. A rule can refuse a place: the line turns amber and the reason shows under what you carry. Let go and the row glides from your hand into its slot; a save that fails takes it back. F2 renames in place, + adds a row inside, Delete removes it with Undo, and Alt with the arrows moves a row by keyboard.
States
- open
- Opening a row turns its chevron (260ms) while its children fade in through a 4px blur where they land and the rows below glide down (480ms). Closing folds the children away where they stood while the rows below glide up. An open row with nothing inside says so.
- lift
- After 4px (or a 300ms hold on touch, with a short vibration where supported) a copy of the row lifts: a hairline ring and a "+3" count of what's inside it. The row and everything under it dim to 40% in place.
- slot
- The gap under the pointer gets a 2px line in the ring colour with a hollow dot at its start; the start is the depth the row will land at (90ms per change). Sideways movement changes the depth one indent at a time, inside the depths that gap allows.
- into
- Over the middle half of a row that holds others, a ring and a light wash surround it: the row will go inside, at the end. Held there 650ms, a closed row with rows inside opens; rows opened this way close again if nothing was dropped in them.
- refuse
- When canDrop returns a reason, the line or ring turns amber and the reason appears under the carried copy. Let go and the copy springs home (300ms, slight overshoot); the reason stays a moment under the tree.
- land
- The moved row glides from where the copy was let go into its slot (480ms), its parent opening if it was closed; rows that changed depth slide sideways into their new indent. If onMove throws, everything glides back, the row shakes, and the line under the tree says so.
- edit
- F2 or a double-click turns the title into a field in place. + on a row grows a new row inside it with a field; Enter keeps it, Escape folds it away. Delete folds the row and everything inside it away and holds "Deleted … · Undo" over a draining hairline (paused on hover) before calling onDelete.
Usage
import { TreeView, type TreeNode } from "@/components/tree-view";const [nodes, setNodes] = useState<TreeNode[]>([ { id: "docs", parent: null, title: "Docs" }, { id: "intro", parent: "docs", title: "Introduction" }, { id: "readme", parent: null, title: "README.md", leaf: true },]);<TreeView nodes={nodes} onNodesChange={setNodes} defaultExpanded={["docs"]} onSelect={(n) => router.push(`/p/${n.id}`)} />Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.
A workspace sidebar that saves every change
Nodes are rows of a pages table: { id, parent, title } in sibling order. Each callback saves one change and throws to undo it; onCreate returns the stored page so it takes its real id. canDrop keeps the rules your server keeps.
"use client";import { useState } from "react";import { useRouter } from "next/navigation";import { TreeView, type TreeNode } from "@/components/tree-view";type Page = TreeNode & { readOnly?: boolean };export function PagesSidebar({ initial, current }: { initial: Page[]; current: string }) { const router = useRouter(); const [pages, setPages] = useState(initial); const send = async (url: string, method: string, body?: unknown) => { const res = await fetch(url, { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); if (!res.ok) throw new Error(await res.text()); return res.status === 204 ? undefined : res.json(); }; return ( <TreeView<Page> className="h-full" label="Pages" nodes={pages} onNodesChange={setPages} selected={current} onSelect={(page) => router.push(`/p/${page.id}`)} canDrop={(page, parent) => (parent?.readOnly ? `${parent.title} is read-only` : null)} onMove={(page, parent, index) => send(`/api/pages/${page.id}/move`, "POST", { parent: parent?.id ?? null, index })} onRename={(page, title) => send(`/api/pages/${page.id}`, "PATCH", { title })} onCreate={(parent, title) => send("/api/pages", "POST", { parent: parent?.id ?? null, title })} onDelete={(page) => send(`/api/pages/${page.id}`, "DELETE")} /> );}Props
| Prop | Type | Description |
|---|---|---|
| nodes / defaultNodes | T extends { id, parent, title, leaf? }[] | Every node, flat; parent is null at the top level. Siblings stand in the order they appear. leaf rows can't hold others. |
| onNodesChange | (nodes: T[]) => void | The new list after every move, rename, add, delete and undo. |
| expanded / defaultExpanded / onExpandedChange | string[] | Ids of the open rows. |
| selected / defaultSelected | string | null | The chosen row. |
| onSelect | (node: T) => void | A row chosen by click, Enter or Space, e.g. to open its page. |
| icon | (node, { expanded, hasChildren }) => ReactNode | The row's icon. Defaults to a folder (open or closed), or a file for leaves. |
| canDrop | (node, parent: T | null) => string | null | A reason parent won't take node; null is the top level. Moving a row into itself or into a leaf is refused for you. |
| onMove | (node, parent, index) => Promise<void> | Saves a move (node already has its new parent). Throw to put it back. |
| onRename | (node, title) => Promise<void> | Turns on renaming (F2, double-click). Throw to put the old title back. |
| onCreate | (parent, title) => T | Promise<T> | Turns on + (inside rows, and New … at the foot). Return the stored node. |
| onDelete | (node) => Promise<void> | Turns on delete. Called once the chance to undo is over; throw to bring the row back. |
| addLabel | stringdefault "page" | The noun in "New …". |
| undoFor | numberdefault 5000 | How long Undo is offered after a delete, ms. |
| indent | numberdefault 14 | Pixels per level. |
| label | stringdefault "Pages" | Accessible name of the tree. |
Accessibility and motion
- A tree of treeitems with their level, position and open state. One row is in the tab order: ↑ ↓ move, → opens a row or steps into it, ← closes it or steps out to its parent, Home and End jump, typing a title's first letters jumps to it, Enter or Space chooses.
- Alt with ↑ or ↓ moves a row among its siblings, Alt+→ moves it into the row above, Alt+← moves it out after its parent. F2 renames and Delete deletes. Every move is announced with where it went.
- Carrying writes transforms only, to the copy, the line and the ring; rows are measured once when the carry starts (and again only when a row opens under it), and nothing re-renders until the drop. Rows are memoised, so opening a folder re-renders only the rows it changes.
- On touch the list scrolls as usual; holding a row for 300ms picks it up, and only then does the page stop scrolling. A chosen row shows its + and delete buttons, since there's no hover. Near the top or bottom of the tree (or the window) it scrolls.
- Reduced motion opens, closes and moves rows in place; the line, the ring, the amber refusal and the announcements still say what happens.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.
Source
"use client";import * as React from "react";import { ChevronRight, File, Folder, FolderOpen, Plus, Trash2 } from "lucide-react";import { useRowMotion } from "./use-row-motion";import { useTreeDrag, type TreeTarget } from "./use-tree-drag";/* ───────────────────────────────────────────────────────── * TREE VIEW: the sidebar of pages, folders or files * * open a row's children slide out from under it while * the rows below make room; closing folds them away * carry drag a row (and everything inside it): up and * down picks the gap, sideways picks the depth, and * a line starting at that depth shows where it lands * into the middle of a row means "inside it"; a closed * one opens itself if you hold it there * refuse a rule (canDrop) turns the line amber and says * why under the row you carry; let go and it flies * home * land the row glides from where you let go into its * place; a save that fails takes it back * edit F2 or double-click renames in place, + adds a row * inside, Delete removes with Undo * keys arrows move and open, Alt+arrows move the row * (up, down, in, out) * * Nodes are a flat list of { id, parent, title }, siblings in * the order they stand: the shape a database keeps. * ───────────────────────────────────────────────────────── */export interface TreeNode { id: string; parent: string | null; title: string; /** Can't hold other rows (a file, say). */ leaf?: boolean;}export interface TreeViewProps<T extends TreeNode> extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "defaultValue" | "onSelect"> { nodes?: T[]; defaultNodes?: T[]; onNodesChange?: (nodes: T[]) => void; /** Ids of the open rows. */ expanded?: string[]; defaultExpanded?: string[]; onExpandedChange?: (ids: string[]) => void; selected?: string | null; defaultSelected?: string | null; /** A row chosen by click, Enter or Space. */ onSelect?: (node: T) => void; /** The row's icon. Defaults to a folder (open or closed) or a file. */ icon?: (node: T, state: { expanded: boolean; hasChildren: boolean }) => React.ReactNode; /** Why `parent` won't take `node` (null is the top level), or nothing. */ canDrop?: (node: T, parent: T | null) => string | null | undefined | void; /** A row moved. Throw to put it back. */ onMove?: (node: T, parent: T | null, index: number) => Promise<void> | void; /** Shows rename (F2, double-click). Throw to put the old title back. */ onRename?: (node: T, title: string) => Promise<void> | void; /** Shows + on rows that hold others (and at the foot). Return the new node. */ onCreate?: (parent: T | null, title: string) => T | Promise<T>; /** Shows delete. Runs once the chance to undo is over; throw to bring the row back. */ onDelete?: (node: T) => Promise<void> | void; /** The noun for new rows: "page", "file". */ addLabel?: string; undoFor?: number; /** Pixels per level. */ indent?: number; label?: string;}type Row<T> = | { kind: "node"; node: T; depth: number; hasChildren: boolean; open: boolean; pos: number; size: number } | { kind: "empty"; parent: string; depth: number } | { kind: "draft"; parent: string | null; depth: number };type Note = { text: string; tone: "warn" | "error"; key: number };type Undo<T> = { key: number; node: T; removed: T[]; nodes: T[] };const EASE = "cubic-bezier(0.16,1,0.3,1)";const GLIDE = 480;const ROW_H = 30;const INSET = 26;const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";const NO_IDS: string[] = [];const NO_RANKS: ReadonlyMap<string, number> = new Map();const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => { const query = window.matchMedia(reducedQuery); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange);};const useReducedMotion = () => React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);/** Every node under `id`, at any depth. */function under<T extends TreeNode>(nodes: T[], id: string) { const out = new Set<string>(); const walk = (p: string) => nodes.forEach((n) => n.parent === p && !out.has(n.id) && (out.add(n.id), walk(n.id))); walk(id); return out;}/** Moves a node into `parent`: first (after null), after a sibling, or at the end. */function place<T extends TreeNode>(nodes: T[], id: string, parent: string | null, after: string | null | "end"): T[] { const node = nodes.find((n) => n.id === id); if (!node) return nodes; const rest = nodes.filter((n) => n.id !== id); const sibs = rest.filter((n) => n.parent === parent); let at: number; if (after === "end") at = sibs.length ? rest.indexOf(sibs[sibs.length - 1]) + 1 : rest.length; else if (after === null) at = sibs.length ? rest.indexOf(sibs[0]) : rest.length; else at = rest.findIndex((n) => n.id === after) + 1; return [...rest.slice(0, at), { ...node, parent } as T, ...rest.slice(at)];}/** A sentence that changes crossfades whole. */function Line({ text, className = "" }: { text: string; className?: string }) { const [shown, setShown] = React.useState({ text, key: 0 }); const [leaving, setLeaving] = React.useState<{ text: string; key: number }[]>([]); if (text !== shown.text) { if (shown.text) setLeaving((l) => [...l, shown]); setShown({ text, key: shown.key + 1 }); } return ( <span className={`relative block ${className}`}> <span key={shown.key} className="block truncate" ref={(el) => { if (!el || shown.key === 0 || el.dataset.in) return; el.dataset.in = "1"; el.animate( [ { opacity: 0, transform: "translateY(0.35em)", filter: "blur(4px)" }, { opacity: 1, transform: "none", filter: "blur(0px)" }, ], { duration: 380, delay: 60, easing: EASE, fill: "backwards" }, ); }} > {shown.text || " "} </span> {leaving.map((l) => ( <span key={l.key} aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 block truncate" ref={(el) => { if (!el || el.dataset.out) return; el.dataset.out = "1"; el.animate( [ { opacity: 1, filter: "blur(0px)" }, { opacity: 0, filter: "blur(4px)" }, ], { duration: 180, easing: EASE, fill: "forwards" }, ).onfinish = () => setLeaving((all) => all.filter((x) => x.key !== l.key)); }} > {l.text} </span> ))} </span> );}type RowActions<T> = { click: (e: React.MouseEvent, node: T) => void; toggle: (node: T) => void; key: (e: React.KeyboardEvent, node: T) => void; focus: (node: T) => void; rename: (node: T) => void; commitRename: (node: T, title: string) => void; cancelRename: () => void; add: (node: T) => void; remove: (node: T) => void; icon: (node: T, state: { expanded: boolean; hasChildren: boolean }) => React.ReactNode;};/** One row, memoised on plain values: opening a folder or carrying a row re-renders only the rows it changes. */function TreeRowView<T extends TreeNode>({ node, depth, hasChildren, open, pos, size, selected, tab, dimmed, renaming, canAdd, canDelete, canRename, indent, reduced, actions,}: { node: T; depth: number; hasChildren: boolean; open: boolean; pos: number; size: number; selected: boolean; tab: boolean; dimmed: boolean; renaming: boolean; canAdd: boolean; canDelete: boolean; canRename: boolean; indent: number; reduced: boolean; actions: React.RefObject<RowActions<T>>;}) { const holds = !node.leaf; return ( <li data-row-id={node.id} data-depth={depth} data-parent={node.parent ?? ""} data-can-hold={holds ? "1" : "0"} data-expanded={open ? "1" : "0"} data-has-children={hasChildren ? "1" : "0"} role="treeitem" aria-level={depth + 1} aria-setsize={size} aria-posinset={pos} aria-expanded={holds ? open : undefined} aria-selected={selected} tabIndex={tab ? 0 : -1} onKeyDown={(e) => actions.current.key(e, node)} onFocus={(e) => e.target === e.currentTarget && actions.current.focus(node)} onClick={(e) => actions.current.click(e, node)} onDoubleClick={() => canRename && actions.current.rename(node)} className={`group/row relative shrink-0 cursor-default select-none rounded-[8px] outline-none [-webkit-touch-callout:none] ${FOCUS}`} style={{ height: ROW_H, opacity: dimmed ? 0.4 : 1, transition: reduced ? "none" : `opacity 160ms ${EASE}` }} > <div data-row-content className={`flex h-full items-center gap-1.5 rounded-[8px] pr-1 text-[13.5px] transition-colors duration-150 ${selected ? "bg-accent font-medium text-foreground" : "text-foreground/85 hover:bg-accent/60"}`} style={{ paddingLeft: 4 + depth * indent }} > {holds ? ( <button type="button" tabIndex={-1} aria-hidden="true" onClick={(e) => { e.stopPropagation(); actions.current.toggle(node); }} className={`flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-[6px] text-muted-foreground hover:bg-accent hover:text-foreground ${hasChildren ? "" : "opacity-40"}`} > <ChevronRight className="size-3.5" strokeWidth={2} style={{ transform: open ? "rotate(90deg)" : "none", transition: reduced ? "none" : `transform 260ms ${EASE}` }} /> </button> ) : ( <span className="size-5 shrink-0" aria-hidden="true" /> )} <span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4" aria-hidden="true"> {actions.current.icon(node, { expanded: open, hasChildren })} </span> {renaming ? ( <input autoFocus defaultValue={node.title} aria-label={`Rename ${node.title}`} onFocus={(e) => e.currentTarget.select()} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") actions.current.commitRename(node, e.currentTarget.value); if (e.key === "Escape") actions.current.cancelRename(); }} onBlur={(e) => actions.current.commitRename(node, e.currentTarget.value)} className="-ml-1 h-6 min-w-0 flex-1 rounded-[6px] bg-background px-1 text-[13.5px] font-normal outline-none shadow-[inset_0_0_0_1px_var(--ring)] [@media(pointer:coarse)]:text-[16px]" /> ) : ( <span className="min-w-0 flex-1 truncate">{node.title}</span> )} {!renaming && (canAdd || canDelete) && ( // Shown on hover and focus; on touch screens, on the chosen row. <span className={`flex shrink-0 items-center opacity-0 transition-opacity duration-150 group-hover/row:opacity-100 group-focus-within/row:opacity-100 ${selected ? "[@media(pointer:coarse)]:opacity-100" : "[@media(pointer:coarse)]:hidden"}`}> {canDelete && ( <button type="button" tabIndex={-1} aria-label={`Delete ${node.title}`} onClick={(e) => { e.stopPropagation(); actions.current.remove(node); }} className="flex size-6 cursor-pointer items-center justify-center rounded-[6px] text-muted-foreground hover:bg-accent hover:text-foreground" > <Trash2 className="size-3.5" strokeWidth={1.75} /> </button> )} {canAdd && holds && ( <button type="button" tabIndex={-1} aria-label={`Add inside ${node.title}`} onClick={(e) => { e.stopPropagation(); actions.current.add(node); }} className="flex size-6 cursor-pointer items-center justify-center rounded-[6px] text-muted-foreground hover:bg-accent hover:text-foreground" > <Plus className="size-3.5" strokeWidth={1.75} /> </button> )} </span> )} </div> </li> );}const TreeRow = React.memo(TreeRowView) as typeof TreeRowView;const defaultIcon = (node: TreeNode, { expanded, hasChildren }: { expanded: boolean; hasChildren: boolean }) => node.leaf ? <File strokeWidth={1.75} /> : expanded && hasChildren ? <FolderOpen strokeWidth={1.75} /> : <Folder strokeWidth={1.75} />;export function TreeView<T extends TreeNode>({ nodes: controlled, defaultNodes, onNodesChange, expanded: expandedProp, defaultExpanded = NO_IDS, onExpandedChange, selected: selectedProp, defaultSelected = null, onSelect, icon = defaultIcon, canDrop, onMove, onRename, onCreate, onDelete, addLabel = "page", undoFor = 5000, indent = 14, label = "Pages", className = "", ...props}: TreeViewProps<T>) { const reduced = useReducedMotion(); const uid = React.useId(); const [own, setOwn] = React.useState<T[]>(defaultNodes ?? []); const nodes = controlled ?? own; const nodesRef = React.useRef(nodes); nodesRef.current = nodes; const [openOwn, setOpenOwn] = React.useState<string[]>(defaultExpanded); const openIds = expandedProp ?? openOwn; const open = React.useMemo(() => new Set(openIds), [openIds]); const openRef = React.useRef(open); openRef.current = open; const [selOwn, setSelOwn] = React.useState<string | null>(defaultSelected); const selected = selectedProp === undefined ? selOwn : selectedProp; const [focusId, setFocusId] = React.useState<string | null>(null); const [lifted, setLifted] = React.useState<string | null>(null); const [renaming, setRenaming] = React.useState<string | null>(null); const [draft, setDraft] = React.useState<{ parent: string | null; key: number } | null>(null); const [note, setNote] = React.useState<Note | null>(null); const [undo, setUndo] = React.useState<Undo<T> | null>(null); const [holdUndo, setHoldUndo] = React.useState(false); const [announce, setAnnounce] = React.useState(""); const scrollerRef = React.useRef<HTMLDivElement>(null); const listRef = React.useRef<HTMLUListElement>(null); const ghostRef = React.useRef<HTMLDivElement>(null); const previewRef = React.useRef<HTMLDivElement>(null); const reasonRef = React.useRef<HTMLParagraphElement>(null); const lineRef = React.useRef<HTMLDivElement>(null); const ringRef = React.useRef<HTMLDivElement>(null); /** Rows that should glide in from somewhere other than their old place (a drop, a depth change). */ const landing = React.useRef<{ id?: string; from?: { x: number; y: number }; depths: Map<string, { depth: number; top: number }> } | null>(null); const sprung = React.useRef<Set<string>>(new Set()); const refocus = React.useRef<string | null>(null); const typed = React.useRef({ text: "", at: 0 }); const renamingRef = React.useRef<string | null>(null); const say = (text: string) => setAnnounce((a) => (a === text ? `${text} ` : text)); const warn = (text: string, tone: Note["tone"] = "warn") => { const key = Date.now(); setNote({ text, tone, key }); window.setTimeout(() => setNote((n) => (n?.key === key ? null : n)), 3600); }; const byId = React.useCallback((id: string | null) => (id ? nodesRef.current.find((n) => n.id === id) ?? null : null), []); const commit = (next: T[]) => { nodesRef.current = next; if (controlled === undefined) setOwn(next); onNodesChange?.(next); }; const setOpenIds = (next: Set<string>) => { openRef.current = next; const ids = [...next]; if (expandedProp === undefined) setOpenOwn(ids); onExpandedChange?.(ids); }; const select = (node: T) => { if (selectedProp === undefined) setSelOwn(node.id); onSelect?.(node); }; // ── The rows in view ─────────────────────────────────── const rows = React.useMemo(() => { const kids = new Map<string | null, T[]>(); for (const n of nodes) { const list = kids.get(n.parent); if (list) list.push(n); else kids.set(n.parent, [n]); } const out: Row<T>[] = []; const walk = (parent: string | null, depth: number) => { const list = kids.get(parent) ?? []; list.forEach((node, i) => { const hasChildren = (kids.get(node.id)?.length ?? 0) > 0; const isOpen = !node.leaf && open.has(node.id); out.push({ kind: "node", node, depth, hasChildren, open: isOpen, pos: i + 1, size: list.length }); if (!isOpen) return; walk(node.id, depth + 1); if (draft?.parent === node.id) out.push({ kind: "draft", parent: node.id, depth: depth + 1 }); else if (!hasChildren) out.push({ kind: "empty", parent: node.id, depth: depth + 1 }); }); }; walk(null, 0); if (draft && draft.parent === null) out.push({ kind: "draft", parent: null, depth: 0 }); return out; }, [nodes, open, draft]); const nodeRows = rows.filter((r): r is Extract<Row<T>, { kind: "node" }> => r.kind === "node"); const tabId = (focusId && nodeRows.some((r) => r.node.id === focusId) ? focusId : null) ?? (selected && nodeRows.some((r) => r.node.id === selected) ? selected : nodeRows[0]?.node.id); useRowMotion(listRef, { reduced, ranks: NO_RANKS, ghosts: ghostRef }); // After a drop or a keyboard move: the moved row comes from where it was let go, rows that changed depth slide sideways. React.useLayoutEffect(() => { const l = landing.current; const list = listRef.current; landing.current = null; if (!l || !list || reduced) return; list.querySelectorAll<HTMLElement>("[data-row-id][data-depth]").forEach((el) => { const id = el.dataset.rowId!; const was = l.depths.get(id); if (!was) return; const depth = Number(el.dataset.depth); if (id === l.id && l.from) { el.animate([{ transform: `translate(${l.from.x + (was.depth - depth) * indent}px, ${l.from.y - was.top}px)` }, { transform: "translate(0px, 0px)" }], { duration: GLIDE, easing: EASE, composite: "add" }); return; } if (was.depth !== depth) el.querySelector<HTMLElement>("[data-row-content]")?.animate([{ transform: `translateX(${(was.depth - depth) * indent}px)` }, { transform: "translateX(0px)" }], { duration: GLIDE, easing: EASE, composite: "add" }); }); }); // Keep focus on a row that moved (React re-inserts it) or was renamed. React.useLayoutEffect(() => { const id = refocus.current; if (!id) return; refocus.current = null; listRef.current?.querySelector<HTMLElement>(`[data-row-id="${CSS.escape(id)}"]`)?.focus({ preventScroll: false }); }); const snapshot = () => { const depths = new Map<string, { depth: number; top: number }>(); listRef.current?.querySelectorAll<HTMLElement>("[data-row-id][data-depth]").forEach((el) => depths.set(el.dataset.rowId!, { depth: Number(el.dataset.depth), top: el.offsetTop })); return depths; }; /** Why a row can't go into parent, or null. */ const refusal = (node: T, parent: T | null) => { if (parent?.leaf) return `${parent.title} can't hold other rows`; if (parent && (parent.id === node.id || under(nodesRef.current, node.id).has(parent.id))) return `Can't move ${node.title} inside itself`; return (canDrop?.(node, parent) as string | null | undefined) || null; }; /** Moves a row (optimistically) and saves it; a failed save puts everything back. */ const moveTo = async (node: T, parent: string | null, after: string | null | "end", from?: { x: number; y: number }) => { const previous = nodesRef.current; const next = place(previous, node.id, parent, after); landing.current = { id: node.id, from, depths: snapshot() }; // The row stays in view: its new parent opens. Rows that only opened while it was carried close again. const ids = new Set(openRef.current); sprung.current.forEach((id) => id !== parent && ids.delete(id)); sprung.current.clear(); if (parent) ids.add(parent); setOpenIds(ids); commit(next); const parentNode = byId(parent); const sibs = next.filter((n) => n.parent === parent); const index = sibs.findIndex((n) => n.id === node.id); say(`Moved ${node.title} ${parentNode ? `into ${parentNode.title}` : "to the top level"}, ${index + 1} of ${sibs.length}.`); if (!onMove) return; try { await onMove({ ...node, parent } as T, parentNode, index); } catch { landing.current = { depths: snapshot() }; commit(place(nodesRef.current, node.id, node.parent, (() => { const before = previous.filter((n) => n.parent === node.parent); const i = before.findIndex((n) => n.id === node.id); return i > 0 ? before[i - 1].id : null; })())); warn(`Couldn't move ${node.title}. It's back.`, "error"); say(`Couldn't move ${node.title}. It's back where it was.`); window.setTimeout(() => { if (reduced) return; listRef.current?.querySelector<HTMLElement>(`[data-row-id="${CSS.escape(node.id)}"] [data-row-content]`)?.animate( [{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }], { duration: 380, easing: "ease-out" }, ); }, GLIDE); } }; // ── Carrying ─────────────────────────────────────────── const drag = useTreeDrag({ list: listRef, scroller: scrollerRef, preview: previewRef, reason: reasonRef, line: lineRef, ring: ringRef, indent, inset: INSET, check: (id, target) => { const node = byId(id) as T | null; if (!node) return { same: true, refused: null }; const parent = byId(target.parent) as T | null; const next = place(nodesRef.current, id, target.parent, target.into ? "end" : target.after); const was = nodesRef.current.filter((n) => n.parent === node.parent).findIndex((n) => n.id === id); const now = next.filter((n) => n.parent === target.parent).findIndex((n) => n.id === id); return { same: target.parent === node.parent && was === now, refused: refusal(node, parent) }; }, onLift: (id) => { setLifted(id); setRenaming(null); const node = byId(id); if (node) say(`Carrying ${node.title}.`); }, onSpring: (id) => { sprung.current.add(id); const ids = new Set(openRef.current); ids.add(id); setOpenIds(ids); }, onDrop: (id, target, from) => { setLifted(null); const node = byId(id) as T | null; if (node) moveTo(node, target.parent, target.into ? "end" : target.after, from); }, onCancel: (id, refused) => { setLifted(null); if (sprung.current.size) { const ids = new Set(openRef.current); sprung.current.forEach((x) => ids.delete(x)); sprung.current.clear(); setOpenIds(ids); } if (refused) { warn(refused); say(`${byId(id)?.title ?? "The row"} went back. ${refused}.`); } }, reduced, disabled: !!renaming || !!draft, }); // The copy appears under the pointer; rows that opened mid-carry are measured again. React.useLayoutEffect(() => { if (lifted) drag.sync(); }, [lifted, drag]); React.useLayoutEffect(() => { drag.remeasure(); }, [open, drag]); // ── Opening, renaming, adding, deleting ──────────────── const toggle = (node: T, to?: boolean) => { if (node.leaf) return; const ids = new Set(openRef.current); const on = to ?? !ids.has(node.id); if (on) ids.add(node.id); else ids.delete(node.id); setOpenIds(ids); }; const commitRename = async (node: T, value: string) => { if (renamingRef.current !== node.id) return; renamingRef.current = null; setRenaming(null); refocus.current = node.id; const title = value.trim(); if (!title || title === node.title) return; commit(nodesRef.current.map((n) => (n.id === node.id ? ({ ...n, title } as T) : n))); say(`Renamed to ${title}.`); try { await onRename?.(node, title); } catch { commit(nodesRef.current.map((n) => (n.id === node.id ? ({ ...n, title: node.title } as T) : n))); warn(`Couldn't rename ${node.title}.`, "error"); } }; const startRename = (node: T) => { if (!onRename) return; renamingRef.current = node.id; setRenaming(node.id); }; const addInside = (parent: T | null) => { if (!onCreate) return; if (parent) toggle(parent, true); setDraft({ parent: parent?.id ?? null, key: Date.now() }); }; const finishDraft = async (title: string) => { const d = draft; if (!d) return; const name = title.trim(); setDraft(null); if (!name || !onCreate) return; try { const made = await onCreate(byId(d.parent) as T | null, name); const node = { ...made, parent: d.parent } as T; commit([...nodesRef.current, node]); select(node); refocus.current = made.id; say(`Added ${made.title}.`); } catch { warn(`Couldn't add “${name}”.`, "error"); } }; const remove = (node: T) => { if (!onDelete) return; if (undo) finishUndo(undo); const gone = new Set([node.id, ...under(nodesRef.current, node.id)]); const previous = nodesRef.current; const i = nodeRows.findIndex((r) => r.node.id === node.id); const nextFocus = nodeRows.slice(i + 1).find((r) => !gone.has(r.node.id)) ?? nodeRows.slice(0, i).reverse()[0]; commit(previous.filter((n) => !gone.has(n.id))); setUndo({ key: Date.now(), node, removed: previous.filter((n) => gone.has(n.id)), nodes: previous }); if (nextFocus) refocus.current = nextFocus.node.id; say(`Deleted ${node.title}${gone.size > 1 ? ` and ${gone.size - 1} inside it` : ""}. Undo is below.`); }; const takeBack = () => { if (!undo) return; const ids = new Set(undo.removed.map((n) => n.id)); // Back in their old places among whatever is there now. const now = nodesRef.current; const restored = undo.nodes.filter((n) => ids.has(n.id) || now.some((m) => m.id === n.id)).map((n) => (ids.has(n.id) ? n : now.find((m) => m.id === n.id)!)); commit([...restored, ...now.filter((n) => !restored.some((r) => r.id === n.id))]); setUndo(null); refocus.current = undo.node.id; say(`${undo.node.title} is back.`); }; const finishUndo = async (u: Undo<T>) => { setUndo((x) => (x?.key === u.key ? null : x)); try { await onDelete?.(u.node); } catch { commit([...nodesRef.current, ...u.removed]); warn(`Couldn't delete ${u.node.title}. It's back.`, "error"); } }; // ── Keys ─────────────────────────────────────────────── const focusRow = (id: string | undefined) => { if (!id) return; setFocusId(id); listRef.current?.querySelector<HTMLElement>(`[data-row-id="${CSS.escape(id)}"]`)?.focus(); }; const onRowKey = (e: React.KeyboardEvent, node: T) => { if (e.target !== e.currentTarget) return; const i = nodeRows.findIndex((r) => r.node.id === node.id); const row = nodeRows[i]; if (!row) return; const sibs = nodesRef.current.filter((n) => n.parent === node.parent); const si = sibs.findIndex((n) => n.id === node.id); if (e.altKey && e.key.startsWith("Arrow")) { e.preventDefault(); let to: { parent: string | null; after: string | null | "end" } | null = null; if (e.key === "ArrowUp" && si > 0) to = { parent: node.parent, after: si > 1 ? sibs[si - 2].id : null }; if (e.key === "ArrowDown" && si < sibs.length - 1) to = { parent: node.parent, after: sibs[si + 1].id }; if (e.key === "ArrowRight" && si > 0 && !sibs[si - 1].leaf) to = { parent: sibs[si - 1].id, after: "end" }; if (e.key === "ArrowLeft" && node.parent) to = { parent: byId(node.parent)?.parent ?? null, after: node.parent }; if (!to) return; const why = refusal(node, byId(to.parent) as T | null); if (why) { warn(why); say(why); return; } refocus.current = node.id; moveTo(node, to.parent, to.after); return; } switch (e.key) { case "ArrowDown": e.preventDefault(); focusRow(nodeRows[i + 1]?.node.id); return; case "ArrowUp": e.preventDefault(); focusRow(nodeRows[i - 1]?.node.id); return; case "Home": e.preventDefault(); focusRow(nodeRows[0]?.node.id); return; case "End": e.preventDefault(); focusRow(nodeRows[nodeRows.length - 1]?.node.id); return; case "ArrowRight": e.preventDefault(); if (!node.leaf && !row.open) toggle(node, true); else if (row.open && row.hasChildren) focusRow(nodeRows[i + 1]?.node.id); return; case "ArrowLeft": e.preventDefault(); if (row.open) toggle(node, false); else if (node.parent) focusRow(node.parent); return; case "Enter": case " ": e.preventDefault(); select(node); return; case "F2": e.preventDefault(); startRename(node); return; case "Delete": case "Backspace": if (!onDelete) return; e.preventDefault(); remove(node); return; } // Type to jump: the next row whose title starts with what was typed. if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) { const now = Date.now(); typed.current = { text: (now - typed.current.at < 600 ? typed.current.text : "") + e.key.toLowerCase(), at: now }; const t = typed.current.text; const order = [...nodeRows.slice(i + (t.length === 1 ? 1 : 0)), ...nodeRows.slice(0, i + (t.length === 1 ? 1 : 0))]; focusRow(order.find((r) => r.node.title.toLowerCase().startsWith(t))?.node.id); } }; // What the memoised rows call, through one ref, so a row never re-renders just because these were re-created. const actions = React.useRef<RowActions<T>>(null!); actions.current = { click: (e, node) => { if (drag.swallowClick() || renaming === node.id) return; if (e.detail > 1) return; setFocusId(node.id); select(node); }, toggle: (node) => toggle(node), key: onRowKey, focus: (node) => setFocusId(node.id), rename: startRename, commitRename, cancelRename: () => { if (renamingRef.current) refocus.current = renamingRef.current; renamingRef.current = null; setRenaming(null); }, add: (node) => addInside(node), remove, icon: icon as RowActions<T>["icon"], }; const liftedRow = lifted ? nodeRows.find((r) => r.node.id === lifted) : undefined; const liftedCount = lifted ? under(nodes, lifted).size : 0; return ( <div className={`@container flex flex-col text-foreground ${className}`} {...props}> <div ref={scrollerRef} className="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain [scrollbar-width:thin]"> <ul ref={listRef} role="tree" aria-label={label} aria-describedby={`${uid}-how`} {...drag.handlers} className="relative flex flex-col py-0.5"> {rows.map((r) => { if (r.kind === "empty") return ( <li key={`${r.parent}::empty`} data-row-id={`${r.parent}::empty`} role="none" className="flex shrink-0 items-center text-[12.5px] text-muted-foreground" style={{ height: ROW_H, paddingLeft: INSET + r.depth * indent }}> Nothing inside </li> ); if (r.kind === "draft") return ( <li key={`draft-${draft?.key}`} data-row-id="__draft" role="none" className="shrink-0" style={{ height: ROW_H }}> <div className="flex h-full items-center gap-1.5 pr-1" style={{ paddingLeft: 4 + r.depth * indent }}> <span className="size-5 shrink-0" aria-hidden="true" /> <span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4" aria-hidden="true"> <File strokeWidth={1.75} /> </span> <input autoFocus placeholder={`New ${addLabel}`} aria-label={`New ${addLabel}${r.parent ? ` in ${byId(r.parent)?.title}` : ""}`} onKeyDown={(e) => { if (e.key === "Enter") finishDraft(e.currentTarget.value); if (e.key === "Escape") { setDraft(null); if (r.parent) refocus.current = r.parent; } }} onBlur={(e) => finishDraft(e.currentTarget.value)} className="-ml-1 h-6 min-w-0 flex-1 rounded-[6px] bg-background px-1 text-[13.5px] outline-none shadow-[inset_0_0_0_1px_var(--ring)] placeholder:text-muted-foreground [@media(pointer:coarse)]:text-[16px]" /> </div> </li> ); const id = r.node.id; return ( <TreeRow key={id} node={r.node} depth={r.depth} hasChildren={r.hasChildren} open={r.open} pos={r.pos} size={r.size} selected={selected === id} tab={tabId === id} dimmed={!!lifted && (lifted === id || (liftedRow ? isUnder(rows, liftedRow, r) : false))} renaming={renaming === id} canAdd={!!onCreate} canDelete={!!onDelete} canRename={!!onRename} indent={indent} reduced={reduced} actions={actions} /> ); })} {/* Where it lands: a line from the depth it would take, or a ring round the row it would go inside. */} <div ref={lineRef} aria-hidden="true" className="pointer-events-none absolute left-0 top-0 h-0.5 opacity-0" style={{ transition: reduced ? "none" : "transform 90ms ease-out, opacity 120ms ease-out" }}> <span className="absolute inset-0 rounded-full bg-current" /> <span className="absolute -left-1 -top-[3px] size-2 rounded-full border-[1.5px] border-current bg-background" /> </div> <div ref={ringRef} aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 rounded-[8px] opacity-0" style={{ boxShadow: "inset 0 0 0 1.5px currentColor", background: "color-mix(in oklab, currentColor 8%, transparent)", transition: reduced ? "none" : "opacity 120ms ease-out" }} /> {/* The copy you carry. */} {liftedRow && ( <div ref={previewRef} aria-hidden="true" className="pointer-events-none absolute left-0 top-0 z-10 w-full will-change-transform"> <div className="flex items-center gap-1.5 rounded-[8px] bg-background pr-2 text-[13.5px] font-medium shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_22%,transparent)]" style={{ height: ROW_H, marginLeft: liftedRow.depth * indent, paddingLeft: 4, width: `min(100% - ${liftedRow.depth * indent}px, 260px)` }}> <span className="size-5 shrink-0" /> <span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">{icon(liftedRow.node, { expanded: false, hasChildren: liftedRow.hasChildren })}</span> <span className="min-w-0 flex-1 truncate">{liftedRow.node.title}</span> {liftedCount > 0 && <span className="shrink-0 rounded-full px-1.5 text-[11px] font-normal tabular-nums text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]">+{liftedCount}</span>} </div> <p ref={reasonRef} hidden className="mt-1 text-[11.5px] font-normal text-amber-700 dark:text-amber-400" style={{ marginLeft: liftedRow.depth * indent + 30 }} /> </div> )} {/* Rows that went: their own elements, folding away where they stood (see use-row-motion). */} <div ref={ghostRef} aria-hidden="true" className="pointer-events-none absolute inset-0" /> </ul> </div> {onCreate && ( <button type="button" onClick={() => addInside(null)} className={`mt-1 flex h-[30px] shrink-0 cursor-pointer items-center gap-1.5 rounded-[8px] pl-1 text-[13px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground ${FOCUS}`} > <span className="flex size-5 items-center justify-center" aria-hidden="true"> <Plus className="size-3.5" strokeWidth={1.75} /> </span> New {addLabel} </button> )} {/* One line under the tree: Undo while a delete can still be taken back, or what went wrong. */} <div className="grid shrink-0 transition-[grid-template-rows] duration-300" style={{ gridTemplateRows: undo || note ? "1fr" : "0fr", transitionTimingFunction: EASE }}> <div className="min-h-0 overflow-hidden"> {undo ? ( <div className="relative mt-1.5 flex h-8 items-center gap-2 rounded-[8px] pl-2.5 pr-1 text-[12.5px] shadow-[inset_0_0_0_1px_var(--border)]" onPointerEnter={() => setHoldUndo(true)} onPointerLeave={() => setHoldUndo(false)}> <span className="min-w-0 flex-1 truncate">Deleted “{undo.node.title}”</span> <button type="button" onClick={takeBack} className={`inline-flex h-6 shrink-0 cursor-pointer items-center rounded-[6px] px-2 font-medium hover:bg-accent ${FOCUS}`}> Undo </button> <span key={undo.key} aria-hidden="true" className="absolute inset-x-2 bottom-0 h-px origin-left bg-foreground/40" style={{ animation: `ui-progress ${undoFor}ms linear reverse forwards`, animationPlayState: holdUndo ? "paused" : "running" }} onAnimationEnd={() => finishUndo(undo)} /> </div> ) : ( <Line text={note?.text ?? ""} className={`px-2 pt-1.5 text-[12px] ${note?.tone === "error" ? "text-red-600 dark:text-red-400" : "text-amber-700 dark:text-amber-400"}`} /> )} </div> </div> <p id={`${uid}-how`} className="sr-only"> Arrow keys move between rows and open them. Alt with the arrow keys moves a row up, down, into the row above or out of its parent. F2 renames, Delete deletes. Drag a row to move it; on a touch screen, hold it first. </p> <p className="sr-only" role="status" aria-live="polite"> {announce} </p> </div> );}/** Is `row` inside `parent` (a contiguous run of deeper rows after it)? */function isUnder<T extends TreeNode>(rows: Row<T>[], parent: Row<T>, row: Row<T>) { const a = rows.indexOf(parent); const b = rows.indexOf(row); if (b <= a) return false; for (let k = a + 1; k <= b; k++) if (rows[k].depth <= parent.depth) return false; return true;}"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * TREE DRAG: carry a row (and everything inside it) to a new * place and a new depth * * lift after 4px (or a hold on touch) a copy of the row * follows the pointer; the row and its children dim * where they are, so nothing under the pointer moves * slot up and down picks the gap between two rows; the * line marking it starts at the depth it will land * depth sideways picks the depth, one level per indent, * within what that gap allows * into the middle of a row that holds others means * "inside it, at the end": the row takes a ring; * a closed one opens itself after a moment * scroll near the edges of the scroller (or the window), * they scroll * * Nothing re-renders while you carry: the copy, the line and the * ring are moved by transforms, and rows are measured once at * pickup (again only when a folder opens). Rows are found by * data-row-id with data-depth, data-parent, data-can-hold, * data-expanded and data-has-children. * ───────────────────────────────────────────────────────── *//** Where a carried row would land: in `parent`, after the sibling `after` (null: first), or `into` it at the end. */export type TreeTarget = { parent: string | null; after: string | null; into: boolean };type RowBox = { id: string; top: number; height: number; depth: number; parent: string | null; canHold: boolean; expanded: boolean; hasChildren: boolean };type Options = { list: React.RefObject<HTMLElement | null>; scroller: React.RefObject<HTMLElement | null>; preview: React.RefObject<HTMLElement | null>; reason: React.RefObject<HTMLElement | null>; line: React.RefObject<HTMLElement | null>; ring: React.RefObject<HTMLElement | null>; indent: number; /** Where depth 0's content starts, px from the list's left. */ inset: number; /** Would this change nothing, and why does the parent refuse it (or null)? */ check: (id: string, target: TreeTarget) => { same: boolean; refused: string | null }; onLift: (id: string) => void; /** A closed row held over for a moment: open it. */ onSpring: (id: string) => void; /** Dropped. `from` is where the copy was (x: how far it was carried sideways, y: its top in list coordinates). */ onDrop: (id: string, target: TreeTarget, from: { x: number; y: number }) => void; /** Put back (refused, or dropped where it was). Called once the copy is home. */ onCancel: (id: string, refused: string | null) => void; reduced: boolean; disabled?: boolean;};const EASE = "cubic-bezier(0.16,1,0.3,1)";const THROW = "cubic-bezier(0.34,1.36,0.64,1)";const HOLD = 300;const SPRING = 650;const EDGE = 36;export function useTreeDrag(o: Options) { const opts = React.useRef(o); opts.current = o; const drag = React.useRef<{ id: string; pointerId: number; pointerType: string; /** Takes the pointer once the carry starts (not before, or the row's click would go to it). */ captor: HTMLElement; x0: number; y0: number; x: number; y: number; /** The list's top on screen at pickup, and the scroll positions then. */ top0: number; scroll0: number; page0: number; rows: RowBox[]; block: Set<string>; depth0: number; rowTop: number; active: boolean; timer: number; key: string; target: TreeTarget | null; refused: string | null; spring: string | null; springTimer: number; box: { top: number; bottom: number }; } | null>(null); const loop = React.useRef(0); const scroll = React.useRef({ top: 0, page: 0 }); const swallowClick = React.useRef(false); /** The pointer's y in list coordinates, from positions kept since pickup (no layout reads). */ const listY = () => { const d = drag.current!; return d.y - d.top0 + (scroll.current.top - d.scroll0) + (scroll.current.page - d.page0); }; const write = () => { const d = drag.current; const p = opts.current.preview.current; if (!d?.active || !p) return; p.style.transform = `translate3d(${d.x - d.x0}px, ${d.rowTop + listY() - (d.y0 - d.top0)}px, 0)`; }; const measure = () => { const d = drag.current; const list = opts.current.list.current; if (!d || !list) return; d.rows = [...list.querySelectorAll<HTMLElement>("[data-row-id][data-depth]")].map((el) => ({ id: el.dataset.rowId!, top: el.offsetTop, height: el.offsetHeight, depth: Number(el.dataset.depth), parent: el.dataset.parent || null, canHold: el.dataset.canHold === "1", expanded: el.dataset.expanded === "1", hasChildren: el.dataset.hasChildren === "1", })); // The carried row and every row under it that's deeper: they travel together and can't be dropped into. const i = d.rows.findIndex((r) => r.id === d.id); d.block = new Set(); if (i < 0) return; d.rowTop = d.rows[i].top; d.depth0 = d.rows[i].depth; for (let j = i; j < d.rows.length && (j === i || d.rows[j].depth > d.depth0); j++) d.block.add(d.rows[j].id); }; const show = (el: HTMLElement | null, on: boolean) => { if (el) el.style.opacity = on ? "1" : "0"; }; const arrange = () => { const d = drag.current!; const { indent, inset, line, ring, reason } = opts.current; const y = listY(); const others = d.rows.filter((r) => !d.block.has(r.id)); const own = d.rows.filter((r) => d.block.has(r.id)); const ownTop = own[0]?.top ?? 0; const ownBottom = own.length ? own[own.length - 1].top + own[own.length - 1].height : 0; let target: TreeTarget | null = null; let into: RowBox | null = null; let lineY = 0; let depth = 0; if (!(y >= ownTop && y < ownBottom)) { const i = others.findIndex((r) => y < r.top + r.height); let slot = others.length; if (i >= 0) { const r = others[i]; const at = (y - r.top) / r.height; if (y < r.top || at < (r.canHold ? 0.25 : 0.5)) slot = i; else if (r.canHold && at <= 0.75) into = r; else slot = i + 1; } if (into) target = { parent: into.id, after: null, into: true }; else { const above = others[slot - 1]; const below = others[slot]; const max = above ? above.depth + (above.canHold ? 1 : 0) : 0; const min = below ? below.depth : 0; depth = Math.max(min, Math.min(max, d.depth0 + Math.round((d.x - d.x0) / indent))); if (!above) target = { parent: null, after: null, into: false }; else if (depth > above.depth) target = { parent: above.id, after: null, into: false }; else { // Climb from the row above to its ancestor at that depth: the carried row goes right after it. let k = others.indexOf(above); let x = above; while (x.depth > depth && k > 0) { const want = x.depth - 1; while (k > 0 && others[k].depth > want) k--; x = others[k]; } target = { parent: x.parent, after: x.id, into: false }; } lineY = above ? above.top + above.height : below ? below.top : 0; } } const key = target ? `${target.parent}|${target.after}|${target.into}|${depth}|${lineY}` : "none"; if (key === d.key) return; d.key = key; const verdict = target ? opts.current.check(d.id, target) : { same: true, refused: null }; d.target = target && !verdict.same ? target : null; d.refused = d.target ? verdict.refused : null; // The line between rows, or the ring round the row it would go inside. const l = line.current; const g = ring.current; const amber = "color-mix(in oklab, #f59e0b 80%, var(--foreground))"; if (l) { show(l, !!d.target && !into); if (d.target && !into) { const x = inset + depth * indent; l.style.transform = `translate3d(${x}px, ${lineY - 1}px, 0)`; l.style.width = `calc(100% - ${x + 6}px)`; l.style.color = d.refused ? amber : "var(--ring)"; } } if (g) { show(g, !!d.target && !!into); if (d.target && into) { g.style.transform = `translate3d(0, ${into.top}px, 0)`; g.style.height = `${into.height}px`; g.style.color = d.refused ? amber : "var(--ring)"; } } const r = reason.current; if (r) { r.textContent = d.refused ?? ""; r.hidden = !d.refused; } // Held over a closed row that has rows inside: it opens after a moment. const spring = d.target && into && into.hasChildren && !into.expanded && !d.refused ? into.id : null; if (spring !== d.spring) { window.clearTimeout(d.springTimer); d.spring = spring; if (spring) d.springTimer = window.setTimeout(() => { if (drag.current?.spring === spring) opts.current.onSpring(spring); }, SPRING); } }; const run = () => { cancelAnimationFrame(loop.current); const tick = () => { const d = drag.current; if (!d?.active) return; const sc = opts.current.scroller.current; const inner = d.y < d.box.top + EDGE ? -(d.box.top + EDGE - d.y) / EDGE : d.y > d.box.bottom - EDGE ? (d.y - (d.box.bottom - EDGE)) / EDGE : 0; const page = d.y < EDGE ? -(EDGE - d.y) / EDGE : d.y > window.innerHeight - EDGE ? (d.y - (window.innerHeight - EDGE)) / EDGE : 0; if (inner && sc && sc.scrollHeight > sc.clientHeight) sc.scrollTop = scroll.current.top + Math.max(-1, Math.min(1, inner)) * 10; else if (page) window.scrollBy(0, Math.max(-1, Math.min(1, page)) * 10); if (inner || page) { write(); arrange(); } loop.current = requestAnimationFrame(tick); }; loop.current = requestAnimationFrame(tick); }; const activate = () => { const d = drag.current; const list = opts.current.list.current; if (!d || d.active || !list) return; d.active = true; if (d.pointerType === "touch") navigator.vibrate?.(8); else try { d.captor.setPointerCapture(d.pointerId); } catch {} const sc = opts.current.scroller.current; const box = (sc && sc.scrollHeight > sc.clientHeight ? sc : list).getBoundingClientRect(); d.box = { top: Math.max(0, box.top), bottom: Math.min(window.innerHeight, box.bottom) }; d.top0 = list.getBoundingClientRect().top; measure(); opts.current.onLift(d.id); run(); }; const finish = () => { const d = drag.current; window.clearTimeout(d?.timer); window.clearTimeout(d?.springTimer); cancelAnimationFrame(loop.current); show(opts.current.line.current, false); show(opts.current.ring.current, false); }; const end = (dropped: boolean) => { const d = drag.current; if (!d) return; finish(); if (!d.active) { drag.current = null; return; } swallowClick.current = true; window.setTimeout(() => (swallowClick.current = false), 0); if (dropped && d.target && !d.refused) { const from = { x: d.x - d.x0, y: d.rowTop + listY() - (d.y0 - d.top0) }; drag.current = null; opts.current.onDrop(d.id, d.target, from); return; } // Back home: the copy glides to the row it came from, then the row takes over. const refused = dropped ? d.refused : null; const p = opts.current.preview.current; drag.current = null; if (p && !opts.current.reduced) { const a = p.animate([{ transform: p.style.transform }, { transform: `translate3d(0px, ${d.rowTop}px, 0)` }], { duration: 300, easing: THROW, fill: "forwards" }); a.onfinish = a.oncancel = () => opts.current.onCancel(d.id, refused); } else opts.current.onCancel(d.id, refused); }; const onPointerDown = (e: React.PointerEvent<HTMLElement>) => { if (opts.current.disabled || drag.current || (e.pointerType === "mouse" && e.button !== 0)) return; const t = e.target as HTMLElement; if (t.closest("button, input, a, textarea, [data-no-drag]")) return; const row = t.closest<HTMLElement>("[data-row-id][data-depth]"); if (!row) return; scroll.current = { top: opts.current.scroller.current?.scrollTop ?? 0, page: window.scrollY }; drag.current = { id: row.dataset.rowId!, pointerId: e.pointerId, pointerType: e.pointerType, captor: e.currentTarget, x0: e.clientX, y0: e.clientY, x: e.clientX, y: e.clientY, top0: 0, scroll0: scroll.current.top, page0: scroll.current.page, rows: [], block: new Set(), depth0: 0, rowTop: 0, active: false, timer: 0, key: "", target: null, refused: null, spring: null, springTimer: 0, box: { top: 0, bottom: 0 }, }; if (e.pointerType === "touch") drag.current.timer = window.setTimeout(activate, HOLD); }; const onPointerMove = (e: React.PointerEvent<HTMLElement>) => { const d = drag.current; if (!d || d.pointerId !== e.pointerId) return; if (!d.active) { const moved = Math.hypot(e.clientX - d.x0, e.clientY - d.y0); if (d.pointerType === "touch") { if (moved > 8) end(false); return; } if (moved < 4) return; activate(); } d.x = e.clientX; d.y = e.clientY; write(); arrange(); }; const onPointerUp = (e: React.PointerEvent<HTMLElement>) => { if (drag.current?.pointerId === e.pointerId) end(true); }; const onPointerCancel = (e: React.PointerEvent<HTMLElement>) => { if (drag.current?.pointerId === e.pointerId) end(false); }; // While a row is held on touch the page mustn't scroll under it; scroll positions are kept from events. React.useEffect(() => { const list = o.list.current; const sc = o.scroller.current; if (!list) return; const touchMove = (e: TouchEvent) => { if (drag.current?.active && e.cancelable) e.preventDefault(); }; const menu = (e: Event) => { if (drag.current) e.preventDefault(); }; const onScroll = () => { scroll.current.top = sc?.scrollTop ?? 0; }; const onPage = () => { scroll.current.page = window.scrollY; }; list.addEventListener("touchmove", touchMove, { passive: false }); list.addEventListener("contextmenu", menu); sc?.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("scroll", onPage, { passive: true }); return () => { list.removeEventListener("touchmove", touchMove); list.removeEventListener("contextmenu", menu); sc?.removeEventListener("scroll", onScroll); window.removeEventListener("scroll", onPage); }; }, [o.list, o.scroller]); React.useEffect( () => () => { cancelAnimationFrame(loop.current); window.clearTimeout(drag.current?.timer); window.clearTimeout(drag.current?.springTimer); }, [], ); return { handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }, /** After a row opened mid-carry: measure the rows again and find the slot under the pointer. */ remeasure: () => { const d = drag.current; if (!d?.active) return; measure(); d.key = ""; arrange(); }, /** Puts the copy under the pointer (once it has rendered). */ sync: write, /** A carry just ended: the click that follows it isn't a click. */ swallowClick: () => swallowClick.current, ease: EASE, };}"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * ROW MOTION: rows that keep their place on screen * * glide after any render (a sort, a filter, new data from * the server) every row that moved glides from its * old place to its new one. Glides are additive, so * a change that lands mid-glide adds its own offset * instead of restarting anything * travel a row that sorts onto the page slides in from the * side where it ranked before; one that sorts off * slides out toward its new rank * enter a row that's new to the list fades in where it * lands while the rows below glide clear of it * leave a row that's gone folds away where it stood. Its * copy is the row's own element, detached by React * and moved into a layer React never renders into, * so a leaving row costs no render at all * stagger a sort can ask for the rows to set off one after * another, top first * * Positions are layout offsets (transforms ignored), kept after * every render and whenever the list changes size (a row * opening its detail), so they're never stale. Rows are found * by data-row-id; ranks are their places in the whole sorted * list, not just the page. * ───────────────────────────────────────────────────────── */type Seen = { top: number; height: number; el: HTMLElement };const EASE = "cubic-bezier(0.16,1,0.3,1)";const GLIDE = 480;export function useRowMotion( container: React.RefObject<HTMLElement | null>, { reduced, resetKey, ranks, ghosts }: { reduced: boolean; resetKey?: string | number; ranks: ReadonlyMap<string, number>; ghosts: React.RefObject<HTMLElement | null> },) { const seen = React.useRef(new Map<string, Seen>()); const lastRanks = React.useRef(ranks); const lastKey = React.useRef(resetKey); const staggerNext = React.useRef(false); const read = React.useCallback(() => { const out = new Map<string, Seen>(); container.current?.querySelectorAll<HTMLElement>("[data-row-id]").forEach((el) => out.set(el.dataset.rowId!, { top: el.offsetTop, height: el.offsetHeight, el })); return out; }, [container]); /** The row React just removed, back on screen where it stood, leaving: sliding toward where it went, or folding. */ const leave = (was: Seen, dy: number) => { const layer = ghosts.current; const el = was.el; if (!layer || el.isConnected) return; // No getAnimations() here (it flushes style for the whole page): the exit below is added last and replaces any // glide still on the element. el.removeAttribute("data-row-id"); el.removeAttribute("tabindex"); el.setAttribute("aria-hidden", "true"); el.inert = true; Object.assign(el.style, { position: "absolute", left: "0", right: "0", top: `${was.top}px`, height: `${was.height}px`, margin: "0", overflow: "hidden" }); layer.appendChild(el); const a = el.animate( dy ? [ { transform: "translateY(0px)", opacity: 1, filter: "blur(0px)" }, { transform: `translateY(${dy}px)`, opacity: 0, filter: "blur(3px)" }, ] : [ { opacity: 1, transform: "none", filter: "blur(0px)" }, { opacity: 0, transform: "scale(0.985)", filter: "blur(4px)" }, ], { duration: dy ? 360 : 280, easing: EASE, fill: "forwards" }, ); a.onfinish = a.oncancel = () => el.remove(); }; React.useLayoutEffect(() => { const el = container.current; if (!el) return; const before = seen.current; const after = read(); const prevRanks = lastRanks.current; seen.current = after; lastRanks.current = ranks; const stagger = staggerNext.current; staggerNext.current = false; if (lastKey.current !== resetKey) { // A different page: the caller slides the whole body; nothing glides and nothing folds. lastKey.current = resetKey; return; } if (reduced || !before.size) return; // A typical row's height. Rows that come onto or leave the page only hint at the way they went (a row and a // quarter at most), so the list never shows holes while they travel. const heights = [...after.values()].map((b) => b.height).sort((a, b) => a - b); const rowH = heights[Math.floor(heights.length / 2)] ?? 48; const clamp = (dy: number) => Math.max(-rowH * 1.25, Math.min(rowH * 1.25, dy)); let order = 0; after.forEach((now, id) => { const row = now.el; const was = before.get(id); const delay = stagger ? Math.min(order++ * 16, 200) : 0; if (was) { const dy = was.top - now.top; if (Math.abs(dy) < 0.5) return; row.animate([{ transform: `translateY(${dy}px)`, zIndex: 1 }, { transform: "translateY(0px)", zIndex: 1 }], { duration: GLIDE, delay, easing: EASE, composite: "add", fill: "backwards" }); return; } const from = prevRanks.get(id); const to = ranks.get(id); if (from !== undefined && to !== undefined) { // It ranked somewhere off the page: it comes in from that side. row.animate( [ { transform: `translateY(${clamp((from - to) * rowH)}px)`, opacity: 0, filter: "blur(3px)" }, { transform: "translateY(0px)", opacity: 1, filter: "blur(0px)" }, ], { duration: GLIDE, delay: delay / 2, easing: EASE, fill: "backwards" }, ); return; } row.animate( [ { opacity: 0, filter: "blur(4px)" }, { opacity: 1, filter: "blur(0px)" }, ], { duration: 420, delay: 90, easing: EASE, fill: "backwards" }, ); }); // Still in the list, just not on this page: its copy slides toward where it went. Gone for good: it folds. before.forEach((was, id) => { if (after.has(id)) return; const from = prevRanks.get(id); const to = ranks.get(id); leave(was, from !== undefined && to !== undefined ? clamp((to - from) * rowH) || rowH : 0); }); }); // The list changed size without a render of ours (a detail opening by transition): keep the positions true. React.useEffect(() => { const el = container.current; if (!el) return; const observer = new ResizeObserver(() => { seen.current = read(); }); observer.observe(el); return () => observer.disconnect(); }, [container, read]); return { /** The next change (a sort) sets the rows off one after another. */ stagger: () => { staggerNext.current = true; }, };}Registry item: /r/tree-view.json