{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sortable",
  "type": "registry:ui",
  "title": "Sortable",
  "description": "A list you reorder by dragging a handle with a mouse, a pen or a finger, or with the keyboard. The held item follows the pointer one to one, the others make room on a spring (FLIP) and it lands with the speed of your hand. Space or Enter picks an item up, the arrow keys move it, Escape cancels, and an assertive live region says each step.",
  "author": "ARLing s. r. o. (https://arling.sk)",
  "registryDependencies": [
    "https://arling.sk/motion/r/core.json"
  ],
  "files": [
    {
      "path": "registry/arling/lib/arling-motion-sortable.js",
      "content": "'use strict';\n// ARLing Motion: sortable. MIT licence, https://arling.sk/motion/ . Installed from the ARLing\n// Motion registry (source: components/sortable/sortable.js). Plain JavaScript; the .d.ts next to it types the exports.\n/*\n * ARLing Motion: Sortable.\n * A list you reorder by dragging a handle with a mouse, a pen or a finger, or with the keyboard,\n * like the Reorder of Framer Motion and the sortable list of dnd-kit. The item you drag follows\n * the pointer one to one and lifts a little (scale and shadow). The other items make room on a\n * spring as it passes their middle: each item stays laid out where the DOM puts it, and a\n * transform carries it from where it was to where it belongs (FLIP). When you let go, the item\n * lands in its place on a spring that starts with the speed of your hand. Every position is a\n * pure function of time, so seek(t) paints any moment of a scheduled drag, and the DOM order\n * changes once, when the item is dropped.\n *\n * Markup:\n *   <div class=\"am-sortable\">\n *     <ul class=\"am-sortable-list\" aria-label=\"Today, in order\">\n *       <li data-value=\"reply\"><span class=\"am-sortable-label\">Reply to customers</span></li>\n *       <li data-value=\"invoices\"><span class=\"am-sortable-label\">Send invoices</span></li>\n *     </ul>\n *   </div>\n * The component adds what is missing: a handle per item (a button with a grip icon, named\n * \"Move <label>\"), the instructions every handle points to (aria-describedby) and an assertive\n * live region for the announcements. One column; items may differ in height.\n *\n * Keyboard (as in the sortable list of dnd-kit): every handle is a button in the tab order.\n * Space or Enter picks the item up, Up and Down move it, Space or Enter drops it, Escape puts it\n * back where it was (Escape also cancels a pointer drag), and moving focus away cancels too. The\n * live region says \"Picked up Send invoices, position 2 of 4\", \"Moved to 3 of 4\", \"Dropped Send\n * invoices, position 3 of 4\" or \"Cancelled. Send invoices is back at position 2 of 4\". While an\n * item is held its handle has aria-pressed=\"true\". A drop never moves the element that has focus\n * (a real browser would drop the focus): the other items are moved around it.\n * Pointer: pointer events with pointer capture on the handle. Only the handle has\n * touch-action: none, so a finger on the rest of the list still scrolls the page. Dragging is\n * never the only way (WCAG 2.5.7): the keyboard works, and set(order) lets you add Move up and\n * Move down buttons. With prefers-reduced-motion the other items and the drop jump to their\n * places; the held item still follows the pointer.\n * MIT licence.\n */\nimport { track, driver, steps, attr, spring, PRESETS } from './arling-motion';\n\nconst clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);\nconst round2 = (v) => Math.round(v * 100) / 100;\nconst px = (v) => `${round2(v)}px`;\nconst n4 = (v) => String(Math.round(v * 1e4) / 1e4);\nconst same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);\nconst SVG = 'http://www.w3.org/2000/svg';\n\nlet uid = 0;\nconst ensureId = (el, prefix) => el.id || (el.id = `${prefix}-${++uid}`);\n\n/** The other items make room on this spring (overshoot under 1 %). */\nexport const SHIFT = PRESETS.snappy;\n/** The dropped item lands on this spring, from where it was let go and with its speed. */\nexport const DROP = spring(0.36, 0.86);\n/** Scale of an item while it is held. */\nexport const LIFT_SCALE = 1.02;\n/** How far an item may be pulled past either end of the list, px (it approaches this). */\nexport const RUBBER = 24;\n/** Samples per second of a scheduled drag (demos and video). */\nexport const DRAG_RATE = 120;\n/** Easing of a scheduled pointer path, with its slope (for the speed at the release). */\nexport const EASE = {\n  linear: [(u) => u, () => 1],\n  in: [(u) => u * u, (u) => 2 * u],\n  out: [(u) => 1 - (1 - u) * (1 - u), (u) => 2 * (1 - u)],\n  inOut: [(u) => u * u * (3 - 2 * u), (u) => 6 * u * (1 - u)],\n};\n/** What the live region says. Each message gets the item's label, its position and the count. */\nexport const MESSAGES = {\n  pickedUp: (label, position, count) => `Picked up ${label}, position ${position} of ${count}`,\n  moved: (label, position, count) => `Moved to ${position} of ${count}`,\n  dropped: (label, position, count) => `Dropped ${label}, position ${position} of ${count}`,\n  cancelled: (label, position, count) => `Cancelled. ${label} is back at position ${position} of ${count}`,\n};\n/** The instructions every handle points to with aria-describedby. */\nexport const INSTRUCTIONS = 'To move an item, press Space or Enter on its handle, then use the Up and Down arrow keys. Press Space or Enter to drop it, or Escape to cancel.';\n\n// a row when the list has no layout yet (hidden, or not in a page); measure() corrects it\nconst EST = { h: 44, gap: 8, hx: 22, hy: 22 };\n\n/** The order with item k moved to position j. */\nexport function moveTo(order, k, j) {\n  const out = order.filter((x) => x !== k);\n  out.splice(Math.max(0, Math.min(out.length, j)), 0, k);\n  return out;\n}\n\n/**\n * A recorded pointer path as a pure function of time: samples [[t, offset]] in time order,\n * linear between samples. The last 2 ms run at the release speed v, so the track that takes\n * over measures exactly v (the same idea as the drawer).\n */\nfunction pathFn(samples, v) {\n  const [t1, end] = samples[samples.length - 1];\n  const tail = 1 / 480;\n  return (t) => {\n    if (t >= t1 - tail) return end + v * (t - t1);\n    if (t <= samples[0][0]) return samples[0][1];\n    for (let i = 1; i < samples.length; i++) {\n      const [tb, b] = samples[i];\n      if (t <= tb) {\n        const [ta, a] = samples[i - 1];\n        return tb === ta ? b : a + ((b - a) * (t - ta)) / (tb - ta);\n      }\n    }\n    return end;\n  };\n}\n\n/** Pointer speed over the last 0.1 s of samples, px per second. */\nfunction releaseVelocity(samples, window = 0.1) {\n  if (samples.length < 2) return 0;\n  const [t1, end] = samples[samples.length - 1];\n  let j = samples.length - 2;\n  while (j > 0 && t1 - samples[j][0] < window) j--;\n  const [t0, start] = samples[j];\n  return t1 > t0 ? (end - start) / (t1 - t0) : 0;\n}\n\n/**\n * createSortable({ root, list, onReorder, moveNodes, messages, instructions, clock, reduced })\n * onReorder(values) runs when a drop by the user changes the order, with the data-value of every\n * item (or its label) in the new order. moveNodes (default true) lets the component reorder the\n * DOM at the drop; pass false when a framework owns the items (React), then reorder them in\n * onReorder and call sync() after the render. drag(item, dy, { t, duration, ease }) and\n * key(name, { t, item }) schedule input for demos; set(order, { t }) reorders with the same\n * springs and calls nothing. An item is its data-value, its element, or its index in the markup.\n * Returns { set, sync, drag, key, order, held, offsetOf, travel, measure, seek, settled, destroy,\n * driver, count, root, list, items, handles, status, keep }.\n */\nexport function createSortable(o) {\n  const root = o.root;\n  const doc = root.ownerDocument || document;\n  const moveNodes = o.moveNodes !== false;\n  root.classList.add('am-sortable');\n  const list = o.list || root.querySelector('.am-sortable-list, ul, ol');\n  if (!list) throw new Error('createSortable: root needs a list (ul or ol) inside it');\n  list.classList.add('am-sortable-list');\n  const isItem = (el) => !!el && !!el.tagName && (el.tagName === 'LI' || el.classList.contains('am-sortable-item'));\n  const items = [...list.children].filter(isItem);\n  if (!items.length) throw new Error('createSortable: the list has no items (li)');\n  const n = items.length;\n  const messages = { ...MESSAGES, ...(o.messages || {}) };\n\n  // ---------------------------------------------------------------- parts\n  const make = (tag, cls, parent) => {\n    const el = doc.createElement(tag);\n    el.className = cls;\n    parent.appendChild(el);\n    return el;\n  };\n  const firstOf = (el) => ('firstChild' in el ? el.firstChild : el.firstElementChild);\n  const help = root.querySelector('.am-sortable-help') || make('span', 'am-sortable-help', root);\n  if (!help.textContent.trim()) help.textContent = o.instructions || INSTRUCTIONS;\n  ensureId(help, 'am-sortable-help');\n  const status = root.querySelector('.am-sortable-status') || make('span', 'am-sortable-status', root);\n  attr(status, 'aria-live', 'assertive');\n  attr(status, 'aria-atomic', 'true');\n\n  const labels = items.map((li) => {\n    const own = li.getAttribute('data-label');\n    if (own) return own;\n    const lab = li.querySelector('.am-sortable-label');\n    return (lab || li).textContent.trim().replace(/\\s+/g, ' ');\n  });\n  const values = items.map((li, k) => li.getAttribute('data-value') || labels[k]);\n\n  function grip() {\n    const s = doc.createElementNS(SVG, 'svg');\n    for (const [k, v] of Object.entries({ viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': 'true', focusable: 'false' })) s.setAttribute(k, v);\n    for (const cy of [6, 12, 18]) {\n      for (const cx of [9, 15]) {\n        const c = doc.createElementNS(SVG, 'circle');\n        c.setAttribute('cx', String(cx));\n        c.setAttribute('cy', String(cy));\n        c.setAttribute('r', '1.6');\n        s.appendChild(c);\n      }\n    }\n    return s;\n  }\n\n  const handles = items.map((li, k) => {\n    li.classList.add('am-sortable-item');\n    let hd = li.querySelector('.am-sortable-handle');\n    if (!hd) {\n      hd = doc.createElement('button');\n      hd.className = 'am-sortable-handle';\n      li.insertBefore(hd, firstOf(li));\n    }\n    if (hd.tagName === 'BUTTON') {\n      if (!hd.hasAttribute('type')) attr(hd, 'type', 'button');\n    } else {\n      attr(hd, 'role', 'button');\n      if (!hd.hasAttribute('tabindex')) attr(hd, 'tabindex', '0');\n    }\n    if (!hd.hasAttribute('aria-label') && !hd.hasAttribute('aria-labelledby')) attr(hd, 'aria-label', `Move ${labels[k]}`);\n    const described = (hd.getAttribute('aria-describedby') || '').split(/\\s+/).filter(Boolean);\n    if (!described.includes(help.id)) attr(hd, 'aria-describedby', [...described, help.id].join(' '));\n    if (!hd.querySelector('svg')) hd.appendChild(grip());\n    return hd;\n  });\n\n  // ---------------------------------------------------------------- geometry, measured on create and on resize\n  const domOrder = () => [...list.children].filter(isItem).map((el) => items.indexOf(el)).filter((k) => k >= 0);\n\n  /** Layout boxes in list pixels, without our transforms (heights, the gap, the handle's centre). */\n  function measureGeo() {\n    const saved = items.map((li) => li.style.transform);\n    for (const li of items) li.style.transform = 'none';\n    const lr = list.getBoundingClientRect();\n    const dom = domOrder();\n    const r = items.map((li) => li.getBoundingClientRect());\n    const hr = handles.map((hd) => hd.getBoundingClientRect());\n    items.forEach((li, k) => { li.style.transform = saved[k]; });\n    const layoutH = list.offsetHeight || lr.height || 0;\n    // k: screen pixels per layout pixel, for a list inside a scaled parent\n    const k = layoutH > 0 && lr.height > 0 ? lr.height / layoutH : 1;\n    const ok = dom.length > 0 && r.some((b) => b.height > 0);\n    const h = r.map((b) => (ok ? b.height / k : EST.h));\n    const gap = ok ? (dom.length > 1 ? Math.max(0, (r[dom[1]].top - r[dom[0]].bottom) / k) : 0) : EST.gap;\n    const base = ok ? (r[dom[0]].top - lr.top) / k : 0;\n    const sum = h.reduce((a, b) => a + b, 0) + gap * (n - 1);\n    return {\n      k,\n      h,\n      gap,\n      base,\n      w: ok ? lr.width / k : 0,\n      listH: ok ? lr.height / k : base + sum,\n      left: r.map((b) => (ok ? (b.left - lr.left) / k : 0)),\n      hand: hr.map((b, i) => (ok && b.height > 0\n        ? { x: (b.left + b.width / 2 - r[i].left) / k, y: (b.top + b.height / 2 - r[i].top) / k }\n        : { x: EST.hx, y: EST.hy })),\n      sum,\n    };\n  }\n  const sameGeo = (a, b) => Math.abs(a.gap - b.gap) < 0.5 && Math.abs(a.base - b.base) < 0.5 && a.h.every((x, i) => Math.abs(x - b.h[i]) < 0.5);\n  let geo = measureGeo();\n\n  /** Top of every item (by item) when the list is in this order, px from the list's top edge. */\n  function tops(order) {\n    const out = new Array(n);\n    let top = geo.base;\n    for (const k of order) {\n      out[k] = top;\n      top += geo.h[k] + geo.gap;\n    }\n    return out;\n  }\n\n  /** Past either end of the list an item resists: it approaches RUBBER px. */\n  function shapeY(k, Y) {\n    const lo = geo.base;\n    const hi = geo.base + geo.sum - geo.h[k];\n    const rubber = (over) => RUBBER * (1 - 1 / (1 + over / RUBBER));\n    if (Y < lo) return lo - rubber(lo - Y);\n    if (Y > hi) return hi + rubber(Y - hi);\n    return Y;\n  }\n\n  /** The position item k belongs at when its top is at Y: the nearest place among the others. */\n  function slotFor(k, Y, order) {\n    const others = order.filter((x) => x !== k);\n    let best = 0;\n    let bestD = Infinity;\n    let top = geo.base;\n    for (let j = 0; j <= others.length; j++) {\n      const dd = Math.abs(Y - top);\n      if (dd < bestD) { best = j; bestD = dd; }\n      if (j < others.length) top += geo.h[others[j]] + geo.gap;\n    }\n    return best;\n  }\n\n  // ---------------------------------------------------------------- state as functions of time\n  const order0 = domOrder();\n  const committed = steps(order0); // the order of the DOM\n  const held = steps(-1); // the item held now, -1 when none\n  const front = steps(-1); // the item held last, drawn above the others until it has landed\n  let draft = order0.slice(); // where the items are heading (the order of the last change)\n  let by = null; // how the held item was taken: 'keyboard' or 'pointer'\n  let origin = null; // the order when it was taken, for Escape\n  const T0 = tops(order0);\n  let y = items.map((_, k) => track(T0[k], SHIFT)); // each item's top, px\n  const lift = items.map(() => track(0, PRESETS.press));\n  let live = null; // a pointer that holds an item: { k, id, t0, grab, start, samples }\n  let keepFocus = null;\n  let endT = -Infinity;\n  const mark = (t) => { if (t > endT) endT = t; };\n  const settledAll = (t) => y.every((tr) => tr.settled(t)) && lift.every((tr) => tr.settled(t));\n\n  const liveTop = () => shapeY(live.k, live.grab + live.samples[live.samples.length - 1][1]);\n\n  /**\n   * Puts the items of the DOM in order. The item that has focus or is held stays where it is and\n   * the others move around it: a real browser drops focus and pointer capture from a moved node.\n   */\n  function arrange(ord) {\n    const cur = domOrder();\n    if (same(cur, ord)) return;\n    const active = doc.activeElement;\n    let a = live ? live.k : items.findIndex((li) => !!active && li.contains(active));\n    if (a < 0 || !ord.includes(a)) a = ord[0];\n    const anchor = items[a];\n    const at = ord.indexOf(a);\n    const kids = () => [...list.children];\n    const nextOf = (el) => { const c = kids(); return c[c.indexOf(el) + 1] || null; };\n    for (let i = 0; i < at; i++) list.insertBefore(items[ord[i]], anchor);\n    let ref = anchor;\n    for (let i = at + 1; i < ord.length; i++) {\n      const el = items[ord[i]];\n      const next = nextOf(ref);\n      if (next !== el) list.insertBefore(el, next);\n      ref = el;\n    }\n  }\n\n  function paint(t, { reduced }) {\n    const ord = committed.at(t);\n    if (moveNodes) arrange(ord);\n    const lay = tops(moveNodes ? ord : domOrder());\n    const h = held.at(t);\n    const f = front.at(t);\n    items.forEach((li, k) => {\n      const v = live && live.k === k && t >= live.t0 ? liveTop() : reduced ? y[k].target(t) : y[k].at(t);\n      const p = reduced ? lift[k].target(t) : clamp01(lift[k].at(t));\n      const s = 1 + (LIFT_SCALE - 1) * p;\n      const from = lay[k] === undefined ? v : lay[k];\n      li.style.transform = `translateY(${px(v - from)})${Math.abs(s - 1) > 1e-4 ? ` scale(${n4(s)})` : ''}`;\n      li.style.setProperty('--am-sortable-lift', n4(p));\n      const up = k === f && (k === h || !lift[k].settled(t) || !y[k].settled(t));\n      li.style.zIndex = up ? '1' : '';\n      attr(li, 'data-state', k === h ? 'dragging' : 'idle');\n      attr(handles[k], 'aria-pressed', String(k === h));\n    });\n    attr(root, 'data-state', h >= 0 ? 'dragging' : 'idle');\n  }\n\n  function draw(t, st) {\n    paint(t, st);\n    if (!api.keep && !live && held.last < 0 && t >= endT && settledAll(t)) {\n      for (const tr of [...y, ...lift]) tr.compact(t);\n      committed.compact();\n      held.compact();\n      front.compact();\n    }\n  }\n\n  const d = driver(draw, { clock: o.clock, reduced: o.reduced });\n  d.busy = (t) => !!live || t < endT || !settledAll(t);\n  const commit = () => { paint(d.now(), { reduced: d.reduced }); d.kick(); };\n  const when = (opt) => (opt && opt.t !== undefined ? { t: opt.t, live: false } : { t: d.now(), live: true });\n\n  // ---------------------------------------------------------------- changes (in time order)\n  /** Every item but skip glides from where it is to its place in order. */\n  function aimAll(order, t, skip = -1) {\n    const T = tops(order);\n    items.forEach((_, k) => {\n      if (k === skip || Math.abs(y[k].target(Infinity) - T[k]) < 0.01) return;\n      y[k].to(t, T[k], SHIFT);\n    });\n    draft = order.slice();\n    mark(t);\n  }\n\n  function take(k, t, how) {\n    origin = committed.last.slice();\n    draft = origin.slice();\n    held.set(t, k);\n    front.set(t, k);\n    lift[k].to(t, 1, PRESETS.press);\n    by = how;\n    mark(t);\n  }\n\n  /** The held item is put down with the list in order: it lands on DROP, its lift springs back. */\n  function put(order, t) {\n    const k = held.last;\n    if (k < 0) return;\n    held.set(t, -1);\n    lift[k].to(t, 0, PRESETS.release);\n    const T = tops(order);\n    if (Math.abs(y[k].target(Infinity) - T[k]) >= 0.01) y[k].to(t, T[k], DROP);\n    aimAll(order, t, k);\n    if (!same(order, committed.last)) committed.set(t, order.slice());\n    by = null;\n    origin = null;\n  }\n\n  /** The held item one place up (-1) or down (1). */\n  function stepBy(delta, t) {\n    const k = held.last;\n    const i = draft.indexOf(k);\n    const j = Math.max(0, Math.min(n - 1, i + delta));\n    if (j === i) return false;\n    aimAll(moveTo(draft, k, j), t);\n    return true;\n  }\n\n  function say(text) {\n    status.textContent = status.textContent === text ? `${text} ` : text;\n  }\n\n  /** After a drop by the user: the announcement and onReorder. */\n  function report(k, before, cancelled) {\n    const pos = committed.last.indexOf(k) + 1;\n    say(cancelled ? messages.cancelled(labels[k], pos, n) : messages.dropped(labels[k], pos, n));\n    if (!moveNodes && doc.activeElement === handles[k]) keepFocus = handles[k];\n    if (!cancelled && !same(before, committed.last) && o.onReorder) o.onReorder(committed.last.map((i) => values[i]));\n  }\n\n  /** Ends a keyboard hold: drops the item, or puts it back where it was. */\n  function endHold(t, cancelled, now) {\n    const k = held.last;\n    const before = committed.last;\n    put(cancelled ? origin : draft, t);\n    commit();\n    if (now) report(k, before, cancelled);\n  }\n\n  /** Ends a pointer hold: the recorded path goes into the item's track, then it lands. */\n  function endLive(t, cancelled, now) {\n    const { k, t0, samples, id } = live;\n    const hd = handles[k];\n    live = null;\n    if (hd.releasePointerCapture && id !== undefined) {\n      try { hd.releasePointerCapture(id); } catch { /* already released */ }\n    }\n    const t1 = Math.max(t, t0 + 1e-3);\n    samples.push([t1, samples[samples.length - 1][1]]);\n    const v = releaseVelocity(samples);\n    const path = pathFn(samples, v);\n    y[k].drag(t0, t1, (tt, grab) => shapeY(k, grab + path(tt)), DROP);\n    const before = committed.last;\n    put(cancelled ? origin : draft, t1);\n    commit();\n    if (now) report(k, before, cancelled);\n  }\n\n  function applyKey(name, k, t, now) {\n    const h = held.last;\n    if (name === ' ' || name === 'Enter' || name === 'Spacebar') {\n      if (h < 0) {\n        take(k, t, 'keyboard');\n        commit();\n        if (now) say(messages.pickedUp(labels[k], draft.indexOf(k) + 1, n));\n      } else if (h === k && by === 'keyboard') endHold(t, false, now);\n      return true;\n    }\n    if (name === 'ArrowUp' || name === 'ArrowDown') {\n      if (h !== k || by !== 'keyboard') return false;\n      if (stepBy(name === 'ArrowUp' ? -1 : 1, t)) {\n        commit();\n        if (now) say(messages.moved(labels[k], draft.indexOf(k) + 1, n));\n      }\n      return true;\n    }\n    if (name === 'Escape') {\n      if (h !== k) return false;\n      if (live) endLive(t, true, now);\n      else endHold(t, true, now);\n      return true;\n    }\n    return false;\n  }\n\n  const idOf = (x) => {\n    if (typeof x === 'number') return Number.isInteger(x) && x >= 0 && x < n ? x : -1;\n    if (x && x.tagName) return items.indexOf(x.closest ? x.closest('.am-sortable-item') || x : x);\n    return values.indexOf(String(x));\n  };\n  const focusedItem = () => items.findIndex((li) => !!doc.activeElement && li.contains(doc.activeElement));\n\n  /** Puts down whatever is held: a pointer drag or a keyboard hold goes back where it was. */\n  function release(t) {\n    if (live) endLive(t, true, false);\n    else if (held.last >= 0) put(origin, t);\n  }\n\n  /** A key on a handle (' ', 'Enter', 'ArrowUp', 'ArrowDown', 'Escape'). opt: { t, item }. */\n  function key(name, opt = {}) {\n    const { t, live: now } = when(opt);\n    const k = opt.item !== undefined ? idOf(opt.item) : held.last >= 0 ? held.last : focusedItem();\n    if (k >= 0) applyKey(name, k, t, now);\n    return api;\n  }\n\n  /**\n   * Scheduled drag for demos: the pointer takes item's handle at t and moves it dy px (layout\n   * pixels, negative is up) over duration seconds; the others make room as it passes, and it\n   * lands on the place nearest to where it was let go.\n   */\n  function drag(item, dy, opt = {}) {\n    const k = idOf(item);\n    if (k < 0) return api;\n    const t0 = opt.t ?? d.now();\n    release(t0);\n    const dur = Math.max(1 / DRAG_RATE, opt.duration ?? 0.5);\n    const [ease] = EASE[opt.ease] || EASE.inOut;\n    const t1 = t0 + dur;\n    const off = (t) => dy * ease(clamp01((t - t0) / dur));\n    const grab = y[k].at(t0);\n    take(k, t0, 'pointer');\n    const count = Math.max(1, Math.ceil(dur * DRAG_RATE));\n    for (let i = 1; i <= count; i++) {\n      const ts = t0 + (dur * i) / count;\n      const j = slotFor(k, shapeY(k, grab + off(ts)), draft);\n      if (j !== draft.indexOf(k)) aimAll(moveTo(draft, k, j), ts, k);\n    }\n    y[k].drag(t0, t1, (t, g) => shapeY(k, g + off(t)), DROP);\n    put(draft, t1);\n    mark(t1);\n    commit();\n    return api;\n  }\n\n  /** The order of every item (values, elements or indices), reordered on the springs. No callbacks. opt: { t }. */\n  function set(order, opt = {}) {\n    const { t } = when(opt);\n    release(t);\n    const next = [];\n    for (const x of order || []) {\n      const k = idOf(x);\n      if (k >= 0 && !next.includes(k)) next.push(k);\n    }\n    for (const k of committed.last) if (!next.includes(k)) next.push(k);\n    if (!same(next, committed.last)) {\n      aimAll(next, t);\n      committed.set(t, next);\n    }\n    commit();\n    return api;\n  }\n\n  /**\n   * Takes the DOM order as it is now (after a framework reordered the items, or you did): items\n   * glide from where they are to where the DOM put them. After a drop by keyboard it gives focus\n   * back to the handle, which a framework may have taken away by moving it.\n   */\n  function sync() {\n    const dom = domOrder();\n    if (dom.length === n && !same(dom, committed.last)) {\n      const t = d.now();\n      release(t);\n      aimAll(dom, t);\n      committed.set(t, dom);\n    }\n    // always: the rows are laid out anew, so every transform is measured from the new place\n    commit();\n    const hd = keepFocus;\n    keepFocus = null;\n    if (hd && list.contains(hd) && (!doc.activeElement || doc.activeElement === doc.body)) {\n      try { hd.focus({ preventScroll: true }); } catch { /* not focusable now */ }\n    }\n    return api;\n  }\n\n  /** Measure again after a layout change. Live and at rest, every item takes its new place at once. */\n  function measure() {\n    const g = measureGeo();\n    if (sameGeo(g, geo)) {\n      // the same rows: keep the numbers the tracks were planned with, take the new width\n      geo = { ...g, h: geo.h, gap: geo.gap, base: geo.base, sum: geo.sum };\n      return api;\n    }\n    geo = g;\n    if (!api.keep && !live && held.last < 0) {\n      const T = tops(committed.last);\n      y = items.map((_, k) => track(T[k], SHIFT));\n    }\n    commit();\n    return api;\n  }\n\n  // ---------------------------------------------------------------- live input\n  const listeners = [];\n  const on = (el, type, fn) => { el.addEventListener(type, fn); listeners.push([el, type, fn]); };\n  const handleOf = (e) => (e.target && e.target.closest ? e.target.closest('.am-sortable-handle') : null);\n  const listTop = () => list.getBoundingClientRect().top;\n\n  on(list, 'pointerdown', (e) => {\n    const k = handles.indexOf(handleOf(e));\n    if (k < 0 || live || (e.button !== undefined && e.button !== 0)) return;\n    e.preventDefault(); // no text selection, and no mouse down that would move focus elsewhere\n    const t = d.now();\n    if (held.last >= 0) endHold(t, true, true); // a keyboard hold goes back first\n    const hd = handles[k];\n    live = { k, id: e.pointerId, t0: t, grab: d.reduced ? y[k].target(t) : y[k].at(t), start: e.clientY - listTop(), samples: [[t, 0]] };\n    try { hd.focus({ preventScroll: true }); } catch { /* not focusable */ }\n    if (hd.setPointerCapture && e.pointerId !== undefined) {\n      try { hd.setPointerCapture(e.pointerId); } catch { /* not captured */ }\n    }\n    take(k, t, 'pointer');\n    commit();\n    say(messages.pickedUp(labels[k], draft.indexOf(k) + 1, n));\n  });\n  const mine = (e) => live && (live.id === undefined || e.pointerId === undefined || e.pointerId === live.id);\n  on(list, 'pointermove', (e) => {\n    if (!mine(e)) return;\n    const t = d.now();\n    live.samples.push([t, (e.clientY - listTop() - live.start) / geo.k]);\n    const k = live.k;\n    const j = slotFor(k, liveTop(), draft);\n    const moved = j !== draft.indexOf(k);\n    if (moved) aimAll(moveTo(draft, k, j), t, k);\n    commit();\n    if (moved) say(messages.moved(labels[k], j + 1, n));\n  });\n  const finish = (cancelled) => (e) => {\n    if (!mine(e)) return;\n    endLive(d.now(), cancelled, true);\n  };\n  on(list, 'pointerup', finish(false));\n  on(list, 'pointercancel', finish(true));\n  on(list, 'lostpointercapture', finish(false));\n  on(list, 'keydown', (e) => {\n    const k = handles.indexOf(handleOf(e));\n    if (k < 0 || e.altKey || e.ctrlKey || e.metaKey) return;\n    if (e.repeat && (e.key === ' ' || e.key === 'Enter')) {\n      e.preventDefault();\n      return;\n    }\n    if (applyKey(e.key, k, d.now(), true)) {\n      e.preventDefault();\n      if (e.key === 'Escape') e.stopPropagation(); // not a surrounding dialog's Escape\n    }\n  });\n  // focus that leaves the held handle puts the item back\n  on(list, 'focusout', (e) => {\n    const k = held.last;\n    if (k < 0 || by !== 'keyboard' || e.target !== handles[k] || e.relatedTarget === handles[k]) return;\n    endHold(d.now(), true, true);\n  });\n\n  let ro = null;\n  if (typeof ResizeObserver === 'function') {\n    ro = new ResizeObserver(() => measure());\n    ro.observe(list);\n  }\n\n  const api = {\n    root,\n    list,\n    items,\n    handles,\n    status,\n    driver: d,\n    keep: false,\n    count: n,\n    set,\n    sync,\n    drag,\n    key,\n    measure,\n    /** The values in order now, or at time t of a scheduled timeline. */\n    order: (t) => (t === undefined ? committed.last : committed.at(t)).map((k) => values[k]),\n    /** The value of the item held now (or at time t), or null. */\n    held: (t) => {\n      const k = t === undefined ? held.last : held.at(t);\n      return k < 0 ? null : values[k];\n    },\n    /** Where item's handle is, from the centre of the list, when the item rests where it is heading, moved by dy px. */\n    offsetOf(item, dy = 0) {\n      const k = idOf(item);\n      if (k < 0) return { dx: 0, dy: 0 };\n      const top = y[k].target(Infinity) + dy;\n      return { dx: round2(geo.left[k] + geo.hand[k].x - geo.w / 2), dy: round2(top + geo.hand[k].y - geo.listH / 2) };\n    },\n    /** Pixels from where item is heading to its top at position index (0 is first). */\n    travel(item, index) {\n      const k = idOf(item);\n      if (k < 0) return 0;\n      return round2(tops(moveTo(draft, k, index))[k] - y[k].target(Infinity));\n    },\n    seek: (t) => paint(t, { reduced: d.reduced }),\n    settled: (t) => !live && t >= endT && settledAll(t),\n    destroy() {\n      if (live) {\n        const hd = handles[live.k];\n        if (hd.releasePointerCapture && live.id !== undefined) {\n          try { hd.releasePointerCapture(live.id); } catch { /* already released */ }\n        }\n        live = null;\n      }\n      d.stop();\n      if (ro) ro.disconnect();\n      for (const [el, type, fn] of listeners) el.removeEventListener(type, fn);\n    },\n  };\n  paint(d.now(), { reduced: d.reduced });\n  return api;\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/arling/lib/arling-motion-sortable.d.ts",
      "content": "// ARLing Motion: sortable. MIT licence, https://arling.sk/motion/ . Types for the plain JavaScript file next to this one.\nexport declare const SHIFT: any;\nexport declare const DROP: any;\nexport declare const LIFT_SCALE: any;\nexport declare const RUBBER: any;\nexport declare const DRAG_RATE: any;\nexport declare const EASE: any;\nexport declare const MESSAGES: any;\nexport declare const INSTRUCTIONS: any;\nexport declare function moveTo(...args: any[]): any;\nexport declare function createSortable(...args: any[]): any;\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/arling/ui/motion-sortable.tsx",
      "content": "'use client';\n// ARLing Motion: Sortable for React. A thin wrapper: the vanilla component owns the drag, the\n// springs, the keyboard, aria-pressed and the announcements. React owns the order of the items:\n// the component never moves them (moveNodes: false), it calls onReorder, the list renders in the\n// new order and sync() lets every item glide from where it was. Without the values prop the\n// wrapper keeps the order itself. In the registry the core lives at '@/lib/arling-motion' and\n// this logic at '@/lib/arling-motion-sortable'.\nimport * as React from 'react';\nimport { createSortable } from '@/lib/arling-motion-sortable';\n\ntype SortableApi = { sync: () => unknown; destroy: () => void };\n\ntype Messages = Partial<Record<'pickedUp' | 'moved' | 'dropped' | 'cancelled', (label: string, position: number, count: number) => string>>;\n\nconst cx = (...c: Array<string | false | null | undefined>) => c.filter(Boolean).join(' ');\nconst SEP = '\\u0000';\n\nexport interface SortableProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children' | 'onChange'> {\n  /** The order, as the value of every SortableItem (optional: without it the wrapper keeps the order). */\n  values?: string[];\n  /** After the user drops an item in a new place, with the values in the new order. */\n  onReorder?: (values: string[]) => void;\n  /** Your own announcements, for example in another language. */\n  messages?: Messages;\n  /** The instructions the handles point to (aria-describedby). */\n  instructions?: string;\n  reducedMotion?: boolean;\n  children?: React.ReactNode;\n}\n\nexport function Sortable({\n  values,\n  onReorder,\n  messages,\n  instructions,\n  reducedMotion,\n  className,\n  children,\n  'aria-label': ariaLabel,\n  'aria-labelledby': ariaLabelledby,\n  ...rest\n}: SortableProps) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  const apiRef = React.useRef<SortableApi | null>(null);\n  const kids = React.Children.toArray(children).filter(React.isValidElement) as Array<React.ReactElement<SortableItemProps>>;\n  const childValues = kids.map((k) => String(k.props.value));\n  const setKey = [...childValues].sort().join(SEP);\n  const [inner, setInner] = React.useState<string[]>(childValues);\n  const innerFits = inner.length === childValues.length && [...inner].sort().join(SEP) === setKey;\n  const order = values ?? (innerFits ? inner : childValues);\n  const byValue = new Map(kids.map((k) => [String(k.props.value), k]));\n  const sorted = order.map((v) => byValue.get(v)).filter(Boolean);\n\n  const reorderRef = React.useRef(onReorder);\n  reorderRef.current = onReorder;\n  const controlledRef = React.useRef(values !== undefined);\n  controlledRef.current = values !== undefined;\n  const messagesRef = React.useRef(messages);\n  messagesRef.current = messages;\n\n  // built once per set of items; a new order goes through sync()\n  React.useEffect(() => {\n    if (!ref.current) return;\n    const api = createSortable({\n      root: ref.current,\n      moveNodes: false,\n      reduced: reducedMotion,\n      instructions,\n      messages: messagesRef.current,\n      onReorder: (next: string[]) => {\n        if (!controlledRef.current) setInner(next);\n        reorderRef.current?.(next);\n      },\n    }) as unknown as SortableApi;\n    apiRef.current = api;\n    return () => {\n      api.destroy();\n      apiRef.current = null;\n    };\n  }, [setKey, reducedMotion, instructions]);\n\n  // after React has put the items in the new order, before the browser paints\n  React.useLayoutEffect(() => {\n    apiRef.current?.sync();\n  }, [order.join(SEP)]);\n\n  return (\n    <div ref={ref} className={cx('am-sortable', className)} {...rest}>\n      <ul className=\"am-sortable-list\" aria-label={ariaLabel} aria-labelledby={ariaLabelledby}>\n        {sorted}\n      </ul>\n    </div>\n  );\n}\n\nexport interface SortableItemProps extends Omit<React.LiHTMLAttributes<HTMLLIElement>, 'value'> {\n  value: string;\n  /** The item's name for the handle (\"Move <label>\") and the announcements. */\n  label: string;\n}\n\nexport function SortableItem({ value, label, className, children, ...rest }: SortableItemProps) {\n  return (\n    <li className={cx('am-sortable-item', className)} data-value={value} data-label={label} {...rest}>\n      <button type=\"button\" className=\"am-sortable-handle\" aria-label={`Move ${label}`}>\n        <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" focusable=\"false\">\n          <circle cx=\"9\" cy=\"6\" r=\"1.6\" />\n          <circle cx=\"15\" cy=\"6\" r=\"1.6\" />\n          <circle cx=\"9\" cy=\"12\" r=\"1.6\" />\n          <circle cx=\"15\" cy=\"12\" r=\"1.6\" />\n          <circle cx=\"9\" cy=\"18\" r=\"1.6\" />\n          <circle cx=\"15\" cy=\"18\" r=\"1.6\" />\n        </svg>\n      </button>\n      <span className=\"am-sortable-label\">{children ?? label}</span>\n    </li>\n  );\n}\n\nexport default Sortable;\n",
      "type": "registry:ui"
    }
  ],
  "css": {
    "@layer components": {
      ".am-sortable": {
        "--am-sortable-gap": "0.5rem",
        "position": "relative",
        "display": "block"
      },
      ".am-sortable-list": {
        "position": "relative",
        "display": "grid",
        "gap": "var(--am-sortable-gap)",
        "margin": "0",
        "padding": "0",
        "list-style": "none"
      },
      ".am-sortable-item": {
        "position": "relative",
        "display": "flex",
        "align-items": "center",
        "gap": "0.5rem",
        "box-sizing": "border-box",
        "min-width": "0",
        "min-height": "2.75rem",
        "margin": "0",
        "padding": "0.25rem 0.875rem 0.25rem 0.25rem",
        "border": "1px solid var(--border, #e5e5e5)",
        "border-radius": "var(--radius, 0.625rem)",
        "background": "var(--card, var(--background, #ffffff))",
        "color": "var(--card-foreground, var(--foreground, #0a0a0a))",
        "font-size": "0.875rem",
        "line-height": "1.25rem",
        "box-shadow": "0 1px 2px rgb(0 0 0 / 0.05), 0 calc(var(--am-sortable-lift, 0) * 12px) calc(var(--am-sortable-lift, 0) * 28px) calc(var(--am-sortable-lift, 0) * -10px) rgb(0 0 0 / calc(var(--am-sortable-lift, 0) * 0.3))",
        "transform-origin": "50% 50%"
      },
      ".am-sortable-item[data-state=\"dragging\"]": {
        "border-color": "var(--input, #d4d4d4)"
      },
      ".am-sortable-handle": {
        "display": "inline-grid",
        "place-items": "center",
        "flex": "none",
        "box-sizing": "border-box",
        "width": "2.25rem",
        "height": "2.25rem",
        "margin": "0",
        "padding": "0",
        "border": "0",
        "border-radius": "calc(var(--radius, 0.625rem) - 4px)",
        "background": "transparent",
        "color": "var(--muted-foreground, #737373)",
        "font": "inherit",
        "cursor": "grab",
        "touch-action": "none",
        "-webkit-user-select": "none",
        "user-select": "none",
        "-webkit-tap-highlight-color": "transparent"
      },
      ".am-sortable-handle:hover, .am-sortable-handle[aria-pressed=\"true\"]": {
        "background": "var(--accent, #f5f5f5)",
        "color": "var(--accent-foreground, #171717)"
      },
      ".am-sortable-handle[aria-pressed=\"true\"]": {
        "cursor": "grabbing"
      },
      ".am-sortable-handle:focus-visible": {
        "outline": "2px solid var(--ring, #a3a3a3)",
        "outline-offset": "1px"
      },
      ".am-sortable-handle svg": {
        "display": "block",
        "width": "1rem",
        "height": "1rem",
        "pointer-events": "none"
      },
      ".am-sortable-label": {
        "flex": "1 1 auto",
        "min-width": "0",
        "font-weight": "500",
        "overflow-wrap": "anywhere"
      },
      ".am-sortable-help, .am-sortable-status": {
        "position": "absolute",
        "width": "1px",
        "height": "1px",
        "overflow": "hidden",
        "clip": "rect(0 0 0 0)",
        "clip-path": "inset(50%)",
        "white-space": "nowrap"
      }
    }
  },
  "docs": "Sortable from ARLing Motion (MIT).\nReact: import { Sortable, SortableItem } from \"@/components/ui/motion-sortable\".\nThe logic is in lib/arling-motion-sortable.js (typed by the .d.ts next to it) and works without React: createSortable on your own markup.\nStyles were added to your global CSS under @layer components (Tailwind v4).\nWith Tailwind v3 or no Tailwind, add https://arling.sk/motion/components/sortable/sortable.css to your styles instead.\nLive demo and docs: https://arling.sk/motion/#sortable",
  "categories": [
    "motion",
    "sortable",
    "reorder",
    "drag-and-drop",
    "list"
  ]
}
