{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sortable-list","type":"registry:component","title":"Sortable List","description":"Drag-to-reorder list where only the grip handle lifts a row, the lifted row rises with a soft shadow and slight scale while its siblings spring out of the way, and a full keyboard path (Space to pick up, arrows to move, Space to drop, Escape to restore) announces every move to screen readers. Generic over any item with an id, controlled/uncontrolled, reduced-motion safe.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/sortable-list.tsx","type":"registry:component","target":"@components/motion/sortable-list.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/sortable-list\n\nimport {\n  motion,\n  Reorder,\n  useDragControls,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { SPRING_LAYOUT } from \"@/lib/ease\";\nimport { TOUCH_GESTURE_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nexport type SortableItem = { id: string | number };\n\nexport type SortableItemState = {\n  /** Zero-based position in the list as currently shown. */\n  index: number;\n  /** Total number of items. */\n  count: number;\n  /** True while the item is lifted by pointer or keyboard. */\n  isDragging: boolean;\n};\n\nexport interface SortableListProps<T extends SortableItem> {\n  /** Controlled order. */\n  items?: T[];\n  /** Initial order when uncontrolled. */\n  defaultItems?: T[];\n  /** Fires once per completed move (pointer release or keyboard drop). */\n  onChange?: (items: T[]) => void;\n  renderItem: (item: T, state: SortableItemState) => ReactNode;\n  /** Human-readable name used for the handle label and announcements. */\n  getLabel?: (item: T) => string;\n  className?: string;\n  itemClassName?: string;\n}\n\n/** Keyboard pick-up session: what to restore if the move is cancelled. */\ntype Grab<T extends SortableItem> = { id: T[\"id\"]; original: T[] };\n\nconst INSTANT = { duration: 0 } as const;\n\nfunction positionOf<T extends SortableItem>(list: T[], id: T[\"id\"]) {\n  return list.findIndex((entry) => entry.id === id) + 1;\n}\n\nfunction moveItem<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\nfunction GripIcon() {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 16 16\"\n      className=\"h-4 w-4\"\n      fill=\"currentColor\"\n    >\n      <circle cx=\"5.5\" cy=\"3.5\" r=\"1.25\" />\n      <circle cx=\"10.5\" cy=\"3.5\" r=\"1.25\" />\n      <circle cx=\"5.5\" cy=\"8\" r=\"1.25\" />\n      <circle cx=\"10.5\" cy=\"8\" r=\"1.25\" />\n      <circle cx=\"5.5\" cy=\"12.5\" r=\"1.25\" />\n      <circle cx=\"10.5\" cy=\"12.5\" r=\"1.25\" />\n    </svg>\n  );\n}\n\nfunction SortableRow<T extends SortableItem>({\n  item,\n  index,\n  count,\n  label,\n  lifted,\n  grabbed,\n  describedBy,\n  reduce,\n  className,\n  registerHandle,\n  onPointerLift,\n  onPointerDrop,\n  onHandleKeyDown,\n  onHandleBlur,\n  children,\n}: {\n  item: T;\n  index: number;\n  count: number;\n  label: string;\n  lifted: boolean;\n  grabbed: boolean;\n  describedBy: string;\n  reduce: boolean;\n  className?: string;\n  registerHandle: (id: T[\"id\"], el: HTMLButtonElement | null) => void;\n  onPointerLift: (item: T) => void;\n  onPointerDrop: (item: T) => void;\n  onHandleKeyDown: (event: KeyboardEvent<HTMLButtonElement>, item: T) => void;\n  onHandleBlur: (relatedTarget: EventTarget | null) => void;\n  children: ReactNode;\n}) {\n  const controls = useDragControls();\n\n  return (\n    <Reorder.Item\n      as=\"li\"\n      value={item}\n      dragListener={false}\n      dragControls={controls}\n      onDragStart={() => onPointerLift(item)}\n      onDragEnd={() => onPointerDrop(item)}\n      transition={reduce ? INSTANT : SPRING_LAYOUT}\n      animate={{ scale: lifted && !reduce ? 1.02 : 1 }}\n      className={cn(\n        \"relative flex items-center gap-3 rounded-2xl border border-border bg-card px-3 py-2.5\",\n        lifted ? \"z-10\" : \"z-0\",\n        className,\n      )}\n    >\n      {/* The lift shadow is a separate layer so only its opacity animates. */}\n      <motion.span\n        aria-hidden=\"true\"\n        initial={false}\n        animate={{ opacity: lifted ? 1 : 0 }}\n        transition={reduce ? INSTANT : { duration: 0.15 }}\n        className=\"pointer-events-none absolute inset-0 -z-10 rounded-2xl shadow-[0_12px_32px_-12px_rgb(0_0_0/0.35)]\"\n      />\n      <button\n        type=\"button\"\n        ref={(el) => registerHandle(item.id, el)}\n        aria-label={`Reorder ${label}, position ${index + 1} of ${count}`}\n        aria-describedby={describedBy}\n        aria-pressed={grabbed}\n        onPointerDown={(event) => {\n          event.preventDefault();\n          controls.start(event);\n        }}\n        onKeyDown={(event) => onHandleKeyDown(event, item)}\n        onBlur={(event) => onHandleBlur(event.relatedTarget)}\n        className={cn(\n          \"grid h-8 w-8 shrink-0 cursor-grab touch-none place-items-center rounded-lg text-muted-foreground outline-none transition-colors\",\n          \"hover:bg-muted hover:text-foreground active:cursor-grabbing\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          grabbed && \"bg-muted text-foreground\",\n          TOUCH_GESTURE_CLASS,\n        )}\n      >\n        <GripIcon />\n      </button>\n      <div className=\"min-w-0 flex-1\">{children}</div>\n    </Reorder.Item>\n  );\n}\n\nexport function SortableList<T extends SortableItem>({\n  items,\n  defaultItems,\n  onChange,\n  renderItem,\n  getLabel = (item) => String(item.id),\n  className,\n  itemClassName,\n}: SortableListProps<T>) {\n  const reduce = useReducedMotion() ?? false;\n  const instructionsId = useId();\n  const [internal, setInternal] = useState<T[]>(defaultItems ?? []);\n  const isControlled = items !== undefined;\n  const committed = isControlled ? items : internal;\n\n  // Working order while a move is in progress; committed on drop.\n  const [draft, setDraft] = useState<T[] | null>(null);\n  const [draggingId, setDraggingId] = useState<T[\"id\"] | null>(null);\n  const [grab, setGrab] = useState<Grab<T> | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const handles = useRef(new Map<T[\"id\"], HTMLButtonElement>());\n  const view = draft ?? committed;\n\n  const commit = useCallback(\n    (next: T[]) => {\n      if (!isControlled) setInternal(next);\n      onChange?.(next);\n    },\n    [isControlled, onChange],\n  );\n\n  const registerHandle = useCallback(\n    (id: T[\"id\"], el: HTMLButtonElement | null) => {\n      if (el) handles.current.set(id, el);\n      else handles.current.delete(id);\n    },\n    [],\n  );\n\n  // React moves list nodes when the order changes, which can drop focus from\n  // the grabbed handle — put it back after every keyboard move.\n  const grabbedId = grab?.id;\n  const grabbedIndex =\n    grabbedId === undefined\n      ? -1\n      : view.findIndex((entry) => entry.id === grabbedId);\n  useEffect(() => {\n    if (grabbedId === undefined || grabbedIndex < 0) return;\n    const el = handles.current.get(grabbedId);\n    if (el && document.activeElement !== el) el.focus();\n  }, [grabbedId, grabbedIndex]);\n\n  const drop = (item: T, list: T[]) => {\n    setGrab(null);\n    setDraft(null);\n    setAnnouncement(\n      `${getLabel(item)} dropped at position ${positionOf(list, item.id)} of ${list.length}.`,\n    );\n    const changed = list.some((entry, i) => entry !== committed[i]);\n    if (changed) commit(list);\n  };\n\n  const onHandleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, item: T) => {\n    const label = getLabel(item);\n    const isToggle = event.key === \" \" || event.key === \"Enter\";\n\n    if (!grab) {\n      if (!isToggle) return;\n      event.preventDefault();\n      setGrab({ id: item.id, original: committed });\n      setDraft(committed);\n      setAnnouncement(\n        `Picked up ${label}, position ${positionOf(committed, item.id)} of ${committed.length}. Use the up and down arrow keys to move, Space to drop, Escape to cancel.`,\n      );\n      return;\n    }\n\n    if (grab.id !== item.id) return;\n\n    if (isToggle) {\n      event.preventDefault();\n      drop(item, view);\n      return;\n    }\n\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      setGrab(null);\n      setDraft(null);\n      setAnnouncement(\n        `Move cancelled. ${label} returned to position ${positionOf(grab.original, item.id)} of ${grab.original.length}.`,\n      );\n      return;\n    }\n\n    const delta =\n      event.key === \"ArrowUp\" ? -1 : event.key === \"ArrowDown\" ? 1 : 0;\n    if (!delta) return;\n    event.preventDefault();\n    const from = view.findIndex((entry) => entry.id === item.id);\n    const to = Math.max(0, Math.min(view.length - 1, from + delta));\n    if (to === from) return;\n    setDraft(moveItem(view, from, to));\n    setAnnouncement(`Moved ${label} to position ${to + 1} of ${view.length}.`);\n  };\n\n  const onHandleBlur = (relatedTarget: EventTarget | null) => {\n    // Focus leaving for another element (Tab, click elsewhere) drops the item\n    // where it is. A null target is React re-parenting the node mid-move.\n    if (!grab || !relatedTarget) return;\n    const item = view.find((entry) => entry.id === grab.id);\n    if (item) drop(item, view);\n  };\n\n  return (\n    <div className={cn(\"w-full\", className)}>\n      <p id={instructionsId} className=\"sr-only\">\n        Press Space or Enter to pick up an item, the arrow keys to move it,\n        Space or Enter to drop it, and Escape to cancel.\n      </p>\n      <Reorder.Group\n        as=\"ul\"\n        axis=\"y\"\n        values={view}\n        onReorder={setDraft}\n        className=\"flex flex-col gap-2\"\n      >\n        {view.map((item, index) => (\n          <SortableRow\n            key={item.id}\n            item={item}\n            index={index}\n            count={view.length}\n            label={getLabel(item)}\n            lifted={draggingId === item.id || grab?.id === item.id}\n            grabbed={grab?.id === item.id}\n            describedBy={instructionsId}\n            reduce={reduce}\n            className={itemClassName}\n            registerHandle={registerHandle}\n            onPointerLift={(lifted) => {\n              setDraggingId(lifted.id);\n              setDraft((current) => current ?? committed);\n            }}\n            onPointerDrop={(dropped) => {\n              setDraggingId(null);\n              drop(dropped, draft ?? committed);\n            }}\n            onHandleKeyDown={onHandleKeyDown}\n            onHandleBlur={onHandleBlur}\n          >\n            {renderItem(item, {\n              index,\n              count: view.length,\n              isDragging: draggingId === item.id || grab?.id === item.id,\n            })}\n          </SortableRow>\n        ))}\n      </Reorder.Group>\n      <div aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      </div>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}