{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"draggable-sortable-list","type":"registry:component","title":"Draggable Sortable List","description":"A dependency-free sortable list: lift a row by its grip and the others glide out of the way on a spring, then it settles into its slot. Pointer and touch drag, plus a full keyboard path with live announcements.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/draggable-sortable-list.tsx","content":"\"use client\";\n\n/*\n * Draggable sortable list: reorder rows with a grip handle, no dependencies.\n *\n * Grab the handle and the row lifts (scale + shadow), the rows around it glide\n * out of the way on a spring, and on release it settles into its slot. Keyboard:\n * focus a handle, press Space to pick the row up, move it with the arrow keys\n * (Home and End jump to the ends), Space to drop, Escape to cancel. Every step\n * is announced through an aria-live region. Positions are tracked with plain\n * pointer events and FLIP animations (Web Animations API).\n */\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\n\nexport type SortableListProps = {\n  /** Row labels, in their starting order. Changing the list resets the order. */\n  items?: string[];\n  /** Called with the labels in their new order after every move. */\n  onChange?: (items: string[]) => void;\n  /** Surface of the rows: white paper (default) or dark glass. */\n  tone?: \"dark\" | \"light\";\n  /** Row size: md matches a compact list, lg is a roomy hero-sized list. */\n  size?: \"md\" | \"lg\";\n  /** Show the position number (01, 02...) at the end of each row. */\n  showIndex?: boolean;\n  /** Classes for the root. The default sets the width; pass your own to replace it. */\n  className?: string;\n};\n\ntype Row = { id: string; label: string };\n\nconst DEFAULT_ITEMS = [\"Wireframe\", \"Design\", \"Build\", \"Ship\"];\nconst SEP = \"\\u0001\";\nconst FALLBACK_EASE = \"cubic-bezier(0.34, 1.3, 0.64, 1)\";\nconst SETTLE_MS = 600;\nconst toRows = (items: string[]): Row[] => items.map((label, i) => ({ id: \"r\" + i, label }));\nconst useIsoLayoutEffect = typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\nfunction moveItem<T>(arr: T[], from: number, to: number): T[] {\n  const next = arr.slice();\n  const [picked] = next.splice(from, 1);\n  if (picked !== undefined) next.splice(to, 0, picked);\n  return next;\n}\n\n/* A damped spring (stiffness 380, damping 26) sampled into a CSS linear() curve, with a cubic-bezier fallback. */\nconst SPRING =\n  \"linear(0, 0.046, 0.156, 0.299, 0.452, 0.597, 0.726, 0.832, 0.916, 0.977, 1.018, 1.044, 1.057, 1.06, 1.057, 1.051, 1.042, 1.033, 1.024, 1.016, 1.01, 1.005, 1.001, 0.999, 0.997, 0.997, 0.996, 0.997, 0.997, 0.997, 0.998, 0.999, 0.999, 0.999, 1, 1, 1)\";\n\nconst CSS = [\n  \".dsl{--dsl-ease:\" + FALLBACK_EASE + \";position:relative;font-size:14px}\",\n  \"@supports (transition-timing-function:linear(0,1)){.dsl{--dsl-ease:\" + SPRING + \"}}\",\n  \".dsl[data-size=md]{--dsl-h:44px;--dsl-gap:8px;--dsl-r:12px;--dsl-fs:14px;--dsl-hs:32px;--dsl-px:8px;--dsl-grip:16px}\",\n  \".dsl[data-size=lg]{--dsl-h:62px;--dsl-gap:10px;--dsl-r:16px;--dsl-fs:17px;--dsl-hs:40px;--dsl-px:11px;--dsl-grip:18px}\",\n  \"@media (pointer:coarse){.dsl[data-size=md]{--dsl-h:50px;--dsl-hs:40px}}\",\n  \".dsl[data-tone=dark]{--dsl-bg:linear-gradient(180deg,#22242b,#181a20);--dsl-line:rgba(255,255,255,.1);--dsl-line-hi:rgba(255,255,255,.3);--dsl-hl:rgba(255,255,255,.07);--dsl-text:rgba(255,255,255,.92);--dsl-muted:rgba(255,255,255,.34);--dsl-muted-hi:rgba(255,255,255,.85);--dsl-sh:0 1px 0 rgba(0,0,0,.4),0 8px 16px -10px rgba(0,0,0,.7);--dsl-lift:0 26px 44px -14px rgba(0,0,0,.85),0 10px 18px -10px rgba(0,0,0,.7);--dsl-sheen:rgba(255,255,255,.13);--dsl-accent:140,170,255;--dsl-ring:rgba(255,255,255,.9)}\",\n  \".dsl[data-tone=light]{--dsl-bg:linear-gradient(180deg,#ffffff,#fafafa);--dsl-line:rgba(15,23,42,.11);--dsl-line-hi:rgba(15,23,42,.34);--dsl-hl:rgba(255,255,255,.9);--dsl-text:#171717;--dsl-muted:#a3a3a3;--dsl-muted-hi:#262626;--dsl-sh:0 1px 2px rgba(15,23,42,.06),0 6px 14px -8px rgba(15,23,42,.12);--dsl-lift:0 26px 40px -16px rgba(15,23,42,.32),0 10px 16px -10px rgba(15,23,42,.18);--dsl-sheen:rgba(15,23,42,.05);--dsl-accent:59,91,255;--dsl-ring:#171717}\",\n  \".dsl-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--dsl-gap);position:relative}\",\n  \".dsl-li{position:relative;margin:0;padding:0}\",\n  \".dsl-li[data-lifted]{z-index:3;will-change:transform}\",\n  \".dsl-card{position:relative;display:flex;align-items:center;gap:10px;box-sizing:border-box;min-height:var(--dsl-h);padding:0 calc(var(--dsl-px) + 6px) 0 var(--dsl-px);border-radius:var(--dsl-r);border:1px solid var(--dsl-line);background:var(--dsl-bg);color:var(--dsl-text);box-shadow:var(--dsl-sh),inset 0 1px 0 var(--dsl-hl);transition:transform .34s var(--dsl-ease),box-shadow .3s ease,border-color .2s ease}\",\n  \".dsl-card::before{content:\\\"\\\";position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:0;transition:opacity .3s ease;background:radial-gradient(200px 90px at var(--dsl-mx,50%) 0%,var(--dsl-sheen),transparent 70%)}\",\n  \".dsl-li[data-lifted] .dsl-card,.dsl-li[data-grabbed] .dsl-card{border-color:var(--dsl-line-hi);box-shadow:var(--dsl-lift),inset 0 1px 0 var(--dsl-hl)}\",\n  \".dsl-li[data-lifted] .dsl-card{transform:scale(1.035)}\",\n  \".dsl-li[data-grabbed] .dsl-card{transform:scale(1.025)}\",\n  \".dsl-li[data-lifted] .dsl-card::before,.dsl-li[data-grabbed] .dsl-card::before{opacity:1}\",\n  \".dsl-li[data-settled] .dsl-card{animation:dsl-settle .9s ease-out}\",\n  \"@keyframes dsl-settle{0%{border-color:rgba(var(--dsl-accent),.95);box-shadow:0 0 0 4px rgba(var(--dsl-accent),.2),var(--dsl-sh)}100%{border-color:var(--dsl-line);box-shadow:0 0 0 0 rgba(var(--dsl-accent),0),var(--dsl-sh)}}\",\n  \"@media (hover:hover){.dsl-li:hover .dsl-card{border-color:var(--dsl-line-hi)}.dsl-li:hover .dsl-handle{color:var(--dsl-muted-hi)}}\",\n  \".dsl-handle{appearance:none;-webkit-appearance:none;position:relative;flex:none;display:grid;place-items:center;width:var(--dsl-hs);height:var(--dsl-hs);margin:0;padding:0;border:0;border-radius:8px;background:transparent;color:var(--dsl-muted);cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent;transition:color .2s ease,background-color .2s ease}\",\n  \".dsl-handle::before{content:\\\"\\\";position:absolute;inset:-6px}\",\n  \".dsl-handle:active,.dsl-li[data-lifted] .dsl-handle,.dsl-li[data-grabbed] .dsl-handle{color:var(--dsl-muted-hi)}\",\n  \".dsl-handle:focus-visible{outline:2px solid var(--dsl-ring);outline-offset:2px;border-radius:8px}\",\n  \".dsl-grip{width:var(--dsl-grip);height:var(--dsl-grip);display:block}\",\n  \".dsl-label{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--dsl-fs);font-weight:500;letter-spacing:-.005em;line-height:1.3}\",\n  \".dsl-idx{flex:none;font:500 11px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.06em;color:var(--dsl-muted);font-variant-numeric:tabular-nums}\",\n  \".dsl[data-size=lg] .dsl-idx{font-size:12px}\",\n  \".dsl[data-dragging],.dsl[data-dragging] *{user-select:none;-webkit-user-select:none}\",\n  \".dsl[data-dragging] *{cursor:grabbing!important}\",\n  \".dsl[data-dragging] .dsl-li:not([data-lifted]){transition:transform .52s var(--dsl-ease)}\",\n  \".dsl-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}\",\n  \"@media (prefers-reduced-motion:reduce){.dsl-card{transition:box-shadow .15s ease,border-color .15s ease}.dsl-li[data-lifted] .dsl-card,.dsl-li[data-grabbed] .dsl-card{transform:none}.dsl[data-dragging] .dsl-li:not([data-lifted]){transition:none}.dsl-li[data-settled] .dsl-card{animation:none}}\",\n].join(\"\\n\");\n\ntype Session = {\n  id: string;\n  pointerId: number;\n  startY: number;\n  scale: number;\n  from: number;\n  target: number;\n  tops: number[];\n  heights: number[];\n  gap: number;\n  handle: HTMLElement;\n};\n\nexport function SortableList({\n  items: initial = DEFAULT_ITEMS,\n  onChange,\n  tone = \"light\",\n  size = \"md\",\n  showIndex = true,\n  className = \"w-72\",\n}: SortableListProps) {\n  const itemsKey = initial.join(SEP);\n  const [rows, setRows] = useState<Row[]>(() => toRows(initial));\n  // The list can be reset from outside; adjust during render rather than in an effect.\n  const [seedKey, setSeedKey] = useState(itemsKey);\n  if (seedKey !== itemsKey) {\n    setSeedKey(itemsKey);\n    setRows(toRows(initial));\n  }\n  const [grabbed, setGrabbed] = useState<string | null>(null);\n  const [settled, setSettled] = useState<string | null>(null);\n  const [message, setMessage] = useState(\"\");\n  const uid = useId().replace(/[^a-zA-Z0-9]/g, \"\");\n\n  const listRef = useRef<HTMLUListElement>(null);\n  const rowsRef = useRef(rows);\n  const grabbedRef = useRef<string | null>(null);\n  const keyStartRef = useRef<Row[] | null>(null);\n  const sessionRef = useRef<Session | null>(null);\n  const pendingRef = useRef<Map<string, number> | null>(null);\n  const reducedRef = useRef(false);\n  const onChangeRef = useRef(onChange);\n  const settleTimer = useRef(0);\n  const cleanupDrag = useRef<(() => void) | null>(null);\n\n  useIsoLayoutEffect(() => {\n    rowsRef.current = rows;\n    onChangeRef.current = onChange;\n    grabbedRef.current = grabbed;\n  });\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const sync = () => {\n      reducedRef.current = mq.matches;\n    };\n    sync();\n    mq.addEventListener(\"change\", sync);\n    return () => mq.removeEventListener(\"change\", sync);\n  }, []);\n\n  useEffect(\n    () => () => {\n      window.clearTimeout(settleTimer.current);\n      cleanupDrag.current?.();\n    },\n    [],\n  );\n\n  const lis = useCallback((): HTMLLIElement[] => {\n    const list = listRef.current;\n    return list ? (Array.from(list.children) as HTMLLIElement[]) : [];\n  }, []);\n\n  /* Where every row visually is right now, in layout pixels from the list top. */\n  const captureTops = useCallback((): Map<string, number> => {\n    const out = new Map<string, number>();\n    const list = listRef.current;\n    if (!list) return out;\n    const lr = list.getBoundingClientRect();\n    const s = lr.height ? list.offsetHeight / lr.height : 1;\n    for (const li of lis()) {\n      const id = li.dataset.rid;\n      if (id) out.set(id, (li.getBoundingClientRect().top - lr.top) * s);\n    }\n    return out;\n  }, [lis]);\n\n  /* FLIP: glide every row from where it looked to where it now is. */\n  const playFrom = useCallback(\n    (from: Map<string, number>) => {\n      if (reducedRef.current) return;\n      const list = listRef.current;\n      if (!list) return;\n      const ease = getComputedStyle(list).getPropertyValue(\"--dsl-ease\").trim() || FALLBACK_EASE;\n      for (const li of lis()) {\n        const id = li.dataset.rid;\n        const was = id ? from.get(id) : undefined;\n        if (was === undefined || typeof li.animate !== \"function\") continue;\n        const delta = was - li.offsetTop;\n        if (Math.abs(delta) < 0.5) continue;\n        li.getAnimations().forEach((a) => a.cancel());\n        li.animate(\n          [{ transform: \"translate3d(0,\" + delta + \"px,0)\" }, { transform: \"translate3d(0,0,0)\" }],\n          { duration: SETTLE_MS, easing: ease },\n        );\n      }\n    },\n    [lis],\n  );\n\n  // A repeated message gets a trailing space so the live region still announces it.\n  const say = useCallback(\n    (text: string) => setMessage((prev) => (prev === text ? text + \" \" : text)),\n    [],\n  );\n\n  const flash = useCallback((id: string) => {\n    setSettled(id);\n    window.clearTimeout(settleTimer.current);\n    settleTimer.current = window.setTimeout(() => setSettled(null), 900);\n  }, []);\n\n  const applyOrder = useCallback((next: Row[], from: Map<string, number>, flashId: string | null) => {\n    pendingRef.current = from;\n    setRows(next);\n    onChangeRef.current?.(next.map((r) => r.label));\n    if (flashId) flash(flashId);\n  }, [flash]);\n\n  useIsoLayoutEffect(() => {\n    const from = pendingRef.current;\n    pendingRef.current = null;\n    if (from) playFrom(from);\n    const id = grabbedRef.current;\n    if (id) {\n      const handle = listRef.current?.querySelector<HTMLElement>('[data-rid=\"' + id + '\"] button');\n      if (handle && document.activeElement !== handle) handle.focus({ preventScroll: true });\n    }\n  }, [rows, playFrom]);\n\n\n  /* ---- Pointer dragging ------------------------------------------------ */\n\n  const endDrag = useCallback(\n    (cancel: boolean) => {\n      const s = sessionRef.current;\n      const list = listRef.current;\n      if (!s || !list) return;\n      sessionRef.current = null;\n      cleanupDrag.current?.();\n      cleanupDrag.current = null;\n      try {\n        s.handle.releasePointerCapture(s.pointerId);\n      } catch {\n        /* capture may already be gone */\n      }\n      const vis = captureTops();\n      for (const li of lis()) {\n        li.style.transform = \"\";\n        delete li.dataset.lifted;\n        li.style.removeProperty(\"--dsl-mx\");\n      }\n      delete list.dataset.dragging;\n      const order = rowsRef.current;\n      const to = cancel ? s.from : s.target;\n      const picked = order[s.from];\n      if (to !== s.from && picked) {\n        applyOrder(moveItem(order, s.from, to), vis, picked.id);\n        say(picked.label + \" dropped at position \" + (to + 1) + \" of \" + order.length + \".\");\n      } else {\n        playFrom(vis);\n        if (picked) say(cancel ? \"Move cancelled.\" : picked.label + \" stays at position \" + (s.from + 1) + \".\");\n      }\n    },\n    [applyOrder, captureTops, lis, playFrom, say],\n  );\n\n  const onHandleDown = (e: ReactPointerEvent<HTMLButtonElement>, id: string) => {\n    if (sessionRef.current || grabbedRef.current) return;\n    if (e.pointerType === \"mouse\" && e.button !== 0) return;\n    const list = listRef.current;\n    if (!list) return;\n    const els = lis();\n    const from = els.findIndex((li) => li.dataset.rid === id);\n    const li = els[from];\n    if (!li) return;\n    if (e.pointerType === \"mouse\") e.preventDefault();\n    els.forEach((el) => el.getAnimations().forEach((a) => a.cancel()));\n    const lr = list.getBoundingClientRect();\n    const handle = e.currentTarget;\n    const session: Session = {\n      id,\n      pointerId: e.pointerId,\n      startY: e.clientY,\n      scale: lr.height ? list.offsetHeight / lr.height : 1,\n      from,\n      target: from,\n      tops: els.map((el) => el.offsetTop),\n      heights: els.map((el) => el.offsetHeight),\n      gap: parseFloat(getComputedStyle(list).rowGap) || 0,\n      handle,\n    };\n    sessionRef.current = session;\n    try {\n      handle.setPointerCapture(e.pointerId);\n    } catch {\n      /* ignore */\n    }\n    list.dataset.dragging = \"\";\n    li.dataset.lifted = \"\";\n    const onKey = (ev: KeyboardEvent) => {\n      if (ev.key === \"Escape\") {\n        ev.preventDefault();\n        endDrag(true);\n      }\n    };\n    window.addEventListener(\"keydown\", onKey);\n    cleanupDrag.current = () => window.removeEventListener(\"keydown\", onKey);\n  };\n\n  const onHandleMove = (e: ReactPointerEvent<HTMLButtonElement>) => {\n    const s = sessionRef.current;\n    if (!s || e.pointerId !== s.pointerId) return;\n    const els = lis();\n    const li = els[s.from];\n    if (!li) return;\n    const h = s.heights[s.from] ?? 0;\n    const top = s.tops[s.from] ?? 0;\n    const first = s.tops[0] ?? 0;\n    const lastIdx = s.tops.length - 1;\n    const end = (s.tops[lastIdx] ?? 0) + (s.heights[lastIdx] ?? 0);\n    const y = Math.min(Math.max((e.clientY - s.startY) * s.scale, first - top), end - (top + h));\n    li.style.transform = \"translate3d(0,\" + y + \"px,0)\";\n    const r = li.getBoundingClientRect();\n    li.style.setProperty(\"--dsl-mx\", Math.round(((e.clientX - r.left) / (r.width || 1)) * 100) + \"%\");\n    const mid = top + y + h / 2;\n    let target = 0;\n    for (let j = 0; j < s.tops.length; j++) {\n      if (j !== s.from && (s.tops[j] ?? 0) + (s.heights[j] ?? 0) / 2 < mid) target++;\n    }\n    if (target === s.target) return;\n    s.target = target;\n    const shift = h + s.gap;\n    els.forEach((el, j) => {\n      if (j === s.from) return;\n      let d = 0;\n      if (s.from < target && j > s.from && j <= target) d = -shift;\n      else if (target < s.from && j >= target && j < s.from) d = shift;\n      el.style.transform = d ? \"translate3d(0,\" + d + \"px,0)\" : \"\";\n    });\n  };\n\n  const onHandleUp = (e: ReactPointerEvent<HTMLButtonElement>) => {\n    const s = sessionRef.current;\n    if (!s || e.pointerId !== s.pointerId) return;\n    endDrag(e.type === \"pointercancel\");\n  };\n\n  /* ---- Keyboard sorting ------------------------------------------------ */\n\n  const keyMove = (id: string, to: number) => {\n    const order = rowsRef.current;\n    const from = order.findIndex((r) => r.id === id);\n    const row = order[from];\n    if (!row || from < 0) return;\n    const next = Math.min(Math.max(to, 0), order.length - 1);\n    if (next === from) {\n      say(row.label + \" is already \" + (next === 0 ? \"first\" : \"last\") + \".\");\n      return;\n    }\n    applyOrder(moveItem(order, from, next), captureTops(), null);\n    say(row.label + \", moved to position \" + (next + 1) + \" of \" + order.length + \".\");\n  };\n\n  const drop = (id: string) => {\n    const order = rowsRef.current;\n    const at = order.findIndex((r) => r.id === id);\n    const row = order[at];\n    keyStartRef.current = null;\n    setGrabbed(null);\n    if (!row) return;\n    flash(id);\n    say(row.label + \" dropped at position \" + (at + 1) + \" of \" + order.length + \".\");\n  };\n\n  const onHandleKey = (e: ReactKeyboardEvent<HTMLButtonElement>, id: string) => {\n    if (sessionRef.current) return;\n    const order = rowsRef.current;\n    const at = order.findIndex((r) => r.id === id);\n    const row = order[at];\n    if (!row) return;\n    const isGrabbed = grabbedRef.current === id;\n    const k = e.key;\n    if (!isGrabbed) {\n      if (k === \" \" || k === \"Enter\") {\n        e.preventDefault();\n        keyStartRef.current = order;\n        setGrabbed(id);\n        say(\n          \"Picked up \" + row.label + \", position \" + (at + 1) + \" of \" + order.length +\n            \". Use the arrow keys to move, space to drop, escape to cancel.\",\n        );\n      } else if (k === \"ArrowUp\" || k === \"ArrowDown\") {\n        e.preventDefault();\n        const nb = order[at + (k === \"ArrowUp\" ? -1 : 1)];\n        if (nb) listRef.current?.querySelector<HTMLElement>('[data-rid=\"' + nb.id + '\"] button')?.focus();\n      }\n      return;\n    }\n    if (k === \"ArrowUp\" || k === \"ArrowLeft\") {\n      e.preventDefault();\n      keyMove(id, at - 1);\n    } else if (k === \"ArrowDown\" || k === \"ArrowRight\") {\n      e.preventDefault();\n      keyMove(id, at + 1);\n    } else if (k === \"Home\") {\n      e.preventDefault();\n      keyMove(id, 0);\n    } else if (k === \"End\") {\n      e.preventDefault();\n      keyMove(id, order.length - 1);\n    } else if (k === \" \" || k === \"Enter\") {\n      e.preventDefault();\n      drop(id);\n    } else if (k === \"Escape\") {\n      e.preventDefault();\n      const start = keyStartRef.current;\n      keyStartRef.current = null;\n      setGrabbed(null);\n      if (start) {\n        applyOrder(start, captureTops(), null);\n        const back = start.findIndex((r) => r.id === id);\n        say(\"Cancelled. \" + row.label + \" returned to position \" + (back + 1) + \" of \" + start.length + \".\");\n      }\n    }\n  };\n\n  const safeTone = tone === \"light\" ? \"light\" : \"dark\";\n  const safeSize = size === \"lg\" ? \"lg\" : \"md\";\n\n  return (\n    <div className={\"dsl \" + className} data-tone={safeTone} data-size={safeSize}>\n      <style>{CSS}</style>\n      <ul ref={listRef} className=\"dsl-list\" aria-label=\"Sortable list\">\n        {rows.map((row, i) => (\n          <li\n            key={row.id}\n            className=\"dsl-li\"\n            data-rid={row.id}\n            data-grabbed={grabbed === row.id ? \"\" : undefined}\n            data-settled={settled === row.id ? \"\" : undefined}\n          >\n            <div className=\"dsl-card\">\n              <button\n                type=\"button\"\n                className=\"dsl-handle\"\n                aria-label={\"Reorder \" + row.label + \", position \" + (i + 1) + \" of \" + rows.length}\n                aria-pressed={grabbed === row.id}\n                aria-describedby={\"dsl-help-\" + uid}\n                onPointerDown={(e) => onHandleDown(e, row.id)}\n                onPointerMove={onHandleMove}\n                onPointerUp={onHandleUp}\n                onPointerCancel={onHandleUp}\n                onKeyDown={(e) => onHandleKey(e, row.id)}\n                onBlur={() => {\n                  if (grabbedRef.current === row.id) drop(row.id);\n                }}\n              >\n                <svg className=\"dsl-grip\" viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden=\"true\">\n                  <circle cx=\"5.5\" cy=\"3.5\" r=\"1.3\" />\n                  <circle cx=\"10.5\" cy=\"3.5\" r=\"1.3\" />\n                  <circle cx=\"5.5\" cy=\"8\" r=\"1.3\" />\n                  <circle cx=\"10.5\" cy=\"8\" r=\"1.3\" />\n                  <circle cx=\"5.5\" cy=\"12.5\" r=\"1.3\" />\n                  <circle cx=\"10.5\" cy=\"12.5\" r=\"1.3\" />\n                </svg>\n              </button>\n              <span className=\"dsl-label\">{row.label}</span>\n              {showIndex ? (\n                <span className=\"dsl-idx\" aria-hidden=\"true\">\n                  {String(i + 1).padStart(2, \"0\")}\n                </span>\n              ) : null}\n            </div>\n          </li>\n        ))}\n      </ul>\n      <div id={\"dsl-help-\" + uid} className=\"dsl-sr\">\n        Press space to pick up, the arrow keys to move, space to drop and escape to cancel.\n      </div>\n      <div className=\"dsl-sr\" role=\"status\" aria-live=\"assertive\" aria-atomic=\"true\">\n        {message}\n      </div>\n    </div>\n  );\n}\n","type":"registry:component","target":"components/viberdy/draggable-sortable-list.tsx"}],"meta":{"tier":"free","tags":["drag","sortable","list","reorder","keyboard","accessible"],"href":"https://viberdy.dev/library/draggable-sortable-list","aiPrompt":"Build a dependency-free React + CSS \"draggable sortable list\" as one \"use client\" file (React only; no Framer Motion, no icon package). Export `SortableList` and the `SortableListProps` type.\n\nProps (all optional): items: string[] = [\"Wireframe\", \"Design\", \"Build\", \"Ship\"]; onChange(items: string[]) called with the new label order after each move; tone: \"dark\" | \"light\" = \"light\"; size: \"md\" | \"lg\" = \"md\"; showIndex: boolean = true (position numbers 01, 02 at the end of each row); className: string = \"w-72\" (sets the root width; passing a value replaces it). Changing the items prop (compare items.join(\"\\u0001\")) resets the order via a render-time state adjustment, not an effect. Rows get stable ids (\"r\" + initial index) so duplicate labels work.\n\nMarkup: root div.dsl (data-tone, data-size) containing a plain <style> element (static CSS string, no interpolated props), a ul.dsl-list (aria-label \"Sortable list\", list-style none, flex column, row gap 8px md / 10px lg, position relative), a hidden help div and an sr-only aria-live region (role status, assertive, atomic). Each li.dsl-li has data-rid and holds div.dsl-card: flex row, gap 6, min-height 44px md (50 on coarse pointers) / 62px lg, radius 12 / 16, 1px border, padding 0 14px 0 8px (lg 11px left), font 14px / 17px weight 500 inheriting the page font. A button.dsl-handle (32px md, 40px lg and on coarse pointers, a ::before extending the hit area by 6px, cursor grab, touch-action none, user-select none) with an inline 16px SVG of six dots (r 1.3 at x 5.5 and 10.5, y 3.5, 8, 12.5), then span.dsl-label (flex 1, ellipsis) and span.dsl-idx (ui-monospace 11px, tabular numerals, aria-hidden).\n\nSurfaces are opaque and self-painted. Dark: gradient #22242b to #181a20, border rgba(255,255,255,.1), text rgba(255,255,255,.92), muted rgba(255,255,255,.34), ring white, accent 140,170,255. Light: gradient #fff to #fafafa, border rgba(15,23,42,.11), text #171717, muted #a3a3a3, ring #171717, accent 59,91,255. Resting shadow is a hairline plus a soft 16px drop; an inset top highlight.\n\nSpring: a damped spring (stiffness 380, damping 26, 0.6 s sampled in 36 steps) written as a CSS linear() curve in the custom property --dsl-ease, with cubic-bezier(.34,1.3,.64,1) as the fallback inside @supports. Read it with getComputedStyle for the Web Animations calls.\n\nPointer drag (on the handle only): pointerdown (ignore non-primary mouse buttons) cancels running animations, measures each li offsetTop/offsetHeight and the row gap in LAYOUT pixels (scale = list.offsetHeight / getBoundingClientRect().height), sets pointer capture, sets data-dragging on the list and data-lifted on the li. The lifted card scales to 1.035 with a deep two-layer shadow, brighter border and a radial sheen that follows the pointer x (--dsl-mx); the li translates with translate3d, clamped between the first row top and last row bottom. Target index = count of other rows whose original midpoint is above the dragged centre. Other rows shift by (dragged height + gap) up or down with a .52s spring transition on transform. Escape cancels. Pointerup/cancel: record every row's visual top, clear all inline transforms and flags, commit the new order to state (and onChange), then in a layout effect FLIP-animate each row from its recorded top to its new offsetTop with li.animate (600ms, the spring). The dropped row pulses a 4px accent ring that fades over .9s. Layout effects use useLayoutEffect on the client and useEffect on the server. Transforms on li are only ever written imperatively, never through React style.\n\nKeyboard: the handle button has aria-label \"Reorder <label>, position i of n\", aria-pressed, aria-describedby pointing at the help text. Not picked up: Space/Enter picks up (shows the lifted look at scale 1.025), Up/Down moves focus to the neighbouring handle. Picked up: Up/Left and Down/Right move one place, Home/End to the ends, Space/Enter drops, Escape restores the order from pickup, blur drops. Every move is a captured-tops + FLIP commit, and focus is returned to the handle after React reorders. Announce: \"Picked up X, position 1 of 4. Use the arrow keys to move, space to drop, escape to cancel.\", \"X, moved to position 2 of 4.\", \"X dropped at position 2 of 4.\", \"Cancelled. X returned to position 1 of 4.\" (append a space to a repeated message so it re-announces).\n\nFocus ring on the handle: 2px solid var(--ring), offset 2px, written as .dsl-handle:focus-visible. Hover (hover-capable devices only) brightens the border and the grip. prefers-reduced-motion: no scale on lift, no sibling transition, no FLIP animation, no ring pulse; the order still changes and is still announced.","license":"https://viberdy.dev/license"}}