{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "drag-to-reorder-list",
  "type": "registry:component",
  "title": "Drag-to-Reorder List",
  "description": "A reorderable list where siblings glide one slot and the drop plays a FLIP, keyboard included.",
  "registryDependencies": [
    "https://lab.moumen.dev/r/lab-theme.json"
  ],
  "files": [
    {
      "path": "registry/lab/drag-to-reorder-list/drag-to-reorder-list.tsx",
      "type": "registry:component",
      "target": "@components/lab/drag-to-reorder-list.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { MotionConfig, animate, motion, useReducedMotion, type MotionValue, motionValue } from \"motion/react\";\n\n// Drag-to-reorder list with FLIP.\n//\n// Three motion systems working together without fighting, all driven through\n// motion/react's motion values:\n//\n//   1. The dragged card follows the pointer RAW - its y motion value is\n//      `jump()`ed straight from the pointermove handler (no easing: any easing\n//      between hand and card reads as lag; no React render on the hot path).\n//   2. Siblings glide out of the way: as the dragged card crosses slot\n//      boundaries each displaced sibling's y is `animate()`d exactly one slot -\n//      retargetable, so reversing mid-glide just works.\n//   3. The drop is a FLIP: capture First rects before the commit, let React\n//      reflow, then Invert each card back to its old screen pixels and\n//      `animate()` it home. Nothing jumps.\n//\n// Plus rubber-banding past the ends (4:1 damped), pointer capture, multi-touch\n// protection, and a full keyboard path (grip → Space grabs → arrows move →\n// Escape cancels) narrated to screen readers. Requires the lab-theme tokens.\n// Fully Tailwind, no CSS files.\n\nconst DRAG_THRESHOLD = 4;\nconst EASE = [0.22, 1, 0.36, 1] as const;\nconst MOVE = { duration: 0.2, ease: EASE } as const;\nconst LIFT = { duration: 0.16, ease: EASE } as const;\n\nexport interface ReorderItem {\n  id: string;\n  label: string;\n  meta?: string;\n}\n\nexport interface ReorderState {\n  order: string[];\n  dragging: string | null;\n  from: number | null;\n  to: number | null;\n  grabbed: boolean;\n  lastMove: { label: string; from: number; to: number } | null;\n}\n\ninterface DragData {\n  row: HTMLLIElement;\n  pointerId: number;\n  index: number;\n  startY: number;\n  active: boolean;\n  slot: number;\n  to: number;\n  rows?: HTMLLIElement[];\n}\n\nexport default function DragReorderList({\n  items: controlledItems,\n  defaultItems = DEFAULT_ITEMS,\n  onReorder,\n  flip = true,\n  inspect = false,\n  onStateChange,\n}: {\n  /** Controlled item order; pair with onReorder. Omit for uncontrolled. */\n  items?: ReorderItem[];\n  defaultItems?: ReorderItem[];\n  onReorder?: (items: ReorderItem[]) => void;\n  /** false = hard snap, no glides/FLIP. */\n  flip?: boolean;\n  inspect?: boolean;\n  onStateChange?: (state: ReorderState) => void;\n}) {\n  const [uncontrolled, setUncontrolled] = useState(controlledItems ?? defaultItems);\n  const items = controlledItems ?? uncontrolled;\n  const setItems = (updater: (list: ReorderItem[]) => ReorderItem[]) => {\n    const next = updater(items);\n    if (controlledItems === undefined) setUncontrolled(next);\n    onReorder?.(next);\n  };\n\n  const [dragging, setDragging] = useState<{ id: string; from: number } | null>(null);\n  const [target, setTarget] = useState<number | null>(null);\n  const [grabbed, setGrabbed] = useState<{ id: string; from: number } | null>(null);\n  const [lastMove, setLastMove] = useState<{ label: string; from: number; to: number } | null>(null);\n  const [announce, setAnnounce] = useState(\"\");\n\n  const listRef = useRef<HTMLUListElement>(null);\n  const flipRectsRef = useRef<Map<string, DOMRect> | null>(null);\n  const dragRef = useRef<DragData | null>(null);\n  const grabSnapshotRef = useRef<ReorderItem[] | null>(null);\n  const justDraggedRef = useRef(false);\n  // One y motion value per row id - the single writing channel for all three\n  // motion systems, so they can never fight over a transform.\n  const yMapRef = useRef(new Map<string, MotionValue<number>>());\n  const slotYRef = useRef(motionValue(0));\n  const reduced = useReducedMotion();\n\n  const yFor = (id: string) => {\n    let mv = yMapRef.current.get(id);\n    if (!mv) {\n      mv = motionValue(0);\n      yMapRef.current.set(id, mv);\n    }\n    return mv;\n  };\n\n  const rowNodes = () => [...(listRef.current?.querySelectorAll<HTMLLIElement>(\"[data-reorder-item]\") ?? [])];\n  const glide = (mv: MotionValue<number>, to: number) => {\n    if (flip && !reduced) animate(mv, to, MOVE);\n    else mv.jump(to);\n  };\n\n  // FLIP: after any commit that captured First rects, zero everyone, measure the\n  // clean layout, invert, then animate() home.\n  useLayoutEffect(() => {\n    const prev = flipRectsRef.current;\n    flipRectsRef.current = null;\n    const list = listRef.current;\n    if (!prev || !list) return;\n    const rows = rowNodes();\n    for (const row of rows) yFor(row.dataset.id!).jump(0);\n    void list.offsetWidth;\n    if (flip && !reduced) {\n      for (const row of rows) {\n        const before = prev.get(row.dataset.id!);\n        if (!before) continue;\n        const dy = before.top - row.getBoundingClientRect().top;\n        if (dy) {\n          const mv = yFor(row.dataset.id!);\n          mv.jump(dy); // Invert: hold the old pixels\n          animate(mv, 0, MOVE); // Play: glide home\n        }\n      }\n    }\n  }, [items, flip, reduced]);\n\n  useEffect(() => {\n    onStateChange?.({\n      order: items.map((item) => item.label),\n      dragging: dragging ? items.find((item) => item.id === dragging.id)?.label ?? null : null,\n      from: dragging?.from ?? grabbed?.from ?? null,\n      to: dragging ? target : grabbed ? items.findIndex((item) => item.id === grabbed.id) : null,\n      grabbed: Boolean(grabbed),\n      lastMove,\n    });\n  }, [items, dragging, target, grabbed, lastMove, onStateChange]);\n\n  function moveItem(list: ReorderItem[], from: number, to: number) {\n    const next = [...list];\n    const [picked] = next.splice(from, 1);\n    next.splice(to, 0, picked);\n    return next;\n  }\n\n  function commitOrder(from: number, to: number) {\n    const list = listRef.current;\n    if (!list) return;\n    const rects = new Map<string, DOMRect>();\n    for (const row of rowNodes()) rects.set(row.dataset.id!, row.getBoundingClientRect());\n    flipRectsRef.current = rects;\n    const moved = items[from];\n    setItems((current) => moveItem(current, from, to));\n    setLastMove({ label: moved.label, from: from + 1, to: to + 1 });\n  }\n\n  function handlePointerDown(event: React.PointerEvent<HTMLLIElement>, index: number) {\n    if (dragRef.current) return;\n    if (event.button !== undefined && event.button !== 0) return;\n    const row = event.currentTarget;\n    row.setPointerCapture(event.pointerId);\n    dragRef.current = { row, pointerId: event.pointerId, index, startY: event.clientY, active: false, slot: 0, to: index };\n  }\n\n  function handlePointerMove(event: React.PointerEvent<HTMLLIElement>) {\n    const drag = dragRef.current;\n    if (!drag || event.pointerId !== drag.pointerId) return;\n    const dy = event.clientY - drag.startY;\n    if (!drag.active) {\n      if (Math.abs(dy) < DRAG_THRESHOLD) return;\n      const rows = rowNodes();\n      drag.active = true;\n      drag.slot = rows.length > 1 ? rows[1].getBoundingClientRect().top - rows[0].getBoundingClientRect().top : rows[0].offsetHeight;\n      drag.rows = rows;\n      slotYRef.current.jump(drag.index * drag.slot);\n      setDragging({ id: drag.row.dataset.id!, from: drag.index });\n      setTarget(drag.index);\n    }\n    const max = (items.length - 1 - drag.index) * drag.slot;\n    const min = -drag.index * drag.slot;\n    let offset = dy;\n    if (offset > max) offset = max + (offset - max) / 4;\n    if (offset < min) offset = min + (offset - min) / 4;\n    // The hand gets no easing: jump(), never animate().\n    yFor(drag.row.dataset.id!).jump(offset);\n\n    const to = Math.max(0, Math.min(items.length - 1, Math.round((drag.index * drag.slot + Math.max(min, Math.min(max, dy))) / drag.slot)));\n    if (to !== drag.to) {\n      drag.to = to;\n      setTarget(to);\n      if (flip && !reduced) slotYRef.current && animate(slotYRef.current, to * drag.slot, MOVE);\n      else slotYRef.current.jump(to * drag.slot);\n      drag.rows!.forEach((row, j) => {\n        if (row === drag.row) return;\n        let shift = 0;\n        if (drag.index < j && j <= to) shift = -drag.slot;\n        if (to <= j && j < drag.index) shift = drag.slot;\n        glide(yFor(row.dataset.id!), shift);\n      });\n    }\n  }\n\n  function settleAll(drag: DragData) {\n    glide(yFor(drag.row.dataset.id!), 0);\n    drag.rows?.forEach((row) => {\n      if (row !== drag.row) glide(yFor(row.dataset.id!), 0);\n    });\n  }\n\n  function handlePointerUp(event: React.PointerEvent<HTMLLIElement>) {\n    const drag = dragRef.current;\n    if (!drag || event.pointerId !== drag.pointerId) return;\n    dragRef.current = null;\n    if (!drag.active) return;\n    justDraggedRef.current = true;\n    setDragging(null);\n    setTarget(null);\n    if (drag.to !== drag.index) commitOrder(drag.index, drag.to);\n    else settleAll(drag);\n  }\n\n  function cancelPointerDrag() {\n    const drag = dragRef.current;\n    if (!drag) return;\n    dragRef.current = null;\n    if (!drag.active) return;\n    justDraggedRef.current = true;\n    setDragging(null);\n    setTarget(null);\n    settleAll(drag);\n  }\n\n  useEffect(() => {\n    if (!dragging) return undefined;\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") cancelPointerDrag();\n    };\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => document.removeEventListener(\"keydown\", onKeyDown);\n  }, [dragging]);\n\n  function handleGripKeyDown(event: { key: string; preventDefault: () => void }, index: number) {\n    const item = items[index];\n    const isGrabbed = grabbed?.id === item.id;\n    if (event.key === \" \" || event.key === \"Enter\") {\n      event.preventDefault();\n      if (isGrabbed) {\n        setGrabbed(null);\n        grabSnapshotRef.current = null;\n        setAnnounce(`${item.label} dropped at position ${index + 1} of ${items.length}.`);\n      } else {\n        setGrabbed({ id: item.id, from: index });\n        grabSnapshotRef.current = items;\n        setAnnounce(`${item.label} grabbed at position ${index + 1} of ${items.length}. Use arrow keys to move, Space to drop, Escape to cancel.`);\n      }\n    } else if (isGrabbed && (event.key === \"ArrowUp\" || event.key === \"ArrowDown\")) {\n      event.preventDefault();\n      const to = event.key === \"ArrowUp\" ? index - 1 : index + 1;\n      if (to < 0 || to >= items.length) return;\n      commitOrder(index, to);\n      setAnnounce(`${item.label} moved to position ${to + 1} of ${items.length}.`);\n      requestAnimationFrame(() => {\n        listRef.current?.querySelectorAll<HTMLButtonElement>(\"[data-reorder-grip]\")[to]?.focus();\n      });\n    } else if (isGrabbed && event.key === \"Escape\") {\n      event.preventDefault();\n      const snapshot = grabSnapshotRef.current;\n      grabSnapshotRef.current = null;\n      setGrabbed(null);\n      if (snapshot && snapshot !== items) {\n        const from = items.findIndex((entry) => entry.id === item.id);\n        const to = snapshot.findIndex((entry) => entry.id === item.id);\n        commitOrder(from, to);\n      }\n      setAnnounce(`Reorder cancelled. ${item.label} is back at its original position.`);\n    }\n  }\n\n  const slotHeights = () => {\n    const rows = rowNodes();\n    return {\n      slot: rows.length > 1 ? rows[1].offsetTop - rows[0].offsetTop : 56,\n      cardH: rows[0]?.offsetHeight ?? 48,\n    };\n  };\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div className=\"relative w-full max-w-80\" data-inspect={inspect ? \"true\" : \"false\"}>\n        <ul\n          className={`relative flex flex-col gap-2${inspect ? \" outline outline-[1.5px] outline-dashed outline-[#3b82f6] outline-offset-[6px]\" : \"\"}`}\n          ref={listRef}\n          role=\"list\"\n          aria-label=\"Release checklist, reorderable\"\n        >\n          {items.map((item, index) => {\n            const isDragged = dragging?.id === item.id;\n            const isGrabbed = grabbed?.id === item.id;\n            const lifted = isDragged || isGrabbed;\n            return (\n              <motion.li\n                key={item.id}\n                data-id={item.id}\n                data-reorder-item\n                className={`relative touch-none select-none ${lifted ? \"z-10\" : \"\"}`}\n                style={{ y: yFor(item.id) }}\n                onPointerDown={(event) => handlePointerDown(event, index)}\n                onPointerMove={handlePointerMove}\n                onPointerUp={handlePointerUp}\n                onPointerCancel={cancelPointerDrag}\n              >\n                <motion.div\n                  className={`flex items-center gap-2.5 py-2.5 pl-2 pr-3.5 bg-card rounded-xl ${lifted ? \"cursor-grabbing\" : \"cursor-grab\"}${\n                    lifted && inspect ? \" outline outline-[1.5px] outline-dashed outline-[#ef4444]\" : \"\"\n                  }`}\n                  initial={false}\n                  animate={\n                    lifted\n                      ? { scale: 1.02, boxShadow: \"0 0 0 1px rgba(0,0,0,0.06), 0 12px 28px -10px rgba(0,0,0,0.28)\" }\n                      : { scale: 1, boxShadow: \"0 0 0 1px rgba(0,0,0,0.06), 0 1px 2px -1px rgba(0,0,0,0.06), 0 2px 4px 0 rgba(0,0,0,0.04)\" }\n                  }\n                  transition={LIFT}\n                >\n                  <button\n                    type=\"button\"\n                    data-reorder-grip\n                    className=\"grid place-items-center w-9 h-9 rounded-lg text-muted-foreground/70 cursor-grab [transition:background-color_200ms_ease,color_200ms_ease] hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring aria-pressed:bg-primary aria-pressed:text-primary-foreground\"\n                    aria-label={`Reorder ${item.label}, position ${index + 1} of ${items.length}${isGrabbed ? \", grabbed\" : \"\"}`}\n                    aria-pressed={isGrabbed}\n                    onKeyDown={(event) => handleGripKeyDown(event, index)}\n                    onClick={(event) => {\n                      if (justDraggedRef.current) {\n                        justDraggedRef.current = false;\n                        return;\n                      }\n                      if (event.detail > 0) handleGripKeyDown({ key: \" \", preventDefault: () => {} }, index);\n                    }}\n                  >\n                    <GripIcon />\n                  </button>\n                  <span className=\"flex flex-col min-w-0 flex-1\">\n                    <span className=\"text-sm font-medium text-foreground whitespace-nowrap overflow-hidden text-ellipsis\">{item.label}</span>\n                    {item.meta && <span className=\"text-[0.6875rem] text-muted-foreground/70\">{item.meta}</span>}\n                  </span>\n                  <span className=\"flex-none text-xs font-medium text-muted-foreground/70 tabular-nums\" aria-hidden=\"true\">\n                    {index + 1}\n                  </span>\n                </motion.div>\n                {inspect && isDragged && (\n                  <span className=\"absolute bottom-[calc(100%+0.3rem)] left-0 z-20 whitespace-nowrap rounded-[0.25rem] border border-[#fecaca] bg-white px-[0.3125rem] py-[0.0625rem] text-[0.625rem] font-medium leading-normal text-[#dc2626] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none\">\n                    y: pointer Δy via mv.jump(), no easing · rubber past ends\n                  </span>\n                )}\n              </motion.li>\n            );\n          })}\n\n          {inspect &&\n            dragging &&\n            target !== null &&\n            (() => {\n              const { cardH } = slotHeights();\n              return (\n                <motion.span\n                  className=\"absolute top-0 left-0 right-0 border-[1.5px] border-dashed border-[#f59e0b] rounded-xl pointer-events-none z-[5]\"\n                  style={{ y: slotYRef.current, height: cardH }}\n                  aria-hidden=\"true\"\n                >\n                  <span className=\"absolute top-1/2 -right-1.5 translate-x-full -translate-y-1/2 whitespace-nowrap rounded-[0.25rem] border border-[#fde68a] bg-white px-[0.3125rem] py-[0.0625rem] text-[0.625rem] font-medium leading-normal text-[#b45309] shadow-[0_1px_2px_rgba(0,0,0,0.08)]\">\n                    drop slot {target + 1}\n                  </span>\n                </motion.span>\n              );\n            })()}\n        </ul>\n\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {announce}\n        </span>\n\n        {inspect && (\n          <span className=\"absolute top-[calc(100%+0.65rem)] left-0 z-20 whitespace-nowrap rounded-[0.25rem] border border-[#bfdbfe] bg-white px-[0.3125rem] py-[0.0625rem] text-[0.625rem] font-medium leading-normal text-[#2563eb] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none\">\n            siblings shift ±1 slot · drop = FLIP: measure → invert → animate()\n          </span>\n        )}\n      </div>\n    </MotionConfig>\n  );\n}\n\nconst DEFAULT_ITEMS: ReorderItem[] = [\n  { id: \"design\", label: \"Design review\", meta: \"figma\" },\n  { id: \"mention\", label: \"Ship mention popover\", meta: \"lab\" },\n  { id: \"staging\", label: \"Deploy staging\", meta: \"vercel\" },\n  { id: \"changelog\", label: \"Write changelog\", meta: \"notion\" },\n  { id: \"announce\", label: \"Announce release\", meta: \"social\" },\n];\n\nfunction GripIcon() {\n  return (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\">\n      <circle cx=\"9\" cy=\"5\" r=\"1.7\" />\n      <circle cx=\"15\" cy=\"5\" r=\"1.7\" />\n      <circle cx=\"9\" cy=\"12\" r=\"1.7\" />\n      <circle cx=\"15\" cy=\"12\" r=\"1.7\" />\n      <circle cx=\"9\" cy=\"19\" r=\"1.7\" />\n      <circle cx=\"15\" cy=\"19\" r=\"1.7\" />\n    </svg>\n  );\n}\n"
    }
  ],
  "dependencies": [
    "motion"
  ]
}
