{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sortable-stack","type":"registry:component","title":"Sortable List","description":"Composable sortable list with group, item, drag handle, content and undo parts, plus a shared state hook. Spring layout, keyboard reordering, accessible position announcements, and controlled or uncontrolled items.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/sortable-list.tsx","type":"registry:component","target":"@components/motion/sortable-list.tsx","content":"\"use client\";\n// beui.dev/components/motion/sortable-stack\n\nimport { GripVertical, Undo2 } from \"lucide-react\";\nimport {\n  type HTMLMotionProps,\n  Reorder,\n  useDragControls,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type ComponentPropsWithRef,\n  type ReactNode,\n  createContext,\n  useContext,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype MoveDirection = -1 | 1 | \"first\" | \"last\";\ntype ListContextValue = {\n  ids: string[];\n  labels: Map<string, string>;\n  label: string;\n  disabled: boolean;\n  instructions: string;\n  itemClassName?: string;\n  canUndo: boolean;\n  reorder: (ids: string[]) => void;\n  move: (id: string, direction: MoveDirection) => void;\n  undo: () => void;\n  beginDrag: () => void;\n  finishDrag: (id: string) => void;\n};\nconst ListContext = createContext<ListContextValue | null>(null);\nconst ItemContext = createContext<{\n  id: string;\n  label: string;\n  controls: ReturnType<typeof useDragControls>;\n  dragging: boolean;\n} | null>(null);\n\nfunction useListContext(part: string) {\n  const context = useContext(ListContext);\n  if (!context) throw new Error(`${part} must be used within SortableList.`);\n  return context;\n}\n\n/** Shared ordering and undo actions for custom list controls. */\nexport function useSortableList() {\n  const context = useListContext(\"useSortableList\");\n  return {\n    ids: context.ids,\n    disabled: context.disabled,\n    canUndo: context.canUndo,\n    moveItem: context.move,\n    undo: context.undo,\n  };\n}\n\nexport interface SortableListProps<T extends { id: string }> {\n  items?: T[];\n  defaultItems?: T[];\n  onItemsChange?: (items: T[]) => void;\n  /** Convenience row contents when children are omitted. */\n  renderItem?: (item: T, index: number) => ReactNode;\n  getItemLabel: (item: T) => string;\n  /** Compose parts; the render function receives items in their current order. */\n  children?: ReactNode | ((items: T[]) => ReactNode);\n  label?: string;\n  disabled?: boolean;\n  /** Show the automatic undo button in the convenience composition. */\n  showUndo?: boolean;\n  className?: string;\n  itemClassName?: string;\n}\n\n/** Stable item ids keep focus and content attached to their row during sorting. */\nexport function SortableList<T extends { id: string }>({\n  items: controlledItems,\n  defaultItems = [],\n  onItemsChange,\n  renderItem,\n  getItemLabel,\n  children,\n  label = \"Sortable items\",\n  disabled = false,\n  showUndo = true,\n  className,\n  itemClassName,\n}: SortableListProps<T>) {\n  const [internalItems, setInternalItems] = useState(defaultItems);\n  const items = controlledItems ?? internalItems;\n  const ids = items.map((item) => item.id);\n  if (new Set(ids).size !== ids.length)\n    throw new Error(\"SortableList requires unique item ids.\");\n  if (children === undefined && !renderItem)\n    throw new Error(\"SortableList requires children or renderItem.\");\n  const [previous, setPrevious] = useState<string[] | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const dragStart = useRef<string[] | null>(null);\n  const instructions = useId();\n  const root = useRef<HTMLDivElement>(null);\n  const pendingFocus = useRef<string | null>(null);\n  const live = useRef(items);\n  useLayoutEffect(() => {\n    live.current = items;\n    if (pendingFocus.current !== null) {\n      const target = Array.from(\n        root.current?.querySelectorAll<HTMLButtonElement>(\n          \"button[data-sortable-id]\",\n        ) ?? [],\n      ).find((button) => button.dataset.sortableId === pendingFocus.current);\n      pendingFocus.current = null;\n      target?.focus({ preventScroll: true });\n    }\n  });\n\n  const change = (next: T[]) => {\n    live.current = next;\n    if (controlledItems === undefined) setInternalItems(next);\n    onItemsChange?.(next);\n  };\n  const reorder = (nextIds: string[]) => {\n    if (disabled) return;\n    const byId = new Map(live.current.map((item) => [item.id, item]));\n    const next = nextIds.flatMap((id) => {\n      const item = byId.get(id);\n      return item ? [item] : [];\n    });\n    // Consumers can remove rows during a drag. Only publish a full permutation.\n    if (\n      next.length === live.current.length &&\n      new Set(nextIds).size === next.length\n    )\n      change(next);\n  };\n  const move = (id: string, direction: MoveDirection) => {\n    const current = live.current;\n    const from = current.findIndex((item) => item.id === id);\n    const to =\n      direction === \"first\"\n        ? 0\n        : direction === \"last\"\n          ? current.length - 1\n          : from + direction;\n    if (disabled || from < 0 || to < 0 || to >= current.length || from === to)\n      return;\n    const next = [...current];\n    const [item] = next.splice(from, 1);\n    next.splice(to, 0, item);\n    setPrevious(current.map((item) => item.id));\n    change(next);\n    setAnnouncement(\n      `${getItemLabel(item)} moved to position ${to + 1} of ${current.length}.`,\n    );\n  };\n  if (\n    previous !== null &&\n    (previous.length !== ids.length || previous.some((id) => !ids.includes(id)))\n  )\n    setPrevious(null);\n  const canUndo =\n    previous !== null &&\n    previous.length === ids.length &&\n    previous.every((id) => ids.includes(id)) &&\n    previous.some((id, index) => id !== ids[index]);\n  const undo = () => {\n    if (disabled || !previous || !canUndo) return;\n    pendingFocus.current =\n      previous.find((id, index) => id !== ids[index]) ?? previous[0];\n    reorder(previous);\n    setPrevious(null);\n    setAnnouncement(\"Previous order restored.\");\n  };\n  const context: ListContextValue = {\n    ids,\n    labels: new Map(items.map((item) => [item.id, getItemLabel(item)])),\n    label,\n    disabled,\n    instructions,\n    itemClassName,\n    canUndo,\n    reorder,\n    move,\n    undo,\n    beginDrag: () => {\n      dragStart.current = live.current.map((item) => item.id);\n    },\n    finishDrag: (id) => {\n      const before = dragStart.current;\n      dragStart.current = null;\n      const current = live.current;\n      const index = current.findIndex((item) => item.id === id);\n      const item = current[index];\n      if (item && before?.some((oldId, at) => oldId !== current[at]?.id)) {\n        setPrevious(before);\n        setAnnouncement(\n          `${getItemLabel(item)} moved to position ${index + 1} of ${current.length}.`,\n        );\n      }\n    },\n  };\n\n  return (\n    <ListContext.Provider value={context}>\n      <div ref={root} className={cn(\"w-full max-w-md\", className)}>\n        <p id={instructions} className=\"sr-only\">\n          Drag a handle to reorder. With a handle focused, use Up and Down to\n          move, Home and End to move to the edges.\n        </p>\n        {children === undefined ? (\n          <>\n            <SortableListGroup aria-label={label}>\n              {items.map((item, index) => (\n                <SortableListItem key={item.id} id={item.id}>\n                  <SortableListHandle />\n                  <SortableListItemContent>\n                    {renderItem?.(item, index)}\n                  </SortableListItemContent>\n                </SortableListItem>\n              ))}\n            </SortableListGroup>\n            {showUndo && <SortableListUndo />}\n          </>\n        ) : typeof children === \"function\" ? (\n          children(items)\n        ) : (\n          children\n        )}\n        <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {announcement}\n        </span>\n      </div>\n    </ListContext.Provider>\n  );\n}\n\nexport interface SortableListGroupProps\n  extends Omit<HTMLMotionProps<\"ul\">, \"children\"> {\n  children?: ReactNode;\n}\n\n/** The ordered rows; keep headers and footer controls outside this group. */\nexport function SortableListGroup({\n  className,\n  ...props\n}: SortableListGroupProps) {\n  const context = useListContext(\"SortableListGroup\");\n  return (\n    <Reorder.Group\n      {...props}\n      axis=\"y\"\n      values={context.ids}\n      onReorder={context.reorder}\n      aria-label={props[\"aria-label\"] ?? context.label}\n      className={cn(\"space-y-2\", className)}\n    />\n  );\n}\n\nexport interface SortableListItemProps\n  extends Omit<HTMLMotionProps<\"li\">, \"id\" | \"value\" | \"children\"> {\n  /** Identity of an item supplied to the root. Use it as the React key too. */\n  id: string;\n  children?: ReactNode;\n}\n\nexport function SortableListItem({\n  id,\n  children,\n  className,\n  onDragStart,\n  onDragEnd,\n  ...props\n}: SortableListItemProps) {\n  const context = useListContext(\"SortableListItem\");\n  const controls = useDragControls();\n  const reduce = useReducedMotion() ?? false;\n  const [dragging, setDragging] = useState(false);\n  const label = context.labels.get(id);\n  if (label === undefined)\n    throw new Error(`SortableListItem ${id} is not present in the root items.`);\n  return (\n    <ItemContext.Provider value={{ id, label, controls, dragging }}>\n      <Reorder.Item\n        {...props}\n        value={id}\n        dragListener={false}\n        dragControls={controls}\n        layout=\"position\"\n        transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n        onDragStart={(event, info) => {\n          setDragging(true);\n          context.beginDrag();\n          onDragStart?.(event, info);\n        }}\n        onDragEnd={(event, info) => {\n          setDragging(false);\n          context.finishDrag(id);\n          onDragEnd?.(event, info);\n        }}\n        whileDrag={reduce ? undefined : { scale: 1.025 }}\n        aria-posinset={context.ids.indexOf(id) + 1}\n        aria-setsize={context.ids.length}\n        className={cn(\n          \"relative flex items-center gap-3 rounded-2xl border border-border bg-background p-3\",\n          dragging && \"shadow-lg\",\n          context.itemClassName,\n          className,\n        )}\n      >\n        {children}\n      </Reorder.Item>\n    </ItemContext.Provider>\n  );\n}\n\nexport type SortableListHandleProps = ComponentPropsWithRef<\"button\">;\n\nexport function SortableListHandle({\n  children,\n  className,\n  disabled,\n  onPointerDown,\n  onKeyDown,\n  style,\n  ...props\n}: SortableListHandleProps) {\n  const context = useListContext(\"SortableListHandle\");\n  const item = useContext(ItemContext);\n  if (!item)\n    throw new Error(\"SortableListHandle must be used within SortableListItem.\");\n  const unavailable = disabled || context.disabled;\n  return (\n    <button\n      {...props}\n      type=\"button\"\n      disabled={unavailable}\n      data-sortable-id={item.id}\n      aria-label={props[\"aria-label\"] ?? `Reorder ${item.label}`}\n      aria-describedby={[context.instructions, props[\"aria-describedby\"]]\n        .filter(Boolean)\n        .join(\" \")}\n      onPointerDown={(event) => {\n        onPointerDown?.(event);\n        if (!event.defaultPrevented && !unavailable && event.button === 0) {\n          event.currentTarget.focus({ preventScroll: true });\n          item.controls.start(event);\n        }\n      }}\n      onKeyDown={(event) => {\n        onKeyDown?.(event);\n        if (\n          event.defaultPrevented ||\n          unavailable ||\n          event.altKey ||\n          event.ctrlKey ||\n          event.metaKey\n        )\n          return;\n        const direction =\n          event.key === \"ArrowUp\"\n            ? -1\n            : event.key === \"ArrowDown\"\n              ? 1\n              : event.key === \"Home\"\n                ? \"first\"\n                : event.key === \"End\"\n                  ? \"last\"\n                  : null;\n        if (direction !== null) {\n          event.preventDefault();\n          context.move(item.id, direction);\n        }\n      }}\n      className={cn(\n        \"flex size-9 shrink-0 touch-none items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40\",\n        className,\n      )}\n      style={{\n        cursor: unavailable ? undefined : item.dragging ? \"grabbing\" : \"grab\",\n        ...style,\n      }}\n    >\n      {children ?? <GripVertical size={18} aria-hidden=\"true\" />}\n    </button>\n  );\n}\n\nexport type SortableListItemContentProps = ComponentPropsWithRef<\"div\">;\nexport function SortableListItemContent({\n  className,\n  ...props\n}: SortableListItemContentProps) {\n  return <div {...props} className={cn(\"min-w-0 flex-1\", className)} />;\n}\n\nexport type SortableListUndoProps = ComponentPropsWithRef<\"button\">;\nexport function SortableListUndo({\n  children,\n  className,\n  disabled,\n  onClick,\n  ...props\n}: SortableListUndoProps) {\n  const context = useListContext(\"SortableListUndo\");\n  return (\n    <button\n      {...props}\n      type=\"button\"\n      disabled={disabled || context.disabled || !context.canUndo}\n      className={cn(\n        \"mt-4 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40\",\n        className,\n      )}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) context.undo();\n      }}\n    >\n      {children ?? (\n        <>\n          <Undo2 size={13} aria-hidden=\"true\" /> Undo reorder\n        </>\n      )}\n    </button>\n  );\n}\n"},{"path":"components/motion/sortable-stack.tsx","type":"registry:component","target":"@components/motion/sortable-stack.tsx","content":"\"use client\";\n// beui.dev/components/motion/sortable-stack\n\n// Preserve imports from the original registry installation.\nexport { SortableList, SortableList as SortableStack } from \"./sortable-list\";\nexport type {\n  SortableListProps,\n  SortableListProps as SortableStackProps,\n} from \"./sortable-list\";\nexport {\n  SortableListGroup,\n  SortableListItem,\n  SortableListHandle,\n  SortableListItemContent,\n  SortableListUndo,\n  useSortableList,\n} from \"./sortable-list\";\nexport type {\n  SortableListGroupProps,\n  SortableListItemProps,\n  SortableListHandleProps,\n  SortableListItemContentProps,\n  SortableListUndoProps,\n} from \"./sortable-list\";\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}