{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sortable-stack",
  "title": "Sortable Stack",
  "description": "Vertical list reordered by drag handle or keyboard, with spring motion and undo.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "@ikas/button",
    "@ikas/motion",
    "@ikas/utils"
  ],
  "files": [
    {
      "path": "registry/ikas/sortable-stack.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { type HTMLMotionProps, Reorder, useDragControls, useReducedMotion } from \"motion/react\"\nimport { GripVerticalIcon, Undo2Icon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { springOrInstant } from \"@/lib/motion\"\nimport { Button } from \"@/components/ui/button\"\n\ntype MoveDirection = -1 | 1 | \"first\" | \"last\"\n\ntype StackContextValue = {\n  ids: string[]\n  labels: Map<string, string>\n  label: string\n  disabled: boolean\n  instructions: string\n  itemClassName?: string\n  canUndo: boolean\n  /** True while any row is being dragged, so the other rows drop their hover. */\n  dragging: 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}\n\nconst StackContext = React.createContext<StackContextValue | null>(null)\nconst ItemContext = React.createContext<{\n  id: string\n  label: string\n  controls: ReturnType<typeof useDragControls>\n  dragging: boolean\n} | null>(null)\n\nfunction useStackContext(part: string) {\n  const context = React.useContext(StackContext)\n  if (!context) throw new Error(`${part} must be used within SortableStack.`)\n  return context\n}\n\n/** Order and undo actions for your own controls, e.g. a \"Move to top\" menu item. */\nfunction useSortableStack() {\n  const context = useStackContext(\"useSortableStack\")\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\ntype SortableStackProps<T extends { id: string }> = {\n  /** Controlled items in their current order. Pair with `onItemsChange`. */\n  items?: T[]\n  /** Starting items when uncontrolled. */\n  defaultItems?: T[]\n  /** Called with the new order once a drag ends, and after each keyboard move and undo. Save it here if the order should persist. */\n  onItemsChange?: (items: T[]) => void\n  /** Row contents for the built-in layout (handle, content and undo) when `children` is omitted. */\n  renderItem?: (item: T, index: number) => React.ReactNode\n  /** Name read by screen readers in move announcements and handle labels. */\n  getItemLabel: (item: T) => string\n  /** Your own composition of the parts. A function receives the items in their current order. */\n  children?: React.ReactNode | ((items: T[]) => React.ReactNode)\n  /** Accessible name of the list. */\n  label?: string\n  /** Turns off dragging, keyboard moves and undo. */\n  disabled?: boolean\n  /** Shows the undo button under the list in the built-in layout. */\n  showUndo?: boolean\n  className?: string\n  /** Extra classes for every row. */\n  itemClassName?: string\n}\n\n/**\n * A vertical list the user reorders by dragging a handle or with the keyboard, with one step of undo.\n * Stable item ids keep focus and content attached to their row while it moves.\n */\nfunction SortableStack<T extends { id: string }>({\n  items: controlledItems,\n  defaultItems = [],\n  onItemsChange,\n  renderItem,\n  getItemLabel,\n  children,\n  label = \"Sıralanabilir öğeler\",\n  disabled = false,\n  showUndo = true,\n  className,\n  itemClassName,\n}: SortableStackProps<T>) {\n  const [internalItems, setInternalItems] = React.useState(defaultItems)\n  // The order while a drag is in progress; published once when it ends.\n  const [draft, setDraft] = React.useState<T[] | null>(null)\n  const source = controlledItems ?? internalItems\n  const items = draft ?? source\n  const ids = items.map((item) => item.id)\n  if (new Set(ids).size !== ids.length) throw new Error(\"SortableStack requires unique item ids.\")\n  if (children === undefined && !renderItem) throw new Error(\"SortableStack requires children or renderItem.\")\n  const [previous, setPrevious] = React.useState<string[] | null>(null)\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  const [dragging, setDragging] = React.useState(false)\n  const dragStart = React.useRef<string[] | null>(null)\n  const instructions = React.useId()\n  const root = React.useRef<HTMLDivElement>(null)\n  const pendingFocus = React.useRef<string | null>(null)\n  const live = React.useRef(items)\n\n  React.useLayoutEffect(() => {\n    live.current = items\n    if (pendingFocus.current !== null) {\n      const target = Array.from(root.current?.querySelectorAll<HTMLButtonElement>(\"button[data-sortable-id]\") ?? []).find(\n        (button) => button.dataset.sortableId === pendingFocus.current\n      )\n      pendingFocus.current = null\n      target?.focus({ preventScroll: true })\n    }\n  })\n\n  const moved = (item: T, index: number, count: number) => `${getItemLabel(item)}, ${count} öğeden ${index + 1}. sıraya taşındı.`\n\n  const change = (next: T[]) => {\n    live.current = next\n    if (controlledItems === undefined) setInternalItems(next)\n    onItemsChange?.(next)\n  }\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    // Rows can be removed during a drag. Only publish a full permutation.\n    if (next.length !== live.current.length || new Set(nextIds).size !== next.length) return\n    if (dragStart.current === null) return change(next)\n    live.current = next\n    setDraft(next)\n  }\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 = direction === \"first\" ? 0 : direction === \"last\" ? current.length - 1 : from + direction\n    if (disabled || from < 0 || to < 0 || to >= current.length || from === to) 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(moved(item, to, current.length))\n  }\n\n  if (previous !== null && (previous.length !== ids.length || previous.some((id) => !ids.includes(id)))) setPrevious(null)\n  const canUndo =\n    previous !== null && previous.length === ids.length && previous.every((id) => ids.includes(id)) && previous.some((id, index) => id !== ids[index])\n\n  const undo = () => {\n    if (disabled || !previous || !canUndo) return\n    pendingFocus.current = previous.find((id, index) => id !== ids[index]) ?? previous[0]\n    reorder(previous)\n    setPrevious(null)\n    setAnnouncement(\"Önceki sıra geri yüklendi.\")\n  }\n\n  const context: StackContextValue = {\n    ids,\n    labels: new Map(items.map((item) => [item.id, getItemLabel(item)])),\n    label,\n    disabled,\n    instructions,\n    itemClassName,\n    canUndo,\n    dragging,\n    reorder,\n    move,\n    undo,\n    beginDrag: () => {\n      dragStart.current = live.current.map((item) => item.id)\n      setDragging(true)\n    },\n    finishDrag: (id) => {\n      const before = dragStart.current\n      dragStart.current = null\n      setDragging(false)\n      setDraft(null)\n      // Apply the dragged order to the latest items, in case rows came or went during the drag.\n      const rank = new Map(live.current.map((item, index) => [item.id, index]))\n      const current = [...source].sort((a, b) => (rank.get(a.id) ?? Infinity) - (rank.get(b.id) ?? Infinity))\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        change(current)\n        setPrevious(before)\n        setAnnouncement(moved(item, index, current.length))\n      }\n    },\n  }\n\n  return (\n    <StackContext.Provider value={context}>\n      <div ref={root} data-slot=\"sortable-stack\" className={cn(\"w-full min-w-0\", className)}>\n        <p id={instructions} className=\"sr-only\">\n          Sıralamak için tutamacı sürükleyin. Tutamaç odaktayken yukarı ve aşağı oklarla taşıyın, Home ve End ile başa veya sona alın.\n        </p>\n        {children === undefined ? (\n          <>\n            <SortableStackGroup aria-label={label}>\n              {items.map((item, index) => (\n                <SortableStackItem key={item.id} id={item.id}>\n                  <SortableStackHandle />\n                  <SortableStackItemContent>{renderItem?.(item, index)}</SortableStackItemContent>\n                </SortableStackItem>\n              ))}\n            </SortableStackGroup>\n            {showUndo && <SortableStackUndo className=\"mt-3\" />}\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    </StackContext.Provider>\n  )\n}\n\ntype SortableStackGroupProps = Omit<HTMLMotionProps<\"ul\">, \"children\"> & {\n  children?: React.ReactNode\n}\n\n/** The ordered rows. Keep headers and the undo button outside it. */\nfunction SortableStackGroup({ className, ...props }: SortableStackGroupProps) {\n  const context = useStackContext(\"SortableStackGroup\")\n  return (\n    <Reorder.Group\n      {...props}\n      axis=\"y\"\n      values={context.ids}\n      onReorder={context.reorder}\n      data-slot=\"sortable-stack-group\"\n      aria-label={props[\"aria-label\"] ?? context.label}\n      className={cn(\"flex flex-col gap-0.5\", className)}\n    />\n  )\n}\n\ntype SortableStackItemProps = Omit<HTMLMotionProps<\"li\">, \"id\" | \"value\" | \"children\"> & {\n  /** Id of an item passed to the root. Use it as the React key too. */\n  id: string\n  children?: React.ReactNode\n}\n\n/** One row. Lifts with an overlay shadow while dragged and glides to its new place. */\nfunction SortableStackItem({ id, children, className, onDragStart, onDragEnd, ...props }: SortableStackItemProps) {\n  const context = useStackContext(\"SortableStackItem\")\n  const controls = useDragControls()\n  const reduce = useReducedMotion()\n  const [dragging, setDragging] = React.useState(false)\n  const label = context.labels.get(id)\n  if (label === undefined) throw new Error(`SortableStackItem ${id} is not present in the root items.`)\n\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={springOrInstant(reduce)}\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.02 }}\n        aria-posinset={context.ids.indexOf(id) + 1}\n        aria-setsize={context.ids.length}\n        data-slot=\"sortable-stack-item\"\n        data-dragging={dragging || undefined}\n        className={cn(\n          // bg-card stays on so a row gliding back over its neighbour never shows through.\n          \"relative flex items-center gap-2.5 rounded-lg bg-card py-1.5 pr-2.5 pl-1 transition-[background-color] duration-150 ease-out\",\n          // The lift shadow fades in faster than it fades out.\n          \"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:opacity-0 after:shadow-overlay after:transition-opacity after:duration-200 after:ease-(--ease-out) data-dragging:after:opacity-100 data-dragging:after:duration-150\",\n          !context.dragging && \"hover:bg-muted/60\",\n          context.itemClassName,\n          className\n        )}\n      >\n        {children}\n      </Reorder.Item>\n    </ItemContext.Provider>\n  )\n}\n\ntype SortableStackHandleProps = Omit<React.ComponentProps<\"button\">, \"color\">\n\n/** The grip that starts a drag. With focus, ↑ ↓ move one step and Home End move to the edges. */\nfunction SortableStackHandle({ children, className, disabled, onPointerDown, onKeyDown, style, ...props }: SortableStackHandleProps) {\n  const context = useStackContext(\"SortableStackHandle\")\n  const item = React.useContext(ItemContext)\n  if (!item) throw new Error(\"SortableStackHandle must be used within SortableStackItem.\")\n  const unavailable = disabled || context.disabled\n\n  return (\n    <Button\n      {...props}\n      type=\"button\"\n      variant=\"ghost\"\n      size=\"icon-sm\"\n      disabled={unavailable}\n      data-sortable-id={item.id}\n      aria-label={props[\"aria-label\"] ?? `Yeniden sırala: ${item.label}`}\n      aria-describedby={[context.instructions, props[\"aria-describedby\"]].filter(Boolean).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 (event.defaultPrevented || unavailable || event.altKey || event.ctrlKey || event.metaKey) return\n        const direction = event.key === \"ArrowUp\" ? -1 : event.key === \"ArrowDown\" ? 1 : event.key === \"Home\" ? \"first\" : event.key === \"End\" ? \"last\" : null\n        if (direction !== null) {\n          event.preventDefault()\n          context.move(item.id, direction)\n        }\n      }}\n      className={cn(\"touch-none text-icon hover:text-foreground\", item.dragging && \"bg-(--c-subtle) text-foreground\", className)}\n      style={{\n        cursor: unavailable ? undefined : item.dragging ? \"grabbing\" : \"grab\",\n        ...style,\n      }}\n    >\n      {children ?? <GripVerticalIcon aria-hidden />}\n    </Button>\n  )\n}\n\ntype SortableStackItemContentProps = React.ComponentProps<\"div\">\n\n/** Takes the row's free width; truncate long text inside it. */\nfunction SortableStackItemContent({ className, ...props }: SortableStackItemContentProps) {\n  return <div data-slot=\"sortable-stack-item-content\" className={cn(\"min-w-0 flex-1\", className)} {...props} />\n}\n\ntype SortableStackUndoProps = Omit<React.ComponentProps<\"button\">, \"color\">\n\n/** Restores the order before the last move. Disabled until something moves. */\nfunction SortableStackUndo({ children, className, disabled, onClick, ...props }: SortableStackUndoProps) {\n  const context = useStackContext(\"SortableStackUndo\")\n  return (\n    <Button\n      {...props}\n      type=\"button\"\n      variant=\"ghost\"\n      size=\"xs\"\n      disabled={disabled || context.disabled || !context.canUndo}\n      className={cn(\"text-muted-foreground hover:text-foreground\", className)}\n      onClick={(event) => {\n        onClick?.(event)\n        if (!event.defaultPrevented) context.undo()\n      }}\n    >\n      {children ?? (\n        <>\n          <Undo2Icon data-icon=\"inline-start\" aria-hidden />\n          Sırayı geri al\n        </>\n      )}\n    </Button>\n  )\n}\n\nexport {\n  SortableStack,\n  SortableStackGroup,\n  SortableStackItem,\n  SortableStackHandle,\n  SortableStackItemContent,\n  SortableStackUndo,\n  useSortableStack,\n  type SortableStackProps,\n  type SortableStackGroupProps,\n  type SortableStackItemProps,\n  type SortableStackHandleProps,\n  type SortableStackItemContentProps,\n  type SortableStackUndoProps,\n}\n",
      "type": "registry:component",
      "target": "@components/ikas/sortable-stack.tsx"
    }
  ],
  "docs": "https://builders.ikas.com/tr/docs/app-development/ui-kit/components/sortable-stack",
  "categories": [
    "motion",
    "data-display"
  ],
  "type": "registry:component"
}