{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sortable-list",
  "type": "registry:ui",
  "title": "Sortable List",
  "description": "A drag-to-reorder list with a grip handle, lifted rows, layout animations and full keyboard reordering.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/sortable-list.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Reorder, useDragControls } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface SortableListItem {\n  /** Stable, unique key. */\n  id: string;\n  /** Visible label; also used in screen reader announcements. */\n  label?: string;\n  /** Optional secondary line for the default renderer. */\n  description?: React.ReactNode;\n}\n\nexport interface SortableItemState {\n  /** Zero-based position in the list. */\n  index: number;\n  /** Number of items in the list. */\n  total: number;\n  /** Being dragged with a pointer. */\n  dragging: boolean;\n  /** Picked up with the keyboard. */\n  grabbed: boolean;\n}\n\nexport interface SortableListProps<T extends SortableListItem>\n  extends Omit<React.HTMLAttributes<HTMLUListElement>, \"children\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\"> {\n  /** Items in their current order (controlled). */\n  items: T[];\n  /** Called with the new order after every move (pointer or keyboard). */\n  onReorder: (items: T[]) => void;\n  /** Renders the body of a row. The handle and shell are provided. */\n  renderItem?: (item: T, state: SortableItemState) => React.ReactNode;\n  /** Text used for an item in announcements. Defaults to `item.label ?? item.id`. */\n  getItemLabel?: (item: T) => string;\n  /** Only the grip handle starts a pointer drag (recommended on touch). */\n  handleOnly?: boolean;\n  /** Show the position number on each row. */\n  showIndex?: boolean;\n  /** Row density. */\n  density?: \"comfortable\" | \"compact\";\n  /** Scale of a lifted row. */\n  lift?: number;\n  /** Scroll the nearest scrollable ancestor while dragging near its edges. */\n  autoScroll?: boolean;\n  /** Spring for rows moving into place. */\n  spring?: Spring;\n  /** Accent for the lifted outline and focus rings. */\n  accent?: string;\n  /** Accessible name of the list. */\n  label?: string;\n}\n\nconst EDGE = 56; // px from a scroll edge where auto-scroll starts\nconst MAX_SPEED = 14; // px per frame\n\nfunction scrollParent(node: HTMLElement | null): HTMLElement {\n  let el = node?.parentElement ?? null;\n  while (el) {\n    const { overflowY } = getComputedStyle(el);\n    if ((overflowY === \"auto\" || overflowY === \"scroll\") && el.scrollHeight > el.clientHeight) return el;\n    el = el.parentElement;\n  }\n  return (document.scrollingElement as HTMLElement | null) ?? document.documentElement;\n}\n\nfunction move<T>(list: T[], from: number, to: number) {\n  const next = list.slice();\n  const [item] = next.splice(from, 1);\n  next.splice(to, 0, item);\n  return next;\n}\n\n/**\n * A drag-to-reorder list built on Motion's Reorder (layout animations). Rows are dragged by a\n * grip handle, or picked up from the keyboard: Space / Enter lifts, arrows move, Space / Enter\n * drops and Escape cancels. Every move is announced through a polite live region.\n */\nexport function SortableList<T extends SortableListItem>({\n  items,\n  onReorder,\n  renderItem,\n  getItemLabel = (item) => item.label ?? item.id,\n  handleOnly = true,\n  showIndex = true,\n  density = \"comfortable\",\n  lift = 1.03,\n  autoScroll = true,\n  spring = { stiffness: 520, damping: 40, mass: 1 },\n  accent = \"#FF7448\",\n  label = \"Sortable list\",\n  className = \"\",\n  style,\n  ...props\n}: SortableListProps<T>) {\n  const uid = React.useId();\n  const instructionsId = `${uid}-instructions`;\n  const reduceMotion = usePrefersReducedMotion();\n  const [grabbedId, setGrabbedId] = React.useState<string | null>(null);\n  const [draggingId, setDraggingId] = React.useState<string | null>(null);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const handleRefs = React.useRef(new Map<string, HTMLButtonElement>());\n  const snapshotRef = React.useRef<T[] | null>(null);\n  const dragStartIndexRef = React.useRef(-1);\n  const refocusRef = React.useRef<string | null>(null);\n  const autoScrollRef = React.useRef<{ frame: number; y: number; el: HTMLElement | null }>({ frame: 0, y: -1, el: null });\n\n  const total = items.length;\n  const position = (index: number) => `position ${index + 1} of ${total}`;\n  const indexOf = (id: string) => items.findIndex((item) => item.id === id);\n\n  // Moving a focused row in the DOM can drop focus; restore it to the handle after each commit.\n  React.useEffect(() => {\n    const id = refocusRef.current;\n    if (!id) return;\n    refocusRef.current = null;\n    const handle = handleRefs.current.get(id);\n    if (handle && document.activeElement !== handle) handle.focus({ preventScroll: true });\n  }, [items]);\n\n  // Stop any auto-scroll loop on unmount.\n  React.useEffect(() => {\n    const state = autoScrollRef.current;\n    return () => cancelAnimationFrame(state.frame);\n  }, []);\n\n  /* -------------------------------- keyboard -------------------------------- */\n\n  const pickUp = (item: T) => {\n    snapshotRef.current = items;\n    setGrabbedId(item.id);\n    setAnnouncement(\n      `Picked up ${getItemLabel(item)}, ${position(indexOf(item.id))}. Use the arrow keys to move, Space to drop, Escape to cancel.`,\n    );\n  };\n\n  const drop = (item: T) => {\n    snapshotRef.current = null;\n    setGrabbedId(null);\n    setAnnouncement(`Dropped ${getItemLabel(item)} at ${position(indexOf(item.id))}.`);\n  };\n\n  const cancel = (item: T) => {\n    const snapshot = snapshotRef.current;\n    snapshotRef.current = null;\n    setGrabbedId(null);\n    if (snapshot) {\n      refocusRef.current = item.id;\n      onReorder(snapshot);\n      const original = snapshot.findIndex((entry) => entry.id === item.id);\n      setAnnouncement(`Reorder cancelled. ${getItemLabel(item)} returned to position ${original + 1} of ${total}.`);\n    }\n  };\n\n  const moveTo = (item: T, to: number) => {\n    const from = indexOf(item.id);\n    const target = Math.max(0, Math.min(total - 1, to));\n    if (from < 0 || target === from) return;\n    refocusRef.current = item.id;\n    onReorder(move(items, from, target));\n    setAnnouncement(`Moved ${getItemLabel(item)} to ${position(target)}.`);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, item: T) => {\n    const grabbed = grabbedId === item.id;\n    const index = indexOf(item.id);\n    if (!grabbed) {\n      if (event.key === \" \" || event.key === \"Enter\") {\n        event.preventDefault();\n        pickUp(item);\n      }\n      return;\n    }\n    switch (event.key) {\n      case \"ArrowUp\":\n      case \"ArrowLeft\":\n        event.preventDefault();\n        moveTo(item, index - 1);\n        break;\n      case \"ArrowDown\":\n      case \"ArrowRight\":\n        event.preventDefault();\n        moveTo(item, index + 1);\n        break;\n      case \"Home\":\n        event.preventDefault();\n        moveTo(item, 0);\n        break;\n      case \"End\":\n        event.preventDefault();\n        moveTo(item, total - 1);\n        break;\n      case \" \":\n      case \"Enter\":\n        event.preventDefault();\n        drop(item);\n        break;\n      case \"Escape\":\n        event.preventDefault();\n        // Keep Escape from also closing a surrounding dialog.\n        event.stopPropagation();\n        cancel(item);\n        break;\n      case \"Tab\":\n        drop(item);\n        break;\n    }\n  };\n\n  /* ---------------------------------- pointer ---------------------------------- */\n\n  const stopAutoScroll = () => {\n    const state = autoScrollRef.current;\n    cancelAnimationFrame(state.frame);\n    state.frame = 0;\n    state.y = -1;\n    state.el = null;\n  };\n\n  const startAutoScroll = (node: HTMLElement | null) => {\n    if (!autoScroll) return;\n    const state = autoScrollRef.current;\n    state.el = scrollParent(node);\n    const tick = () => {\n      const el = state.el;\n      if (el && state.y >= 0) {\n        const isRoot = el === document.scrollingElement || el === document.documentElement;\n        const top = isRoot ? 0 : el.getBoundingClientRect().top;\n        const bottom = isRoot ? window.innerHeight : el.getBoundingClientRect().bottom;\n        let speed = 0;\n        if (state.y < top + EDGE) speed = -MAX_SPEED * Math.min(1, (top + EDGE - state.y) / EDGE);\n        else if (state.y > bottom - EDGE) speed = MAX_SPEED * Math.min(1, (state.y - (bottom - EDGE)) / EDGE);\n        if (speed) el.scrollTop += speed;\n      }\n      state.frame = requestAnimationFrame(tick);\n    };\n    state.frame = requestAnimationFrame(tick);\n  };\n\n  const transition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n\n  return (\n    <div className={`w-full ${className}`} style={{ [\"--sl-accent\" as string]: accent, ...style }}>\n      <p id={instructionsId} className=\"sr-only\">\n        Press Space or Enter to pick up an item. While it is picked up, use the arrow keys to move it, Space or Enter to\n        drop it, and Escape to cancel.\n      </p>\n      <Reorder.Group\n        as=\"ul\"\n        axis=\"y\"\n        values={items}\n        onReorder={onReorder}\n        aria-label={label}\n        className={`flex flex-col ${density === \"compact\" ? \"gap-1.5\" : \"gap-2\"}`}\n        {...props}\n      >\n        {items.map((item, index) => (\n          <SortableRow\n            key={item.id}\n            item={item}\n            state={{ index, total, dragging: draggingId === item.id, grabbed: grabbedId === item.id }}\n            label={getItemLabel(item)}\n            instructionsId={instructionsId}\n            handleOnly={handleOnly}\n            showIndex={showIndex}\n            density={density}\n            lift={lift}\n            transition={transition}\n            renderItem={renderItem}\n            registerHandle={(node) => {\n              if (node) handleRefs.current.set(item.id, node);\n              else handleRefs.current.delete(item.id);\n            }}\n            onHandleKeyDown={(event) => handleKeyDown(event, item)}\n            onHandleBlur={() => {\n              // Focus leaving a lifted row (other than our own refocus after a move) drops it in place.\n              if (grabbedId === item.id && refocusRef.current !== item.id) drop(item);\n            }}\n            onDragStart={(node) => {\n              if (grabbedId) setGrabbedId(null);\n              snapshotRef.current = null;\n              dragStartIndexRef.current = index;\n              setDraggingId(item.id);\n              startAutoScroll(node);\n            }}\n            onDragMove={(clientY) => {\n              autoScrollRef.current.y = clientY;\n            }}\n            onDragEnd={() => {\n              stopAutoScroll();\n              setDraggingId(null);\n              const finalIndex = indexOf(item.id);\n              if (finalIndex !== dragStartIndexRef.current) {\n                setAnnouncement(`Moved ${getItemLabel(item)} to ${position(finalIndex)}.`);\n              }\n              dragStartIndexRef.current = -1;\n            }}\n          />\n        ))}\n      </Reorder.Group>\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      </div>\n    </div>\n  );\n}\n\ninterface SortableRowProps<T extends SortableListItem> {\n  item: T;\n  state: SortableItemState;\n  label: string;\n  instructionsId: string;\n  handleOnly: boolean;\n  showIndex: boolean;\n  density: \"comfortable\" | \"compact\";\n  lift: number;\n  transition: { duration: number } | ({ type: \"spring\" } & Spring);\n  renderItem?: (item: T, state: SortableItemState) => React.ReactNode;\n  registerHandle: (node: HTMLButtonElement | null) => void;\n  onHandleKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>) => void;\n  onHandleBlur: () => void;\n  onDragStart: (node: HTMLElement | null) => void;\n  onDragMove: (clientY: number) => void;\n  onDragEnd: () => void;\n}\n\nfunction SortableRow<T extends SortableListItem>({\n  item,\n  state,\n  label,\n  instructionsId,\n  handleOnly,\n  showIndex,\n  density,\n  lift,\n  transition,\n  renderItem,\n  registerHandle,\n  onHandleKeyDown,\n  onHandleBlur,\n  onDragStart,\n  onDragMove,\n  onDragEnd,\n}: SortableRowProps<T>) {\n  const controls = useDragControls();\n  const rowRef = React.useRef<HTMLLIElement>(null);\n  const lifted = state.dragging || state.grabbed;\n  const compact = density === \"compact\";\n\n  return (\n    <Reorder.Item\n      ref={rowRef}\n      value={item}\n      dragListener={!handleOnly}\n      dragControls={controls}\n      onDragStart={() => onDragStart(rowRef.current)}\n      onDrag={(event) => {\n        if (\"clientY\" in event) onDragMove(event.clientY);\n      }}\n      onDragEnd={onDragEnd}\n      animate={{ scale: lifted ? lift : 1 }}\n      transition={transition}\n      data-lifted={lifted || undefined}\n      className={`relative flex items-center rounded-xl border bg-white text-zinc-900 transition-[box-shadow,border-color] duration-200 dark:bg-zinc-900 dark:text-zinc-100 ${\n        compact ? \"gap-2 px-2 py-1.5\" : \"gap-3 px-2.5 py-2.5\"\n      } ${\n        lifted\n          ? \"border-[var(--sl-accent)] shadow-[0_18px_40px_-16px_rgba(0,0,0,0.35),0_2px_6px_rgba(0,0,0,0.08)] dark:shadow-[0_18px_40px_-12px_rgba(0,0,0,0.9)]\"\n          : \"border-zinc-900/10 shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:border-white/10\"\n      } ${handleOnly ? \"\" : \"cursor-grab touch-none select-none active:cursor-grabbing\"}`}\n    >\n      <button\n        ref={registerHandle}\n        type=\"button\"\n        aria-label={`Reorder ${label}`}\n        aria-roledescription=\"sortable\"\n        aria-describedby={instructionsId}\n        aria-pressed={state.grabbed}\n        onPointerDown={(event) => {\n          if (event.button !== 0) return;\n          controls.start(event);\n        }}\n        onKeyDown={onHandleKeyDown}\n        onBlur={onHandleBlur}\n        className={`flex shrink-0 cursor-grab touch-none items-center justify-center rounded-lg text-zinc-400 transition-colors hover:bg-zinc-900/[0.05] hover:text-zinc-700 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--sl-accent)] active:cursor-grabbing aria-pressed:bg-[var(--sl-accent)] aria-pressed:text-zinc-950 dark:text-zinc-500 dark:hover:bg-white/[0.06] dark:hover:text-zinc-200 dark:aria-pressed:text-zinc-950 ${\n          compact ? \"h-7 w-6\" : \"h-9 w-7\"\n        }`}\n      >\n        <svg viewBox=\"0 0 10 16\" className=\"h-4 w-2.5\" aria-hidden=\"true\">\n          {[3, 8, 13].map((y) => (\n            <React.Fragment key={y}>\n              <circle cx=\"2.5\" cy={y} r=\"1.25\" fill=\"currentColor\" />\n              <circle cx=\"7.5\" cy={y} r=\"1.25\" fill=\"currentColor\" />\n            </React.Fragment>\n          ))}\n        </svg>\n      </button>\n      {showIndex && (\n        <span aria-hidden=\"true\" className=\"w-5 shrink-0 font-mono text-[10px] tabular-nums tracking-[0.08em] text-zinc-400 dark:text-zinc-500\">\n          {String(state.index + 1).padStart(2, \"0\")}\n        </span>\n      )}\n      <div className=\"min-w-0 flex-1\">\n        {renderItem ? (\n          renderItem(item, state)\n        ) : (\n          <>\n            <span className={`block truncate font-medium tracking-tight ${compact ? \"text-[13px]\" : \"text-sm\"}`}>{label}</span>\n            {item.description && !compact && (\n              <span className=\"block truncate text-[13px] text-zinc-500 dark:text-zinc-400\">{item.description}</span>\n            )}\n          </>\n        )}\n      </div>\n    </Reorder.Item>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/sortable-list"
}
