{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sortable","type":"registry:ui","title":"Sortable","description":"Items you put in order by dragging: a menu, priorities, a table's columns, a product's photos.","dependencies":["@dnd-kit/core","@dnd-kit/sortable","lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/lib-motion.json"],"files":[{"path":"registry/dashral/sortable.tsx","type":"registry:file","target":"components/dashral/sortable.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport {\n  type Announcements,\n  closestCenter,\n  type CollisionDetection,\n  DndContext,\n  type DragEndEvent,\n  KeyboardSensor,\n  type Modifier,\n  MouseSensor,\n  TouchSensor,\n  useSensor,\n  useSensors,\n} from \"@dnd-kit/core\"\nimport { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from \"@dnd-kit/sortable\"\nimport { GripVerticalIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cssDuration, reducedMotion } from \"./lib/motion\"\nimport { cn } from \"./lib/utils\"\n\ntype SortableItem = {\n  /** A stable, unique key: the order you get back is a list of these items. */\n  id: string\n  /** The row's main line, or the caption under a tile. */\n  title: React.ReactNode\n  /** A second line under it. */\n  description?: React.ReactNode\n  /** At the start of a row (an icon in a tile, an Avatar), or the picture that fills a tile in a grid. */\n  media?: React.ReactNode\n  /** At the end of a row, quiet: a status Badge, a count. */\n  meta?: React.ReactNode\n  /** At the very end of a row, or over a tile's corner: buttons, a Switch, a menu. They keep their own clicks. */\n  actions?: React.ReactNode\n  /** Its name as plain text, for screen readers, when title isn't text. */\n  label?: string\n  /** Stays where it is: it can't be picked up, and nothing can be dropped past it (a column that's always first). */\n  disabled?: boolean\n}\n\ntype SortableMove = { id: string; from: number; to: number }\n\ntype SortableProps = Omit<React.ComponentProps<\"ul\">, \"children\"> & {\n  items: SortableItem[]\n  /** The new order after each drop, and the move that made it. Without it the list keeps its order itself. */\n  onReorder?: (items: SortableItem[], move: SortableMove) => void\n  /** list: rows that move up and down. grid: tiles that move anywhere (a product's photos). */\n  layout?: \"list\" | \"grid\"\n  /** What you pick up: a grip at the start (the row's own buttons and fields stay free), or the whole item. A list\n   * defaults to the grip, a grid to the whole tile. */\n  dragBy?: \"handle\" | \"item\"\n  /** Its position before each item (1, 2, 3), for priorities: it follows the item while you drag. */\n  numbered?: boolean\n  /** default: rows on the page; card: inside a bordered card of their own. A list only. */\n  variant?: \"default\" | \"card\"\n  /** Lines between the rows. On by default. A list only. */\n  divided?: boolean\n  /** sm for a popover, a sidebar or a dense panel. */\n  size?: \"sm\" | \"default\"\n  /** Nothing can move. */\n  disabled?: boolean\n  /** Shown in place of the items when there are none, e.g. an EmptyState. */\n  empty?: React.ReactNode\n  classNames?: { item?: string; handle?: string; media?: string; title?: string; description?: string; meta?: string; actions?: string }\n}\n\n// A list moves only up and down; both stay inside the list, so a card's edge never clips the item you hold.\nconst verticalOnly: Modifier = ({ transform }) => ({ ...transform, x: 0 })\nconst insideList: Modifier = ({ transform, draggingNodeRect, containerNodeRect }) => {\n  if (!draggingNodeRect || !containerNodeRect) return transform\n  const x = Math.min(Math.max(transform.x, containerNodeRect.left - draggingNodeRect.left), containerNodeRect.right - draggingNodeRect.right)\n  const y = Math.min(Math.max(transform.y, containerNodeRect.top - draggingNodeRect.top), containerNodeRect.bottom - draggingNodeRect.bottom)\n  return { ...transform, x, y }\n}\n\n// Pressing a button, link or field inside an item never picks the item up.\nconst interactive = \"button, a[href], input, textarea, select, label, [contenteditable=''], [contenteditable='true'], [role='switch'], [role='checkbox'], [data-no-drag]\"\n\n/**\n * Items you put in order by dragging: a menu, priorities, a table's columns, a product's photos. Rows in a list or\n * tiles in a grid, in one tag, with the same parts as List. Pick one up with the pointer (press and hold on a touch\n * screen, or grab its grip), or from the keyboard with Space, move it with the arrow keys and drop it with Space; each\n * step is announced. The others slide out of its way, and onReorder gets the new order.\n */\nfunction Sortable({\n  items,\n  onReorder,\n  layout = \"list\",\n  dragBy = layout === \"grid\" ? \"item\" : \"handle\",\n  numbered = false,\n  variant = \"default\",\n  divided = true,\n  size = \"default\",\n  disabled = false,\n  empty,\n  className,\n  classNames,\n  ...props\n}: SortableProps) {\n  const { t } = useLocale()\n  const id = React.useId()\n  const grid = layout === \"grid\"\n\n  // The order on screen. It follows `items` whenever their ids or order change (you saved a move, added an item), and\n  // keeps its own moves otherwise, so the list works with or without onReorder.\n  const key = items.map((item) => item.id).join(\"\\u0000\")\n  const [seen, setSeen] = React.useState(key)\n  const [order, setOrder] = React.useState(() => items.map((item) => item.id))\n  if (key !== seen) {\n    setSeen(key)\n    setOrder(items.map((item) => item.id))\n  }\n  const byId = new Map(items.map((item) => [item.id, item]))\n  const shown = order.map((itemId) => byId.get(itemId)).filter((item) => item !== undefined)\n\n  const sensors = useSensors(\n    useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),\n    // A grip is made for dragging, so a touch on it starts at once; a whole item waits for a press and hold, so a\n    // swipe across the list still scrolls the page.\n    useSensor(TouchSensor, { activationConstraint: dragBy === \"handle\" ? { distance: 4 } : { delay: 200, tolerance: 6 } }),\n    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, keyboardCodes: { start: [\"Space\", \"Enter\"], cancel: [\"Escape\"], end: [\"Space\", \"Enter\"] } })\n  )\n\n  const name = (itemId: string | number) => {\n    const item = byId.get(String(itemId))\n    if (!item) return String(itemId)\n    return item.label ?? (typeof item.title === \"string\" || typeof item.title === \"number\" ? String(item.title) : item.id)\n  }\n  // Items that stay put split the list into stretches; an item moves only within its own stretch, so nothing ever\n  // pushes a fixed one out of its place.\n  const fixed = new Set(shown.filter((item) => item.disabled).map((item) => item.id))\n  const stretch = (itemId: string) => {\n    let s = 0\n    for (const other of order) {\n      if (other === itemId) return s\n      if (fixed.has(other)) s++\n    }\n    return -1\n  }\n  const collisionDetection: CollisionDetection = (args) => {\n    if (fixed.size === 0) return closestCenter(args)\n    const own = stretch(String(args.active.id))\n    return closestCenter({ ...args, droppableContainers: args.droppableContainers.filter((c) => !fixed.has(String(c.id)) && stretch(String(c.id)) === own) })\n  }\n  const position = (itemId: string | number | undefined) => (itemId === undefined ? 0 : order.indexOf(String(itemId)) + 1)\n  const count = order.length\n  // Picking an item up puts it over itself: that's not a move yet, so the \"Picked up\" line isn't talked over.\n  const moved = React.useRef(false)\n  const announcements: Announcements = {\n    onDragStart: ({ active }) => {\n      moved.current = false\n      return t(\"sortablePicked\", { name: name(active.id), position: position(active.id), count })\n    },\n    onDragOver: ({ active, over }) => {\n      if (!over || (!moved.current && over.id === active.id)) return undefined\n      moved.current = true\n      return t(\"sortableOver\", { name: name(active.id), position: position(over.id), count })\n    },\n    onDragEnd: ({ active, over }) => (over ? t(\"sortableDropped\", { name: name(active.id), position: position(over.id), count }) : undefined),\n    onDragCancel: ({ active }) => t(\"sortableCancelled\", { name: name(active.id), position: position(active.id) }),\n  }\n\n  function onDragEnd({ active, over }: DragEndEvent) {\n    if (!over || active.id === over.id) return\n    const from = order.indexOf(String(active.id))\n    const to = order.indexOf(String(over.id))\n    if (from < 0 || to < 0 || stretch(order[from]!) !== stretch(order[to]!)) return\n    const next = arrayMove(order, from, to)\n    setOrder(next)\n    onReorder?.(next.map((itemId) => byId.get(itemId)).filter((item) => item !== undefined), { id: String(active.id), from, to })\n  }\n\n  if (items.length === 0 && empty !== undefined) return <>{empty}</>\n\n  return (\n    <DndContext\n      id={id}\n      sensors={sensors}\n      collisionDetection={collisionDetection}\n      modifiers={grid ? [insideList] : [verticalOnly, insideList]}\n      accessibility={{ announcements, screenReaderInstructions: { draggable: t(\"sortableInstructions\") } }}\n      onDragEnd={onDragEnd}\n    >\n      <SortableContext items={order} strategy={grid ? rectSortingStrategy : verticalListSortingStrategy} disabled={disabled}>\n        <ul\n          role=\"list\"\n          data-slot=\"sortable\"\n          data-layout={layout}\n          className={cn(\n            grid\n              ? \"grid grid-cols-[repeat(auto-fill,minmax(8.5rem,1fr))] gap-3\"\n              : cn(\"grid\", divided && \"divide-y\", variant === \"card\" && \"overflow-hidden rounded-xl border bg-card text-card-foreground shadow-xs\"),\n            \"text-sm\",\n            className\n          )}\n          {...props}\n        >\n          {shown.map((item, i) => (\n            <SortableRow\n              key={item.id}\n              item={item}\n              index={i}\n              grid={grid}\n              dragBy={dragBy}\n              numbered={numbered}\n              rounded={variant === \"default\" && !divided}\n              sm={size === \"sm\"}\n              disabled={disabled || !!item.disabled}\n              label={name(item.id)}\n              classNames={classNames}\n            />\n          ))}\n        </ul>\n      </SortableContext>\n    </DndContext>\n  )\n}\n\nfunction SortableRow({\n  item,\n  index,\n  grid,\n  dragBy,\n  numbered,\n  rounded,\n  sm,\n  disabled,\n  label,\n  classNames,\n}: {\n  item: SortableItem\n  index: number\n  grid: boolean\n  dragBy: \"handle\" | \"item\"\n  numbered: boolean\n  rounded: boolean\n  sm: boolean\n  disabled: boolean\n  label: string\n  classNames: SortableProps[\"classNames\"]\n}) {\n  const { t, locale } = useLocale()\n  // The others slide out of the way on the kit's standard timing; with reduced motion they're simply there. Read once\n  // mounted, so the server and the first render agree.\n  const [transition, setTransition] = React.useState<{ duration: number; easing: string } | null>({ duration: 240, easing: \"cubic-bezier(0.2, 0, 0, 1)\" })\n  React.useEffect(() => {\n    setTransition(reducedMotion() ? null : { duration: cssDuration(\"--duration-standard\", 240), easing: \"cubic-bezier(0.2, 0, 0, 1)\" })\n  }, [])\n  const sortable = useSortable({ id: item.id, disabled, transition })\n  const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, isDragging, activeIndex, overIndex } = sortable\n\n  // Where it sits right now, while something is held: the numbers count with the items as they slide.\n  let place = index\n  if (activeIndex >= 0 && overIndex >= 0) {\n    if (index === activeIndex) place = overIndex\n    else if (activeIndex < index && index <= overIndex) place = index - 1\n    else if (overIndex <= index && index < activeIndex) place = index + 1\n  }\n\n  const handleDrag = dragBy === \"handle\"\n  // The whole item: skip presses that start on its own buttons and fields.\n  const itemListeners =\n    !handleDrag && listeners\n      ? Object.fromEntries(\n          Object.entries(listeners).map(([event, handler]) => [\n            event,\n            (e: React.SyntheticEvent) => {\n              const target = e.target as Element\n              if (e.type !== \"keydown\" && target !== e.currentTarget && target.closest(interactive)) return\n              ;(handler as (e: React.SyntheticEvent) => void)(e)\n            },\n          ])\n        )\n      : undefined\n\n  const roledescription = t(\"sortableItem\")\n  const grip = handleDrag && (\n    <button\n      type=\"button\"\n      ref={setActivatorNodeRef}\n      {...attributes}\n      {...listeners}\n      aria-roledescription={roledescription}\n      aria-label={t(\"sortableReorder\", { name: label })}\n      disabled={disabled}\n      data-slot=\"sortable-handle\"\n      className={cn(\n        \"grid shrink-0 cursor-grab touch-none place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 active:cursor-grabbing disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent\",\n        sm ? \"size-6 [&_svg]:size-3.5\" : \"size-7 [&_svg]:size-4\",\n        grid && \"absolute start-3 top-3 z-10 bg-background/85 shadow-xs backdrop-blur-sm\",\n        classNames?.handle\n      )}\n    >\n      <GripVerticalIcon aria-hidden />\n    </button>\n  )\n  const number = numbered && (\n    <span\n      aria-hidden\n      className={cn(\n        \"shrink-0 text-center font-medium text-muted-foreground tabular-nums\",\n        grid\n          ? cn(\"absolute top-1.5 z-10 grid h-6 min-w-6 place-items-center rounded-md bg-background/85 px-1.5 text-xs shadow-xs backdrop-blur-sm\", handleDrag ? \"start-11\" : \"start-3\")\n          : \"min-w-5 text-xs\"\n      )}\n    >\n      {new Intl.NumberFormat(locale).format(place + 1)}\n    </span>\n  )\n\n  const style: React.CSSProperties = {\n    transform: transform ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` : undefined,\n    transition: sortable.transition,\n  }\n\n  // The item you hold rises above the rest: a card's surface and a shadow, the cursor a closed hand.\n  const lifted = isDragging && \"relative z-20 cursor-grabbing bg-card shadow-lg ring-1 ring-border\"\n\n  if (grid) {\n    return (\n      <li\n        ref={setNodeRef}\n        style={style}\n        data-slot=\"sortable-item\"\n        data-dragging={isDragging || undefined}\n        data-disabled={disabled || undefined}\n        {...(!handleDrag ? { ...attributes, \"aria-roledescription\": roledescription, \"aria-label\": label } : {})}\n        {...itemListeners}\n        className={cn(\n          // A little room all round, outside the tile's place (the negative margin): unseen at rest, it's the card's padding\n          // when the tile is lifted and the focus ring's gap.\n          \"group/sortable relative -m-1.5 grid min-w-0 content-start gap-2 rounded-xl p-1.5 outline-none focus-visible:ring-2 focus-visible:ring-ring/50\",\n          !handleDrag && !disabled && \"cursor-grab touch-manipulation\",\n          disabled && !handleDrag && \"opacity-60\",\n          lifted,\n          classNames?.item\n        )}\n      >\n        {grip}\n        {number}\n        <div\n          className={cn(\n            \"relative aspect-square overflow-hidden rounded-lg border bg-muted [&_img]:size-full [&_img]:object-cover [&_img]:select-none [&_img]:[-webkit-user-drag:none]\",\n            classNames?.media\n          )}\n        >\n          {item.media}\n        </div>\n        {item.actions !== undefined && (\n          <div className={cn(\"absolute end-3 top-3 z-10 flex items-center gap-1\", classNames?.actions)}>{item.actions}</div>\n        )}\n        <div className=\"grid min-w-0 gap-0.5 px-0.5\">\n          <span className={cn(\"truncate font-medium\", sm ? \"text-xs\" : \"text-[13px]\", classNames?.title)}>{item.title}</span>\n          {item.description && <span className={cn(\"truncate text-xs text-muted-foreground\", classNames?.description)}>{item.description}</span>}\n          {item.meta !== undefined && <div className={cn(\"flex items-center gap-2 text-xs text-muted-foreground tabular-nums\", classNames?.meta)}>{item.meta}</div>}\n        </div>\n      </li>\n    )\n  }\n\n  const trailing = item.meta !== undefined || item.actions !== undefined\n  return (\n    <li\n      ref={setNodeRef}\n      style={style}\n      data-slot=\"sortable-item\"\n      data-dragging={isDragging || undefined}\n      data-disabled={disabled || undefined}\n      {...(!handleDrag ? { ...attributes, \"aria-roledescription\": roledescription, \"aria-label\": label } : {})}\n      {...itemListeners}\n      className={cn(\n        \"relative flex items-center outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset\",\n        sm ? \"gap-2 px-2 py-1.5\" : \"gap-3 px-3 py-2.5\",\n        rounded && \"rounded-lg\",\n        !handleDrag && !disabled && \"cursor-grab touch-manipulation\",\n        disabled && !handleDrag && \"opacity-60\",\n        lifted,\n        isDragging && \"rounded-lg\",\n        classNames?.item\n      )}\n    >\n      {grip}\n      {number}\n      {item.media && <div className={cn(\"flex shrink-0 items-center\", classNames?.media)}>{item.media}</div>}\n      <div className=\"grid min-w-0 flex-1 gap-0.5\">\n        <span className={cn(\"block truncate font-medium\", classNames?.title)}>{item.title}</span>\n        {item.description && (\n          <span className={cn(\"truncate text-muted-foreground\", sm ? \"text-xs\" : \"text-[13px]\", classNames?.description)}>{item.description}</span>\n        )}\n      </div>\n      {trailing && (\n        <div className=\"flex shrink-0 items-center gap-3\">\n          {item.meta !== undefined && (\n            <div className={cn(\"flex items-center gap-2 text-xs text-muted-foreground tabular-nums\", classNames?.meta)}>{item.meta}</div>\n          )}\n          {item.actions !== undefined && <div className={cn(\"flex items-center gap-1.5\", classNames?.actions)}>{item.actions}</div>}\n        </div>\n      )}\n    </li>\n  )\n}\n\nexport { Sortable, type SortableItem, type SortableMove, type SortableProps }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}