{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"reorderable-grid","type":"registry:ui","title":"ReorderableGrid","description":"Reorder grid cells in place by pointer or keyboard: controlled id order, row-major columns, locked cells, aria-live announcements and an agent-friendly moveItem().","package":"react-fancy","dependencies":["tailwind-merge"],"registryDependencies":[],"files":[{"path":"components\/fancy\/reorderable-grid\/ReorderableGrid.tsx","content":"import {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport type {\n  KeyboardEvent as ReactKeyboardEvent,\n  PointerEvent as ReactPointerEvent,\n  ReactElement,\n  Ref,\n} from \"react\";\nimport { cn } from \"..\/lib\/utils\/cn\";\nimport { composeRefs } from \"..\/lib\/utils\/compose-refs\";\nimport { useControllableState } from \"..\/lib\/hooks\/use-controllable-state\";\nimport { useEscapeKey } from \"..\/lib\/hooks\/use-escape-key\";\nimport { moveItem } from \".\/move-item\";\nimport type {\n  ReorderableGridAnnouncementContext,\n  ReorderableGridItem,\n  ReorderableGridLabels,\n  ReorderableGridProps,\n} from \".\/ReorderableGrid.types\";\n\n\/*\n * Literal class names, not `grid-cols-${n}`: Tailwind only generates classes it\n * can read in source, and the consumer's build scans this file's dist. That is\n * also why the column count is not an inline style, as Grid's is.\n *\/\nconst COLUMN_CLASSES = [\n  \"grid-cols-1\",\n  \"grid-cols-2\",\n  \"grid-cols-3\",\n  \"grid-cols-4\",\n  \"grid-cols-5\",\n  \"grid-cols-6\",\n  \"grid-cols-7\",\n  \"grid-cols-8\",\n  \"grid-cols-9\",\n  \"grid-cols-10\",\n  \"grid-cols-11\",\n  \"grid-cols-12\",\n] as const;\n\nconst GAP_CLASSES = { none: \"gap-0\", sm: \"gap-3\", md: \"gap-6\", lg: \"gap-10\" } as const;\n\nconst FOCUS_RING =\n  \"ring-[color:var(--fancy-focus,var(--color-blue-500))] dark:ring-[color:var(--fancy-focus-dark,var(--color-blue-400))]\";\n\nconst CELL_BASE =\n  \"relative min-w-0 select-none touch-none outline-none \" +\n  \"focus-visible:ring-2 focus-visible:ring-[color:var(--fancy-focus,var(--color-blue-500))] dark:focus-visible:ring-[color:var(--fancy-focus-dark,var(--color-blue-400))]\";\n\nconst CELL_STYLED =\n  \"rounded-lg border p-3 text-sm \" +\n  \"border-[color:var(--fancy-border,var(--color-zinc-200))] dark:border-[color:var(--fancy-border-dark,var(--color-zinc-700))] \" +\n  \"bg-[color:var(--fancy-surface,var(--color-white))] dark:bg-[color:var(--fancy-surface-dark,var(--color-zinc-900))] \" +\n  \"text-[color:var(--fancy-text,var(--color-zinc-900))] dark:text-[color:var(--fancy-text-dark,var(--color-zinc-100))]\";\n\nconst CELL_LOCKED_STYLED =\n  \"border-dashed bg-[color:var(--fancy-surface-muted,var(--color-zinc-100))] dark:bg-[color:var(--fancy-surface-muted-dark,var(--color-zinc-800))]\";\n\n\/** Movement before a press becomes a drag, so clicks inside a cell still click. *\/\nconst DRAG_THRESHOLD = 4;\n\/** Distance from a scroll container's edge at which a drag starts scrolling it. *\/\nconst EDGE = 48;\nconst MAX_SCROLL_STEP = 18;\nconst SHIFT_MS = 150;\n\n\/** A press on one of these inside a cell is the element's, not a drag (unless it is the handle). *\/\nconst INTERACTIVE = \"input, textarea, select, button, a[href], [contenteditable=''], [contenteditable='true']\";\n\nconst DEFAULT_LABELS: ReorderableGridLabels = {\n  instructions:\n    \"Press Space or Enter to pick up. Use the arrow keys to move, Space or Enter to drop, Escape to cancel.\",\n  roleDescription: \"reorderable item\",\n  pickedUp: (c) =>\n    `Picked up ${c.label} at position ${c.position} of ${c.total}, row ${c.row} column ${c.column}. ` +\n    \"Arrow keys move it, Space or Enter drops it, Escape cancels\",\n  moved: (c) => `Moved to position ${c.position} of ${c.total}, row ${c.row} column ${c.column}`,\n  dropped: (c) => `Dropped ${c.label} at position ${c.position} of ${c.total}, row ${c.row} column ${c.column}`,\n  cancelled: (c) =>\n    `Cancelled. ${c.label} returned to position ${c.position} of ${c.total}, row ${c.row} column ${c.column}`,\n  locked: (c) => `${c.label} is locked and cannot be moved`,\n};\n\n\/**\n * The order to show: `order`'s known ids first, then any item it does not\n * mention, in `items` order. A stale or partial order can never hide a cell.\n *\/\nfunction resolveOrder(order: readonly string[] | undefined, items: readonly ReorderableGridItem[]): string[] {\n  const ids = items.map((i) => i.id);\n  const known = new Set(ids);\n  const seen = new Set<string>();\n  const out: string[] = [];\n  for (const id of order ?? []) {\n    if (known.has(id) && !seen.has(id)) {\n      seen.add(id);\n      out.push(id);\n    }\n  }\n  for (const id of ids) {\n    if (!seen.has(id)) {\n      seen.add(id);\n      out.push(id);\n    }\n  }\n  return out;\n}\n\nfunction sameOrder(a: readonly string[], b: readonly string[]): boolean {\n  return a.length === b.length && a.every((id, i) => id === b[i]);\n}\n\nfunction prefersReducedMotion(): boolean {\n  return typeof matchMedia === \"function\" && matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\ntype Mode = \"pointer\" | \"keyboard\";\n\ninterface Session {\n  id: string;\n  mode: Mode;\n  \/** The committed order when the interaction began. Cancel returns here. *\/\n  origin: string[];\n  originIndex: number;\n  \/** Where the item would land. `null`: the pointer is outside the grid, and a release cancels. *\/\n  target: number | null;\n}\n\ninterface PointerDrag {\n  id: string;\n  pointerId: number;\n  \/** The cell's layout offset when the drag began. *\/\n  startOffset: { x: number; y: number };\n  \/** Pointer, in the grid's coordinates, when the drag began. *\/\n  startPos: { x: number; y: number };\n  clientX: number;\n  clientY: number;\n  scroller: HTMLElement | null;\n}\n\n\/** Nearest ancestor that can scroll, else the document's scroller. *\/\nfunction scrollParent(el: HTMLElement): HTMLElement | null {\n  for (let p = el.parentElement; p; p = p.parentElement) {\n    const s = getComputedStyle(p);\n    if (\/(auto|scroll)\/.test(s.overflowY + s.overflowX)) {\n      if (p.scrollHeight > p.clientHeight || p.scrollWidth > p.clientWidth) return p;\n    }\n  }\n  return (document.scrollingElement as HTMLElement | null) ?? null;\n}\n\nfunction ReorderableGridInner<T extends ReorderableGridItem>(\n  {\n    items,\n    value,\n    defaultValue,\n    onReorder,\n    renderItem,\n    columns = 2,\n    gap = \"sm\",\n    disabled = false,\n    label,\n    getItemLabel,\n    labels: labelOverrides,\n    unstyled = false,\n    cellClassName,\n    className,\n    ...props\n  }: ReorderableGridProps<T>,\n  forwardedRef: Ref<HTMLDivElement>,\n) {\n  const cols = Math.min(12, Math.max(1, Math.round(columns) || 1));\n  const labels = useMemo(() => ({ ...DEFAULT_LABELS, ...labelOverrides }), [labelOverrides]);\n\n  \/\/ \u2500\u2500 order \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  const [stored, setStored] = useControllableState<string[] | undefined>(value, defaultValue, (next) => {\n    if (next) onReorder?.(next);\n  });\n  const committed = useMemo(() => resolveOrder(stored, items), [stored, items]);\n\n  const byId = useMemo(() => new Map(items.map((i) => [i.id, i])), [items]);\n  const lockedIds = useMemo(() => items.filter((i) => i.locked).map((i) => i.id), [items]);\n  const isLocked = useCallback((id: string) => byId.get(id)?.locked === true, [byId]);\n\n  const [session, setSessionState] = useState<Session | null>(null);\n  const sessionRef = useRef<Session | null>(null);\n  const setSession = useCallback((s: Session | null) => {\n    sessionRef.current = s;\n    setSessionState(s);\n  }, []);\n\n  const previewOf = useCallback(\n    (s: Session): string[] =>\n      s.target === null || s.target === s.originIndex\n        ? s.origin\n        : moveItem(s.origin, s.originIndex, s.target, { locked: lockedIds }),\n    [lockedIds],\n  );\n\n  const shown = useMemo(\n    () => (session ? resolveOrder(previewOf(session), items) : committed),\n    [session, previewOf, items, committed],\n  );\n  const shownRef = useRef(shown);\n  shownRef.current = shown;\n\n  \/\/ \u2500\u2500 announcements \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  const [announcement, setAnnouncement] = useState(\"\");\n  const labelOf = useCallback(\n    (id: string) => {\n      const item = byId.get(id);\n      if (!item) return id;\n      if (getItemLabel) return getItemLabel(item);\n      const l = (item as { label?: unknown }).label;\n      return typeof l === \"string\" ? l : id;\n    },\n    [byId, getItemLabel],\n  );\n  const ctxAt = useCallback(\n    (id: string, index: number, total: number): ReorderableGridAnnouncementContext => ({\n      id,\n      label: labelOf(id),\n      position: index + 1,\n      total,\n      row: Math.floor(index \/ cols) + 1,\n      column: (index % cols) + 1,\n    }),\n    [labelOf, cols],\n  );\n\n  \/\/ \u2500\u2500 elements \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const cellRefs = useRef(new Map<string, HTMLDivElement>());\n  const cellRef = useCallback(\n    (id: string) => (el: HTMLDivElement | null) => {\n      if (el) cellRefs.current.set(id, el);\n      else cellRefs.current.delete(id);\n    },\n    [],\n  );\n\n  const instructionsId = useId();\n  const [activeId, setActiveId] = useState<string | null>(null);\n  const tabStop = activeId !== null && shown.includes(activeId) ? activeId : shown[0];\n  \/** Focus to put back after a drop or cancel moves the focused node. *\/\n  const refocus = useRef<string | null>(null);\n\n  \/\/ \u2500\u2500 commit \/ cancel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  const drag = useRef<PointerDrag | null>(null);\n  const pending = useRef<{ id: string; pointerId: number; clientX: number; clientY: number } | null>(null);\n  const scrollFrame = useRef<number | null>(null);\n\n  const settle = useCallback((id: string) => {\n    const el = cellRefs.current.get(id);\n    if (!el) return;\n    if (el.style.transform && !prefersReducedMotion()) {\n      el.style.transition = `transform ${SHIFT_MS}ms ease`;\n      window.setTimeout(() => {\n        if (!el.style.transform) el.style.transition = \"\";\n      }, SHIFT_MS + 50);\n    }\n    el.style.transform = \"\";\n  }, []);\n\n  const endPointer = useCallback(() => {\n    const d = drag.current;\n    drag.current = null;\n    pending.current = null;\n    if (scrollFrame.current !== null) {\n      cancelAnimationFrame(scrollFrame.current);\n      scrollFrame.current = null;\n    }\n    if (!d) return;\n    const root = rootRef.current;\n    if (root?.hasPointerCapture?.(d.pointerId)) root.releasePointerCapture?.(d.pointerId);\n    settle(d.id);\n  }, [settle]);\n\n  const cancel = useCallback(() => {\n    const s = sessionRef.current;\n    if (!s) return;\n    endPointer();\n    setSession(null);\n    if (s.mode === \"keyboard\") refocus.current = s.id;\n    setAnnouncement(labels.cancelled(ctxAt(s.id, s.originIndex, s.origin.length)));\n  }, [ctxAt, endPointer, labels, setSession]);\n\n  const drop = useCallback(() => {\n    const s = sessionRef.current;\n    if (!s) return;\n    if (s.target === null) {\n      cancel();\n      return;\n    }\n    const final = previewOf(s);\n    endPointer();\n    setSession(null);\n    if (s.mode === \"keyboard\") refocus.current = s.id;\n    setAnnouncement(labels.dropped(ctxAt(s.id, s.target, final.length)));\n    if (!sameOrder(final, s.origin)) setStored(final);\n  }, [cancel, ctxAt, endPointer, labels, previewOf, setSession, setStored]);\n\n  \/\/ Escape joins the kit's layer stack, so inside a Modal or a full-screen\n  \/\/ surface it cancels the pick-up before it closes anything else.\n  useEscapeKey(cancel, session !== null);\n\n  useEffect(() => {\n    if (disabled) cancel();\n  }, [disabled, cancel]);\n\n  useEffect(() => () => endPointer(), [endPointer]);\n\n  const begin = useCallback(\n    (id: string, mode: Mode): boolean => {\n      const origin = shownRef.current;\n      const index = origin.indexOf(id);\n      if (index < 0) return false;\n      if (isLocked(id)) {\n        setAnnouncement(labels.locked(ctxAt(id, index, origin.length)));\n        return false;\n      }\n      setSession({ id, mode, origin, originIndex: index, target: index });\n      setAnnouncement(labels.pickedUp(ctxAt(id, index, origin.length)));\n      return true;\n    },\n    [ctxAt, isLocked, labels, setSession],\n  );\n\n  const retarget = useCallback(\n    (target: number | null) => {\n      const s = sessionRef.current;\n      if (!s || target === s.target) return;\n      setSession({ ...s, target });\n      if (target !== null) setAnnouncement(labels.moved(ctxAt(s.id, target, s.origin.length)));\n    },\n    [ctxAt, labels, setSession],\n  );\n\n  \/\/ \u2500\u2500 keyboard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n  \/**\n   * The slot an arrow key leads to from `from`, or null for none. Left\/Right\n   * step one slot, Up\/Down one row. When `skipLocked`, a pinned slot is passed\n   * over: Left\/Right go on to the next free slot that way; Up\/Down take the\n   * nearest free slot after the pinned one in reading order (forward for Down,\n   * backward for Up). Down from a row above a short last row lands on the last\n   * slot.\n   *\/\n  const step = useCallback(\n    (order: string[], from: number, key: string, skipLocked: boolean): number | null => {\n      const n = order.length;\n      const free = (i: number) => !skipLocked || !isLocked(order[i] as string);\n      const scan = (start: number, dir: 1 | -1) => {\n        for (let i = start; i >= 0 && i < n; i += dir) if (free(i)) return i;\n        return null;\n      };\n      switch (key) {\n        case \"ArrowRight\":\n          return scan(from + 1, 1);\n        case \"ArrowLeft\":\n          return scan(from - 1, -1);\n        case \"ArrowDown\": {\n          let t = from + cols;\n          if (t > n - 1) {\n            if (Math.floor(from \/ cols) >= Math.floor((n - 1) \/ cols)) return null;\n            t = n - 1;\n          }\n          return scan(t, 1);\n        }\n        case \"ArrowUp\":\n          return from - cols < 0 ? null : scan(from - cols, -1);\n        case \"Home\":\n          return scan(0, 1);\n        case \"End\":\n          return scan(n - 1, -1);\n        default:\n          return null;\n      }\n    },\n    [cols, isLocked],\n  );\n\n  const onCellKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>, id: string) => {\n    \/\/ Keys typed into a field inside the cell belong to the field.\n    if (disabled || e.target !== e.currentTarget) return;\n    const s = sessionRef.current;\n\n    if (e.key === \" \" || e.key === \"Enter\") {\n      e.preventDefault();\n      if (s?.mode === \"keyboard\") drop();\n      else if (!s) begin(id, \"keyboard\");\n      return;\n    }\n\n    if (![\"ArrowRight\", \"ArrowLeft\", \"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(e.key)) return;\n    e.preventDefault();\n\n    if (s?.mode === \"keyboard\") {\n      if (s.target === null) return;\n      const t = step(shownRef.current, s.target, e.key, true);\n      if (t !== null) retarget(t);\n      return;\n    }\n    if (s) return;\n\n    const order = shownRef.current;\n    const t = step(order, order.indexOf(id), e.key, false);\n    if (t === null) return;\n    const next = order[t] as string;\n    setActiveId(next);\n    cellRefs.current.get(next)?.focus();\n  };\n\n  \/\/ \u2500\u2500 pointer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n  \/** Client point to the grid's own coordinates (the space cell offsets are in). *\/\n  const toLocal = (clientX: number, clientY: number) => {\n    const root = rootRef.current!;\n    const r = root.getBoundingClientRect();\n    return { x: clientX - r.left - root.clientLeft, y: clientY - r.top - root.clientTop, rect: r };\n  };\n\n  \/**\n   * The slot under the pointer. Kanban's rule (compare the pointer with each\n   * card's box) extended to two dimensions: the nearest cell box wins, so a\n   * point in a gutter belongs to the closer neighbour. Boxes are layout\n   * offsets, which ignore transforms, so cells mid-animation do not move the\n   * target. Outside the grid is `null`: a release there cancels. A pinned cell\n   * is not a target; the current target stands.\n   *\/\n  const hitTest = (clientX: number, clientY: number): number | null => {\n    const s = sessionRef.current;\n    if (!s) return null;\n    const { x, y, rect } = toLocal(clientX, clientY);\n    if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) return null;\n\n    const order = shownRef.current;\n    let best = -1;\n    let bestDist = Infinity;\n    order.forEach((id, i) => {\n      const el = cellRefs.current.get(id);\n      if (!el) return;\n      const l = el.offsetLeft;\n      const t = el.offsetTop;\n      const dx = Math.max(l - x, 0, x - (l + el.offsetWidth));\n      const dy = Math.max(t - y, 0, y - (t + el.offsetHeight));\n      const dist = dx * dx + dy * dy;\n      if (dist < bestDist) {\n        bestDist = dist;\n        best = i;\n      }\n    });\n    if (best < 0) return s.target;\n    if (isLocked(order[best] as string)) return s.target ?? s.originIndex;\n    return best;\n  };\n\n  \/** The dragged cell follows the pointer from where it STARTED, whatever slot it now has. *\/\n  const applyDragTransform = useCallback(() => {\n    const d = drag.current;\n    const root = rootRef.current;\n    if (!d || !root) return;\n    const el = cellRefs.current.get(d.id);\n    if (!el) return;\n    const r = root.getBoundingClientRect();\n    const px = d.clientX - r.left - root.clientLeft;\n    const py = d.clientY - r.top - root.clientTop;\n    const tx = d.startOffset.x + (px - d.startPos.x) - el.offsetLeft;\n    const ty = d.startOffset.y + (py - d.startPos.y) - el.offsetTop;\n    el.style.transition = \"\";\n    el.style.transform = `translate3d(${tx}px, ${ty}px, 0)`;\n  }, []);\n\n  const track = () => {\n    const d = drag.current;\n    if (!d) return;\n    retarget(hitTest(d.clientX, d.clientY));\n    applyDragTransform();\n  };\n\n  \/** Scroll the nearest scroller while the pointer is near its edge. *\/\n  const autoScroll = () => {\n    const d = drag.current;\n    if (!d?.scroller || typeof requestAnimationFrame !== \"function\") return;\n    const sc = d.scroller;\n    const isDoc = sc === document.scrollingElement;\n    const box = isDoc\n      ? { top: 0, left: 0, bottom: window.innerHeight, right: window.innerWidth }\n      : sc.getBoundingClientRect();\n    const speed = (dist: number) => (dist < EDGE ? Math.ceil(((EDGE - dist) \/ EDGE) * MAX_SCROLL_STEP) : 0);\n    let vy = speed(d.clientY - box.top) ? -speed(d.clientY - box.top) : speed(box.bottom - d.clientY);\n    let vx = speed(d.clientX - box.left) ? -speed(d.clientX - box.left) : speed(box.right - d.clientX);\n    \/\/ Only where it can actually go.\n    if ((vy < 0 && sc.scrollTop <= 0) || (vy > 0 && sc.scrollTop + sc.clientHeight >= sc.scrollHeight)) vy = 0;\n    if ((vx < 0 && sc.scrollLeft <= 0) || (vx > 0 && sc.scrollLeft + sc.clientWidth >= sc.scrollWidth)) vx = 0;\n    if (!vx && !vy) {\n      scrollFrame.current = null;\n      return;\n    }\n    if (scrollFrame.current !== null) return;\n    scrollFrame.current = requestAnimationFrame(() => {\n      scrollFrame.current = null;\n      if (!drag.current) return;\n      sc.scrollTop += vy;\n      sc.scrollLeft += vx;\n      track();\n      autoScroll();\n    });\n  };\n\n  const onCellPointerDown = (e: ReactPointerEvent<HTMLDivElement>, id: string) => {\n    if (disabled || e.button !== 0 || sessionRef.current || isLocked(id)) return;\n    const cellEl = e.currentTarget;\n    const target = e.target as Element;\n    const handle = target.closest(\"[data-reorderable-handle]\");\n    if (handle && cellEl.contains(handle)) {\n      \/\/ The handle: always a drag.\n    } else if (cellEl.querySelector(\"[data-reorderable-handle]\")) {\n      return; \/\/ This cell has a handle and the press was elsewhere.\n    } else {\n      const interactive = target.closest(INTERACTIVE);\n      if (interactive && interactive !== cellEl && cellEl.contains(interactive)) return;\n    }\n    pending.current = { id, pointerId: e.pointerId, clientX: e.clientX, clientY: e.clientY };\n  };\n\n  const onRootPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {\n    const p = pending.current;\n    if (p && p.pointerId === e.pointerId && !drag.current) {\n      if (Math.hypot(e.clientX - p.clientX, e.clientY - p.clientY) < DRAG_THRESHOLD) return;\n      const root = rootRef.current;\n      const el = cellRefs.current.get(p.id);\n      pending.current = null;\n      if (!root || !el || !begin(p.id, \"pointer\")) return;\n      \/\/ Capture on the ROOT, never the cell: the live preview moves the cell's\n      \/\/ DOM node, and moving a node can drop a capture held on it.\n      root.setPointerCapture?.(e.pointerId);\n      window.getSelection?.()?.removeAllRanges();\n      const start = toLocal(p.clientX, p.clientY);\n      drag.current = {\n        id: p.id,\n        pointerId: e.pointerId,\n        startOffset: { x: el.offsetLeft, y: el.offsetTop },\n        startPos: { x: start.x, y: start.y },\n        clientX: e.clientX,\n        clientY: e.clientY,\n        scroller: scrollParent(root),\n      };\n    }\n    const d = drag.current;\n    if (!d || d.pointerId !== e.pointerId) return;\n    d.clientX = e.clientX;\n    d.clientY = e.clientY;\n    track();\n    autoScroll();\n  };\n\n  const onRootPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {\n    if (pending.current?.pointerId === e.pointerId) {\n      pending.current = null;\n      return;\n    }\n    const d = drag.current;\n    if (!d || d.pointerId !== e.pointerId) return;\n    d.clientX = e.clientX;\n    d.clientY = e.clientY;\n    const target = hitTest(e.clientX, e.clientY);\n    const s = sessionRef.current;\n    if (s && target !== s.target) {\n      \/\/ Commit what the release point says, not the last move event.\n      sessionRef.current = { ...s, target };\n    }\n    drop();\n  };\n\n  const onRootPointerCancel = (e: ReactPointerEvent<HTMLDivElement>) => {\n    if (pending.current?.pointerId === e.pointerId) pending.current = null;\n    if (drag.current?.pointerId === e.pointerId) cancel();\n  };\n\n  \/\/ \u2500\u2500 layout effects: follow the pointer, shift the others, keep focus \u2500\u2500\u2500\u2500\u2500\u2500\n\n  const offsets = useRef(new Map<string, { x: number; y: number }>());\n  const shiftTimers = useRef(new Map<string, number>());\n  const shownKey = shown.join(\"\\u0000\");\n\n  useLayoutEffect(() => {\n    const prev = offsets.current;\n    const next = new Map<string, { x: number; y: number }>();\n    for (const [id, el] of cellRefs.current) next.set(id, { x: el.offsetLeft, y: el.offsetTop });\n    offsets.current = next;\n\n    \/\/ FLIP: cells the preview displaced slide from where they were.\n    if (prev.size && !prefersReducedMotion()) {\n      for (const [id, el] of cellRefs.current) {\n        if (id === drag.current?.id) continue;\n        const a = prev.get(id);\n        const b = next.get(id);\n        if (!a || !b || (a.x === b.x && a.y === b.y)) continue;\n        el.style.transition = \"none\";\n        el.style.transform = `translate3d(${a.x - b.x}px, ${a.y - b.y}px, 0)`;\n        void el.offsetWidth;\n        el.style.transition = `transform ${SHIFT_MS}ms ease`;\n        el.style.transform = \"\";\n        const old = shiftTimers.current.get(id);\n        if (old !== undefined) window.clearTimeout(old);\n        shiftTimers.current.set(\n          id,\n          window.setTimeout(() => {\n            shiftTimers.current.delete(id);\n            if (!el.style.transform) el.style.transition = \"\";\n          }, SHIFT_MS + 50),\n        );\n      }\n    }\n\n    applyDragTransform();\n\n    \/\/ React moves DOM nodes to reorder them, and a moved node can lose focus.\n    const s = sessionRef.current;\n    const keep = s?.mode === \"keyboard\" ? s.id : refocus.current;\n    refocus.current = null;\n    if (keep) {\n      const el = cellRefs.current.get(keep);\n      if (el && document.activeElement !== el) el.focus();\n    }\n  }, [shownKey, session, applyDragTransform]);\n\n  useEffect(() => {\n    const timers = shiftTimers.current;\n    return () => {\n      for (const t of timers.values()) window.clearTimeout(t);\n    };\n  }, []);\n\n  \/\/ \u2500\u2500 render \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n  const state = session ? (session.mode === \"pointer\" ? \"dragging\" : \"picked-up\") : \"idle\";\n  const dropIndex = session && session.target !== null ? session.target : undefined;\n  const total = shown.length;\n\n  return (\n    <div\n      {...props}\n      ref={composeRefs(rootRef, forwardedRef)}\n      data-react-fancy-reorderable-grid=\"\"\n      data-columns={cols}\n      data-state={state}\n      data-drop-index={dropIndex}\n      data-disabled={disabled ? \"\" : undefined}\n      role=\"group\"\n      aria-label={label}\n      aria-disabled={disabled || undefined}\n      onPointerMove={onRootPointerMove}\n      onPointerUp={onRootPointerUp}\n      onPointerCancel={onRootPointerCancel}\n      onLostPointerCapture={(e) => {\n        if (drag.current?.pointerId === e.pointerId) cancel();\n      }}\n      className={cn(\n        \"relative grid\",\n        COLUMN_CLASSES[cols - 1],\n        GAP_CLASSES[gap],\n        session?.mode === \"pointer\" && \"cursor-grabbing select-none\",\n        disabled && \"opacity-60\",\n        className,\n      )}\n    >\n      {shown.map((id, index) => {\n        const item = byId.get(id);\n        if (!item) return null;\n        const locked = item.locked === true;\n        const active = session?.id === id;\n        const dragging = active && session?.mode === \"pointer\";\n        const pickedUp = active && session?.mode === \"keyboard\";\n        const dropTarget = active && dropIndex === index;\n        const outside = active && session?.target === null;\n        const itemState = {\n          index,\n          position: index + 1,\n          row: Math.floor(index \/ cols) + 1,\n          column: (index % cols) + 1,\n          total,\n          dragging,\n          pickedUp,\n          dropTarget,\n          locked,\n          disabled,\n          handleProps: { \"data-reorderable-handle\": \"\" as const },\n        };\n        return (\n          <div\n            key={id}\n            ref={cellRef(id)}\n            data-react-fancy-reorderable-grid-item=\"\"\n            data-reorderable-id={id}\n            data-index={index}\n            data-state={dragging ? \"dragging\" : pickedUp ? \"picked-up\" : \"idle\"}\n            data-locked={locked ? \"true\" : undefined}\n            data-drop-target={dropTarget ? \"\" : undefined}\n            role=\"button\"\n            aria-roledescription={labels.roleDescription}\n            aria-describedby={instructionsId}\n            aria-pressed={active}\n            aria-disabled={disabled || locked || undefined}\n            tabIndex={disabled ? -1 : id === tabStop ? 0 : -1}\n            onFocus={(e) => {\n              if (e.target === e.currentTarget) setActiveId(id);\n            }}\n            onKeyDown={(e) => onCellKeyDown(e, id)}\n            onPointerDown={(e) => onCellPointerDown(e, id)}\n            className={cn(\n              CELL_BASE,\n              !unstyled && CELL_STYLED,\n              !unstyled && locked && CELL_LOCKED_STYLED,\n              locked || disabled ? \"cursor-default\" : \"cursor-grab\",\n              dragging && cn(\"z-10 cursor-grabbing shadow-lg ring-2\", FOCUS_RING),\n              pickedUp && cn(\"z-10 shadow-md ring-2\", FOCUS_RING),\n              outside && \"opacity-60\",\n              cellClassName,\n            )}\n          >\n            {renderItem ? renderItem(item, itemState) : labelOf(id)}\n          <\/div>\n        );\n      })}\n      <div id={instructionsId} className=\"sr-only\">\n        {labels.instructions}\n      <\/div>\n      <div data-react-fancy-reorderable-grid-live=\"\" aria-live=\"assertive\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      <\/div>\n    <\/div>\n  );\n}\n\n\/**\n * A grid of cells reordered in place, row-major, by pointer or keyboard.\n *\n * Built for field-order designers: the grid looks like the grid it designs.\n * Order is a list of ids (`value` \/ `defaultValue` \/ `onReorder`); the pure\n * `moveItem(ids, from, to, { locked })` is the same rule without a DOM, for\n * agents.\n *\/\nexport const ReorderableGrid = forwardRef(ReorderableGridInner) as (<T extends ReorderableGridItem>(\n  props: ReorderableGridProps<T> & { ref?: Ref<HTMLDivElement> },\n) => ReactElement) & { displayName?: string };\n\nReorderableGrid.displayName = \"ReorderableGrid\";\n","type":"registry:ui","target":"components\/fancy\/reorderable-grid\/ReorderableGrid.tsx"},{"path":"components\/fancy\/reorderable-grid\/ReorderableGrid.types.ts","content":"import type { HTMLAttributes, ReactNode } from \"react\";\n\n\/** The least an item needs. Anything else on it is yours, and reaches `renderItem` untouched. *\/\nexport interface ReorderableGridItem {\n  \/** Stable id. The order is reported as these. *\/\n  id: string;\n  \/**\n   * Pinned: this cell keeps its slot. It cannot be picked up or dragged, and\n   * nothing can be dropped onto it; other cells flow around it.\n   *\/\n  locked?: boolean;\n}\n\n\/** What `renderItem` gets alongside the item. *\/\nexport interface ReorderableGridItemState {\n  \/** 0-based index in the order currently shown (the live preview while dragging). *\/\n  index: number;\n  \/** 1-based position. *\/\n  position: number;\n  \/** 1-based row. *\/\n  row: number;\n  \/** 1-based column. *\/\n  column: number;\n  total: number;\n  \/** Being dragged with a pointer. *\/\n  dragging: boolean;\n  \/** Picked up with the keyboard. *\/\n  pickedUp: boolean;\n  \/** This cell's slot is where the active item will land. *\/\n  dropTarget: boolean;\n  locked: boolean;\n  disabled: boolean;\n  \/**\n   * Spread on an element to make it the drag handle. Once a cell contains a\n   * handle, only the handle starts a pointer drag; without one, the whole cell\n   * does. The keyboard always works on the cell itself.\n   *\/\n  handleProps: { \"data-reorderable-handle\": \"\" };\n}\n\n\/** Facts every announcement can use. Positions are 1-based. *\/\nexport interface ReorderableGridAnnouncementContext {\n  id: string;\n  \/** From `getItemLabel`, else `item.label` when it is a string, else the id. *\/\n  label: string;\n  position: number;\n  total: number;\n  row: number;\n  column: number;\n}\n\n\/** Every string the grid speaks or shows to assistive tech. Override any of them (i18n). *\/\nexport interface ReorderableGridLabels {\n  \/** Read with each cell (`aria-describedby`). *\/\n  instructions: string;\n  \/** `aria-roledescription` of each cell. *\/\n  roleDescription: string;\n  pickedUp: (ctx: ReorderableGridAnnouncementContext) => string;\n  moved: (ctx: ReorderableGridAnnouncementContext) => string;\n  dropped: (ctx: ReorderableGridAnnouncementContext) => string;\n  \/** `ctx` is the restored (original) position. *\/\n  cancelled: (ctx: ReorderableGridAnnouncementContext) => string;\n  locked: (ctx: ReorderableGridAnnouncementContext) => string;\n}\n\nexport interface ReorderableGridProps<T extends ReorderableGridItem>\n  extends Omit<HTMLAttributes<HTMLDivElement>, \"children\" | \"defaultValue\" | \"onChange\"> {\n  \/** The cells' data. Each needs a unique `id`. *\/\n  items: T[];\n  \/**\n   * Controlled order, as ids. Ids that are not in `items` are ignored, and\n   * items missing from it follow in `items` order, so a stale or partial order\n   * never drops a cell.\n   *\/\n  value?: string[];\n  \/** Uncontrolled starting order. Omit both to start in `items` order. *\/\n  defaultValue?: string[];\n  \/**\n   * The full new order, once per committed move: a pointer release or a\n   * keyboard drop that changed something. Never on a cancel, never on a\n   * no-op drop, never during the live preview.\n   *\/\n  onReorder?: (ids: string[]) => void;\n  \/** Renders a cell's content. Default: the item's label. *\/\n  renderItem?: (item: T, state: ReorderableGridItemState) => ReactNode;\n  \/** Columns, 1 to 12. Placement is row-major. Default `2`. *\/\n  columns?: number;\n  \/** Gutter. Default `sm`. *\/\n  gap?: \"none\" | \"sm\" | \"md\" | \"lg\";\n  \/** No reordering at all, and no tab stops. *\/\n  disabled?: boolean;\n  \/** Accessible name of the grid. *\/\n  label?: string;\n  \/** The name an item is announced by. *\/\n  getItemLabel?: (item: T) => string;\n  \/** Overrides for any announcement or label. *\/\n  labels?: Partial<ReorderableGridLabels>;\n  \/** Skip the cell's default visuals (border, surface, padding). Behaviour stays. *\/\n  unstyled?: boolean;\n  \/** Extra classes on every cell. *\/\n  cellClassName?: string;\n}\n","type":"registry:ui","target":"components\/fancy\/reorderable-grid\/ReorderableGrid.types.ts"},{"path":"components\/fancy\/reorderable-grid\/index.ts","content":"export { ReorderableGrid } from \".\/ReorderableGrid\";\nexport { moveItem } from \".\/move-item\";\nexport type { MoveItemOptions } from \".\/move-item\";\nexport type {\n  ReorderableGridProps,\n  ReorderableGridItem,\n  ReorderableGridItemState,\n  ReorderableGridLabels,\n  ReorderableGridAnnouncementContext,\n} from \".\/ReorderableGrid.types\";\n","type":"registry:ui","target":"components\/fancy\/reorderable-grid\/index.ts"},{"path":"components\/fancy\/reorderable-grid\/move-item.ts","content":"export interface MoveItemOptions<T> {\n  \/**\n   * Pinned entries. A locked entry keeps its absolute index: it cannot be\n   * moved, nothing can be dropped onto its slot, and the other entries flow\n   * around it. Either the locked ids themselves or a predicate.\n   *\/\n  locked?: Iterable<T> | ((id: T) => boolean);\n}\n\nfunction lockPredicate<T>(locked: MoveItemOptions<T>[\"locked\"]): ((id: T) => boolean) | null {\n  if (!locked) return null;\n  if (typeof locked === \"function\") return locked;\n  const set = new Set(locked);\n  return set.size ? (id: T) => set.has(id) : null;\n}\n\n\/**\n * Move the entry at `fromIndex` so that it ends up at `toIndex`, row-major,\n * and return a NEW array. The input is never mutated.\n *\n * This is the whole reorder rule of `ReorderableGrid`, with no DOM: the grid's\n * pointer drags and arrow keys both go through it, and an agent bridge can call\n * it directly to reorder from a tool call.\n *\n * - `toIndex` is the entry's index in the RESULT, so `moveItem(ids, 0, ids.length - 1)`\n *   moves the first entry to the end.\n * - An index outside the list (or not an integer) throws `RangeError`. That is\n *   a caller bug, and a silent no-op would hide it.\n * - With `locked`: a locked entry keeps its absolute index and the move happens\n *   among the free slots only. Moving a locked entry, or onto a locked slot,\n *   returns the order unchanged: that is the rule doing its job, not an error.\n *\/\nexport function moveItem<T>(\n  ids: readonly T[],\n  fromIndex: number,\n  toIndex: number,\n  options: MoveItemOptions<T> = {},\n): T[] {\n  const n = ids.length;\n  for (const [name, i] of [\n    [\"fromIndex\", fromIndex],\n    [\"toIndex\", toIndex],\n  ] as const) {\n    if (!Number.isInteger(i) || i < 0 || i >= n) {\n      throw new RangeError(`moveItem: ${name} ${i} is outside 0..${n - 1}`);\n    }\n  }\n\n  const out = ids.slice();\n  if (fromIndex === toIndex) return out;\n\n  const isLocked = lockPredicate(options.locked);\n  if (!isLocked) {\n    const [moved] = out.splice(fromIndex, 1);\n    out.splice(toIndex, 0, moved as T);\n    return out;\n  }\n\n  if (isLocked(ids[fromIndex] as T) || isLocked(ids[toIndex] as T)) return out;\n\n  \/\/ Move within the free slots only; locked entries never leave theirs.\n  const slots: number[] = [];\n  for (let i = 0; i < n; i++) if (!isLocked(ids[i] as T)) slots.push(i);\n  const free = slots.map((i) => ids[i] as T);\n  const [moved] = free.splice(slots.indexOf(fromIndex), 1);\n  free.splice(slots.indexOf(toIndex), 0, moved as T);\n  slots.forEach((slot, k) => {\n    out[slot] = free[k] as T;\n  });\n  return out;\n}\n","type":"registry:ui","target":"components\/fancy\/reorderable-grid\/move-item.ts"},{"path":"components\/fancy\/lib\/utils\/cn.ts","content":"import { twMerge } from \"tailwind-merge\";\n\n\/**\n * A class value: a string, a number, a conditional map, an array of any of\n * those, or nothing. Mirrors what `clsx` accepted, because every component in\n * this package already passes these shapes.\n *\/\nexport type ClassValue =\n  | string\n  | number\n  | bigint\n  | null\n  | undefined\n  | false\n  | ClassDictionary\n  | ClassValue[];\n\ntype ClassDictionary = Record<string, unknown>;\n\n\/**\n * Flatten class values into a space-separated string.\n *\n * This was `clsx` until 2026-08-19. It is roughly twenty lines of logic, it\n * shipped as a RUNTIME dependency to every consumer of this package, and its\n * last commit was 801 days old \u2014 so it fell to the rule that third-party code\n * must be both approved and actively maintained. Owning it costs less than\n * carrying it.\n *\n * `tailwind-merge` deliberately stays: deciding which of two conflicting\n * Tailwind utilities wins requires modelling the entire utility space, which is\n * emphatically not twenty lines, and it is actively maintained.\n *\n * Falsy values are dropped \u2014 including `0` and `\"\"`, which is what `clsx` did\n * and what `tests\/cn.test.ts` pins. A truthy number is kept, because digits are\n * legal class names. `bigint` is admitted because clsx's signature did and\n * the compiler proved it reachable from two call sites here.\n *\/\nfunction flatten(value: ClassValue): string {\n  if (!value) return \"\";\n\n  if (typeof value === \"string\" || typeof value === \"number\" || typeof value === \"bigint\") {\n    return String(value);\n  }\n\n  if (Array.isArray(value)) {\n    let out = \"\";\n    for (const item of value) {\n      const part = flatten(item);\n      if (part) out = out ? `${out} ${part}` : part;\n    }\n    return out;\n  }\n\n  let out = \"\";\n  for (const key in value) {\n    if (value[key]) out = out ? `${out} ${key}` : key;\n  }\n  return out;\n}\n\nexport function cn(...inputs: ClassValue[]): string {\n  return twMerge(flatten(inputs));\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/utils\/cn.ts"},{"path":"components\/fancy\/lib\/utils\/compose-refs.ts","content":"import type { Ref, RefCallback } from \"react\";\n\n\/**\n * One ref callback that feeds every ref it is given.\n *\n * For a component that needs its own handle on an element AND has to honour a\n * ref someone else put there \u2014 a Tooltip anchoring to its trigger, a control\n * measuring itself while a caller holds a ref to it. Assigning one ref over the\n * other silently breaks whichever side lost.\n *\/\nexport function composeRefs<T>(...refs: Array<Ref<T> | undefined | null>): RefCallback<T> {\n  return (node: T | null) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) (ref as { current: T | null }).current = node;\n    }\n  };\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/utils\/compose-refs.ts"},{"path":"components\/fancy\/lib\/hooks\/use-controllable-state.ts","content":"import { useCallback, useRef, useState } from \"react\";\n\n\/**\n * One state slot that works controlled or uncontrolled.\n *\n * ---------------------------------------------------------------------------\n * Why the value is tracked in a ref (fixed 5.32.0)\n * ---------------------------------------------------------------------------\n *\n * This resolved a functional update against the RENDER CLOSURE's `value` and then\n * handed React an already-computed value:\n *\n *     const nextValue = typeof next === \"function\" ? next(value) : next;\n *     setUncontrolledValue(nextValue);\n *\n * So `setValue(fn)` twice in one batch had both calls compute from the same stale\n * `value`, and **the first update was silently lost.** React's own `setState`\n * updater form does not have that problem precisely because React resolves it\n * against the latest state; passing a pre-computed value gives that up.\n *\n * Found by a hook test that paste-held two items inside one `act()` and got one.\n * It is not an exotic case \u2014 any caller that loops, or handles two events in a\n * batch, hits it, and the symptom is a dropped update with nothing to indicate one\n * happened.\n *\n * The fix cannot be \"pass the updater to React\", because `onChange` has to be\n * called with the resulting value and an updater must stay pure \u2014 React may invoke\n * it twice (StrictMode), which would fire `onChange` twice. So the previous value\n * comes from a ref that is re-synced from the authoritative `value` on every render\n * and advanced on every set. Within a batch the ref carries intent; after a render\n * the real value wins.\n *\n * A side effect worth having: `setValue` no longer depends on `value`, so it is\n * stable for the life of the component.\n *\/\nexport function useControllableState<T>(\n  controlledValue: T | undefined,\n  defaultValue: T,\n  onChange?: (value: T) => void,\n): [T, (value: T | ((prev: T) => T)) => void] {\n  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);\n  const isControlled = controlledValue !== undefined;\n  const value = isControlled ? controlledValue : uncontrolledValue;\n\n  const onChangeRef = useRef(onChange);\n  onChangeRef.current = onChange;\n\n  \/\/ Re-synced every render from whichever side owns the value. A controlled parent\n  \/\/ that ignores `onChange` therefore wins on the next render rather than being\n  \/\/ overridden by what this hook optimistically recorded.\n  const valueRef = useRef(value);\n  valueRef.current = value;\n\n  const isControlledRef = useRef(isControlled);\n  isControlledRef.current = isControlled;\n\n  const setValue = useCallback((next: T | ((prev: T) => T)) => {\n    const nextValue =\n      typeof next === \"function\"\n        ? (next as (prev: T) => T)(valueRef.current)\n        : next;\n\n    \/\/ Advance the ref BEFORE notifying, so a second call in the same batch builds\n    \/\/ on this one instead of on what the last render saw.\n    valueRef.current = nextValue;\n\n    if (!isControlledRef.current) {\n      setUncontrolledValue(nextValue);\n    }\n\n    onChangeRef.current?.(nextValue);\n  }, []);\n\n  return [value, setValue];\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/hooks\/use-controllable-state.ts"},{"path":"components\/fancy\/lib\/hooks\/use-escape-key.ts","content":"import { createContext, useContext, useEffect, useRef } from \"react\";\n\n\/**\n * Escape belongs to the innermost open layer.\n *\n * Every `useEscapeKey(handler, enabled)` call is a LAYER while `enabled`: a\n * Dropdown's menu, a Select's listbox, a Popover, a Modal, a Drawer, a Tooltip,\n * a full-screen surface. They all join one stack, one document listener reads\n * each Escape, and only one layer gets it.\n *\n * Why not one document listener per layer, as this hook used to do: every\n * open layer then received the same Escape. A Dropdown inside a Modal closed\n * the menu AND the dialog. Inside a full-screen surface it was worse: the\n * surface's own key handler runs during the event's trip through the surface,\n * before any document listener, so it could not tell that a menu wanted the\n * key. DeckEditor exited full screen with its Shape menu still open. Fixing\n * that by ordering listeners would only last until someone registers one in a\n * different place. So the decision is made from the stack instead.\n *\n * \"Innermost\" is decided in two steps:\n *\n * 1. The most recently opened layer is the candidate. Opening a menu from a\n *    dialog is the normal order of things.\n * 2. A full-screen surface knows its own descendants: layers rendered inside\n *    it register under it through `EscapeLayerContext`, and that context flows\n *    through portals. If the candidate is a surface with open layers inside,\n *    the most recent of those wins instead. Without this step, a menu opened\n *    BEFORE an agent put its surface full screen would lose the Escape to the\n *    surface.\n *\n * An Escape that something already handled (`event.defaultPrevented`, for\n * example a text field clearing itself) is left alone.\n *\/\n\ninterface LayerEntry {\n  handler: { current: () => void };\n  \/** The surface this layer was rendered inside, if any. *\/\n  parent: EscapeScope | null;\n  \/** Set when the layer IS a surface: layers inside it point here. *\/\n  scope: EscapeScope | null;\n}\n\n\/** Identity of a full-screen surface for the layers rendered inside it. *\/\nexport type EscapeScope = object;\n\nconst stack: LayerEntry[] = [];\n\n\/** The surface (if any) that layers rendered here belong to. *\/\nexport const EscapeLayerContext = createContext<EscapeScope | null>(null);\n\nfunction innermost(): LayerEntry | undefined {\n  let candidate = stack[stack.length - 1];\n  while (candidate?.scope) {\n    const scope = candidate.scope;\n    let inner: LayerEntry | undefined;\n    for (let i = stack.length - 1; i >= 0; i--) {\n      if (stack[i].parent === scope) {\n        inner = stack[i];\n        break;\n      }\n    }\n    if (!inner) break;\n    candidate = inner;\n  }\n  return candidate;\n}\n\nfunction onDocumentKeyDown(event: KeyboardEvent) {\n  if (event.key !== \"Escape\" || event.defaultPrevented) return;\n  const target = innermost();\n  if (!target) return;\n  event.preventDefault();\n  target.handler.current();\n}\n\nfunction push(entry: LayerEntry): () => void {\n  if (stack.length === 0) document.addEventListener(\"keydown\", onDocumentKeyDown);\n  stack.push(entry);\n  return () => {\n    const i = stack.indexOf(entry);\n    if (i >= 0) stack.splice(i, 1);\n    if (stack.length === 0) document.removeEventListener(\"keydown\", onDocumentKeyDown);\n  };\n}\n\n\/**\n * Register a layer that closes on Escape while `enabled`. Pass `scope` only for\n * a surface that other layers can open inside (a full-screen surface).\n *\n * Returns `isInnermost()`, for a layer that also handles Escape in its own key\n * handler and must not act when a layer above it owns the key.\n *\n * @internal Exported for full-screen surfaces in sibling packages; ordinary\n * layers call `useEscapeKey`.\n *\/\nexport function useEscapeLayer(\n  handler: () => void,\n  enabled: boolean,\n  scope: EscapeScope | null = null,\n): { isInnermost: () => boolean } {\n  const parent = useContext(EscapeLayerContext);\n  const handlerRef = useRef(handler);\n  handlerRef.current = handler;\n  const entryRef = useRef<LayerEntry | null>(null);\n\n  \/\/ Depends on `enabled` (and identity), NOT on `handler`: callers pass inline\n  \/\/ callbacks, and re-registering on every render would move a dialog above\n  \/\/ the menu it had just opened.\n  useEffect(() => {\n    if (!enabled) return;\n    const entry: LayerEntry = { handler: handlerRef, parent, scope };\n    entryRef.current = entry;\n    const pop = push(entry);\n    return () => {\n      pop();\n      if (entryRef.current === entry) entryRef.current = null;\n    };\n  }, [enabled, parent, scope]);\n\n  const isInnermostRef = useRef(() => entryRef.current !== null && innermost() === entryRef.current);\n  return { isInnermost: isInnermostRef.current };\n}\n\nexport function useEscapeKey(handler: () => void, enabled: boolean = true): void {\n  useEscapeLayer(handler, enabled);\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/hooks\/use-escape-key.ts"}]}