{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "transfer-list",
  "title": "Transfer List",
  "description": "Two lists with buttons between them to move items across, with search, select-all, move-all, disabled items and spoken results.",
  "registryDependencies": [
    "button",
    "input"
  ],
  "files": [
    {
      "path": "registry/tealmac/transfer-list.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  Check,\n  ChevronLeft,\n  ChevronRight,\n  ChevronsLeft,\n  ChevronsRight,\n  Minus,\n  Search,\n} from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\n\nexport interface TransferItem {\n  id: string\n  label: string\n  description?: string\n  disabled?: boolean\n}\n\nfunction fold(text: string) {\n  return text.normalize(\"NFD\").replace(/\\p{M}/gu, \"\").toLowerCase()\n}\n\nexport interface OptionState {\n  selected: boolean\n  focused: boolean\n  index: number\n}\n\nexport interface TransferPaneProps {\n  title: string\n  items: TransferItem[]\n  selected: string[]\n  onSelectedChange: (ids: string[]) => void\n  /** Enter or a double click on one option (the list moves it to the other side). */\n  onActivate?: (item: TransferItem) => void\n  /** Shows a search field. */\n  searchable?: boolean\n  searchLabel?: string\n  emptyText?: string\n  /** Replaces the whole option element. Spread `optionProps` on it. */\n  renderOption?: (\n    item: TransferItem,\n    state: OptionState,\n    optionProps: React.ComponentProps<\"li\">\n  ) => React.ReactNode\n  /** Extra keys for the focused option (alt+arrows reorder in the pick list). */\n  onOptionKeyDown?: (event: React.KeyboardEvent, item: TransferItem) => void\n  className?: string\n  listClassName?: string\n}\n\n/**\n * One side of a transfer: a titled multi-select listbox with a select-all\n * checkbox, a count and an optional search. Arrow keys move, Space toggles,\n * Shift+Arrow extends, Ctrl+A selects all, Home and End jump, letters jump by\n * typeahead and Enter activates.\n */\nexport function TransferPane({\n  title,\n  items,\n  selected,\n  onSelectedChange,\n  onActivate,\n  searchable,\n  searchLabel,\n  emptyText = \"No items\",\n  renderOption,\n  onOptionKeyDown,\n  className,\n  listClassName,\n}: TransferPaneProps) {\n  const [query, setQuery] = React.useState(\"\")\n  const [focusId, setFocusId] = React.useState<string | null>(null)\n  const listRef = React.useRef<HTMLUListElement>(null)\n  const anchor = React.useRef<string | null>(null)\n  const typed = React.useRef({ buffer: \"\", timer: 0 })\n  const headingId = React.useId()\n\n  const visible = React.useMemo(\n    () =>\n      items.filter(\n        (i) => !query.trim() || fold(i.label).includes(fold(query.trim()))\n      ),\n    [items, query]\n  )\n  const selectable = visible.filter((i) => !i.disabled)\n  const selectedSet = new Set(selected)\n  const pickedVisible = selectable.filter((i) => selectedSet.has(i.id)).length\n  const all =\n    selectable.length > 0 && pickedVisible === selectable.length\n      ? true\n      : pickedVisible > 0\n        ? \"mixed\"\n        : false\n  const tabbable =\n    focusId && visible.some((i) => i.id === focusId)\n      ? focusId\n      : (visible.find((i) => selectedSet.has(i.id))?.id ??\n        visible[0]?.id ??\n        null)\n\n  React.useEffect(() => () => window.clearTimeout(typed.current.timer), [])\n\n  const focusItem = (id: string) => {\n    setFocusId(id)\n    requestAnimationFrame(() => {\n      Array.from(\n        listRef.current?.querySelectorAll<HTMLElement>(\"[data-option-id]\") ?? []\n      )\n        .find((el) => el.dataset.optionId === id)\n        ?.focus()\n    })\n  }\n\n  const toggle = (item: TransferItem) => {\n    if (item.disabled) return\n    anchor.current = item.id\n    onSelectedChange(\n      selectedSet.has(item.id)\n        ? selected.filter((x) => x !== item.id)\n        : [...selected, item.id]\n    )\n  }\n\n  const extendTo = (item: TransferItem) => {\n    const from = visible.findIndex((i) => i.id === anchor.current)\n    const to = visible.findIndex((i) => i.id === item.id)\n    if (from < 0 || to < 0) return toggle(item)\n    const [a, b] = from < to ? [from, to] : [to, from]\n    const span = visible\n      .slice(a, b + 1)\n      .filter((i) => !i.disabled)\n      .map((i) => i.id)\n    onSelectedChange([...new Set([...selected, ...span])])\n  }\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {\n    const el = (event.target as HTMLElement).closest<HTMLElement>(\n      \"[data-option-id]\"\n    )\n    if (!el) return\n    const at = visible.findIndex((i) => i.id === el.dataset.optionId)\n    if (at < 0) return\n    const item = visible[at]\n    const key = event.key\n    const go = (to: number) => {\n      event.preventDefault()\n      const next = visible[Math.max(0, Math.min(visible.length - 1, to))]\n      if (!next) return\n      focusItem(next.id)\n      if (event.shiftKey) extendTo(next)\n    }\n    if (event.altKey) return onOptionKeyDown?.(event, item)\n    if (key === \"ArrowDown\") return go(at + 1)\n    if (key === \"ArrowUp\") return go(at - 1)\n    if (key === \"Home\") return go(0)\n    if (key === \"End\") return go(visible.length - 1)\n    if (key === \" \") {\n      event.preventDefault()\n      return event.shiftKey ? extendTo(item) : toggle(item)\n    }\n    if (key === \"Enter\") {\n      event.preventDefault()\n      if (!item.disabled) onActivate?.(item)\n      return\n    }\n    if ((event.ctrlKey || event.metaKey) && key.toLowerCase() === \"a\") {\n      event.preventDefault()\n      onSelectedChange([\n        ...new Set([...selected, ...selectable.map((i) => i.id)]),\n      ])\n      return\n    }\n    if (key.length === 1 && !event.ctrlKey && !event.metaKey) {\n      const state = typed.current\n      window.clearTimeout(state.timer)\n      state.buffer += key\n      state.timer = window.setTimeout(() => (state.buffer = \"\"), 600)\n      const needle = fold(state.buffer)\n      const order = [...visible.slice(at), ...visible.slice(0, at)]\n      const hit = order.find(\n        (i, n) =>\n          (n > 0 || needle.length > 1) && fold(i.label).startsWith(needle)\n      )\n      if (hit) focusItem(hit.id)\n      return\n    }\n    onOptionKeyDown?.(event, item)\n  }\n\n  return (\n    <div\n      className={cn(\n        \"bg-card flex min-w-0 flex-col rounded-lg border\",\n        className\n      )}\n    >\n      <div className=\"flex items-center gap-2 border-b px-3 py-2\">\n        <button\n          type=\"button\"\n          role=\"checkbox\"\n          aria-checked={all}\n          aria-label={`Select all in ${title}`}\n          disabled={selectable.length === 0}\n          className={cn(\n            \"border-input flex size-4 shrink-0 items-center justify-center rounded-[4px] border shadow-xs outline-none\",\n            \"focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:opacity-50\",\n            all && \"bg-primary border-primary text-primary-foreground\"\n          )}\n          onClick={() =>\n            onSelectedChange(\n              all === true\n                ? selected.filter((id) => !selectable.some((i) => i.id === id))\n                : [...new Set([...selected, ...selectable.map((i) => i.id)])]\n            )\n          }\n        >\n          {all === \"mixed\" ? (\n            <Minus aria-hidden className=\"size-3\" strokeWidth={3} />\n          ) : all ? (\n            <Check aria-hidden className=\"size-3\" strokeWidth={3} />\n          ) : null}\n        </button>\n        <h3 id={headingId} className=\"flex-1 truncate text-sm font-medium\">\n          {title}\n        </h3>\n        <span\n          className=\"text-muted-foreground text-xs tabular-nums\"\n          aria-live=\"polite\"\n        >\n          {pickedVisible}/{selectable.length} selected\n        </span>\n      </div>\n      {searchable ? (\n        <div className=\"relative border-b p-2\">\n          <Search\n            aria-hidden\n            className=\"text-muted-foreground pointer-events-none absolute start-4.5 top-1/2 size-4 -translate-y-1/2\"\n          />\n          <Input\n            type=\"search\"\n            value={query}\n            aria-label={searchLabel ?? `Search ${title}`}\n            placeholder=\"Search\"\n            className=\"h-8 ps-8\"\n            onChange={(event) => setQuery(event.target.value)}\n          />\n        </div>\n      ) : null}\n      {visible.length === 0 ? (\n        <p\n          className={cn(\n            \"text-muted-foreground flex-1 px-3 py-8 text-center text-sm\",\n            listClassName\n          )}\n        >\n          {query ? \"No matches\" : emptyText}\n        </p>\n      ) : (\n        <ul\n          ref={listRef}\n          role=\"listbox\"\n          aria-multiselectable=\"true\"\n          aria-labelledby={headingId}\n          onKeyDown={onKeyDown}\n          className={cn(\"flex-1 overflow-auto p-1\", listClassName)}\n        >\n          {visible.map((item, index) => {\n            const isSelected = selectedSet.has(item.id)\n            const optionProps: React.ComponentProps<\"li\"> & {\n              \"data-option-id\": string\n            } = {\n              role: \"option\",\n              \"aria-selected\": isSelected,\n              \"aria-disabled\": item.disabled || undefined,\n              tabIndex: item.id === tabbable ? 0 : -1,\n              \"data-option-id\": item.id,\n              onFocus: () => setFocusId(item.id),\n              onClick: (event) =>\n                event.shiftKey ? extendTo(item) : toggle(item),\n              onDoubleClick: () => !item.disabled && onActivate?.(item),\n            }\n            const state = {\n              selected: isSelected,\n              focused: item.id === tabbable,\n              index,\n            }\n            return renderOption ? (\n              <React.Fragment key={item.id}>\n                {renderOption(item, state, optionProps)}\n              </React.Fragment>\n            ) : (\n              <li\n                key={item.id}\n                {...optionProps}\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\"\n                )}\n              >\n                <span className=\"min-w-0 flex-1\">\n                  <span className=\"block truncate\">{item.label}</span>\n                  {item.description ? (\n                    <span className=\"text-muted-foreground block truncate text-xs\">\n                      {item.description}\n                    </span>\n                  ) : null}\n                </span>\n                {isSelected ? (\n                  <Check aria-hidden className=\"size-4 shrink-0\" />\n                ) : null}\n              </li>\n            )\n          })}\n        </ul>\n      )}\n    </div>\n  )\n}\n\n/** The four move buttons between two panes. */\nexport function TransferActions({\n  onMoveRight,\n  onMoveLeft,\n  onMoveAllRight,\n  onMoveAllLeft,\n  canRight,\n  canLeft,\n  canAllRight,\n  canAllLeft,\n  rightLabel,\n  leftLabel,\n  allRightLabel,\n  allLeftLabel,\n}: {\n  onMoveRight: () => void\n  onMoveLeft: () => void\n  onMoveAllRight?: () => void\n  onMoveAllLeft?: () => void\n  canRight: boolean\n  canLeft: boolean\n  canAllRight?: boolean\n  canAllLeft?: boolean\n  rightLabel: string\n  leftLabel: string\n  allRightLabel?: string\n  allLeftLabel?: string\n}) {\n  const base = \"size-8 shrink-0\"\n  return (\n    <div className=\"flex items-center justify-center gap-2 max-md:flex-row md:flex-col\">\n      {onMoveAllRight ? (\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          size=\"icon\"\n          className={base}\n          disabled={!canAllRight}\n          aria-label={allRightLabel}\n          onClick={onMoveAllRight}\n        >\n          <ChevronsRight\n            aria-hidden\n            className=\"max-md:rotate-90 rtl:rotate-180 rtl:max-md:-rotate-90\"\n          />\n        </Button>\n      ) : null}\n      <Button\n        type=\"button\"\n        variant=\"outline\"\n        size=\"icon\"\n        className={base}\n        disabled={!canRight}\n        aria-label={rightLabel}\n        onClick={onMoveRight}\n      >\n        <ChevronRight\n          aria-hidden\n          className=\"max-md:rotate-90 rtl:rotate-180 rtl:max-md:-rotate-90\"\n        />\n      </Button>\n      <Button\n        type=\"button\"\n        variant=\"outline\"\n        size=\"icon\"\n        className={base}\n        disabled={!canLeft}\n        aria-label={leftLabel}\n        onClick={onMoveLeft}\n      >\n        <ChevronLeft\n          aria-hidden\n          className=\"max-md:rotate-90 rtl:rotate-180 rtl:max-md:-rotate-90\"\n        />\n      </Button>\n      {onMoveAllLeft ? (\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          size=\"icon\"\n          className={base}\n          disabled={!canAllLeft}\n          aria-label={allLeftLabel}\n          onClick={onMoveAllLeft}\n        >\n          <ChevronsLeft\n            aria-hidden\n            className=\"max-md:rotate-90 rtl:rotate-180 rtl:max-md:-rotate-90\"\n          />\n        </Button>\n      ) : null}\n    </div>\n  )\n}\n\nexport interface TransferListProps {\n  items: TransferItem[]\n  /** Ids on the right, in order. */\n  value?: string[]\n  defaultValue?: string[]\n  onValueChange?: (ids: string[]) => void\n  /** Left and right titles. Default [\"Available\", \"Selected\"]. */\n  titles?: [string, string]\n  searchable?: boolean\n  /** Show the move-all buttons. Default true. */\n  moveAll?: boolean\n  emptyTexts?: [string, string]\n  \"aria-label\"?: string\n  className?: string\n  listClassName?: string\n}\n\n/**\n * Two lists and buttons between them: pick items on the left and move them to\n * the right, or the other way. Every move is announced, disabled items stay\n * where they are, and Enter or a double click moves one item.\n */\nexport function TransferList({\n  items,\n  value,\n  defaultValue = [],\n  onValueChange,\n  titles = [\"Available\", \"Selected\"],\n  searchable,\n  moveAll = true,\n  emptyTexts = [\"Nothing left to add\", \"Nothing selected yet\"],\n  className,\n  listClassName,\n  ...rest\n}: TransferListProps) {\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 movableLeft = leftPicked.filter(\n    (id) => byId.get(id) && !byId.get(id)!.disabled && !targetSet.has(id)\n  )\n  const movableRight = rightPicked.filter(\n    (id) => targetSet.has(id) && !byId.get(id)?.disabled\n  )\n\n  const say = (count: number, to: string) =>\n    setAnnouncement(\n      `${count} ${count === 1 ? \"item\" : \"items\"} moved to ${to}.`\n    )\n\n  const moveRight = (ids: string[]) => {\n    if (!ids.length) return\n    setTarget([...target, ...ids])\n    setLeftPicked((p) => p.filter((x) => !ids.includes(x)))\n    say(ids.length, titles[1])\n  }\n  const moveLeft = (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    say(ids.length, titles[0])\n  }\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  return (\n    <div\n      role=\"group\"\n      aria-label={rest[\"aria-label\"] ?? \"Transfer 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) => moveRight([item.id])}\n        searchable={searchable}\n        emptyText={emptyTexts[0]}\n        listClassName={cn(\"h-56\", listClassName)}\n      />\n      <TransferActions\n        rightLabel={`Move selected to ${titles[1]}`}\n        leftLabel={`Move selected to ${titles[0]}`}\n        allRightLabel={`Move all to ${titles[1]}`}\n        allLeftLabel={`Move all to ${titles[0]}`}\n        canRight={movableLeft.length > 0}\n        canLeft={movableRight.length > 0}\n        canAllRight={allLeft.length > 0}\n        canAllLeft={allRight.length > 0}\n        onMoveRight={() => moveRight(movableLeft)}\n        onMoveLeft={() => moveLeft(movableRight)}\n        onMoveAllRight={moveAll ? () => moveRight(allLeft) : undefined}\n        onMoveAllLeft={moveAll ? () => moveLeft(allRight) : undefined}\n      />\n      <TransferPane\n        title={titles[1]}\n        items={right}\n        selected={rightPicked}\n        onSelectedChange={setRightPicked}\n        onActivate={(item) => moveLeft([item.id])}\n        searchable={searchable}\n        emptyText={emptyTexts[1]}\n        listClassName={cn(\"h-56\", listClassName)}\n      />\n      <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}