{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reorder-list",
  "type": "registry:ui",
  "title": "Reorder List",
  "description": "Lifts on press, springs neighbors aside and reorders from the keyboard too.",
  "categories": [
    "gestures"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/reorder-list.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { Reorder, useDragControls, useReducedMotion } from \"motion/react\";\nimport type { DragControls } from \"motion/react\";\nimport { Children, createContext, Fragment, isValidElement, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Grip } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Context\n * -----------------------------------------------------------------------------------------------*/\n\ntype Active = { id: string; mode: \"keyboard\" | \"pointer\"; from: string[] };\n\ntype ListContext = {\n  order: string[];\n  active: Active | null;\n  pressed: string | null;\n  disabled: boolean;\n  numbered: boolean;\n  reduce: boolean;\n  hintId: string;\n  press: (id: string) => void;\n  lift: (id: string, label: string) => void;\n  moveTo: (id: string, label: string, index: number) => void;\n  drop: (id: string, label: string) => void;\n  cancel: (id: string, label: string) => void;\n  dragStart: (id: string) => void;\n  dragEnd: (id: string, label: string) => void;\n  cancelDrag: () => void;\n};\n\nconst ListCtx = createContext<ListContext | null>(null);\nconst useList = () => {\n  const ctx = useContext(ListCtx);\n  if (!ctx) throw new Error(\"ReorderListItem must be used inside ReorderList\");\n  return ctx;\n};\n\ntype ItemContext = { value: string; label: string; disabled: boolean; controls: DragControls; handleRef: React.RefObject<HTMLButtonElement | null> };\nconst ItemCtx = createContext<ItemContext | null>(null);\n\n/* -------------------------------------------------------------------------------------------------\n * ReorderList\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ReorderListProps = Omit<\n  React.ComponentProps<\"ul\">,\n  \"children\" | \"defaultValue\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\"\n> & {\n  /** The order, as item values. Controlled. */\n  value?: string[];\n  /** The starting order when uncontrolled. Defaults to the order the children are written in. */\n  defaultValue?: string[];\n  /** Called on every swap, while dragging and on each arrow press. */\n  onValueChange?: (value: string[]) => void;\n  /** Called once when a drag or keyboard move is dropped somewhere new. The place to save. */\n  onValueCommitted?: (value: string[]) => void;\n  /** Stops every item from being picked up. */\n  disabled?: boolean;\n  /** Shows each item's position as a rolling number after its handle. */\n  numbered?: boolean;\n  /** ReorderListItem elements, each with a unique value. */\n  children: React.ReactNode;\n};\n\n/** A vertical list whose items are reordered by their handle or the keyboard. */\nexport function ReorderList({\n  value,\n  defaultValue,\n  onValueChange,\n  onValueCommitted,\n  disabled = false,\n  numbered = false,\n  className,\n  children,\n  ...rest\n}: ReorderListProps) {\n  const items = Children.toArray(children).filter((c) => isValidElement<ReorderListItemProps>(c));\n  const childIds = items.map((c) => c.props.value);\n  const [stored, setStored] = useControllableState({ value, defaultValue: defaultValue ?? childIds, onChange: onValueChange });\n\n  // Children added later join the end; removed ones drop out. Order always matches what's rendered.\n  const order = [...stored.filter((id) => childIds.includes(id)), ...childIds.filter((id) => !stored.includes(id))];\n  const byId = new Map(items.map((c) => [c.props.value, c]));\n\n  const reduce = !!useReducedMotion();\n  const hintId = useId();\n  const [active, setActive] = useState<Active | null>(null);\n  const [pressed, setPressed] = useState<string | null>(null);\n  const [message, setMessage] = useState(\"\");\n  const canceled = useRef(false);\n\n  const at = (ids: string[], id: string) => `position ${ids.indexOf(id) + 1} of ${ids.length}`;\n  const commit = (from: string[], to: string[]) => {\n    if (from.join() !== to.join()) onValueCommitted?.(to);\n  };\n\n  // A press lifts the item before the pointer has moved, so the hand gets an answer at once.\n  useEffect(() => {\n    if (!pressed) return;\n    const release = () => setPressed(null);\n    window.addEventListener(\"pointerup\", release);\n    window.addEventListener(\"pointercancel\", release);\n    return () => {\n      window.removeEventListener(\"pointerup\", release);\n      window.removeEventListener(\"pointercancel\", release);\n    };\n  }, [pressed]);\n\n  const ctx: ListContext = {\n    order,\n    active,\n    pressed,\n    disabled,\n    numbered,\n    reduce,\n    hintId,\n    press: setPressed,\n    lift(id, label) {\n      setActive({ id, mode: \"keyboard\", from: order });\n      setMessage(`${label} lifted, ${at(order, id)}. Arrow keys move it, Space drops it, Escape cancels.`);\n    },\n    moveTo(id, label, index) {\n      const from = order.indexOf(id);\n      const to = Math.max(0, Math.min(order.length - 1, index));\n      if (from === to) return;\n      const next = [...order];\n      next.splice(to, 0, next.splice(from, 1)[0]);\n      setStored(next);\n      setMessage(`${label}, ${at(next, id)}.`);\n      // Alt+arrow moves without lifting, so it commits straight away.\n      if (active?.id !== id) commit(order, next);\n    },\n    drop(id, label) {\n      if (active?.id !== id) return;\n      setActive(null);\n      setMessage(`${label} dropped, ${at(order, id)}.`);\n      commit(active.from, order);\n    },\n    cancel(id, label) {\n      if (active?.id !== id) return;\n      setStored(active.from);\n      setActive(null);\n      setMessage(`Move canceled. ${label} is back at ${at(active.from, id)}.`);\n    },\n    dragStart(id) {\n      canceled.current = false;\n      setActive({ id, mode: \"pointer\", from: order });\n    },\n    dragEnd(id, label) {\n      const from = active?.id === id ? active.from : order;\n      setActive(null);\n      if (canceled.current) {\n        setMessage(`Move canceled. ${label} is back at ${at(from, id)}.`);\n        return;\n      }\n      setMessage(`${label} dropped, ${at(order, id)}.`);\n      commit(from, order);\n    },\n    cancelDrag() {\n      if (active?.mode !== \"pointer\") return;\n      canceled.current = true;\n      setStored(active.from);\n    },\n  };\n\n  // While an item is in the air the cursor stays a closed hand everywhere, and text can't be selected.\n  const grabbing = active?.mode === \"pointer\";\n  useEffect(() => {\n    if (!grabbing) return;\n    const style = document.body.style;\n    const prev = { cursor: style.cursor, userSelect: style.userSelect };\n    style.cursor = \"grabbing\";\n    style.userSelect = \"none\";\n    return () => {\n      style.cursor = prev.cursor;\n      style.userSelect = prev.userSelect;\n    };\n  }, [grabbing]);\n\n  return (\n    <ListCtx.Provider value={ctx}>\n      <Reorder.Group\n        as=\"ul\"\n        axis=\"y\"\n        values={order}\n        onReorder={(next: string[]) => setStored(next)}\n        data-dragging={active ? \"\" : undefined}\n        data-disabled={disabled ? \"\" : undefined}\n        className={cn(\"relative flex flex-col gap-1.5\", className)}\n        {...rest}\n      >\n        {/* Keyed by value, so unkeyed children still keep their identity as they move. */}\n        {order.map((id) => <Fragment key={id}>{byId.get(id)}</Fragment>)}\n      </Reorder.Group>\n      <span id={hintId} className=\"sr-only\">\n        Press Space to lift, the arrow keys to move, Space again to drop and Escape to cancel. Alt with an arrow key moves without lifting.\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {message}\n      </span>\n    </ListCtx.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * ReorderListItem\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ReorderListItemProps = Omit<\n  React.ComponentProps<\"li\">,\n  \"value\" | \"children\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\"\n> & {\n  /** Unique within the list. */\n  value: string;\n  /** Plain-text name, used for the handle's label and the announcements. */\n  label: string;\n  /** This item can't be picked up. Others can still move past it. */\n  disabled?: boolean;\n  /** Render the grip before the content. Turn off to place a ReorderListHandle yourself. */\n  handle?: boolean;\n  children?: React.ReactNode;\n};\n\nexport function ReorderListItem({ value, label, disabled = false, handle = true, className, children, ...rest }: ReorderListItemProps) {\n  const list = useList();\n  const controls = useDragControls();\n  const handleRef = useRef<HTMLButtonElement | null>(null);\n  const lifted = list.active?.id === value || list.pressed === value;\n  const mode = list.active?.id === value ? list.active.mode : null;\n  const position = list.order.indexOf(value) + 1;\n\n  // Escape during a pointer drag puts everything back and lets the item spring home.\n  useEffect(() => {\n    if (mode !== \"pointer\") return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\") return;\n      e.preventDefault();\n      list.cancelDrag();\n      controls.stop();\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [mode, list, controls]);\n\n  return (\n    <ItemCtx.Provider value={{ value, label, disabled: disabled || list.disabled, controls, handleRef }}>\n      <Reorder.Item\n        value={value}\n        dragListener={false}\n        dragControls={controls}\n        onDragStart={() => list.dragStart(value)}\n        onDragEnd={() => list.dragEnd(value, label)}\n        // Siblings make room on the soft spring; keyboard moves use the snappier one so they keep up with key repeat.\n        animate={{ scale: lifted && !list.reduce ? 1.02 : 1 }}\n        transition={\n          list.reduce\n            ? { duration: 0 }\n            : { layout: list.active?.mode === \"keyboard\" ? spring.snappy : spring.soft, scale: spring.snappy }\n        }\n        // How the dropped item settles back into its slot after release.\n        dragTransition={{ bounceStiffness: 520, bounceDamping: 38 }}\n        data-lifted={lifted ? \"\" : undefined}\n        data-dragging={mode === \"pointer\" ? \"\" : undefined}\n        data-disabled={disabled || list.disabled ? \"\" : undefined}\n        className={cn(\n          \"relative flex min-h-12 items-center gap-1 rounded-lg border border-line bg-raised pe-3 ps-1 text-[13px] text-fg\",\n          \"transition-[border-color,box-shadow] duration-200 ease-out-quart\",\n          lifted && \"z-10 border-line-2 shadow-pop\",\n          className,\n        )}\n        {...rest}\n      >\n        {handle && <ReorderListHandle />}\n        {list.numbered && (\n          <span aria-hidden className=\"w-5 shrink-0 text-center font-mono text-[11px] text-fg-3 tabular\">\n            <NumberFlow value={position} animated={!list.reduce} />\n          </span>\n        )}\n        <div className=\"flex min-w-0 flex-1 items-center gap-3 py-2 ps-1\">{children}</div>\n      </Reorder.Item>\n    </ItemCtx.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * ReorderListHandle\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ReorderListHandleProps = Omit<React.ComponentProps<\"button\">, \"children\"> & {\n  children?: React.ReactNode;\n};\n\n/** The grip that picks an item up. Rendered by ReorderListItem unless `handle={false}`. */\nexport function ReorderListHandle({ className, children, onPointerDown, onKeyDown, onBlur, ...rest }: ReorderListHandleProps) {\n  const list = useList();\n  const item = useContext(ItemCtx);\n  if (!item) throw new Error(\"ReorderListHandle must be used inside ReorderListItem\");\n  const { value, label, disabled, controls, handleRef } = item;\n  const keyboardLifted = list.active?.id === value && list.active.mode === \"keyboard\";\n\n  // React moves the focused node when it reorders, which can drop focus. Put it back.\n  useLayoutEffect(() => {\n    if (keyboardLifted && document.activeElement !== handleRef.current) handleRef.current?.focus({ preventScroll: true });\n  });\n\n  return (\n    <button\n      ref={handleRef}\n      type=\"button\"\n      disabled={disabled}\n      aria-label={`Reorder ${label}`}\n      aria-describedby={list.hintId}\n      aria-pressed={keyboardLifted}\n      data-lifted={keyboardLifted ? \"\" : undefined}\n      onPointerDown={(e) => {\n        onPointerDown?.(e);\n        if (e.defaultPrevented || disabled || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n        e.preventDefault();\n        list.press(value);\n        controls.start(e);\n      }}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (e.defaultPrevented || disabled) return;\n        const index = list.order.indexOf(value);\n        const lifted = list.active?.id === value;\n        const step = e.key === \"ArrowUp\" ? -1 : e.key === \"ArrowDown\" ? 1 : 0;\n        if (e.key === \" \" || e.key === \"Enter\") {\n          e.preventDefault();\n          if (lifted) list.drop(value, label);\n          else list.lift(value, label);\n        } else if (step && (lifted || e.altKey)) {\n          e.preventDefault();\n          list.moveTo(value, label, index + step);\n        } else if (lifted && (e.key === \"Home\" || e.key === \"End\")) {\n          e.preventDefault();\n          list.moveTo(value, label, e.key === \"Home\" ? 0 : list.order.length - 1);\n        } else if (lifted && e.key === \"Escape\") {\n          e.preventDefault();\n          e.stopPropagation();\n          list.cancel(value, label);\n        }\n      }}\n      onBlur={(e) => {\n        onBlur?.(e);\n        // Blur caused by the reorder itself is refocused above; a real Tab away drops the item.\n        const node = e.currentTarget;\n        window.setTimeout(() => {\n          if (document.activeElement !== node && list.active?.id === value && list.active.mode === \"keyboard\") list.drop(value, label);\n        }, 0);\n      }}\n      className={cn(\n        \"relative grid h-8 w-6 shrink-0 touch-none select-none place-items-center rounded-md text-fg-4 outline-none\",\n        \"cursor-grab transition-colors duration-150 hover:text-fg-2 active:cursor-grabbing\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n        \"data-lifted:text-fg-2 in-data-lifted:text-fg-2\",\n        \"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:text-fg-4\",\n        // A 44px target on touch without widening the drawing.\n        \"before:absolute before:-inset-x-2.5 before:-inset-y-1.5 before:content-[''] pointer-fine:before:hidden\",\n        className,\n      )}\n      {...rest}\n    >\n      {children ?? <Grip size={14} />}\n    </button>\n  );\n}\n\n/** Position and state of the item it's called inside, for content that reacts to being moved. */\nexport function useReorderListItem() {\n  const list = useList();\n  const item = useContext(ItemCtx);\n  if (!item) throw new Error(\"useReorderListItem must be used inside ReorderListItem\");\n  return {\n    position: list.order.indexOf(item.value) + 1,\n    total: list.order.length,\n    lifted: list.active?.id === item.value || list.pressed === item.value,\n  };\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/reorder-list"
}
