{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sortable-grid",
  "type": "registry:ui",
  "title": "Sortable Grid",
  "description": "Tiles lift off the grid, leave a slot behind and land where dropped.",
  "categories": [
    "gestures"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/sortable-grid.tsx",
      "content": "\"use client\";\nimport { animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { Children, createContext, Fragment, isValidElement, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Context\n * -----------------------------------------------------------------------------------------------*/\n\ntype Cell = { left: number; top: number; width: number; height: number };\ntype Drag = { id: string; from: string[]; phase: \"dragging\" | \"settling\" };\ntype Lift = { id: string; from: string[] };\n\ntype GridContext = {\n  order: string[];\n  drag: Drag | null;\n  lift: Lift | null;\n  pressed: string | null;\n  focusId: string | null;\n  disabled: boolean;\n  reduce: boolean;\n  hintId: string;\n  onPointerDown: (e: React.PointerEvent<HTMLElement>, id: string, disabled: boolean) => void;\n  onKeyDown: (e: React.KeyboardEvent<HTMLElement>, id: string, label: string, disabled: boolean) => void;\n  onFocus: (id: string) => void;\n  onBlur: (e: React.FocusEvent<HTMLElement>, id: string, label: string) => void;\n};\n\nconst GridCtx = createContext<GridContext | null>(null);\n\n// Mouse and pen start dragging after a few pixels, so a click stays a click. Touch waits for a\n// short hold instead, so a swipe over the grid still scrolls the page.\nconst THRESHOLD = 5;\nconst HOLD_MS = 240;\nconst HOLD_SLOP = 8;\n\nconst tileClass = cn(\n  \"relative h-full min-w-0 select-none rounded-xl border border-line bg-raised p-3 text-left text-fg shadow-[var(--shadow)]\",\n  \"[-webkit-touch-callout:none]\",\n);\n\n/* -------------------------------------------------------------------------------------------------\n * SortableGrid\n * -----------------------------------------------------------------------------------------------*/\n\nexport type SortableGridProps = Omit<\n  React.ComponentProps<\"ul\">,\n  \"children\" | \"defaultValue\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\"\n> & {\n  /** The order, as item values. Controlled. */\n  value?: string[];\n  /** The starting order when uncontrolled. Defaults to the order the children are written in. */\n  defaultValue?: string[];\n  /** Called whenever the order changes, including live while a tile is dragged. */\n  onValueChange?: (value: string[]) => void;\n  /** Called once when a tile is dropped somewhere new. The place to save. */\n  onValueCommitted?: (value: string[]) => void;\n  /** Stops every tile from being picked up. */\n  disabled?: boolean;\n  /** SortableGridItem elements, each with a unique value. */\n  children: React.ReactNode;\n};\n\n/** A grid of tiles that are reordered by dragging them anywhere, or with the keyboard. */\nexport function SortableGrid({\n  value,\n  defaultValue,\n  onValueChange,\n  onValueCommitted,\n  disabled = false,\n  className,\n  children,\n  ...rest\n}: SortableGridProps) {\n  const items = Children.toArray(children).filter((c) => isValidElement<SortableGridItemProps>(c));\n  const childIds = items.map((c) => c.props.value);\n  const [stored, setStored] = useControllableState({ value, defaultValue: defaultValue ?? childIds, onChange: onValueChange });\n  const order = [...stored.filter((id) => childIds.includes(id)), ...childIds.filter((id) => !stored.includes(id))];\n  const byId = new Map(items.map((c) => [c.props.value, c]));\n\n  const reduce = !!useReducedMotion();\n  const hintId = useId();\n  const listRef = useRef<HTMLUListElement | null>(null);\n  const [drag, setDrag] = useState<Drag | null>(null);\n  const [lift, setLift] = useState<Lift | null>(null);\n  const [pressed, setPressed] = useState<string | null>(null);\n  const [focusId, setFocusId] = useState<string | null>(null);\n  const [message, setMessage] = useState(\"\");\n\n  // The ghost that follows the pointer, in the list's own coordinates.\n  const gx = useMotionValue(0);\n  const gy = useMotionValue(0);\n  const gScale = useMotionValue(1);\n  const [ghostSize, setGhostSize] = useState({ width: 0, height: 0 });\n\n  // Everything a live gesture needs, kept out of render.\n  const session = useRef<{\n    id: string;\n    label: string;\n    pointerId: number;\n    startX: number;\n    startY: number;\n    grabX: number;\n    grabY: number;\n    cells: Cell[];\n    from: string[];\n    order: string[];\n    started: boolean;\n    hold?: number;\n    cleanup: () => void;\n  } | null>(null);\n\n  const at = (ids: string[], id: string) => `position ${ids.indexOf(id) + 1} of ${ids.length}`;\n  const labelOf = (id: string) => byId.get(id)?.props.label ?? id;\n\n  // Every slot's box, read from layout (offsets ignore the springs in flight) in DOM order.\n  const measureCells = (): Cell[] => {\n    const list = listRef.current;\n    if (!list) return [];\n    return Array.from(list.querySelectorAll<HTMLElement>(\":scope > [data-slot]\")).map((el) => ({\n      left: el.offsetLeft,\n      top: el.offsetTop,\n      width: el.offsetWidth,\n      height: el.offsetHeight,\n    }));\n  };\n\n  const columns = (cells: Cell[]) => Math.max(1, cells.filter((c) => Math.abs(c.top - cells[0]?.top) < 2).length);\n\n  const reorder = (ids: string[], id: string, to: number) => {\n    const next = [...ids];\n    next.splice(to, 0, next.splice(next.indexOf(id), 1)[0]);\n    return next;\n  };\n\n  const endSession = () => {\n    const s = session.current;\n    if (!s) return;\n    window.clearTimeout(s.hold);\n    s.cleanup();\n    session.current = null;\n  };\n\n  // Clean up a gesture that's still live when the grid unmounts.\n  useEffect(\n    () => () => {\n      const s = session.current;\n      if (!s) return;\n      window.clearTimeout(s.hold);\n      s.cleanup();\n    },\n    [],\n  );\n\n  useGrabbingCursor(drag?.phase === \"dragging\");\n\n  const beginDrag = () => {\n    const s = session.current;\n    if (!s) return;\n    s.started = true;\n    const cell = s.cells[s.from.indexOf(s.id)];\n    setGhostSize({ width: cell.width, height: cell.height });\n    gx.set(cell.left);\n    gy.set(cell.top);\n    gScale.set(1);\n    if (!reduce) animate(gScale, 1.04, spring.snappy);\n    setPressed(null);\n    setDrag({ id: s.id, from: s.from, phase: \"dragging\" });\n    setMessage(`${s.label} picked up, ${at(s.from, s.id)}.`);\n  };\n\n  const settle = (finalOrder: string[], canceled: boolean) => {\n    const s = session.current;\n    if (!s) return;\n    const { id, label, cells, from } = s;\n    endSession();\n    const target = cells[finalOrder.indexOf(id)];\n    setDrag({ id, from, phase: \"settling\" });\n    const done = () => setDrag((d) => (d?.id === id ? null : d));\n    if (reduce || !target) {\n      done();\n    } else {\n      animate(gx, target.left, spring.soft);\n      animate(gScale, 1, spring.soft);\n      animate(gy, target.top, spring.soft).then(done);\n    }\n    if (canceled) {\n      setMessage(`Move canceled. ${label} is back at ${at(finalOrder, id)}.`);\n    } else {\n      setMessage(`${label} dropped, ${at(finalOrder, id)}.`);\n      if (from.join() !== finalOrder.join()) onValueCommitted?.(finalOrder);\n    }\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLElement>, id: string, itemDisabled: boolean) => {\n    if (disabled || itemDisabled || drag || lift || session.current) return;\n    if (e.pointerType === \"mouse\" && e.button !== 0) return;\n    const list = listRef.current;\n    const slot = e.currentTarget.closest<HTMLElement>(\"[data-slot]\");\n    if (!list || !slot) return;\n    const rect = list.getBoundingClientRect();\n    const touch = e.pointerType === \"touch\";\n\n    const onMove = (ev: PointerEvent) => {\n      const s = session.current;\n      if (!s || ev.pointerId !== s.pointerId) return;\n      const dx = ev.clientX - s.startX;\n      const dy = ev.clientY - s.startY;\n      if (!s.started) {\n        if (touch) {\n          // Moved before the hold finished: that was a scroll, not a pick-up.\n          if (Math.hypot(dx, dy) > HOLD_SLOP) {\n            endSession();\n            setPressed(null);\n          }\n          return;\n        }\n        if (Math.hypot(dx, dy) < THRESHOLD) return;\n        beginDrag();\n      }\n      const box = list.getBoundingClientRect();\n      const x = ev.clientX - box.left - s.grabX;\n      const y = ev.clientY - box.top - s.grabY;\n      gx.set(x);\n      gy.set(y);\n      // The slot under the ghost's center wins. The gutters belong to nobody, so the order holds\n      // steady while the ghost crosses them instead of flickering between neighbors.\n      const cx = x + s.cells[0].width / 2;\n      const cy = y + s.cells[0].height / 2;\n      const over = s.cells.findIndex((c) => cx >= c.left && cx <= c.left + c.width && cy >= c.top && cy <= c.top + c.height);\n      if (over >= 0 && s.order.indexOf(s.id) !== over) {\n        s.order = reorder(s.order, s.id, over);\n        setStored(s.order);\n      }\n    };\n    const onUp = (ev: PointerEvent) => {\n      const s = session.current;\n      if (!s || ev.pointerId !== s.pointerId) return;\n      if (!s.started) {\n        endSession();\n        setPressed(null);\n        return;\n      }\n      settle(s.order, false);\n    };\n    const onCancel = (ev: PointerEvent) => {\n      const s = session.current;\n      if (!s || ev.pointerId !== s.pointerId) return;\n      if (!s.started) {\n        endSession();\n        setPressed(null);\n        return;\n      }\n      setStored(s.from);\n      settle(s.from, true);\n    };\n    const onKey = (ev: KeyboardEvent) => {\n      const s = session.current;\n      if (ev.key !== \"Escape\" || !s?.started) return;\n      ev.preventDefault();\n      setStored(s.from);\n      settle(s.from, true);\n    };\n    // Once a touch drag has started, the page must not scroll under the finger.\n    const onTouchMove = (ev: TouchEvent) => {\n      if (session.current?.started) ev.preventDefault();\n    };\n    const onContext = (ev: Event) => {\n      if (session.current) ev.preventDefault();\n    };\n\n    window.addEventListener(\"pointermove\", onMove);\n    window.addEventListener(\"pointerup\", onUp);\n    window.addEventListener(\"pointercancel\", onCancel);\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"touchmove\", onTouchMove, { passive: false });\n    window.addEventListener(\"contextmenu\", onContext);\n\n    session.current = {\n      id,\n      label: labelOf(id),\n      pointerId: e.pointerId,\n      startX: e.clientX,\n      startY: e.clientY,\n      grabX: e.clientX - rect.left - slot.offsetLeft,\n      grabY: e.clientY - rect.top - slot.offsetTop,\n      cells: measureCells(),\n      from: order,\n      order,\n      started: false,\n      hold: touch ? window.setTimeout(beginDrag, HOLD_MS) : undefined,\n      cleanup: () => {\n        window.removeEventListener(\"pointermove\", onMove);\n        window.removeEventListener(\"pointerup\", onUp);\n        window.removeEventListener(\"pointercancel\", onCancel);\n        window.removeEventListener(\"keydown\", onKey);\n        window.removeEventListener(\"touchmove\", onTouchMove);\n        window.removeEventListener(\"contextmenu\", onContext);\n      },\n    };\n    setPressed(id);\n  };\n\n  const focusTile = (id: string) => {\n    setFocusId(id);\n    listRef.current?.querySelector<HTMLElement>(`[data-slot=\"${CSS.escape(id)}\"] [data-tile]`)?.focus();\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLElement>, id: string, label: string, itemDisabled: boolean) => {\n    if (drag) return;\n    const cells = measureCells();\n    const cols = columns(cells);\n    const index = order.indexOf(id);\n    const lifted = lift?.id === id;\n    const delta =\n      e.key === \"ArrowLeft\" ? -1 : e.key === \"ArrowRight\" ? 1 : e.key === \"ArrowUp\" ? -cols : e.key === \"ArrowDown\" ? cols : 0;\n    const jump = e.key === \"Home\" ? 0 : e.key === \"End\" ? order.length - 1 : null;\n\n    if (e.key === \" \" || e.key === \"Enter\") {\n      e.preventDefault();\n      if (lifted) {\n        setLift(null);\n        setMessage(`${label} dropped, ${at(order, id)}.`);\n        if (lift.from.join() !== order.join()) onValueCommitted?.(order);\n      } else if (!disabled && !itemDisabled) {\n        setLift({ id, from: order });\n        setMessage(`${label} picked up, ${at(order, id)}. Arrow keys move it, Space drops it, Escape cancels.`);\n      }\n    } else if (lifted && e.key === \"Escape\") {\n      e.preventDefault();\n      e.stopPropagation();\n      setStored(lift.from);\n      setLift(null);\n      setMessage(`Move canceled. ${label} is back at ${at(lift.from, id)}.`);\n    } else if (delta || jump !== null) {\n      e.preventDefault();\n      const to = jump ?? index + delta;\n      if (to < 0 || to >= order.length || to === index) return;\n      if (lifted) {\n        const next = reorder(order, id, to);\n        setStored(next);\n        setMessage(`${label}, ${at(next, id)}.`);\n      } else {\n        focusTile(order[to]);\n      }\n    }\n  };\n\n  // Reordering moves the focused node in the DOM, which can drop focus. Put it back on the lifted tile.\n  useLayoutEffect(() => {\n    if (!lift) return;\n    const tile = listRef.current?.querySelector<HTMLElement>(`[data-slot=\"${CSS.escape(lift.id)}\"] [data-tile]`);\n    if (tile && document.activeElement !== tile) tile.focus({ preventScroll: true });\n  });\n\n  const ctx: GridContext = {\n    order,\n    drag,\n    lift,\n    pressed,\n    focusId: focusId && order.includes(focusId) ? focusId : (order[0] ?? null),\n    disabled,\n    reduce,\n    hintId,\n    onPointerDown,\n    onKeyDown,\n    onFocus: setFocusId,\n    onBlur(e, id, label) {\n      const node = e.currentTarget;\n      // Blur caused by the reorder itself is refocused above; a real Tab away drops the tile.\n      window.setTimeout(() => {\n        if (document.activeElement === node || lift?.id !== id) return;\n        setLift(null);\n        setMessage(`${label} dropped, ${at(order, id)}.`);\n        if (lift.from.join() !== order.join()) onValueCommitted?.(order);\n      }, 0);\n    },\n  };\n\n  const ghost = drag ? byId.get(drag.id) : undefined;\n\n  return (\n    <GridCtx.Provider value={ctx}>\n      <ul\n        ref={listRef}\n        data-dragging={drag ? \"\" : undefined}\n        data-disabled={disabled ? \"\" : undefined}\n        // Default columns and gap only when the caller hasn't set their own, so theirs never fight ours.\n        className={cn(\"relative grid\", !/\\bgap-/.test(className ?? \"\") && \"gap-2\", !className?.includes(\"grid-cols\") && \"grid-cols-2 sm:grid-cols-3\", className)}\n        {...rest}\n      >\n        {/* Keyed by value, so unkeyed children still keep their identity as they move. */}\n        {order.map((id) => (\n          <Fragment key={id}>{byId.get(id)}</Fragment>\n        ))}\n        {ghost && (\n          <motion.li\n            aria-hidden\n            className=\"pointer-events-none absolute left-0 top-0 z-10 list-none\"\n            style={{ x: gx, y: gy, scale: gScale, width: ghostSize.width, height: ghostSize.height }}\n          >\n            <div\n              data-phase={drag?.phase}\n              className={cn(\n                tileClass,\n                \"cursor-grabbing border-line-2 shadow-pop transition-[box-shadow,border-color] duration-200\",\n                \"data-[phase=settling]:border-line data-[phase=settling]:shadow-[var(--shadow)]\",\n                ghost.props.className,\n              )}\n            >\n              {ghost.props.children}\n            </div>\n          </motion.li>\n        )}\n      </ul>\n      <span id={hintId} className=\"sr-only\">\n        Press Space to pick up a tile, the arrow keys to move it, Space again to drop it and Escape to cancel.\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {message}\n      </span>\n    </GridCtx.Provider>\n  );\n}\n\n// While something is in the air the cursor stays a closed hand everywhere, and text can't be selected.\nfunction useGrabbingCursor(on: boolean) {\n  useEffect(() => {\n    if (!on) return;\n    const style = document.body.style;\n    const prev = { cursor: style.cursor, userSelect: style.userSelect };\n    style.cursor = \"grabbing\";\n    style.userSelect = \"none\";\n    return () => {\n      style.cursor = prev.cursor;\n      style.userSelect = prev.userSelect;\n    };\n  }, [on]);\n}\n\n/* -------------------------------------------------------------------------------------------------\n * SortableGridItem\n * -----------------------------------------------------------------------------------------------*/\n\nexport type SortableGridItemProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"children\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\"\n> & {\n  /** Unique within the grid. */\n  value: string;\n  /** Plain-text name, used as the tile's accessible name and in announcements. */\n  label: string;\n  /** This tile can't be picked up. Others can still move past it. */\n  disabled?: boolean;\n  /** Display content. The whole tile is the drag handle, so keep other controls out of it. */\n  children?: React.ReactNode;\n};\n\nexport function SortableGridItem({ value, label, disabled = false, className, children, ...rest }: SortableGridItemProps) {\n  const grid = useContext(GridCtx);\n  if (!grid) throw new Error(\"SortableGridItem must be used inside SortableGrid\");\n  const off = disabled || grid.disabled;\n  const dragged = grid.drag?.id === value;\n  const lifted = grid.lift?.id === value;\n  const pressed = grid.pressed === value;\n\n  return (\n    <motion.li\n      data-slot={value}\n      layout={grid.reduce ? false : \"position\"}\n      transition={grid.lift ? spring.snappy : spring.soft}\n      className={cn(\"relative list-none\", lifted && \"z-10\")}\n    >\n      {/* The slot the tile will land in: a quiet outline while its tile is in the air. */}\n      {dragged && (\n        <div\n          aria-hidden\n          className=\"absolute inset-0 rounded-xl border border-dashed border-line-2 bg-fg/[0.02]\"\n        />\n      )}\n      <motion.div\n        data-tile=\"\"\n        role=\"button\"\n        tabIndex={grid.focusId === value ? 0 : -1}\n        aria-roledescription=\"sortable tile\"\n        aria-label={label}\n        aria-describedby={grid.hintId}\n        aria-pressed={lifted}\n        aria-disabled={off || undefined}\n        data-lifted={lifted ? \"\" : undefined}\n        data-pressed={pressed ? \"\" : undefined}\n        data-disabled={off ? \"\" : undefined}\n        onPointerDown={(e) => grid.onPointerDown(e, value, off)}\n        onKeyDown={(e) => grid.onKeyDown(e, value, label, off)}\n        onFocus={() => grid.onFocus(value)}\n        onBlur={(e) => grid.onBlur(e, value, label)}\n        animate={{ scale: grid.reduce ? 1 : lifted ? 1.04 : pressed ? 0.98 : 1 }}\n        transition={spring.snappy}\n        className={cn(\n          tileClass,\n          \"outline-none transition-[border-color,box-shadow] duration-200 ease-out-quart\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          !off && \"cursor-grab touch-manipulation\",\n          \"data-lifted:border-line-2 data-lifted:shadow-pop\",\n          \"data-disabled:cursor-default data-disabled:opacity-60\",\n          dragged && \"opacity-0\",\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n      </motion.div>\n    </motion.li>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/sortable-grid"
}
