{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sortable",
  "title": "Sortable",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Reorder lists, grids and multi-column boards by pointer, touch or keyboard, with spoken announcements, move buttons for people who cannot drag, a floating lifted item and reduced motion.",
  "dependencies": [
    "@dnd-kit/core@^6",
    "@dnd-kit/sortable@^10",
    "@dnd-kit/utilities@^3",
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json",
    "https://ui.ballmac.com/r/direction.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/sortable.tsx",
      "content": "// Ballmac UI: Sortable. https://ui.ballmac.com/components/sortable\n// Based on dnd-kit (MIT, Copyright (c) 2021 Claudéric Demers), composed into list, grid and multi-list parts with translated announcements, move buttons and reduced motion.\n\"use client\"\n\nimport * as React from \"react\"\nimport {\n  closestCorners,\n  DndContext,\n  DragOverlay,\n  KeyboardSensor,\n  PointerSensor,\n  pointerWithin,\n  rectIntersection,\n  TouchSensor,\n  useDroppable,\n  useSensor,\n  useSensors,\n  type Announcements,\n  type CollisionDetection,\n  type DragEndEvent,\n  type DragOverEvent,\n  type DragStartEvent,\n  type DraggableAttributes,\n  type Modifier,\n  type UniqueIdentifier,\n} from \"@dnd-kit/core\"\nimport {\n  arrayMove,\n  horizontalListSortingStrategy,\n  rectSortingStrategy,\n  SortableContext,\n  sortableKeyboardCoordinates,\n  useSortable,\n  verticalListSortingStrategy,\n} from \"@dnd-kit/sortable\"\nimport { CSS as DndCSS } from \"@dnd-kit/utilities\"\nimport { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, GripVertical } from \"lucide-react\"\nimport { useReducedMotion } from \"motion/react\"\n\nimport { useDirection } from \"@/lib/ballmac/direction\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\nimport { cn } from \"@/lib/utils\"\n\ntype SortableOrientation = \"vertical\" | \"horizontal\" | \"grid\"\ntype SortableInfo = { activeId: string; from: number; to: number }\n\n// ---------------------------------------------------------------- shared context\n\ntype RootContext = {\n  orientation: SortableOrientation\n  disabled: boolean\n  reduced: boolean\n  rtl: boolean\n  labelOf: (id: string) => string\n  /** Where an item sits, as a group id and index, so a move button knows its neighbours. */\n  locate: (id: string) => { group: string; index: number; count: number } | null\n  move: (id: string, delta: -1 | 1) => void\n  /** What the overlay draws for the item being dragged. */\n  register: (id: string, render: () => React.ReactNode) => () => void\n  activeId: string | null\n}\nconst RootCtx = React.createContext<RootContext | null>(null)\nconst useRoot = () => {\n  const ctx = React.useContext(RootCtx)\n  if (!ctx) throw new Error(\"Sortable parts must be used inside <Sortable> or <SortableGroups>.\")\n  return ctx\n}\n\ntype ItemContext = {\n  id: string\n  label: string\n  attributes: DraggableAttributes | null\n  listeners: ReturnType<typeof useSortable>[\"listeners\"]\n  setActivatorNodeRef: ((el: HTMLElement | null) => void) | null\n  dragging: boolean\n  overlay: boolean\n}\nconst ItemCtx = React.createContext<ItemContext | null>(null)\nconst useItem = () => {\n  const ctx = React.useContext(ItemCtx)\n  if (!ctx) throw new Error(\"SortableHandle and SortableMove must be used inside <SortableItem>.\")\n  return ctx\n}\n\n// ---------------------------------------------------------------- engine pieces both lists share\n\nconst strategyFor = (o: SortableOrientation) => (o === \"vertical\" ? verticalListSortingStrategy : o === \"horizontal\" ? horizontalListSortingStrategy : rectSortingStrategy)\n\n/** Keeps a list on its own axis while it is dragged. */\nconst axisModifier = (o: SortableOrientation): Modifier | undefined =>\n  o === \"vertical\" ? ({ transform }) => ({ ...transform, x: 0 }) : o === \"horizontal\" ? ({ transform }) => ({ ...transform, y: 0 }) : undefined\n\nfunction useSortableSensors() {\n  return useSensors(\n    useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),\n    useSensor(TouchSensor, { activationConstraint: { delay: 160, tolerance: 8 } }),\n    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })\n  )\n}\n\n/** The pieces of one drag the page needs to show: what is lifted, and a live region for the keyboard moves of the move buttons. */\nfunction useEngineState(labelOf: (id: string) => string) {\n  const msg = useMessages()\n  const [activeId, setActiveId] = React.useState<string | null>(null)\n  const [spoken, setSpoken] = React.useState(\"\")\n  const renderers = React.useRef(new Map<string, () => React.ReactNode>())\n  const register = React.useCallback((id: string, render: () => React.ReactNode) => {\n    renderers.current.set(id, render)\n    return () => {\n      renderers.current.delete(id)\n    }\n  }, [])\n  const instructions = msg(\n    \"sortable.instructions\",\n    \"To pick up an item, press Space or Enter. While dragging, use the arrow keys to move it, Space or Enter to drop it, and Escape to cancel.\"\n  )\n  return { msg, activeId, setActiveId, spoken, setSpoken, renderers, register, instructions, labelOf }\n}\n\n// ---------------------------------------------------------------- Sortable: one list or grid\n\ntype SortableProps = Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> & {\n  /** The ids of the items, in order. */\n  value: string[]\n  /** Called with the new order when an item is dropped or moved with a button. Keep it in state and render the items in that order. */\n  onValueChange: (ids: string[], info: SortableInfo) => void\n  /** What the list is, read by screen readers: \"Playlist\". */\n  label: string\n  /** \"vertical\" (default) and \"horizontal\" lists move on one axis; \"grid\" moves anywhere. */\n  orientation?: SortableOrientation\n  /** Says each item's name when it is picked up and dropped. Defaults to the item's `label`, then its id. */\n  getLabel?: (id: string) => string\n  disabled?: boolean\n  /** Draw the lifted item in a floating layer under the pointer. Turn off for items that need to stay in place. */\n  overlay?: boolean\n  /** The items: `SortableItem`s, in the same order as `value`. */\n  children: React.ReactNode\n}\n\n/**\n * Reorder a list by dragging. Use a handle, the keyboard (Space to lift, arrows to move, Space to drop) or the move buttons, which are the\n * alternative for people who cannot drag. The new order is yours to keep: `value` and `onValueChange`.\n */\nfunction Sortable({\n  value: ids,\n  onValueChange,\n  label,\n  orientation = \"vertical\",\n  getLabel,\n  disabled = false,\n  overlay = true,\n  className,\n  children,\n  ...props\n}: SortableProps) {\n  const dir = useDirection()\n  const reduced = !!useReducedMotion()\n  const labels = React.useRef(new Map<string, string>())\n  const labelOf = React.useCallback((id: string) => getLabel?.(id) ?? labels.current.get(id) ?? id, [getLabel])\n  const engine = useEngineState(labelOf)\n  const { msg, activeId, setActiveId } = engine\n  const sensors = useSortableSensors()\n\n  const commit = (next: string[], info: SortableInfo) => onValueChange(next, info)\n  const position = (id: UniqueIdentifier | undefined) => ids.indexOf(String(id)) + 1\n\n  const announcements: Announcements = {\n    onDragStart: ({ active }) => msg(\"sortable.pickedUp\", \"Picked up {item}. It is position {position} of {total}.\", { item: labelOf(String(active.id)), position: position(active.id), total: ids.length }),\n    onDragOver: ({ active, over }) => (over ? msg(\"sortable.over\", \"{item} is over position {position} of {total}.\", { item: labelOf(String(active.id)), position: position(over.id), total: ids.length }) : undefined),\n    onDragEnd: ({ active, over }) =>\n      over\n        ? msg(\"sortable.dropped\", \"Dropped {item} at position {position} of {total}.\", { item: labelOf(String(active.id)), position: position(over.id), total: ids.length })\n        : msg(\"sortable.droppedBack\", \"Dropped {item}. It did not move.\", { item: labelOf(String(active.id)) }),\n    onDragCancel: ({ active }) => msg(\"sortable.cancelled\", \"Cancelled. {item} went back to position {position}.\", { item: labelOf(String(active.id)), position: position(active.id) }),\n  }\n\n  const onDragStart = (e: DragStartEvent) => setActiveId(String(e.active.id))\n  const onDragEnd = (e: DragEndEvent) => {\n    setActiveId(null)\n    const { active, over } = e\n    if (!over || active.id === over.id) return\n    const from = ids.indexOf(String(active.id))\n    const to = ids.indexOf(String(over.id))\n    if (from < 0 || to < 0) return\n    commit(arrayMove(ids, from, to), { activeId: String(active.id), from, to })\n  }\n\n  const move = React.useCallback(\n    (id: string, delta: -1 | 1) => {\n      const from = ids.indexOf(id)\n      const to = from + delta\n      if (from < 0 || to < 0 || to >= ids.length) return\n      commit(arrayMove(ids, from, to), { activeId: id, from, to })\n      engine.setSpoken(msg(\"sortable.moved\", \"Moved {item} to position {position} of {total}.\", { item: labelOf(id), position: to + 1, total: ids.length }))\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [ids, labelOf]\n  )\n  useRefocusAfterMove(ids)\n\n  const ctx: RootContext = {\n    orientation,\n    disabled,\n    reduced,\n    rtl: dir === \"rtl\",\n    labelOf,\n    locate: (id) => {\n      const index = ids.indexOf(id)\n      return index < 0 ? null : { group: \"\", index, count: ids.length }\n    },\n    move,\n    register: engine.register,\n    activeId,\n  }\n\n  return (\n    <RootCtx.Provider value={ctx}>\n      <LabelRegistry.Provider value={labels}>\n        <DndContext\n          sensors={sensors}\n          collisionDetection={closestCorners}\n          modifiers={axisModifier(orientation) ? [axisModifier(orientation)!] : undefined}\n          onDragStart={onDragStart}\n          onDragEnd={onDragEnd}\n          onDragCancel={() => setActiveId(null)}\n          accessibility={{ announcements, screenReaderInstructions: { draggable: engine.instructions } }}\n        >\n          <SortableContext items={ids} strategy={strategyFor(orientation)} disabled={disabled}>\n            <div\n              role=\"list\"\n              aria-label={label}\n              data-slot=\"sortable\"\n              data-orientation={orientation}\n              className={cn(\n                orientation === \"vertical\" && \"flex flex-col gap-2\",\n                orientation === \"horizontal\" && \"flex flex-row flex-wrap gap-2\",\n                orientation === \"grid\" && \"grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3\",\n                className\n              )}\n              {...props}\n            >\n              {children}\n            </div>\n          </SortableContext>\n          {overlay ? <Overlay engine={engine} reduced={reduced} /> : null}\n        </DndContext>\n        <span data-slot=\"sortable-status\" role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {engine.spoken}\n        </span>\n      </LabelRegistry.Provider>\n    </RootCtx.Provider>\n  )\n}\n\nconst LabelRegistry = React.createContext<React.MutableRefObject<Map<string, string>> | null>(null)\n\nfunction Overlay({ engine, reduced }: { engine: ReturnType<typeof useEngineState>; reduced: boolean }) {\n  const { activeId, renderers } = engine\n  return (\n    <DragOverlay dropAnimation={reduced ? null : { duration: 180, easing: \"cubic-bezier(0.2, 0.8, 0.2, 1)\" }} zIndex={60}>\n      {activeId ? renderers.current.get(activeId)?.() ?? null : null}\n    </DragOverlay>\n  )\n}\n\n/** After a move button changes the order, the browser may drop focus as the row moves; put it back on the same button. */\nfunction useRefocusAfterMove(order: unknown) {\n  const pending = React.useRef<string | null>(null)\n  React.useEffect(() => {\n    const target = pending.current\n    if (!target) return\n    pending.current = null\n    Array.from(document.querySelectorAll<HTMLElement>(\"[data-sortable-move]\"))\n      .find((el) => el.dataset.sortableMove === target)\n      ?.focus()\n  }, [order])\n  React.useEffect(() => {\n    const onClick = (e: MouseEvent | KeyboardEvent) => {\n      const el = (e.target as HTMLElement | null)?.closest<HTMLElement>(\"[data-sortable-move]\")\n      pending.current = el?.dataset.sortableMove ?? null\n    }\n    document.addEventListener(\"click\", onClick, true)\n    return () => document.removeEventListener(\"click\", onClick, true)\n  }, [])\n}\n\n// ---------------------------------------------------------------- SortableItem\n\ntype SortableItemProps = Omit<React.ComponentProps<\"div\">, \"id\"> & {\n  /** The item's id, one of the ids in `value`. */\n  id: string\n  /** The item's name for screen readers: \"Chorus\". */\n  label?: string\n  /** Drag the whole item. Leave it off and add a `SortableHandle` for items that hold buttons, links or inputs. */\n  dragEverywhere?: boolean\n  disabled?: boolean\n}\n\nconst itemBase =\n  \"relative flex min-w-0 items-center gap-3 rounded-xl border bg-card px-3 py-2.5 text-sm text-card-foreground shadow-xs outline-none transition-[box-shadow,border-color,opacity] duration-150 motion-reduce:transition-none\"\n\nfunction SortableItem({ id, label, dragEverywhere = false, disabled: itemDisabled, className, children, ...props }: SortableItemProps) {\n  const root = useRoot()\n  const labels = React.useContext(LabelRegistry)\n  const name = label ?? root.labelOf(id)\n  React.useEffect(() => {\n    if (label && labels) labels.current.set(id, label)\n    return () => {\n      if (labels) labels.current.delete(id)\n    }\n  }, [id, label, labels])\n\n  const sortable = useSortable({\n    id,\n    disabled: root.disabled || itemDisabled,\n    transition: root.reduced ? null : { duration: 200, easing: \"cubic-bezier(0.2, 0.8, 0.2, 1)\" },\n    animateLayoutChanges: root.reduced ? () => false : undefined,\n  })\n  const { setNodeRef, setActivatorNodeRef, attributes, listeners, transform, transition, isDragging } = sortable\n\n  // what the floating copy shows: the same content, drawn without the sortable hook\n  const content = (\n    <div data-slot=\"sortable-item\" data-overlay=\"\" role=\"listitem\" className={cn(itemBase, \"cursor-grabbing scale-[1.02] shadow-xl ring-1 ring-ring/30\", className)} {...props}>\n      <ItemCtx.Provider value={{ id, label: name, attributes: null, listeners: undefined, setActivatorNodeRef: null, dragging: true, overlay: true }}>{children}</ItemCtx.Provider>\n    </div>\n  )\n  const latest = React.useRef(content)\n  latest.current = content\n  const { register } = root\n  React.useEffect(() => register(id, () => latest.current), [register, id])\n\n  const whole = dragEverywhere && !(root.disabled || itemDisabled)\n  return (\n    <div\n      ref={setNodeRef}\n      data-slot=\"sortable-item\"\n      data-dragging={isDragging || undefined}\n      data-sortable-id={id}\n      style={{ transform: DndCSS.Translate.toString(transform), transition }}\n      className={cn(\n        itemBase,\n        whole && \"cursor-grab touch-none focus-visible:ring-[3px] focus-visible:ring-ring/50 active:cursor-grabbing\",\n        \"data-[dragging=true]:border-dashed data-[dragging=true]:opacity-40 data-[dragging=true]:shadow-none\",\n        className\n      )}\n      {...(whole ? { ...attributes, ...listeners, \"aria-label\": name, tabIndex: 0 } : {})}\n      {...props}\n      role=\"listitem\"\n    >\n      <ItemCtx.Provider value={{ id, label: name, attributes, listeners, setActivatorNodeRef, dragging: isDragging, overlay: false }}>{children}</ItemCtx.Provider>\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------- handle and move buttons\n\ntype SortableHandleProps = Omit<React.ComponentProps<\"button\">, \"children\"> & {\n  /** Replace the grip icon. */\n  children?: React.ReactNode\n}\n\n/** The grip that starts a drag. It is a button, so the keyboard can lift the item with Space or Enter. */\nfunction SortableHandle({ className, children, \"aria-label\": ariaLabel, ...props }: SortableHandleProps) {\n  const msg = useMessages()\n  const item = useItem()\n  const root = useRoot()\n  const grip = children ?? <GripVertical className=\"size-4\" aria-hidden=\"true\" />\n  const classes = cn(\n    \"inline-flex size-8 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 active:cursor-grabbing disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none\",\n    className\n  )\n  if (item.overlay) {\n    return (\n      <span data-slot=\"sortable-handle\" aria-hidden=\"true\" className={cn(classes, \"cursor-grabbing text-foreground\")}>\n        {grip}\n      </span>\n    )\n  }\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"sortable-handle\"\n      ref={item.setActivatorNodeRef}\n      disabled={root.disabled}\n      aria-label={ariaLabel ?? msg(\"sortable.handle\", \"Reorder {item}\", { item: item.label })}\n      className={classes}\n      {...item.attributes}\n      {...item.listeners}\n      {...props}\n    >\n      {grip}\n    </button>\n  )\n}\n\ntype SortableMoveProps = Omit<React.ComponentProps<\"button\">, \"children\"> & {\n  /** Which way the item goes: toward the start of the list, or toward the end. */\n  direction: \"previous\" | \"next\"\n  children?: React.ReactNode\n}\n\n/**\n * A plain button that moves the item one place. Dragging is hard or impossible for some people, so give them this too.\n */\nfunction SortableMove({ direction, className, children, \"aria-label\": ariaLabel, ...props }: SortableMoveProps) {\n  const msg = useMessages()\n  const item = useItem()\n  const root = useRoot()\n  if (item.overlay) return null\n  const place = root.locate(item.id)\n  const atEdge = !place || (direction === \"previous\" ? place.index === 0 : place.index === place.count - 1)\n  const vertical = root.orientation === \"vertical\"\n  const Icon = vertical ? (direction === \"previous\" ? ChevronUp : ChevronDown) : direction === \"previous\" ? ChevronLeft : ChevronRight\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"sortable-move\"\n      data-sortable-move={`${item.id}:${direction}`}\n      disabled={root.disabled || atEdge}\n      onClick={() => root.move(item.id, direction === \"previous\" ? -1 : 1)}\n      aria-label={ariaLabel ?? (direction === \"previous\" ? msg(\"sortable.moveEarlier\", \"Move {item} earlier\", { item: item.label }) : msg(\"sortable.moveLater\", \"Move {item} later\", { item: item.label }))}\n      className={cn(\n        \"inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none\",\n        !vertical && \"rtl:rotate-180\",\n        className\n      )}\n      {...props}\n    >\n      {children ?? <Icon className=\"size-4\" aria-hidden=\"true\" />}\n    </button>\n  )\n}\n\n// ---------------------------------------------------------------- SortableGroups: several lists, items move between them\n\ntype SortableGroupsValue = Record<string, string[]>\ntype SortableGroupsInfo = { activeId: string; fromGroup: string; toGroup: string; from: number; to: number }\n\ntype SortableGroupsProps = Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> & {\n  /** The ids in each group: `{ todo: [\"a\", \"b\"], doing: [\"c\"] }`. The object's key order is the order of the groups. */\n  value: SortableGroupsValue\n  /** Called with the new arrangement when an item is dropped or moved with a button. Keep it in state and render the items from it. */\n  onValueChange: (groups: SortableGroupsValue, info: SortableGroupsInfo) => void\n  /** What the set of lists is: \"Board\". */\n  label: string\n  getLabel?: (id: string) => string\n  /** Says a group's name in announcements. Defaults to the group id. */\n  getGroupLabel?: (groupId: string) => string\n  /** Orientation of the items inside each group. */\n  orientation?: SortableOrientation\n  disabled?: boolean\n  children: React.ReactNode\n}\n\n/** Several lists that share items: drag a card from one column to another, by pointer, touch or keyboard. */\nfunction SortableGroups({\n  value: groups,\n  onValueChange,\n  label,\n  getLabel,\n  getGroupLabel,\n  orientation = \"vertical\",\n  disabled = false,\n  className,\n  children,\n  ...props\n}: SortableGroupsProps) {\n  const dir = useDirection()\n  const reduced = !!useReducedMotion()\n  // while an item is carried between lists the new arrangement is held here, and only given to the caller when it is dropped\n  const [draft, setDraft] = React.useState<SortableGroupsValue | null>(null)\n  const current = draft ?? groups\n  const labels = React.useRef(new Map<string, string>())\n  const labelOf = React.useCallback((id: string) => getLabel?.(id) ?? labels.current.get(id) ?? id, [getLabel])\n  const groupLabel = (g: string) => getGroupLabel?.(g) ?? g\n  const engine = useEngineState(labelOf)\n  const { msg, activeId, setActiveId } = engine\n  const sensors = useSortableSensors()\n  const origin = React.useRef<{ group: string; index: number } | null>(null)\n\n  const groupOf = (id: UniqueIdentifier, from: SortableGroupsValue = current) => (id in from ? String(id) : Object.keys(from).find((g) => from[g]!.includes(String(id))))\n  const commit = (next: SortableGroupsValue, info: SortableGroupsInfo) => onValueChange(next, info)\n\n  const collision: CollisionDetection = (args) => {\n    const pointer = pointerWithin(args)\n    const hits = pointer.length ? pointer : rectIntersection(args)\n    return hits.length ? hits : closestCorners(args)\n  }\n\n  const spot = (id: UniqueIdentifier) => {\n    const g = groupOf(id)\n    return g ? { group: g, position: String(id) in current ? current[String(id)]!.length : current[g]!.indexOf(String(id)) + 1, total: current[g]!.length } : null\n  }\n\n  const announcements: Announcements = {\n    onDragStart: ({ active }) => {\n      const s = spot(active.id)\n      return s ? msg(\"sortable.groupPickedUp\", \"Picked up {item} from {group}. It is position {position} of {total}.\", { item: labelOf(String(active.id)), group: groupLabel(s.group), position: s.position, total: s.total }) : undefined\n    },\n    onDragOver: ({ active, over }) => {\n      const s = over ? spot(over.id) : null\n      return s ? msg(\"sortable.groupOver\", \"{item} is over {group}, position {position} of {total}.\", { item: labelOf(String(active.id)), group: groupLabel(s.group), position: Math.max(1, s.position), total: s.total }) : undefined\n    },\n    onDragEnd: ({ active }) => {\n      const s = spot(active.id)\n      return s ? msg(\"sortable.groupDropped\", \"Dropped {item} in {group} at position {position} of {total}.\", { item: labelOf(String(active.id)), group: groupLabel(s.group), position: s.position, total: s.total }) : undefined\n    },\n    onDragCancel: ({ active }) => msg(\"sortable.groupCancelled\", \"Cancelled. {item} went back to {group}.\", { item: labelOf(String(active.id)), group: groupLabel(origin.current?.group ?? \"\") }),\n  }\n\n  const onDragStart = (e: DragStartEvent) => {\n    const id = String(e.active.id)\n    setActiveId(id)\n    const g = groupOf(id)\n    origin.current = g ? { group: g, index: current[g]!.indexOf(id) } : null\n  }\n\n  const onDragOver = (e: DragOverEvent) => {\n    const { active, over } = e\n    if (!over) return\n    const from = groupOf(active.id)\n    const to = groupOf(over.id)\n    if (!from || !to || from === to) return\n    const base = draft ?? groups\n    const fromList = base[from]!\n    const toList = base[to]!\n    const activeIndex = fromList.indexOf(String(active.id))\n    const overIndex = String(over.id) in base ? toList.length : toList.indexOf(String(over.id))\n    const rect = active.rect.current.translated\n    const below = rect && over.rect ? (orientation === \"horizontal\" ? rect.left > over.rect.left + over.rect.width / 2 : rect.top > over.rect.top + over.rect.height / 2) : false\n    const at = overIndex < 0 ? toList.length : overIndex + (String(over.id) in base ? 0 : below ? 1 : 0)\n    setDraft({\n      ...base,\n      [from]: fromList.filter((x) => x !== String(active.id)),\n      [to]: [...toList.slice(0, at), fromList[activeIndex]!, ...toList.slice(at)],\n    })\n  }\n\n  const onDragEnd = (e: DragEndEvent) => {\n    setActiveId(null)\n    const { active, over } = e\n    const start = origin.current\n    origin.current = null\n    let next = draft ?? groups\n    setDraft(null)\n    if (over && start) {\n      const g = groupOf(active.id, next)\n      const overGroup = groupOf(over.id, next)\n      if (g && overGroup === g && active.id !== over.id) {\n        const list = next[g]!\n        const from = list.indexOf(String(active.id))\n        const to = String(over.id) in next ? list.length - 1 : list.indexOf(String(over.id))\n        if (from >= 0 && to >= 0) next = { ...next, [g]: arrayMove(list, from, to) }\n      }\n    }\n    const end = groupOf(active.id, next)\n    if (!start || !end) return\n    const index = next[end]!.indexOf(String(active.id))\n    if (end === start.group && index === start.index) return\n    commit(next, { activeId: String(active.id), fromGroup: start.group, toGroup: end, from: start.index, to: index })\n  }\n\n  const move = React.useCallback(\n    (id: string, delta: -1 | 1) => {\n      const g = groupOf(id, groups)\n      if (!g) return\n      const list = groups[g]!\n      const from = list.indexOf(id)\n      const to = from + delta\n      if (to < 0 || to >= list.length) return\n      commit({ ...groups, [g]: arrayMove(list, from, to) }, { activeId: id, fromGroup: g, toGroup: g, from, to })\n      engine.setSpoken(msg(\"sortable.groupMoved\", \"Moved {item} to position {position} of {total} in {group}.\", { item: labelOf(id), position: to + 1, total: list.length, group: groupLabel(g) }))\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [groups, labelOf]\n  )\n  useRefocusAfterMove(groups)\n\n  const ctx: RootContext = {\n    orientation,\n    disabled,\n    reduced,\n    rtl: dir === \"rtl\",\n    labelOf,\n    locate: (id) => {\n      const g = groupOf(id)\n      return g ? { group: g, index: current[g]!.indexOf(id), count: current[g]!.length } : null\n    },\n    move,\n    register: engine.register,\n    activeId,\n  }\n\n  return (\n    <RootCtx.Provider value={ctx}>\n      <LabelRegistry.Provider value={labels}>\n        <GroupsCtx.Provider value={{ groups: current, groupLabel }}>\n          <DndContext\n            sensors={sensors}\n            collisionDetection={collision}\n            onDragStart={onDragStart}\n            onDragOver={onDragOver}\n            onDragEnd={onDragEnd}\n            onDragCancel={() => {\n              setActiveId(null)\n              setDraft(null)\n            }}\n            accessibility={{ announcements, screenReaderInstructions: { draggable: engine.instructions } }}\n          >\n            <div role=\"group\" aria-label={label} data-slot=\"sortable-groups\" className={cn(\"flex items-start gap-4 overflow-x-auto\", className)} {...props}>\n              {children}\n            </div>\n            <Overlay engine={engine} reduced={reduced} />\n          </DndContext>\n          <span data-slot=\"sortable-status\" role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n            {engine.spoken}\n          </span>\n        </GroupsCtx.Provider>\n      </LabelRegistry.Provider>\n    </RootCtx.Provider>\n  )\n}\n\nconst GroupsCtx = React.createContext<{ groups: SortableGroupsValue; groupLabel: (g: string) => string } | null>(null)\n\ntype SortableGroupProps = Omit<React.ComponentProps<\"div\">, \"id\"> & {\n  /** The group's key in `value`. */\n  id: string\n  /** The group's name for screen readers. Defaults to `getGroupLabel(id)`. */\n  label?: string\n}\n\n/** One list inside `SortableGroups`. Put `SortableItem`s in it. An empty group still accepts drops. */\nfunction SortableGroup({ id, label, className, children, ...props }: SortableGroupProps) {\n  const root = useRoot()\n  const g = React.useContext(GroupsCtx)\n  if (!g) throw new Error(\"SortableGroup must be used inside <SortableGroups>.\")\n  const ids = g.groups[id] ?? []\n  const { setNodeRef, isOver } = useDroppable({ id })\n  return (\n    <SortableContext items={ids} strategy={strategyFor(root.orientation)} disabled={root.disabled}>\n      <div\n        ref={setNodeRef}\n        role=\"list\"\n        aria-label={label ?? g.groupLabel(id)}\n        data-slot=\"sortable-group\"\n        data-over={isOver || undefined}\n        className={cn(\n          \"flex min-h-24 min-w-0 flex-1 gap-2 rounded-xl border border-dashed bg-muted/40 p-2 transition-colors data-[over=true]:border-ring data-[over=true]:bg-accent/60 motion-reduce:transition-none\",\n          root.orientation === \"vertical\" ? \"flex-col\" : \"flex-row flex-wrap\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n      </div>\n    </SortableContext>\n  )\n}\n\nexport {\n  Sortable,\n  SortableGroup,\n  SortableGroups,\n  SortableHandle,\n  SortableItem,\n  SortableMove,\n  type SortableGroupProps,\n  type SortableGroupsInfo,\n  type SortableGroupsProps,\n  type SortableGroupsValue,\n  type SortableHandleProps,\n  type SortableInfo,\n  type SortableItemProps,\n  type SortableMoveProps,\n  type SortableOrientation,\n  type SortableProps,\n}\n",
      "type": "registry:ui",
      "target": "@components/ballmac/sortable.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "drag",
      "drop",
      "reorder",
      "sortable",
      "dnd",
      "kanban",
      "keyboard"
    ],
    "version": "1.0.0",
    "updated": "2026-10-19",
    "tests": true,
    "ai": {
      "summary": "Hold the order in state: <Sortable label value={ids} onValueChange={setIds}> with a <SortableItem id> per id, each holding a <SortableHandle>. SortableGroups and SortableGroup move items between lists.",
      "whenToUse": [
        "Playlists, checklists, filters and settings people put in their own order",
        "Dashboard tiles in a grid and chips in a row",
        "Boards where cards move between columns"
      ],
      "whenNotToUse": [
        "A full kanban product with cards, tags and assignees; use kanban-board",
        "Sorting by a column's value; use data-table or data-grid",
        "Reordering rows of a huge virtualized list"
      ],
      "composesWith": [
        "button",
        "badge",
        "kanban-board"
      ],
      "a11y": [
        {
          "keys": "Space / Enter on the handle",
          "action": "Picks up the item; the same keys drop it"
        },
        {
          "keys": "Arrow keys",
          "action": "Move the lifted item; the new position is announced"
        },
        {
          "keys": "Escape",
          "action": "Cancels and puts the item back"
        },
        {
          "keys": "SortableMove buttons",
          "action": "Move an item one place with a click, for people who cannot drag (WCAG 2.5.7)"
        },
        {
          "keys": "Screen readers",
          "action": "Instructions, picked-up, over, dropped and cancelled messages are read out, in the page's language"
        }
      ],
      "customization": [
        "orientation: vertical | horizontal | grid",
        "dragEverywhere on an item to drag by its whole body instead of a handle",
        "overlay={false} to keep the lifted item in place",
        "getLabel and getGroupLabel for spoken names",
        "className on the list, the group and each item"
      ]
    },
    "statesNone": "Nothing loads or fails: it reorders the items it is given; an empty list or group simply draws no items.",
    "source": {
      "name": "dnd-kit",
      "url": "https://github.com/clauderic/dnd-kit",
      "license": "MIT",
      "copyright": "Copyright (c) 2021, Claudéric Demers",
      "modified": true
    },
    "examples": [
      "sortable-demo",
      "sortable-grid",
      "sortable-horizontal",
      "sortable-board",
      "sortable-buttons"
    ],
    "url": "https://ui.ballmac.com/components/sortable"
  },
  "categories": [
    "primitives"
  ],
  "type": "registry:ui"
}