{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sortable-list",
  "title": "Sortable List",
  "description": "Reorderable list with drag-and-drop and full keyboard accessibility.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "@drift-ui/use-controllable-state"
  ],
  "files": [
    {
      "path": "registry/default/ui/sortable-list.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { GripVerticalIcon } from \"lucide-react\"\nimport {\n  MotionConfig,\n  Reorder,\n  useDragControls,\n  type DragControls,\n  type HTMLMotionProps,\n} from \"motion/react\"\n\nimport { cn } from \"@/registry/default/lib/utils\"\nimport { useControllableState } from \"@/registry/default/hooks/use-controllable-state\"\nimport {\n  applyOrder,\n  getCancelAnnouncement,\n  getDefaultItemKey,\n  getDefaultItemLabel,\n  getDropAnnouncement,\n  getMoveAnnouncement,\n  getPickupAnnouncement,\n  getTargetIndex,\n  reorderItem,\n  type SortableKey,\n  type SortableOrientation,\n} from \"@/registry/default/lib/sortable-list\"\n\nconst SORTABLE_SPRING = {\n  type: \"spring\",\n  bounce: 0.15,\n  visualDuration: 0.25,\n} as const\n\nconst DROP_SETTLE = { bounceStiffness: 500, bounceDamping: 45 }\nconst LIFTED_SCALE = 1.02\nconst INTERACTIVE_SELECTOR = \"a,button,input,select,textarea,[contenteditable]\"\n\ninterface SortSession {\n  key: SortableKey\n  order: SortableKey[]\n  dragControls?: DragControls\n}\n\ninterface SortableContextValue {\n  activeKey: SortableKey | null\n  isPointerDrag: boolean\n  orientation: SortableOrientation\n  disabled: boolean\n  instructionsId: string\n  getLabel: (key: SortableKey) => string\n  beginPointerDrag: (key: SortableKey, dragControls: DragControls) => void\n  finishSession: () => void\n  onItemKeyDown: (\n    event: React.KeyboardEvent<HTMLElement>,\n    key: SortableKey\n  ) => void\n}\n\nconst SortableContext = React.createContext<SortableContextValue | null>(null)\n\nfunction useSortableContext() {\n  const context = React.useContext(SortableContext)\n  if (!context) {\n    throw new Error(\"SortableItem must be used within a SortableList\")\n  }\n  return context\n}\n\ninterface SortableItemContextValue {\n  value: SortableKey\n  disabled: boolean\n  isGrabbed: boolean\n  registerHandle: () => () => void\n  startDrag: (event: React.PointerEvent<HTMLElement>) => void\n}\n\nconst SortableItemContext =\n  React.createContext<SortableItemContextValue | null>(null)\n\nfunction useSortableItemContext() {\n  const context = React.useContext(SortableItemContext)\n  if (!context) {\n    throw new Error(\"SortableDragHandle must be used within a SortableItem\")\n  }\n  return context\n}\n\nexport interface SortableListProps<T> extends Omit<\n  HTMLMotionProps<\"ul\">,\n  \"children\" | \"defaultValue\" | \"layout\" | \"values\"\n> {\n  value?: T[]\n  defaultValue?: T[]\n  onValueChange?: (items: T[]) => void\n  getKey?: (item: T, index: number) => SortableKey\n  getItemLabel?: (item: T, index: number) => string\n  orientation?: SortableOrientation\n  disabled?: boolean\n  children: (item: T, index: number) => React.ReactNode\n}\n\nexport function SortableList<T>({\n  value,\n  defaultValue = [],\n  onValueChange,\n  getKey = getDefaultItemKey,\n  getItemLabel = getDefaultItemLabel,\n  orientation = \"vertical\",\n  disabled = false,\n  className,\n  children,\n  ...props\n}: SortableListProps<T>) {\n  const { value: items, setValue: setItems } = useControllableState<T[]>({\n    value,\n    defaultValue,\n    onChange: onValueChange,\n  })\n\n  const [session, setSession] = React.useState<SortSession | null>(null)\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  const sessionRef = React.useRef<SortSession | null>(null)\n  const instructionsId = React.useId()\n\n  const keys = items.map((item, index) => getKey(item, index))\n  const orderedItems = session\n    ? applyOrder(items, session.order, getKey)\n    : items\n\n  function updateSession(next: SortSession | null) {\n    sessionRef.current = next\n    setSession(next)\n  }\n\n  function getLabel(key: SortableKey) {\n    const index = keys.indexOf(key)\n    return index === -1 ? String(key) : getItemLabel(items[index], index)\n  }\n\n  function pickUp(key: SortableKey, dragControls?: DragControls) {\n    updateSession({ key, order: keys, dragControls })\n    setAnnouncement(\n      getPickupAnnouncement(getLabel(key), keys.indexOf(key), keys.length)\n    )\n  }\n\n  function moveTo(order: SortableKey[]) {\n    const current = sessionRef.current\n    if (!current) return\n\n    updateSession({ ...current, order })\n    setAnnouncement(\n      getMoveAnnouncement(\n        getLabel(current.key),\n        order.indexOf(current.key),\n        order.length\n      )\n    )\n  }\n\n  function finishSession() {\n    const current = sessionRef.current\n    if (!current) return\n\n    const nextItems = applyOrder(items, current.order, getKey)\n    updateSession(null)\n    setAnnouncement(\n      getDropAnnouncement(\n        getLabel(current.key),\n        current.order.indexOf(current.key),\n        current.order.length\n      )\n    )\n    if (nextItems.some((item, index) => item !== items[index])) {\n      setItems(nextItems)\n    }\n  }\n\n  function cancelSession() {\n    const current = sessionRef.current\n    if (!current) return\n\n    updateSession(null)\n    current.dragControls?.stop()\n    setAnnouncement(\n      getCancelAnnouncement(\n        getLabel(current.key),\n        keys.indexOf(current.key),\n        keys.length\n      )\n    )\n  }\n\n  function onItemKeyDown(\n    event: React.KeyboardEvent<HTMLElement>,\n    key: SortableKey\n  ) {\n    if (disabled) return\n\n    const current = sessionRef.current\n    const isGrabbed = current?.key === key && !current.dragControls\n\n    if (event.key === \" \" || event.key === \"Enter\") {\n      event.preventDefault()\n      if (event.repeat) return\n      if (isGrabbed) {\n        finishSession()\n      } else {\n        pickUp(key)\n      }\n      return\n    }\n\n    if (!isGrabbed) return\n\n    const currentIndex = current.order.indexOf(key)\n    const targetIndex = getTargetIndex(\n      event.key,\n      currentIndex,\n      current.order.length,\n      orientation\n    )\n    if (targetIndex === null) return\n\n    event.preventDefault()\n    if (targetIndex !== currentIndex) {\n      moveTo(reorderItem(current.order, currentIndex, targetIndex))\n    }\n  }\n\n  React.useEffect(() => {\n    if (!session) return\n\n    function handleEscape(event: KeyboardEvent) {\n      if (event.key !== \"Escape\") return\n      event.preventDefault()\n      event.stopPropagation()\n      cancelSession()\n    }\n\n    window.addEventListener(\"keydown\", handleEscape, true)\n    return () => window.removeEventListener(\"keydown\", handleEscape, true)\n  })\n\n  return (\n    <SortableContext.Provider\n      value={{\n        activeKey: session?.key ?? null,\n        isPointerDrag: Boolean(session?.dragControls),\n        orientation,\n        disabled,\n        instructionsId,\n        getLabel,\n        beginPointerDrag: pickUp,\n        finishSession,\n        onItemKeyDown,\n      }}\n    >\n      <MotionConfig reducedMotion=\"user\" transition={SORTABLE_SPRING}>\n        <Reorder.Group\n          as=\"ul\"\n          role=\"list\"\n          axis={orientation === \"vertical\" ? \"y\" : \"x\"}\n          values={session?.order ?? keys}\n          onReorder={moveTo}\n          data-orientation={orientation}\n          data-disabled={disabled ? \"\" : undefined}\n          className={cn(\n            \"flex gap-2\",\n            orientation === \"vertical\" ? \"flex-col\" : \"flex-row\",\n            session && \"select-none\",\n            className\n          )}\n          {...props}\n        >\n          {orderedItems.map((item, index) => children(item, index))}\n        </Reorder.Group>\n      </MotionConfig>\n\n      <span id={instructionsId} className=\"sr-only\">\n        Press Space or Enter to pick up the item. Use arrow keys to reorder.\n        Press Space or Enter again to drop. Press Escape to cancel.\n      </span>\n\n      <span aria-live=\"assertive\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </SortableContext.Provider>\n  )\n}\n\nexport interface SortableItemProps extends Omit<\n  HTMLMotionProps<\"li\">,\n  \"children\" | \"layout\" | \"value\"\n> {\n  value: SortableKey\n  disabled?: boolean\n  children?: React.ReactNode\n}\n\nexport function SortableItem({\n  value,\n  disabled: itemDisabled = false,\n  className,\n  children,\n  onBlur,\n  onDragEnd,\n  onDragStart,\n  onKeyDown,\n  onPointerDown,\n  ...props\n}: SortableItemProps) {\n  const {\n    activeKey,\n    isPointerDrag,\n    orientation,\n    disabled: listDisabled,\n    instructionsId,\n    beginPointerDrag,\n    finishSession,\n    onItemKeyDown,\n  } = useSortableContext()\n\n  const dragControls = useDragControls()\n  const [hasHandle, setHasHandle] = React.useState(false)\n  const disabled = listDisabled || itemDisabled\n  const isActive = activeKey === value\n  const isGrabbed = isActive && !isPointerDrag\n  const isDragging = isActive && isPointerDrag\n  const isRowDraggable = !hasHandle && !disabled\n\n  const registerHandle = React.useCallback(() => {\n    setHasHandle(true)\n    return () => setHasHandle(false)\n  }, [])\n\n  const startDrag = (event: React.PointerEvent<HTMLElement>) => {\n    if (!disabled && event.button === 0 && event.isPrimary) {\n      dragControls.start(event)\n    }\n  }\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLLIElement>) => {\n    onPointerDown?.(event)\n    const isInteractiveTarget =\n      event.target instanceof Element &&\n      event.target.closest(INTERACTIVE_SELECTOR) !== null\n    if (!hasHandle && !event.defaultPrevented && !isInteractiveTarget) {\n      startDrag(event)\n    }\n  }\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLLIElement>) => {\n    onKeyDown?.(event)\n    if (\n      isRowDraggable &&\n      event.target === event.currentTarget &&\n      !event.defaultPrevented\n    ) {\n      onItemKeyDown(event, value)\n    }\n  }\n\n  const handleBlur = (event: React.FocusEvent<HTMLLIElement>) => {\n    onBlur?.(event)\n    if (isGrabbed && !event.currentTarget.contains(event.relatedTarget)) {\n      finishSession()\n    }\n  }\n\n  return (\n    <SortableItemContext.Provider\n      value={{ value, disabled, isGrabbed, registerHandle, startDrag }}\n    >\n      <Reorder.Item\n        as=\"li\"\n        role=\"listitem\"\n        value={value}\n        layout=\"position\"\n        drag={disabled ? false : orientation === \"vertical\" ? \"y\" : \"x\"}\n        dragListener={false}\n        dragControls={dragControls}\n        dragMomentum={false}\n        dragTransition={DROP_SETTLE}\n        initial={false}\n        animate={{ scale: isActive ? LIFTED_SCALE : 1 }}\n        data-sortable-item=\"\"\n        data-grabbed={isGrabbed ? \"\" : undefined}\n        data-dragging={isDragging ? \"\" : undefined}\n        data-orientation={orientation}\n        data-disabled={disabled ? \"\" : undefined}\n        tabIndex={isRowDraggable ? 0 : undefined}\n        aria-roledescription=\"sortable item\"\n        aria-grabbed={isActive}\n        aria-describedby={hasHandle ? undefined : instructionsId}\n        onBlur={handleBlur}\n        onDragEnd={(event, info) => {\n          onDragEnd?.(event, info)\n          finishSession()\n        }}\n        onDragStart={(event, info) => {\n          onDragStart?.(event, info)\n          beginPointerDrag(value, dragControls)\n        }}\n        onKeyDown={handleKeyDown}\n        onPointerDown={handlePointerDown}\n        className={cn(\n          \"group relative flex items-center gap-3 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xs transition-[border-color,box-shadow,opacity]\",\n          isRowDraggable &&\n            \"cursor-grab select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none active:cursor-grabbing\",\n          isRowDraggable &&\n            (orientation === \"vertical\" ? \"touch-pan-x\" : \"touch-pan-y\"),\n          isGrabbed && \"border-ring shadow-md ring-2 ring-ring/50\",\n          isDragging && \"cursor-grabbing border-ring shadow-lg\",\n          disabled && \"pointer-events-none opacity-50\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n      </Reorder.Item>\n    </SortableItemContext.Provider>\n  )\n}\n\nexport type SortableDragHandleProps = React.ComponentProps<\"button\">\n\nexport function SortableDragHandle({\n  className,\n  children,\n  onKeyDown,\n  onPointerDown,\n  ...props\n}: SortableDragHandleProps) {\n  const { instructionsId, getLabel, onItemKeyDown } = useSortableContext()\n  const { value, disabled, isGrabbed, registerHandle, startDrag } =\n    useSortableItemContext()\n\n  React.useLayoutEffect(registerHandle, [registerHandle])\n\n  return (\n    <button\n      type=\"button\"\n      data-sortable-handle=\"\"\n      aria-label={`Reorder ${getLabel(value)}`}\n      aria-describedby={instructionsId}\n      aria-pressed={isGrabbed}\n      disabled={disabled}\n      onKeyDown={(event) => {\n        onKeyDown?.(event)\n        if (!event.defaultPrevented) onItemKeyDown(event, value)\n      }}\n      onPointerDown={(event) => {\n        onPointerDown?.(event)\n        if (!event.defaultPrevented) startDrag(event)\n      }}\n      className={cn(\n        \"shrink-0 cursor-grab touch-none rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none active:cursor-grabbing\",\n        isGrabbed && \"bg-muted text-foreground\",\n        className\n      )}\n      {...props}\n    >\n      {children ?? <GripVerticalIcon className=\"size-4\" />}\n    </button>\n  )\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/sortable-list.ts",
      "content": "export type SortableOrientation = \"vertical\" | \"horizontal\"\nexport type SortableKey = string | number\n\nexport function getDefaultItemKey(item: unknown, index: number): SortableKey {\n  if (typeof item === \"string\" || typeof item === \"number\") return item\n  if (typeof item === \"object\" && item !== null && \"id\" in item) {\n    const { id } = item\n    if (typeof id === \"string\" || typeof id === \"number\") return id\n  }\n  return index\n}\n\nexport function getDefaultItemLabel(item: unknown, index: number): string {\n  if (typeof item === \"string\") return item\n  if (typeof item === \"object\" && item !== null) {\n    if (\"label\" in item && typeof item.label === \"string\") return item.label\n    if (\"title\" in item && typeof item.title === \"string\") return item.title\n    if (\"name\" in item && typeof item.name === \"string\") return item.name\n  }\n  return `Item ${index + 1}`\n}\n\nexport function applyOrder<T>(\n  items: readonly T[],\n  order: readonly SortableKey[],\n  getKey: (item: T, index: number) => SortableKey\n): T[] {\n  const positions = new Map(order.map((key, index) => [key, index]))\n  return items\n    .map((item, index) => ({\n      item,\n      position: positions.get(getKey(item, index)) ?? order.length,\n    }))\n    .sort((a, b) => a.position - b.position)\n    .map(({ item }) => item)\n}\n\nexport function reorderItem<T>(\n  items: readonly T[],\n  fromIndex: number,\n  toIndex: number\n): T[] {\n  if (\n    fromIndex === toIndex ||\n    fromIndex < 0 ||\n    fromIndex >= items.length ||\n    toIndex < 0 ||\n    toIndex >= items.length\n  ) {\n    return [...items]\n  }\n\n  const result = [...items]\n  const [removed] = result.splice(fromIndex, 1)\n  result.splice(toIndex, 0, removed)\n  return result\n}\n\nexport function getPickupAnnouncement(\n  itemLabel: string,\n  index: number,\n  total: number\n): string {\n  return `Picked up ${itemLabel}, position ${index + 1} of ${total}. Use arrow keys to move, Space to drop, Escape to cancel.`\n}\n\nexport function getMoveAnnouncement(\n  itemLabel: string,\n  index: number,\n  total: number\n): string {\n  return `Moved ${itemLabel} to position ${index + 1} of ${total}.`\n}\n\nexport function getDropAnnouncement(\n  itemLabel: string,\n  index: number,\n  total: number\n): string {\n  return `Dropped ${itemLabel}, final position ${index + 1} of ${total}.`\n}\n\nexport function getCancelAnnouncement(\n  itemLabel: string,\n  originalIndex: number,\n  total: number\n): string {\n  return `Reorder cancelled. ${itemLabel} returned to position ${originalIndex + 1} of ${total}.`\n}\n\nexport function getTargetIndex(\n  key: string,\n  currentIndex: number,\n  totalCount: number,\n  orientation: SortableOrientation = \"vertical\"\n): number | null {\n  const isPreviousKey =\n    (orientation === \"vertical\" && key === \"ArrowUp\") ||\n    (orientation === \"horizontal\" && key === \"ArrowLeft\")\n\n  const isNextKey =\n    (orientation === \"vertical\" && key === \"ArrowDown\") ||\n    (orientation === \"horizontal\" && key === \"ArrowRight\")\n\n  if (isPreviousKey) {\n    return Math.max(0, currentIndex - 1)\n  }\n\n  if (isNextKey) {\n    return Math.min(totalCount - 1, currentIndex + 1)\n  }\n\n  if (key === \"Home\") {\n    return 0\n  }\n\n  if (key === \"End\") {\n    return totalCount - 1\n  }\n\n  return null\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}