{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"reorder-list","type":"registry:ui","title":"Reorder List","description":"The gap the siblings open is the drop target.","dependencies":["motion"],"categories":["gesture"],"docs":"https://www.interior.dev/docs/reorder-list","files":[{"path":"registry/interior/reorder-list.tsx","type":"registry:ui","target":"components/interior/reorder-list.tsx","content":"\"use client\";\n\nimport { useCallback, useId, useRef, useState } from \"react\";\nimport { Reorder, useReducedMotion } from \"motion/react\";\n\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\nconst INSTANT = { duration: 0 } as const;\n\nconst moveItem = <T,>(list: readonly T[], from: number, to: number): T[] => {\n  const next = [...list];\n  const [taken] = next.splice(from, 1);\n  next.splice(to, 0, taken);\n  return next;\n};\n\nexport type UseReorderListOptions<T> = {\n  items: readonly T[];\n  getId: (item: T) => string;\n  getLabel: (item: T) => string;\n  onReorder: (next: T[]) => void;\n  onCommit?: (next: T[]) => void;\n  disabled?: boolean;\n};\n\nexport function useReorderList<T>({\n  items,\n  getId,\n  getLabel,\n  onReorder,\n  onCommit,\n  disabled = false,\n}: UseReorderListOptions<T>) {\n  const [grabbed, setGrabbed] = useState<string | null>(null);\n  const [dragging, setDragging] = useState<string | null>(null);\n  const [spoken, setSpoken] = useState(\"\");\n\n  const emit = useRef(onReorder);\n  emit.current = onReorder;\n  const settle = useRef(onCommit);\n  settle.current = onCommit;\n  const live = useRef(items);\n  live.current = items;\n  const snapshot = useRef<readonly T[] | null>(null);\n\n  const indexOf = useCallback(\n    (id: string) => live.current.findIndex((item) => getId(item) === id),\n    [getId],\n  );\n\n  const grab = useCallback(\n    (id: string) => {\n      snapshot.current = live.current;\n      setGrabbed(id);\n      const at = indexOf(id);\n      const item = live.current[at];\n      setSpoken(\n        `${getLabel(item)} grabbed, position ${at + 1} of ${live.current.length}.`,\n      );\n    },\n    [getLabel, indexOf],\n  );\n\n  const drop = useCallback(\n    (id: string) => {\n      snapshot.current = null;\n      setGrabbed(null);\n      const at = indexOf(id);\n      const item = live.current[at];\n      setSpoken(`${getLabel(item)} dropped at position ${at + 1}.`);\n      settle.current?.([...live.current]);\n    },\n    [getLabel, indexOf],\n  );\n\n  const cancel = useCallback(() => {\n    if (snapshot.current) emit.current([...snapshot.current]);\n    snapshot.current = null;\n    setGrabbed(null);\n    setSpoken(\"Reorder cancelled, original order restored.\");\n  }, []);\n\n  const step = useCallback(\n    (id: string, delta: -1 | 1) => {\n      const from = indexOf(id);\n      const to = from + delta;\n      if (from < 0 || to < 0 || to >= live.current.length) return;\n      const next = moveItem(live.current, from, to);\n      emit.current(next);\n      const item = next[to];\n      setSpoken(\n        `${getLabel(item)}, position ${to + 1} of ${next.length}.`,\n      );\n      if (snapshot.current === null) settle.current?.(next);\n    },\n    [getLabel, indexOf],\n  );\n\n  const rowKeyDown = useCallback(\n    (id: string) => (event: React.KeyboardEvent<HTMLElement>) => {\n      if (disabled || event.target !== event.currentTarget) return;\n      const held = grabbed === id;\n      if (event.key === \" \" || event.key === \"Enter\") {\n        event.preventDefault();\n        if (held) drop(id);\n        else grab(id);\n        return;\n      }\n      if ((event.key === \"ArrowUp\" || event.key === \"ArrowDown\") && held) {\n        event.preventDefault();\n        step(id, event.key === \"ArrowUp\" ? -1 : 1);\n        return;\n      }\n      if (event.key === \"Escape\" && held) {\n        event.preventDefault();\n        cancel();\n      }\n    },\n    [disabled, grabbed, grab, drop, step, cancel],\n  );\n\n  const onDragStart = useCallback(\n    (id: string) => {\n      snapshot.current = live.current;\n      setDragging(id);\n    },\n    [],\n  );\n\n  const onDragEnd = useCallback(\n    (id: string) => {\n      snapshot.current = null;\n      setDragging(null);\n      const at = indexOf(id);\n      const item = live.current[at];\n      setSpoken(`${getLabel(item)} dropped at position ${at + 1}.`);\n      settle.current?.([...live.current]);\n    },\n    [getLabel, indexOf],\n  );\n\n  return {\n    grabbed,\n    dragging,\n    spoken,\n    grab,\n    drop,\n    cancel,\n    step,\n    rowKeyDown,\n    onDragStart,\n    onDragEnd,\n  };\n}\n\nexport type ReorderListProps<T> = UseReorderListOptions<T> & {\n  children: (item: T) => React.ReactNode;\n  label: string;\n  className?: string;\n};\n\nconst GRIP = (\n  <svg width=\"10\" height=\"14\" viewBox=\"0 0 10 14\" fill=\"currentColor\" aria-hidden>\n    <circle cx=\"2.5\" cy=\"2.5\" r=\"1.2\" />\n    <circle cx=\"7.5\" cy=\"2.5\" r=\"1.2\" />\n    <circle cx=\"2.5\" cy=\"7\" r=\"1.2\" />\n    <circle cx=\"7.5\" cy=\"7\" r=\"1.2\" />\n    <circle cx=\"2.5\" cy=\"11.5\" r=\"1.2\" />\n    <circle cx=\"7.5\" cy=\"11.5\" r=\"1.2\" />\n  </svg>\n);\n\nexport function ReorderList<T>({\n  children,\n  label,\n  className = \"\",\n  ...options\n}: ReorderListProps<T>) {\n  const { items, getId, getLabel, onReorder, disabled = false } = options;\n  const list = useReorderList(options);\n  const reduced = useReducedMotion() === true;\n  const hintId = useId();\n\n  return (\n    <div className={`w-full ${className}`}>\n      <Reorder.Group\n        axis=\"y\"\n        values={items as T[]}\n        onReorder={onReorder}\n        aria-label={label}\n        className=\"m-0 list-none space-y-1.5 p-0\"\n      >\n        {items.map((item) => {\n          const id = getId(item);\n          const held = list.grabbed === id;\n          const lifted = held || list.dragging === id;\n          return (\n            <Reorder.Item\n              key={id}\n              value={item}\n              drag={disabled ? false : \"y\"}\n              dragListener={!disabled}\n              tabIndex={disabled ? -1 : 0}\n              aria-describedby={hintId}\n              aria-pressed={held}\n              role=\"button\"\n              onKeyDown={list.rowKeyDown(id)}\n              onDragStart={() => list.onDragStart(id)}\n              onDragEnd={() => list.onDragEnd(id)}\n              onBlur={() => held && list.cancel()}\n              transition={reduced ? INSTANT : CELL}\n              whileDrag={reduced ? undefined : { scale: 1.02 }}\n              style={{ touchAction: \"pan-x\" }}\n              className={`relative flex items-center gap-2.5 rounded-[9px] border bg-white px-3 py-2.5 outline-none transition-[border-color,box-shadow,background-color] duration-150 focus-visible:outline-none dark:bg-[#1D1D1A] ${\n                lifted\n                  ? \"z-10 cursor-grabbing border-stone-200 shadow-[0_1px_2px_rgba(28,25,23,0.08),0_14px_28px_-16px_rgba(28,25,23,0.5)] dark:border-white/[0.16] dark:shadow-[0_2px_14px_rgba(0,0,0,0.55)]\"\n                  : \"cursor-grab border-stone-200 shadow-[0_1px_2px_rgba(28,25,23,0.06)] dark:border-white/[0.16] dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)]\"\n              } ${\n                held\n                  ? \"border-[#4568FF] bg-[#4568FF]/[0.04] dark:border-[#93B0FF] dark:bg-[#93B0FF]/[0.08]\"\n                  : \"focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\"\n              }`}\n            >\n              <span\n                aria-hidden\n                className={`shrink-0 transition-colors duration-150 ${\n                  lifted\n                    ? \"text-stone-500 dark:text-stone-300\"\n                    : \"text-stone-300 dark:text-stone-600\"\n                }`}\n              >\n                {GRIP}\n              </span>\n              <span className=\"sr-only\">{getLabel(item)}</span>\n              <div aria-hidden className=\"min-w-0 flex-1\">\n                {children(item)}\n              </div>\n            </Reorder.Item>\n          );\n        })}\n      </Reorder.Group>\n      <span id={hintId} className=\"sr-only\">\n        Drag to reorder. With the keyboard, Space grabs the row, the arrow keys\n        move it, Space drops it, and Escape puts everything back.\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {list.spoken}\n      </span>\n    </div>\n  );\n}\n"}]}