{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sortable-list",
  "title": "Sortable List",
  "description": "A list reordered by dragging a handle, or from the keyboard without one.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/sortable-list/sortable-list.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { GripVertical } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type SortableListProps<TItem> = Omit<\n  React.HTMLAttributes<HTMLOListElement>,\n  \"children\"\n> & {\n  items: readonly TItem[]\n  getKey: (item: TItem) => string\n  onReorder: (items: TItem[]) => void\n  renderItem: (item: TItem, index: number) => React.ReactNode\n  /** Names the item in announcements and on its handle. */\n  getLabel?: (item: TItem) => string\n  label?: string\n  disabled?: boolean\n}\n\nfunction moved<TItem>(items: readonly TItem[], from: number, to: number) {\n  const next = [...items]\n  const [lifted] = next.splice(from, 1)\n  if (lifted !== undefined) next.splice(to, 0, lifted)\n  return next\n}\n\n/**\n * A list reordered by dragging a handle, or from the keyboard without one.\n * Every move is announced, because a list that silently rearranges itself is\n * unusable to anyone who is not watching it.\n */\nexport function SortableList<TItem>({\n  items,\n  getKey,\n  onReorder,\n  renderItem,\n  getLabel,\n  label = \"Sortable list\",\n  disabled,\n  className,\n  ...rootProps\n}: SortableListProps<TItem>) {\n  const listRef = React.useRef<HTMLOListElement>(null)\n  const [dragging, setDragging] = React.useState<string | null>(null)\n  const [lifted, setLifted] = React.useState<string | null>(null)\n  const [message, setMessage] = React.useState(\"\")\n  const origin = React.useRef<readonly TItem[]>(items)\n\n  const nameOf = (item: TItem, index: number) =>\n    getLabel?.(item) ?? `Item ${index + 1}`\n\n  const announce = (item: TItem, from: number, to: number) => {\n    setMessage(\n      `${nameOf(item, from)} moved to position ${to + 1} of ${items.length}`\n    )\n  }\n\n  const rowsOf = () =>\n    Array.from(\n      listRef.current?.querySelectorAll<HTMLLIElement>(\n        \"[data-slot='sortable-list-item']\"\n      ) ?? []\n    )\n\n  const moveTo = (from: number, to: number) => {\n    if (to < 0 || to >= items.length || to === from) return\n    const item = items[from]\n    if (item === undefined) return\n\n    onReorder(moved(items, from, to))\n    announce(item, from, to)\n  }\n\n  return (\n    <>\n      <ol\n        ref={listRef}\n        data-slot=\"sortable-list\"\n        aria-label={label}\n        className={cn(\"grid gap-2\", className)}\n        {...rootProps}\n      >\n        {items.map((item, index) => {\n          const key = getKey(item)\n          const isDragging = dragging === key\n          const isLifted = lifted === key\n\n          return (\n            <li\n              key={key}\n              data-slot=\"sortable-list-item\"\n              data-dragging={isDragging ? \"\" : undefined}\n              data-lifted={isLifted ? \"\" : undefined}\n              className={cn(\n                \"border-border bg-card flex items-center gap-2 rounded-[var(--radius)] border p-2\",\n                \"transition-[box-shadow,transform] duration-150 motion-reduce:transition-none\",\n                isDragging && \"shadow-lg\",\n                isLifted && \"ring-ring ring-2\"\n              )}\n            >\n              <button\n                type=\"button\"\n                disabled={disabled}\n                aria-label={`Reorder ${nameOf(item, index)}`}\n                aria-pressed={isLifted}\n                data-slot=\"sortable-list-handle\"\n                className=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex size-8 shrink-0 cursor-grab touch-none items-center justify-center rounded-md focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed\"\n                onPointerDown={(event) => {\n                  if (disabled || event.button !== 0) return\n                  event.preventDefault()\n                  event.currentTarget.setPointerCapture(event.pointerId)\n                  origin.current = items\n                  setDragging(key)\n                }}\n                onPointerMove={(event) => {\n                  if (dragging !== key) return\n\n                  const rows = rowsOf()\n                  const at = items.findIndex(\n                    (candidate) => getKey(candidate) === key\n                  )\n\n                  const target = rows.findIndex((row) => {\n                    const rect = row.getBoundingClientRect()\n                    return event.clientY < rect.top + rect.height / 2\n                  })\n\n                  const next = target === -1 ? items.length - 1 : target\n                  if (next !== at) moveTo(at, next)\n                }}\n                onPointerUp={() => setDragging(null)}\n                onPointerCancel={() => setDragging(null)}\n                onKeyDown={(event) => {\n                  const at = index\n\n                  if (event.key === \" \" || event.key === \"Enter\") {\n                    event.preventDefault()\n\n                    if (isLifted) {\n                      setLifted(null)\n                      setMessage(`${nameOf(item, at)} dropped`)\n                    } else {\n                      origin.current = items\n                      setLifted(key)\n                      setMessage(\n                        `${nameOf(item, at)} lifted. Use the arrow keys to move it, then press space to drop it.`\n                      )\n                    }\n                    return\n                  }\n\n                  if (!isLifted) return\n\n                  if (event.key === \"ArrowUp\") {\n                    event.preventDefault()\n                    moveTo(at, at - 1)\n                  }\n\n                  if (event.key === \"ArrowDown\") {\n                    event.preventDefault()\n                    moveTo(at, at + 1)\n                  }\n\n                  if (event.key === \"Escape\") {\n                    event.preventDefault()\n                    onReorder([...origin.current])\n                    setLifted(null)\n                    setMessage(\"Move cancelled\")\n                  }\n                }}\n              >\n                <GripVertical aria-hidden=\"true\" size={16} />\n              </button>\n\n              <div className=\"min-w-0 flex-1\">{renderItem(item, index)}</div>\n            </li>\n          )\n        })}\n      </ol>\n\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {message}\n      </span>\n    </>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "list",
    "drag",
    "reorder",
    "control"
  ],
  "type": "registry:ui"
}