{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reorder-list",
  "type": "registry:ui",
  "title": "ReorderList",
  "description": "Drag-to-reorder list with an accessible arrow-button fallback, so it works by pointer, touch, and keyboard.",
  "categories": [
    "forms"
  ],
  "registryDependencies": [
    "https://whiskeyjack.net/r/merge-refs.json",
    "https://whiskeyjack.net/r/use-drag-reorder.json",
    "https://whiskeyjack.net/r/use-reduced-motion.json",
    "https://whiskeyjack.net/r/use-scroll-fade.json",
    "https://whiskeyjack.net/r/utils.json"
  ],
  "files": [
    {
      "path": "components/ui/reorder-list.tsx",
      "type": "registry:ui",
      "target": "components/ui/reorder-list.tsx",
      "content": "import * as React from 'react'\nimport { cn } from '@/lib/utils'\nimport { useMergedRefs } from '@/lib/merge-refs'\nimport { useDragReorder } from '@/hooks/use-drag-reorder'\nimport { useReducedMotion } from '@/hooks/use-reduced-motion'\nimport { useScrollFade } from '@/hooks/use-scroll-fade'\n\n// Ref map keyed by item index; rebuilt each render but stable enough for\n// scroll-into-view after arrow moves.\ntype ItemRefMap = Map<number, HTMLLIElement>\n\n// Inline SVG arrows -- same documented exception as the Select caret so the\n// DS stays free of icon library dependencies.\nfunction ArrowUpIcon() {\n  return (\n    <svg\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n      className=\"w-3.5 h-3.5\"\n    >\n      <path d=\"M7 11V3M3.5 6.5L7 3l3.5 3.5\" />\n    </svg>\n  )\n}\n\nfunction ArrowDownIcon() {\n  return (\n    <svg\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n      className=\"w-3.5 h-3.5\"\n    >\n      <path d=\"M7 3v8M3.5 7.5L7 11l3.5-3.5\" />\n    </svg>\n  )\n}\n\n// Inline drag handle SVG -- avoids requiring DotsSixVertical from Phosphor.\nfunction DragHandleIcon() {\n  return (\n    <svg\n      viewBox=\"0 0 18 18\"\n      fill=\"currentColor\"\n      aria-hidden\n      className=\"w-[18px] h-[18px] shrink-0 text-[var(--color-text-muted-light)] dark:text-[var(--color-text-muted-dark)]\"\n    >\n      <circle cx=\"6\" cy=\"4\" r=\"1.5\" />\n      <circle cx=\"12\" cy=\"4\" r=\"1.5\" />\n      <circle cx=\"6\" cy=\"9\" r=\"1.5\" />\n      <circle cx=\"12\" cy=\"9\" r=\"1.5\" />\n      <circle cx=\"6\" cy=\"14\" r=\"1.5\" />\n      <circle cx=\"12\" cy=\"14\" r=\"1.5\" />\n    </svg>\n  )\n}\n\n/**\n * The scroller a touch drag should auto-scroll: the list itself when the caller\n * height-capped it, else its nearest scrollable ancestor (a drawer body, a\n * page's <main>). Walking up matters because the same list is height-capped in\n * one app and flows into the page scroller in another.\n */\nfunction findScrollParent(el: HTMLElement | null): HTMLElement | null {\n  let node: HTMLElement | null = el\n  while (node) {\n    const overflowY = getComputedStyle(node).overflowY\n    if (\n      (overflowY === 'auto' || overflowY === 'scroll') &&\n      node.scrollHeight > node.clientHeight\n    ) {\n      return node\n    }\n    node = node.parentElement\n  }\n  return null\n}\n\nexport interface ReorderListProps<T> {\n  /** The ordered array of items to display. */\n  items: T[]\n  /** Returns a stable string key for each item. */\n  getId: (item: T) => string\n  /** Renders the item label/content inside the row. */\n  renderLabel: (item: T) => React.ReactNode\n  /** Called with the new array whenever the user commits a reorder. */\n  onReorder: (next: T[]) => void\n  /** aria-label for the move-up button. Pass a translated string. */\n  moveUpLabel?: string\n  /** aria-label for the move-down button. Pass a translated string. */\n  moveDownLabel?: string\n  className?: string\n  /** Inline styles for the root element. */\n  style?: React.CSSProperties\n}\n\n/**\n * Accessible drag-and-drop + arrow-button reorder list.\n *\n * Built on the existing DS `useDragReorder` hook. Includes ArrowUp / ArrowDown\n * keyboard/touch-friendly buttons (accessibility improvement from Uradi's\n * `ListOrderReorder`). Arrow icons use inline SVG so the DS stays icon-free.\n *\n * Props:\n * - `items` / `getId` / `renderLabel` / `onReorder` -- the data contract\n * - `moveUpLabel` / `moveDownLabel` -- translated aria-labels for the buttons\n *\n * The caller owns the state; `onReorder` receives the new array and should\n * update state + persist as needed.\n *\n * Long lists: pass `className=\"max-h-72 overflow-y-auto\"` (or similar) to cap\n * the height and enable internal scrolling. Native HTML5 drag auto-scrolls\n * scrollable ancestors in modern browsers; arrow button moves scroll the moved\n * item into view automatically. Add `pr-1` to give the scrollbar a little\n * breathing room so it does not overlap row content.\n *\n * **Touch**: the drag handle carries the pointer path (`getHandleProps`), so a\n * finger on the handle drags and a finger anywhere else on the row still\n * scrolls. That split is deliberate -- the row stays scrollable so a capped\n * list inside a drawer is not a gesture trap. Auto-scroll targets the list\n * itself when height-capped, otherwise the nearest scrollable ancestor.\n */\nfunction ReorderListInner<T>(\n  {\n    items,\n    getId,\n    renderLabel,\n    onReorder,\n    moveUpLabel = 'Move up',\n    moveDownLabel = 'Move down',\n    className,\n    style,\n  }: ReorderListProps<T>,\n  ref: React.ForwardedRef<HTMLUListElement>,\n) {\n  // Holds a ref to each rendered <li> by its current index. Used to scroll the\n  // moved item into view after an arrow-button move.\n  const itemRefs = React.useRef<ItemRefMap>(new Map())\n\n  // Edge-fade masks for the height-capped scrolling case (the caller passes a\n  // max-h + overflow className): the overflowing edge fades out, both edges\n  // once scrolled. Resolves to no class while the list has no overflow.\n  const listRef = React.useRef<HTMLUListElement>(null)\n  const mergedRef = useMergedRefs(listRef, ref)\n  const fade = useScrollFade(listRef, items.length)\n\n  const reduced = useReducedMotion()\n\n  const reorder = useDragReorder(\n    (from, to) => {\n      const next = [...items]\n      const [moved] = next.splice(from, 1)\n      next.splice(to, 0, moved)\n      onReorder(next)\n    },\n    {\n      count: items.length,\n      getItemElement: (index) => itemRefs.current.get(index) ?? null,\n      // Resolved per call rather than cached: whether the list is its own\n      // scroller depends on the caller's className, and an expandable list\n      // (Chip Away's goal order) changes that answer at runtime.\n      getScrollContainer: () => findScrollParent(listRef.current),\n    },\n  )\n\n  const moveBy = (index: number, delta: number) => {\n    const newIndex = index + delta\n    if (newIndex < 0 || newIndex >= items.length) return\n    const next = [...items]\n    const [moved] = next.splice(index, 1)\n    next.splice(newIndex, 0, moved)\n    onReorder(next)\n    // After the parent re-renders with the new order, scroll the moved item --\n    // now at newIndex -- into view. requestAnimationFrame waits for the DOM\n    // update so the ref map reflects the new positions.\n    requestAnimationFrame(() => {\n      itemRefs.current.get(newIndex)?.scrollIntoView({\n        block: 'nearest',\n        behavior: reduced ? 'auto' : 'smooth',\n      })\n    })\n  }\n\n  return (\n    <ul ref={mergedRef} className={cn('space-y-1.5 overscroll-contain', fade, className)} style={style}>\n      {items.map((item, index) => {\n        const id = getId(item)\n        const isDragging = reorder.draggingIndex === index\n        const isOver =\n          reorder.dragOverIndex === index && reorder.draggingIndex !== index\n\n        return (\n          <li\n            key={id}\n            ref={(el) => {\n              if (el) {\n                itemRefs.current.set(index, el)\n              } else {\n                itemRefs.current.delete(index)\n              }\n            }}\n            {...reorder.getItemProps(index)}\n            className={cn(\n              // py + the 28px nudge buttons + the border stay UNDER the 44px floor, so\n              // min-h is what sets the row height and it matches every other\n              // control. Taller buttons here push the row past it instead.\n              'flex items-center gap-2 min-h-control-md px-3 py-1.5 rounded-lg border transition-colors',\n              'cursor-grab active:cursor-grabbing select-none',\n              // A pointer drag repaints rows under a still finger, so the\n              // color transition would lag the reorder it is describing.\n              reorder.pointerDragging && 'transition-none',\n              'bg-[var(--color-surface-light)] border-[var(--color-border-light)] text-[var(--color-text-primary-light)]',\n              'dark:bg-[var(--color-surface-dark)] dark:border-[var(--color-border-dark)] dark:text-[var(--color-text-primary-dark)]',\n              isDragging && 'opacity-50',\n              isOver && 'border-[var(--color-accent-500)]',\n            )}\n          >\n            {/* The touch grip. Padded out to a real target rather than sized to\n                the glyph, and `touch-action: none` (from getHandleProps) makes\n                a drag starting here a drag rather than a scroll. */}\n            <span\n              {...reorder.getHandleProps(index)}\n              aria-hidden\n              className=\"-my-1.5 py-1.5 -ms-1 ps-1 pe-1 shrink-0 cursor-grab active:cursor-grabbing\"\n            >\n              <DragHandleIcon />\n            </span>\n            <span className=\"flex-1 truncate text-sm font-medium\">\n              {renderLabel(item)}\n            </span>\n            <div className=\"flex gap-1 shrink-0\">\n              <button\n                type=\"button\"\n                aria-label={moveUpLabel}\n                title={moveUpLabel}\n                disabled={index === 0}\n                onClick={() => moveBy(index, -1)}\n                className={cn(\n                  'wj-focus-ring h-7 w-7 inline-flex items-center justify-center rounded text-xs font-bold',\n                  'border border-[var(--color-border-light)] dark:border-[var(--color-border-dark)]',\n                  'text-[var(--color-text-secondary-light)] dark:text-[var(--color-text-secondary-dark)]',\n                  'hover:bg-[var(--color-surface-muted-light)] dark:hover:bg-[var(--color-surface-muted-dark)]',\n                  'disabled:opacity-40 disabled:cursor-not-allowed',\n                )}\n              >\n                <ArrowUpIcon />\n              </button>\n              <button\n                type=\"button\"\n                aria-label={moveDownLabel}\n                title={moveDownLabel}\n                disabled={index === items.length - 1}\n                onClick={() => moveBy(index, 1)}\n                className={cn(\n                  'wj-focus-ring h-7 w-7 inline-flex items-center justify-center rounded text-xs font-bold',\n                  'border border-[var(--color-border-light)] dark:border-[var(--color-border-dark)]',\n                  'text-[var(--color-text-secondary-light)] dark:text-[var(--color-text-secondary-dark)]',\n                  'hover:bg-[var(--color-surface-muted-light)] dark:hover:bg-[var(--color-surface-muted-dark)]',\n                  'disabled:opacity-40 disabled:cursor-not-allowed',\n                )}\n              >\n                <ArrowDownIcon />\n              </button>\n            </div>\n          </li>\n        )\n      })}\n    </ul>\n  )\n}\n\n// forwardRef erases the generic type parameter, so re-assert it on the exported\n// component: ReorderList keeps `items`/`getId`/`renderLabel`/`onReorder` typed\n// to T AND forwards a ref to the <ul> element.\nconst ReorderListWithRef = React.forwardRef(ReorderListInner)\nReorderListWithRef.displayName = 'ReorderList'\nexport const ReorderList = ReorderListWithRef as <T>(\n  props: ReorderListProps<T> & { ref?: React.Ref<HTMLUListElement> },\n) => React.ReactElement\n"
    }
  ],
  "docs": "Give it items, getId, renderLabel, and onReorder, plus translated moveUpLabel and moveDownLabel for the buttons. For long lists pass className=\"max-h-72 overflow-y-auto pr-1\": dragging auto-scrolls and arrow moves scroll the moved item into view. On touch the drag handle owns the gesture and the rest of the row still scrolls, so a capped list inside a drawer stays scrollable.",
  "meta": {
    "group": "forms",
    "related": [
      "use-drag-reorder",
      "use-scroll-fade"
    ],
    "exports": [
      "ReorderList"
    ],
    "siteSlug": "reorder-list"
  }
}
