{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "swapy",
  "title": "Swapy",
  "description": "Rows you put in order by hand. A move word is the handle; the row you hold reverses out of ink and travels with you, or a proofreader's loop marks where it went.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json",
    "https://thedirectors.agency/ui/r/rows.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/swapy.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { useComposedRefs } from \"@/lib/0nlytype/refs\"\nimport { cn } from \"@/lib/utils\"\nimport { Row, RowKind, RowMeta, RowTitle, Rows } from \"@/components/ui/rows\"\n\ntype SwapyItem = {\n  id: string\n  /** The row's name. It is also what a screen reader hears as the row moves. */\n  label: string\n  /** What it is, in graphite. */\n  kind?: React.ReactNode\n  /** The quiet fact at the end: a length, a year. */\n  meta?: React.ReactNode\n}\n\ntype SwapyProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\"> & {\n  /** Names the list: \"Running order\". */\n  label: string\n  items: readonly SwapyItem[]\n  /** The ids in order. Ids missing from it keep their place at the end. */\n  order?: readonly string[]\n  defaultOrder?: readonly string[]\n  onOrderChange?: (order: string[]) => void\n  /**\n   * default: the row you hold reverses out of ink and travels with your hand. transpose: the proofreader's\n   * transposition mark, a loop in the margin from where the row was to where it is, and the held name in italic.\n   */\n  variant?: \"default\" | \"transpose\"\n  disabled?: boolean\n  /** Starts with this row picked up, as if its move word had just been pressed. */\n  defaultHeld?: string\n}\n\n/** The given order, without ids that are gone, then any new ids at the end. */\nfunction reconcile(order: readonly string[], ids: string[]) {\n  const known = new Set(ids)\n  const kept = order.filter((id) => known.has(id))\n  const seen = new Set(kept)\n  return [...kept, ...ids.filter((id) => !seen.has(id))]\n}\n\nconst place = (n: number) => String(n + 1).padStart(2, \"0\")\n\n/**\n * Rows you put in order by hand. Each row's \"move\" word is its handle: press it and the row is held (it reverses\n * out of ink), the arrow keys carry it up and down, and pressing again sets it down; Escape puts it back. Or drag\n * the word. Rows glide to their new places, their numbers turn over, and every move is said aloud.\n */\nfunction Swapy({\n  label,\n  items,\n  order,\n  defaultOrder = [],\n  onOrderChange,\n  variant = \"default\",\n  disabled = false,\n  defaultHeld,\n  className,\n  ref: forwardedRef,\n  ...props\n}: SwapyProps) {\n  const [local, setLocal] = React.useState<readonly string[]>(defaultOrder)\n  const ids = reconcile(order ?? local, items.map((item) => item.id))\n  const byId = new Map(items.map((item) => [item.id, item]))\n  const [held, setHeld] = React.useState<string | null>(defaultHeld ?? null)\n  const [origin, setOrigin] = React.useState(() => (defaultHeld ? ids.indexOf(defaultHeld) : -1))\n  const [said, setSaid] = React.useState(\"\")\n  const [dragging, setDragging] = React.useState(false)\n  const drag = React.useRef<{ id: string; pointer: number; moved: boolean; wasHeld: boolean } | null>(null)\n  const root = React.useRef<HTMLDivElement>(null)\n  const composedRef = useComposedRefs(root, forwardedRef)\n  const list = React.useRef<HTMLUListElement>(null)\n  const arc = React.useRef<SVGSVGElement>(null)\n  const was = React.useRef(new Map<string, { top: number; at: number }>())\n  const hint = React.useId()\n  const n = ids.length\n  const name = (id: string) => byId.get(id)?.label ?? id\n\n  const commit = (next: string[]) => {\n    if (order === undefined) setLocal(next)\n    onOrderChange?.(next)\n  }\n  const moveTo = (id: string, to: number) => {\n    const at = ids.indexOf(id)\n    const end = Math.min(Math.max(to, 0), n - 1)\n    if (at < 0 || end === at) return false\n    const next = ids.filter((x) => x !== id)\n    next.splice(end, 0, id)\n    commit(next)\n    setSaid(`${name(id)}, ${end + 1} of ${n}.`)\n    return true\n  }\n  const lift = (id: string) => {\n    setHeld(id)\n    setOrigin(ids.indexOf(id))\n    setSaid(`${name(id)} picked up, ${ids.indexOf(id) + 1} of ${n}.`)\n  }\n  const setDown = (id: string) => {\n    setHeld(null)\n    setSaid(`${name(id)} set down, ${ids.indexOf(id) + 1} of ${n}.`)\n  }\n  const putBack = (id: string) => {\n    moveTo(id, origin)\n    setHeld(null)\n    setSaid(`${name(id)} put back, ${origin + 1} of ${n}.`)\n  }\n\n  /** Which place the pointer is over, read from the layout (not the gliding rows) so nothing jitters. */\n  const slotAt = (y: number) => {\n    const ul = list.current\n    if (!ul) return -1\n    const top = ul.getBoundingClientRect().top\n    const lis = Array.from(ul.children) as HTMLElement[]\n    for (let i = 0; i < lis.length; i++) if (y < top + lis[i].offsetTop + lis[i].offsetHeight) return i\n    return lis.length - 1\n  }\n\n  // After every render: rows that changed place glide there (FLIP), and their numbers turn over the way they went.\n  React.useLayoutEffect(() => {\n    const ul = list.current\n    if (!ul) return\n    const still = matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n    const cs = getComputedStyle(ul)\n    const glide = { duration: parseFloat(cs.getPropertyValue(\"--ot-moderato\")) || 320, easing: cs.getPropertyValue(\"--ot-breath\").trim() || \"ease\" }\n    const turn = { duration: glide.duration, easing: cs.getPropertyValue(\"--ot-exhale\").trim() || \"ease-out\" }\n    const seen = new Map<string, { top: number; at: number }>()\n    Array.from(ul.children).forEach((li, at) => {\n      if (!(li instanceof HTMLElement)) return\n      const id = li.dataset.id ?? \"\"\n      const before = was.current.get(id)\n      seen.set(id, { top: li.offsetTop, at })\n      if (still || !before) return\n      if (before.top !== li.offsetTop) li.animate([{ translate: `0 ${before.top - li.offsetTop}px` }, { translate: \"0 0\" }], glide)\n      // ponytail: only the new figure turns in; the old one isn't kept to turn out, since React has already written the new one.\n      const fig = li.querySelector<HTMLElement>(\".ot-swap-n\")\n      if (fig && before.at !== at) fig.animate([{ opacity: 0, translate: `0 ${at > before.at ? \"0.6em\" : \"-0.6em\"}` }, { opacity: 1, translate: \"0 0\" }], turn)\n    })\n    was.current = seen\n\n    // transpose: the loop runs in the margin from the place the row was picked up to the place it's in now.\n    const svg = arc.current\n    if (!svg || !held || origin < 0) return\n    const lis = Array.from(ul.children) as HTMLElement[]\n    const mid = (li?: HTMLElement) => (li ? ul.offsetTop + li.offsetTop + li.offsetHeight / 2 : 0)\n    const now = ids.indexOf(held)\n    if (now === origin) return\n    const y0 = mid(lis[origin])\n    const y1 = mid(lis[now])\n    const w = svg.clientWidth - 6 // the ends stop short of the numbers\n    svg.querySelector(\"path\")?.setAttribute(\"d\", `M ${w} ${y0} C ${-w * 0.33} ${y0} ${-w * 0.33} ${y1} ${w} ${y1}`)\n    svg.querySelector(\"circle\")?.setAttribute(\"cx\", String(w))\n    svg.querySelector(\"circle\")?.setAttribute(\"cy\", String(y1))\n    root.current?.style.setProperty(\"--ot-swap-mid\", `${(y0 + y1) / 2}px`)\n  })\n\n  const looped = variant === \"transpose\" && held !== null && origin >= 0 && ids.indexOf(held) !== origin\n\n  return (\n    <div\n      ref={composedRef}\n      data-slot=\"swapy\"\n      data-variant={variant === \"default\" ? undefined : variant}\n      data-holding={held ? \"\" : undefined}\n      data-dragging={dragging || undefined}\n      data-disabled={disabled || undefined}\n      className={cn(\"ot-swap\", className)}\n      {...props}\n    >\n      <Rows ref={list} aria-label={label}>\n        {ids.map((id, at) => {\n          const item = byId.get(id)\n          if (!item) return null\n          const mine = held === id\n          return (\n            <Row key={id} data-id={id} data-held={mine || undefined}>\n              <span className=\"ot-swap-n\" aria-hidden=\"true\">{place(at)}</span>\n              <RowTitle>{item.label}</RowTitle>\n              {item.kind != null ? <RowKind>{item.kind}</RowKind> : null}\n              {item.meta != null ? <RowMeta>{item.meta}</RowMeta> : null}\n              <button\n                type=\"button\"\n                data-slot=\"swapy-move\"\n                className=\"ot-swap-move\"\n                aria-label={`Move ${item.label}, ${at + 1} of ${n}`}\n                aria-pressed={mine}\n                aria-describedby={hint}\n                disabled={disabled || n < 2}\n                onPointerDown={(e) => {\n                  if (e.button !== 0 || disabled) return\n                  e.preventDefault() // no text selection; focus by hand instead\n                  e.currentTarget.focus({ focusVisible: false } as FocusOptions)\n                  e.currentTarget.setPointerCapture(e.pointerId)\n                  drag.current = { id, pointer: e.pointerId, moved: false, wasHeld: mine }\n                  setDragging(true)\n                  if (!mine) lift(id)\n                }}\n                onPointerMove={(e) => {\n                  const d = drag.current\n                  if (!d || d.pointer !== e.pointerId) return\n                  if (moveTo(d.id, slotAt(e.clientY))) d.moved = true\n                }}\n                onPointerUp={(e) => {\n                  const d = drag.current\n                  if (!d || d.pointer !== e.pointerId) return\n                  drag.current = null\n                  setDragging(false)\n                  // A drag sets the row down where it's let go; a plain press picks it up, and a second press sets it down.\n                  if (d.moved || d.wasHeld) setDown(d.id)\n                }}\n                onPointerCancel={() => {\n                  if (drag.current) setDown(drag.current.id)\n                  drag.current = null\n                  setDragging(false)\n                }}\n                onClick={(e) => {\n                  if (e.detail !== 0) return // the pointer already did it; this is Enter or Space\n                  if (mine) setDown(id)\n                  else lift(id)\n                }}\n                onKeyDown={(e) => {\n                  const to = { ArrowUp: at - 1, ArrowDown: at + 1, Home: 0, End: n - 1 }[e.key]\n                  if (to !== undefined) {\n                    e.preventDefault()\n                    if (!mine) lift(id)\n                    moveTo(id, to)\n                  } else if (e.key === \"Escape\" && mine) {\n                    e.preventDefault()\n                    putBack(id)\n                  }\n                }}\n                onBlur={(e) => {\n                  // Moving a row can blur it for a moment while React reinserts it; only a real departure sets it down.\n                  const el = e.currentTarget\n                  requestAnimationFrame(() => {\n                    if (mine && !drag.current && document.activeElement !== el) setDown(id)\n                  })\n                }}\n              >\n                <span data-word=\"move\">move</span>\n                <span data-word=\"moving\">moving</span>\n                <span className=\"ot-swap-arrow\" aria-hidden=\"true\">↕</span>\n              </button>\n            </Row>\n          )\n        })}\n      </Rows>\n      {variant === \"transpose\" ? (\n        <>\n          <svg ref={arc} className=\"ot-swap-arc\" data-shown={looped || undefined} aria-hidden=\"true\">\n            <path />\n            <circle r=\"3.5\" />\n          </svg>\n          <span className=\"ot-swap-tr\" data-shown={looped || undefined} aria-hidden=\"true\">tr</span>\n        </>\n      ) : null}\n      <p id={hint} className=\"ot-sr\">Press to pick the row up, then the arrow keys to move it, and press again to set it down. Escape puts it back.</p>\n      <p role=\"status\" className=\"ot-sr\">{said}</p>\n    </div>\n  )\n}\n\nexport { Swapy, type SwapyProps, type SwapyItem }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/swapy.css",
      "content": "@layer components {\n  /* ── ot-swap ── rows you put in order by hand, after \"the uncreative\": the row you hold reverses out of an ink block and\n     travels with your hand. A \"move\" word is the handle. Borrows ot-rows for the hairlines, the title and the columns. */\n  .ot-swap { position: relative; container-type: inline-size; }\n  .ot-swap .ot-rows { position: relative; }\n  .ot-swap .ot-rows > li { position: relative; }\n  .ot-swap .ot-rows .ot-rows-link { grid-template-columns: auto minmax(0, 1fr); }\n  .ot-swap .ot-rows .ot-rows-link::after { content: none; } /* nothing opens, so no arrow and no room kept for one */\n  .ot-swap .ot-rows-link > * { transition: color var(--ot-allegro), translate var(--ot-moderato) var(--ot-exhale); }\n  .ot-swap .ot-rows-title { line-height: calc(var(--ot-mf) * var(--ot-mf-lh)); transition: color var(--ot-allegro), translate var(--ot-moderato) var(--ot-exhale), font-weight var(--ot-moderato) var(--ot-breath), letter-spacing var(--ot-moderato) var(--ot-breath); }\n  /* The place, a number used as data: it turns over as the row moves. Figures always read left to right. */\n  .ot-swap-n { min-inline-size: 2ch; font-size: var(--ot-pp); color: var(--ot-pencil); font-variant-numeric: tabular-nums; direction: ltr; unicode-bidi: isolate; }\n\n  /* The handle is a word, and the arrow says which way it goes. Both words share one cell, so the row never reflows. */\n  .ot-swap-move {\n    display: inline-grid; grid-template-columns: auto auto; column-gap: 0.3em; align-items: baseline;\n    appearance: none; border: 0; background: none; margin: calc(-1 * var(--ot-space-3)) calc(-1 * var(--ot-space-2)); padding: var(--ot-space-3) var(--ot-space-2);\n    font: inherit; color: var(--ot-graphite); cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none;\n    transition: color var(--ot-allegro);\n  }\n  .ot-swap-move > [data-word] { grid-area: 1 / 1; }\n  .ot-swap-move > [data-word=\"moving\"], .ot-swap-move[aria-pressed=\"true\"] > [data-word=\"move\"] { visibility: hidden; }\n  .ot-swap-move[aria-pressed=\"true\"] > [data-word=\"moving\"] { visibility: visible; }\n  .ot-swap-arrow { grid-area: 1 / 2; color: var(--ot-pencil); display: inline-block; transition: color var(--ot-allegro), translate var(--ot-moderato) var(--ot-spiccato); }\n  .ot-swap-move:is(:hover, :focus-visible), .ot-swap[data-force~=\"hover\"] > .ot-rows > li:first-child .ot-swap-move { color: var(--ot-ink); }\n  .ot-swap-move:is(:hover, :focus-visible) .ot-swap-arrow, .ot-swap[data-force~=\"hover\"] > .ot-rows > li:first-child .ot-swap-arrow { color: var(--ot-ink); translate: 0 -0.08em; }\n  .ot-swap[data-dragging], .ot-swap[data-dragging] .ot-swap-move { cursor: grabbing; }\n\n  /* Held: an ink block prints across the row from the inline start and the words reverse to paper; the name steps\n     forward and sets heavier and tighter, as the poster's reversed word does. */\n  .ot-swap:not([data-variant]) .ot-rows-link::before {\n    content: \"\"; position: absolute; inset: 0 calc(-1 * var(--ot-space-3)); z-index: -1;\n    background: var(--ot-ink); scale: 0 1; transform-origin: 0 50%;\n    transition: scale var(--ot-moderato) var(--ot-exhale);\n  }\n  :is([dir=\"rtl\"], [dir=\"rtl\"] *) .ot-swap:not([data-variant]) .ot-rows-link::before { transform-origin: 100% 50%; }\n  .ot-swap .ot-rows > li[data-held] { z-index: 1; }\n  .ot-swap:not([data-variant]) li[data-held] .ot-rows-link::before { scale: 1 1; }\n  .ot-swap:not([data-variant]) li[data-held] .ot-rows-link > *, .ot-swap:not([data-variant]) li[data-held] .ot-swap-arrow { color: var(--ot-paper); }\n  .ot-swap li[data-held] .ot-rows-title { translate: var(--step) 0; }\n  .ot-swap:not([data-variant]) li[data-held] .ot-rows-title { font-weight: 560; letter-spacing: -0.035em; }\n  .ot-swap:not([data-variant]) li[data-held] .ot-swap-move:focus-visible { outline-color: var(--ot-paper); }\n\n  /* Narrow: the names step down a size and the kind gives way, so a name never runs into its neighbours. */\n  @container (max-width: 30rem) {\n    .ot-swap .ot-rows .ot-rows-link { column-gap: var(--ot-space-4); }\n    .ot-swap .ot-rows-title { font-size: var(--ot-mp); line-height: calc(var(--ot-mp) * var(--ot-mp-lh)); letter-spacing: var(--ot-mp-tr); overflow-wrap: break-word; }\n    .ot-swap[data-variant=\"transpose\"] .ot-rows li[data-held] .ot-rows-title { font-size: var(--ot-mp); } /* no lift here, so the name keeps its one line */\n    .ot-swap .ot-rows-kind { display: none; }\n  }\n\n  .ot-swap[data-disabled] :is(.ot-rows-title, .ot-rows-kind, .ot-swap-move, .ot-swap-arrow) { color: var(--ot-pencil); }\n  .ot-swap-move:disabled { cursor: default; }\n\n  /* ── transpose ── the proofreader's transposition mark. No ink: the held name turns italic (yours now) and the rest step\n     back to pencil; a hairline loop in the margin runs from the place it was picked up to the place it is, ending in a dot,\n     with \"tr\" written by it. Set down, the mark fades: the correction is made. */\n  .ot-swap[data-variant=\"transpose\"] { --ot-swap-margin: var(--ot-space-7); padding-inline-start: var(--ot-swap-margin); }\n  .ot-swap[data-variant=\"transpose\"][data-holding] li:not([data-held]) .ot-rows-link > :is(.ot-rows-title, .ot-rows-kind) { color: var(--ot-pencil); }\n  .ot-swap[data-variant=\"transpose\"] li[data-held] .ot-rows-title { font-family: var(--ot-expression); font-style: italic; font-size: calc(var(--ot-expression-scale) * var(--ot-mf)); letter-spacing: 0; }\n  .ot-swap-arc {\n    position: absolute; inset-block-start: 0; inset-inline-start: calc(var(--ot-swap-margin) - var(--ot-space-5)); inline-size: var(--ot-space-5); block-size: 100%;\n    overflow: visible; pointer-events: none; opacity: 0; transition: opacity var(--ot-moderato) var(--ot-breath);\n  }\n  .ot-swap-arc path { fill: none; stroke: var(--ot-ink); stroke-width: var(--ot-hairline); }\n  .ot-swap-arc circle { fill: var(--ot-ink); }\n  :is([dir=\"rtl\"], [dir=\"rtl\"] *) .ot-swap-arc { scale: -1 1; }\n  .ot-swap-tr {\n    position: absolute; inset-inline-end: calc(100% - var(--ot-swap-margin) + var(--ot-space-5) + var(--ot-space-2)); inset-block-start: var(--ot-swap-mid, 0); translate: 0 -50%; pointer-events: none;\n    font: italic 400 calc(var(--ot-expression-scale) * var(--ot-p)) / 1 var(--ot-expression); color: var(--ot-pencil);\n    opacity: 0; transition: opacity var(--ot-moderato) var(--ot-breath);\n  }\n  .ot-swap :is(.ot-swap-arc, .ot-swap-tr)[data-shown] { opacity: 1; }\n  @media (max-width: 40rem) { .ot-swap[data-variant=\"transpose\"] { --ot-swap-margin: var(--ot-space-6); } .ot-swap-tr { font-size: var(--ot-pp); } }\n\n  @media (forced-colors: active) {\n    .ot-swap:not([data-variant]) li[data-held] .ot-rows-link::before { background: Highlight; forced-color-adjust: none; }\n    .ot-swap:not([data-variant]) li[data-held] .ot-rows-link > *, .ot-swap:not([data-variant]) li[data-held] .ot-swap-arrow { color: HighlightText; forced-color-adjust: none; }\n    .ot-swap-arc path { stroke: CanvasText; } .ot-swap-arc circle { fill: CanvasText; }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/swapy.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/swapy.css\"": {}
  },
  "meta": {
    "movement": "X",
    "underneath": "native",
    "contract": "ot-swap"
  },
  "type": "registry:ui"
}
