{ "$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\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 \n \n \n )\n}\n\nfunction ArrowDownIcon() {\n return (\n \n \n \n )\n}\n\n// Inline drag handle SVG -- avoids requiring DotsSixVertical from Phosphor.\nfunction DragHandleIcon() {\n return (\n \n \n \n \n \n \n \n \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
). 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 {\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(\n {\n items,\n getId,\n renderLabel,\n onReorder,\n moveUpLabel = 'Move up',\n moveDownLabel = 'Move down',\n className,\n style,\n }: ReorderListProps,\n ref: React.ForwardedRef,\n) {\n // Holds a ref to each rendered
  • by its current index. Used to scroll the\n // moved item into view after an arrow-button move.\n const itemRefs = React.useRef(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(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
      \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 {\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 'flex items-center gap-2 px-3 py-2 rounded-lg border-2 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 \n \n \n \n {renderLabel(item)}\n \n
      \n 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 \n \n 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 \n \n
      \n \n )\n })}\n
    \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
      element.\nconst ReorderListWithRef = React.forwardRef(ReorderListInner)\nReorderListWithRef.displayName = 'ReorderList'\nexport const ReorderList = ReorderListWithRef as (\n props: ReorderListProps & { ref?: React.Ref },\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" } }