{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pick-list",
  "title": "Pick List",
  "description": "A transfer list where the order of the chosen items matters, reordered by drag, by four order buttons or with Alt and the arrow keys.",
  "dependencies": [
    "@dnd-kit/react"
  ],
  "registryDependencies": [
    "button",
    "@tealmac/transfer-list"
  ],
  "files": [
    {
      "path": "registry/tealmac/pick-list.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { DragDropProvider, PointerSensor } from \"@dnd-kit/react\"\nimport { isSortable, useSortable } from \"@dnd-kit/react/sortable\"\nimport {\n  ArrowDown,\n  ArrowDownToLine,\n  ArrowUp,\n  ArrowUpToLine,\n  Check,\n  GripVertical,\n} from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  TransferActions,\n  TransferPane,\n  type TransferItem,\n} from \"@/components/ui/transfer-list\"\n\n/** Move the ids in `picked` as a block by `delta` places (or to an end) inside `order`, keeping their relative order. */\nexport function reorderSelection(\n  order: string[],\n  picked: string[],\n  to: \"up\" | \"down\" | \"top\" | \"bottom\"\n) {\n  const set = new Set(picked)\n  if (!order.some((id) => set.has(id))) return order\n  if (to === \"top\")\n    return [\n      ...order.filter((id) => set.has(id)),\n      ...order.filter((id) => !set.has(id)),\n    ]\n  if (to === \"bottom\")\n    return [\n      ...order.filter((id) => !set.has(id)),\n      ...order.filter((id) => set.has(id)),\n    ]\n  const next = order.slice()\n  if (to === \"up\") {\n    for (let i = 1; i < next.length; i++) {\n      if (set.has(next[i]) && !set.has(next[i - 1]))\n        [next[i - 1], next[i]] = [next[i], next[i - 1]]\n    }\n  } else {\n    for (let i = next.length - 2; i >= 0; i--) {\n      if (set.has(next[i]) && !set.has(next[i + 1]))\n        [next[i + 1], next[i]] = [next[i], next[i + 1]]\n    }\n  }\n  return next\n}\n\nexport interface PickListProps {\n  items: TransferItem[]\n  /** Ids on the right, in the order they were picked. */\n  value?: string[]\n  defaultValue?: string[]\n  onValueChange?: (ids: string[]) => void\n  titles?: [string, string]\n  searchable?: boolean\n  emptyTexts?: [string, string]\n  \"aria-label\"?: string\n  className?: string\n  listClassName?: string\n}\n\n/**\n * A transfer list where the order of the chosen items matters: priorities,\n * column order, a playlist. The right list reorders by dragging, by the four\n * order buttons, or with Alt+ArrowUp and Alt+ArrowDown on an item; each step is\n * announced.\n */\nexport function PickList({\n  items,\n  value,\n  defaultValue = [],\n  onValueChange,\n  titles = [\"Available\", \"Chosen, in order\"],\n  searchable,\n  emptyTexts = [\"Nothing left to add\", \"Nothing chosen yet\"],\n  className,\n  listClassName,\n  ...rest\n}: PickListProps) {\n  const [inner, setInner] = React.useState(defaultValue)\n  const target = value ?? inner\n  const setTarget = (next: string[]) => {\n    if (value === undefined) setInner(next)\n    onValueChange?.(next)\n  }\n  const [leftPicked, setLeftPicked] = React.useState<string[]>([])\n  const [rightPicked, setRightPicked] = React.useState<string[]>([])\n  const [announcement, setAnnouncement] = React.useState(\"\")\n\n  const byId = new Map(items.map((i) => [i.id, i]))\n  const targetSet = new Set(target)\n  const left = items.filter((i) => !targetSet.has(i.id))\n  const right = target\n    .map((id) => byId.get(id))\n    .filter((i): i is TransferItem => !!i)\n  const addable = leftPicked.filter(\n    (id) => byId.has(id) && !targetSet.has(id) && !byId.get(id)!.disabled\n  )\n  const removable = rightPicked.filter(\n    (id) => targetSet.has(id) && !byId.get(id)?.disabled\n  )\n  const allLeft = left.filter((i) => !i.disabled).map((i) => i.id)\n  const allRight = right.filter((i) => !i.disabled).map((i) => i.id)\n\n  const add = (ids: string[]) => {\n    if (!ids.length) return\n    setTarget([...target, ...ids])\n    setLeftPicked((p) => p.filter((x) => !ids.includes(x)))\n    setAnnouncement(\n      `${ids.length} added at the end. ${target.length + ids.length} chosen.`\n    )\n  }\n  const remove = (ids: string[]) => {\n    if (!ids.length) return\n    setTarget(target.filter((x) => !ids.includes(x)))\n    setRightPicked((p) => p.filter((x) => !ids.includes(x)))\n    setAnnouncement(`${ids.length} removed.`)\n  }\n  const reorder = (ids: string[], to: \"up\" | \"down\" | \"top\" | \"bottom\") => {\n    const next = reorderSelection(target, ids, to)\n    if (next.every((id, i) => id === target[i])) return\n    setTarget(next)\n    const first = byId.get(ids[0])\n    setAnnouncement(\n      ids.length === 1 && first\n        ? `${first.label} moved ${to === \"up\" || to === \"down\" ? to : `to the ${to}`}. Position ${next.indexOf(first.id) + 1} of ${next.length}.`\n        : `${ids.length} items moved ${to === \"up\" || to === \"down\" ? to : `to the ${to}`}.`\n    )\n  }\n\n  const canOrder = removable.length > 0\n  const orderButton = (\n    to: \"up\" | \"down\" | \"top\" | \"bottom\",\n    label: string,\n    Icon: typeof ArrowUp\n  ) => (\n    <Button\n      type=\"button\"\n      variant=\"outline\"\n      size=\"icon\"\n      className=\"size-8\"\n      aria-label={label}\n      disabled={!canOrder}\n      onClick={() => reorder(removable, to)}\n    >\n      <Icon aria-hidden />\n    </Button>\n  )\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={rest[\"aria-label\"] ?? \"Pick list\"}\n      className={cn(\n        \"grid items-stretch gap-3 md:grid-cols-[1fr_auto_1fr]\",\n        className\n      )}\n    >\n      <TransferPane\n        title={titles[0]}\n        items={left}\n        selected={leftPicked}\n        onSelectedChange={setLeftPicked}\n        onActivate={(item) => add([item.id])}\n        searchable={searchable}\n        emptyText={emptyTexts[0]}\n        listClassName={cn(\"h-56\", listClassName)}\n      />\n      <TransferActions\n        rightLabel={`Add selected to ${titles[1]}`}\n        leftLabel={`Remove selected from ${titles[1]}`}\n        allRightLabel=\"Add all\"\n        allLeftLabel=\"Remove all\"\n        canRight={addable.length > 0}\n        canLeft={removable.length > 0}\n        canAllRight={allLeft.length > 0}\n        canAllLeft={allRight.length > 0}\n        onMoveRight={() => add(addable)}\n        onMoveLeft={() => remove(removable)}\n        onMoveAllRight={() => add(allLeft)}\n        onMoveAllLeft={() => remove(allRight)}\n      />\n      <div className=\"flex min-w-0 gap-2\">\n        <DragDropProvider\n          sensors={[PointerSensor]}\n          plugins={(defaults) =>\n            defaults.filter(\n              (plugin) => (plugin as { name?: string }).name !== \"Accessibility\"\n            )\n          }\n          onDragEnd={(event) => {\n            const { source } = event.operation\n            if (event.canceled || !isSortable(source)) return\n            const from = source.initialIndex\n            const to = source.index\n            if (from === to) return\n            const next = target.slice()\n            const [moved] = next.splice(from, 1)\n            next.splice(to, 0, moved)\n            setTarget(next)\n            const item = byId.get(moved)\n            setAnnouncement(\n              `${item?.label ?? moved} dropped at position ${to + 1} of ${next.length}.`\n            )\n          }}\n        >\n          <TransferPane\n            className=\"min-w-0 flex-1\"\n            title={titles[1]}\n            items={right}\n            selected={rightPicked}\n            onSelectedChange={setRightPicked}\n            onActivate={(item) => remove([item.id])}\n            emptyText={emptyTexts[1]}\n            listClassName={cn(\"h-56\", listClassName)}\n            onOptionKeyDown={(event, item) => {\n              if (!event.altKey) return\n              if (event.key === \"ArrowUp\" || event.key === \"ArrowDown\") {\n                event.preventDefault()\n                reorder([item.id], event.key === \"ArrowUp\" ? \"up\" : \"down\")\n                // Keep focus on the item after it moves.\n                const id = item.id\n                requestAnimationFrame(() =>\n                  Array.from(\n                    document.querySelectorAll<HTMLElement>(\"[data-option-id]\")\n                  )\n                    .find((el) => el.dataset.optionId === id)\n                    ?.focus()\n                )\n              }\n            }}\n            renderOption={(item, state, optionProps) => (\n              <SortableOption\n                item={item}\n                index={state.index}\n                selected={state.selected}\n                optionProps={optionProps}\n              />\n            )}\n          />\n        </DragDropProvider>\n        <div\n          className=\"flex flex-col justify-center gap-2\"\n          role=\"group\"\n          aria-label=\"Change order\"\n        >\n          {orderButton(\"top\", \"Move to top\", ArrowUpToLine)}\n          {orderButton(\"up\", \"Move up\", ArrowUp)}\n          {orderButton(\"down\", \"Move down\", ArrowDown)}\n          {orderButton(\"bottom\", \"Move to bottom\", ArrowDownToLine)}\n        </div>\n      </div>\n      <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </div>\n    </div>\n  )\n}\n\nfunction SortableOption({\n  item,\n  index,\n  selected,\n  optionProps,\n}: {\n  item: TransferItem\n  index: number\n  selected: boolean\n  optionProps: React.ComponentProps<\"li\">\n}) {\n  const { ref, isDragging } = useSortable({\n    id: item.id,\n    index,\n    disabled: item.disabled,\n  })\n  return (\n    <li\n      ref={ref}\n      {...optionProps}\n      data-dragging={isDragging ? \"\" : undefined}\n      className={cn(\n        \"flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none select-none\",\n        \"hover:bg-accent/60 focus-visible:ring-ring/60 focus-visible:ring-2\",\n        \"aria-selected:bg-accent aria-selected:text-accent-foreground\",\n        \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-[dragging]:opacity-60\"\n      )}\n    >\n      <span\n        aria-hidden\n        className=\"text-muted-foreground w-5 shrink-0 text-xs tabular-nums\"\n      >\n        {index + 1}\n      </span>\n      <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n      {selected ? <Check aria-hidden className=\"size-4 shrink-0\" /> : null}\n      <GripVertical\n        aria-hidden\n        className=\"text-muted-foreground size-4 shrink-0 cursor-grab\"\n      />\n    </li>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}