{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "transfer-list",
  "title": "Transfer List",
  "description": "Two lists side by side and the controls that move rows between them — the dual-listbox or \"pick list\" pattern: what is available on the left, what has been chosen on the right, and four named buttons in the middle. Reach for it wherever the question is which of these and the answer is long enough that seeing what is in and what is out at the same time beats a dropdown: permissions, scopes and API-token access on an admin screen, assigning roles or groups to a user, picking the members of a team, project, channel or on-call rotation, choosing which columns a table shows or which fields an export, report or integration syncs, building the recipient list of an email, invite or notification, selecting which metrics a dashboard plots, which locations, stores or warehouses a rule covers, which tags, categories or facets hang off a post or product, which features a plan includes, which modules a course or curriculum contains, moving players between a squad and a bench, and the \"applies to\" of any policy, coupon or automation. Common asks it answers: \"transfer list\", \"transfer list react\", \"dual listbox\", \"dual list box react\", \"shadcn transfer list\", \"shadcn dual listbox\", \"pick list component\", \"picklist react\", \"two list multi select\", \"move items between two lists react\", \"move selected items left and right\", \"arrow buttons between two lists\", \"available and selected lists react\", \"side by side multi select\", \"MUI TransferList equivalent\", \"antd Transfer component alternative\", \"PrimeReact PickList alternative\", \"react-dual-listbox alternative\", \"react-widgets Multiselect two columns\", \"shadcn assign permissions ui\", \"permission assignment component\", \"role assignment two lists\", \"column picker react\", \"choose which columns to show component\", \"table column chooser react\", \"field picker component\", \"recipient picker react\", \"add and remove between lists\", \"select all and move all button\", \"move all visible rows\", \"filter both lists before moving\", \"search inside transfer list\", \"disabled items in transfer list\", \"locked rows cannot be moved\", \"multi select listbox accessible react\", \"aria-multiselectable listbox example\", \"aria-activedescendant react listbox\", \"role listbox multiple selection\", \"accessible listbox keyboard react\", \"space to select enter to move listbox\", \"focus lost after moving an item between lists\", \"disabled button steals focus react\", \"focus jumps to body when button becomes disabled\", \"where should focus go after moving an item\", \"aria-activedescendant points at removed option\", \"screen reader silent after moving items\", \"announce move between lists aria-live\", \"order changes when I move an item back\", \"item appended to end instead of its original position\", \"list reorders itself after undo\", \"move button says right arrow\", \"accessible name for arrow buttons\", \"二つのリストで項目を行き来させる\", \"振り分け UI react\", \"権限の割り当て 画面\", \"ロールの割り当て コンポーネント\", \"表示する列を選ぶ UI\", \"左右のリストで移動\", \"選択済みと未選択を並べて見せる\", \"dual listbox 実装\", \"移動ボタンの読み上げ名\", \"戻したら並び順が変わる\", \"リストボックス 複数選択 アクセシビリティ\", \"全部移動 ボタン\", \"フィルタしてから移動\". Official shadcn/ui has nothing of this kind, and the measurement is more surprising than the usual zero. Taken today over its whole registry — 63 items in the catalogue, 62 fetchable (questionnaire is indexed and 404s on both style tracks), 223,287 bytes of files[].content concatenated — TransferList, transfer-list, dualListbox, selectedItems and moveRight are all zero hits, which is expected. So are listbox, role=\"option\", aria-activedescendant, aria-multiselectable and the word multiple: official ships no listbox markup at all, Radix owning it inside select (6,343 bytes) and combobox (9,169 bytes), so the whole ARIA pattern this component is built on appears nowhere in the registry to copy from. The single aria-selected in all 223KB is a Tailwind variant in calendar styling react-day-picker's output (aria-selected:text-muted-foreground) — a styling hook, not a list. The things official has that hold more than one answer are a different tool: checkbox is 1,204 bytes of one control per option, toggle-group 2,309 bytes of always-visible buttons, and both stop working somewhere around a dozen options. Five things go wrong in the version everybody writes first, and four of them are invisible to a mouse. First, where the cursor goes after a move — two different failures wearing the same face. Empty a side with the button you are holding and that button can no longer act: given the disabled attribute it stops being focusable while it holds focus, so the browser drops focus to <body>, the next Tab restarts at the top of the document, and a screen reader says nothing. Every unavailable control here is aria-disabled instead, so it keeps its tab stop, keeps the focus it had, and announces itself as unavailable. Move from inside a list, though — Enter on a selection, a double-click — and focus never left the listbox; what is lost is aria-activedescendant, still pointing at an option that is now on the other side, so the ring is drawn and there is nothing to read. nextActiveAfterMove is one subtraction rather than two rules: the new position is where the cursor was minus however many removed rows were above it, clamped to the end. That answers both the row under the cursor having moved away (it lands on the row that took the slot, so emptying a list is presses in one place) and the row under the cursor having stayed (it follows that row), which is the case a rule written only for the first gets wrong — move a selection from the top of a list while standing in the middle of it and \"go to the first vacated slot\" yanks the cursor to the top for no visible reason. Second, the two sides are listboxes and not columns of checkboxes: aria-multiselectable with aria-selected on every option is a list of which several are chosen, where checkboxes are independent controls, one tab stop each, announced one at a time with no sense of how many there are. Third, the order rows come back in. Push onto the destination and a row moved right then left lands at the bottom of the list it came from, so changing your mind rearranges the thing you were reading; do it twice and the order is one nobody chose. Neither side stores an order here — both are items filtered, so the canonical order is the one you sorted items into and a move is reversible in the only sense that matters. The emitted array is in that order too, so the same choice is always the same array and nothing downstream diffing it sees a change that is not one. Fourth, the names of the move buttons: four arrows announce as four arrows, and two of them are \"move the three I picked\" and \"move all four hundred\". Each is named for its effect and its true count — \"Move 3 selected to Granted\", \"Move all 12 to Granted\" — where the count is what will actually move, locked rows excluded, so the name never over-promises. Fifth, both empty states: an empty side keeps its tab stop, because the side nobody can reach by keyboard is exactly the one about to be moved into, and it says whether it is empty or merely filtered to nothing, which are different problems. A move is also announced through a polite live region, because it changes two lists and no field's value — without it the only evidence a press did anything is that two numbers changed. What you get: TransferList, taking items (every row, in the order both sides are shown in) and the chosen values controlled with value + onChange (handed the next array and a { to, values } saying what went where) or uncontrolled with defaultValue; per-item disabled for rows that are shown, readable, focusable and never moved; a filter box on each side, on by default and off with searchable={false}, which narrows its side for every purpose so nothing you cannot see is ever moved, while a selection you filtered out of sight is kept rather than destroyed; availableLabel and chosenLabel, the one pair of words every button, count and announcement is built from (\"All permissions\" and \"Granted\" rewrites the lot), with labels to override any single one or translate it; label, description, name and form for an ordinary form POST as one hidden input per value — disabled mirrored onto them, because a disabled control is left out of the form data and a hidden input that was never told so is not; and per-part className for the group, the listboxes, an option and the buttons. The keyboard is complete on both sides: one tab stop each, arrows to move the cursor, Space to pick, Shift+Arrow to extend by adding, Home and End, Ctrl/Cmd+A for everything movable on screen, Enter to move what is picked (or the row under the cursor when nothing is), and a double-click for one row. transferListSides, transferValues, movableValues and nextActiveAfterMove are exported as pure functions, so the decisions that are actually hard — which side a row is on, what the next array is, what will really move, and where the cursor lands — are assertable and usable headless; a chosen value that no item claims is carried through a move untouched, so a paginated or lazily loaded items does not silently drop choices made on another page. One file, five lucide icons, no Radix and no state library. Distinct from its neighbours by what it shows rather than what it returns: multi-select picks from a list inside one field and shows the chosen as badges, nested-checkbox selects from a hierarchy, tag-input takes free text that did not exist before, and tree-view is hierarchy you navigate rather than select — none of them puts both sets on screen at once, which is the thing this is for. sortable-list is the one to pair it with rather than replace it with: this answers which, not in what order, and if the order of the chosen set is itself the decision then feed one into the other. bulk-action-bar acts on rows already selected, and filter-chips shows which conditions are in force.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/transfer-list.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Search } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** One row, on whichever side it currently sits. */\nexport interface TransferListItem {\n  value: string\n  label: string\n  /**\n   * Held where it is: shown, readable, focusable, and never moved.\n   *\n   * Usually the point is that somebody needs to know the thing exists and is locked — a permission\n   * that comes with a role, a column the report always includes, a recipient the policy requires.\n   * What a locked row must never do is get moved by a control that claims to be moving something\n   * else, which is why every count and every move in this component goes through\n   * {@link movableValues} and {@link transferValues} rather than over the rendered rows: \"Move all\n   * 10\" moves ten, not the twelve that are on screen.\n   */\n  disabled?: boolean\n}\n\n/** Which side an item is on, and which way a move goes. */\nexport type TransferSide = \"available\" | \"chosen\"\n\n/** What changed, handed to `onChange` beside the new array. */\nexport interface TransferChange {\n  /** The side the items landed on. */\n  to: TransferSide\n  /** The values that moved. */\n  values: string[]\n}\n\n/**\n * Splits every item into the two sides, both in the order `items` arrived in.\n *\n * This is the whole answer to the third thing that goes wrong, and it is a decision about where\n * order lives rather than a detail of rendering. The obvious implementation keeps two arrays and\n * pushes onto the destination — and then moving an item right and immediately left again puts it at\n * the bottom of the list it came from. Nothing broke, no value was lost, and the person has still\n * been punished for changing their mind: the row they were looking at is now somewhere else, the\n * rows either side of it are different, and if they were working down a long list they have lost\n * their place in it. Do it twice and the left-hand list is in an order nobody chose.\n *\n * So neither side stores an order. Both are `items` filtered, which makes the canonical order the\n * one the caller wrote — alphabetical, grouped, by frequency, whatever they sorted it by — and makes\n * a move reversible in the only sense that matters to somebody using it: everything goes back where\n * it was. Undo is free because there is nothing to undo.\n *\n * The cost is deliberate and worth being explicit about: the chosen side cannot be ordered by the\n * person. If the order of the chosen set is itself the thing being decided — the column order of a\n * table, the sequence of steps in a pipeline, the priority of a list of rules — then this is the\n * wrong control and `sortable-list` is the right one, or this one feeding it. A transfer list\n * answers *which*, not *in what order*.\n *\n * Exported because it is the rule worth asserting on its own, and because it is useful headless:\n * rendering the two sides somewhere else, or reading them in a submit handler, should not require\n * mounting anything.\n */\nexport function transferListSides<T extends TransferListItem>(\n  items: readonly T[],\n  chosen: readonly string[]\n): { available: T[]; chosen: T[] } {\n  const picked = new Set(chosen)\n  const available: T[] = []\n  const taken: T[] = []\n  for (const item of items) (picked.has(item.value) ? taken : available).push(item)\n  return { available, chosen: taken }\n}\n\n/**\n * The next chosen array after a move, in `items` order, carrying through values it does not know.\n *\n * Two rules live here, and the second one is the one that only shows up in production.\n *\n * The emitted order is `items` order, for the reason {@link transferListSides} gives: a `value`\n * array whose order depends on the sequence of presses is an array that changes when nothing about\n * the choice changed, and anything downstream that compares it — a dirty-form check, a `useEffect`\n * dependency, an optimistic cache, a PATCH body diffed against the server's copy — sees a change\n * and acts on it. Derived from `items` instead, the same choice is always the same array.\n *\n * And a value in `chosen` that no `item` claims is carried through untouched rather than dropped.\n * Rebuilding the array from what is on screen is the obvious implementation and it quietly discards\n * choices: the moment `items` is a page of a longer list, a filtered query from a server, or\n * anything still loading, a move on page two deletes every selection made on page one. Nothing\n * reports it — `onChange` fires with a shorter array that looks deliberate. The carried values keep\n * their relative order and sit at the front, where they are stable; a locked item named in `moving`\n * is ignored, so the lock is enforced in one place rather than at each of the four call sites.\n */\nexport function transferValues(\n  items: readonly TransferListItem[],\n  chosen: readonly string[],\n  moving: readonly string[],\n  to: TransferSide\n): string[] {\n  const known = new Set(items.map((item) => item.value))\n  const picked = new Set(chosen)\n  const move = new Set(moving)\n  const next: string[] = chosen.filter((value) => !known.has(value))\n  for (const item of items) {\n    const isMoving = move.has(item.value) && !item.disabled\n    const stays = to === \"chosen\" ? picked.has(item.value) || isMoving : picked.has(item.value) && !isMoving\n    if (stays) next.push(item.value)\n  }\n  return next\n}\n\n/**\n * The values on this side that a move may actually take.\n *\n * Every count in this component is this function's length, which is the only way the move buttons\n * can be named truthfully — see {@link TransferListLabels.moveAll}. Call it with the rows already\n * narrowed to one side and one filter; it only takes the lock off the table.\n */\nexport function movableValues(items: readonly TransferListItem[]): string[] {\n  const values: string[] = []\n  for (const item of items) if (!item.disabled) values.push(item.value)\n  return values\n}\n\n/**\n * Where the cursor goes on the side that just got shorter.\n *\n * `count` is how many rows that side was showing *before* the move, `removed` their positions, `at`\n * where the cursor was standing. The answer is a position rather than a row because the row under\n * the cursor may be the one that is no longer there — the same problem every list in this registry\n * has when it loses the thing under the cursor, and the registry answers it the same way each time:\n * `repeatable-fields` re-focuses the row that took the vacated slot, `sortable-list` re-focuses the\n * handle it moved, `split-button` restores to the half that opened the menu.\n *\n * It is worth separating this from the failure the pattern is famous for, because they look alike\n * and only one of them is a dropped focus. Press \"move the selection\" until a side is empty and the\n * button you are pressing is the control that can no longer do anything: given the `disabled`\n * attribute it stops being focusable while it holds focus, the browser drops focus to `<body>`, and\n * the next Tab starts again at the top of the document with a screen reader saying nothing at all.\n * That one is fixed by not using the attribute — every unavailable control here is `aria-disabled`,\n * so it keeps its tab stop and the focus it already had, and announces itself as unavailable.\n *\n * This function is the other half, which survives that fix. Moving from inside a list — Enter on a\n * selection, a double-click — leaves DOM focus on the listbox, where the thing that says \"you are\n * here\" is `aria-activedescendant` pointing at an option's id. The options that moved are gone from\n * this side, so the id resolves to nothing: the listbox still has focus, the ring is still drawn\n * around it, and a screen reader has nothing to read. There is no error and nothing to see, and the\n * keyboard user's position in the list is simply lost.\n *\n * The rule is one subtraction, and writing it as two rules is how it goes wrong. Rows that moved\n * were taken from somewhere, so every removed row above the cursor shifts it up by one: the new\n * position is `at` minus however many of the removed positions were above it, clamped to the end.\n * That single formula answers both of the cases people reach for separately:\n *\n *   - **the row under the cursor moved away** → the subtraction lands on the row that took its slot,\n *     so the cursor stays where it was on screen and the next press takes the next one. Moving six\n *     rows out one at a time is six presses in one place rather than six presses and five hunts for\n *     the row. Rows taken from the end clamp back onto the row that is now last.\n *   - **the row under the cursor stayed** → the subtraction follows it to its new position, which is\n *     the case a rule written only for the first one gets wrong. Move a selection of rows from the\n *     top of the list while standing further down it, and \"go to the first vacated slot\" yanks the\n *     cursor to the top of a list the person was reading the middle of. Nothing announces it and\n *     nothing moves on screen; the keyboard has simply lost its place for no visible reason.\n *\n * `null` means the side is empty and there is nowhere to stand. The caller leaves its stored index\n * alone: with no options rendered there is no `aria-activedescendant` to be stale, and the index is\n * clamped back onto a row when rows return.\n */\nexport function nextActiveAfterMove(\n  count: number,\n  removed: readonly number[],\n  at: number\n): number | null {\n  const total = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0\n  const positions = new Set<number>()\n  for (const position of removed) {\n    if (Number.isInteger(position) && position >= 0 && position < total) positions.add(position)\n  }\n  const remaining = total - positions.size\n  if (remaining < 1) return null\n  const cursor = Number.isFinite(at) ? Math.max(0, Math.floor(at)) : 0\n  let above = 0\n  for (const position of positions) if (position < cursor) above += 1\n  return Math.min(Math.max(0, cursor - above), remaining - 1)\n}\n\nexport interface TransferListLabels {\n  /** The left-hand side's heading. */\n  available: string\n  /** The right-hand side's heading. */\n  chosen: string\n  /**\n   * The button that moves what is selected, named by what it will move.\n   *\n   * An arrow is not a name. `aria-label=\"→\"` is read as \"right pointing arrow\" or skipped entirely,\n   * and the four controls in the middle of this widget then announce as four arrows — which says\n   * there are four of them and nothing about what any one does. Worse, two of them are nearly the\n   * same gesture with very different consequences: \"move the three rows I picked\" and \"move all\n   * four hundred\" sit next to each other, and a person who cannot see the icons has no way to tell\n   * them apart. So each is named by its effect and its count, and the count is the number that will\n   * actually move — `movableValues` of what is selected *and* on screen.\n   */\n  moveSelected: (count: number, to: string) => string\n  /** The button that moves everything on screen. `count` excludes locked rows. */\n  moveAll: (count: number, to: string) => string\n  /** A side's count, beside its heading. `shown` differs from `total` only while filtered. */\n  count: (shown: number, total: number) => string\n  /** How much of a side is selected, under its heading. Explains an unavailable move button. */\n  selectedCount: (count: number) => string\n  /** A side with no rows at all. */\n  empty: (side: string) => string\n  /** A side whose filter matches nothing. */\n  noMatch: (side: string) => string\n  /** The accessible name of a side's filter box. */\n  search: (side: string) => string\n  /**\n   * Announced after a move.\n   *\n   * A move changes two lists at once and is not a change to any field's value, so nothing announces\n   * it on its own: without this, the only evidence that pressing the button did anything is that the\n   * counts on screen changed. `remaining` is what is left on the side the rows came from, which is\n   * the number a person is usually watching.\n   */\n  moved: (count: number, to: string, from: string, remaining: number) => string\n}\n\n/**\n * The labels, built around what the two sides are called.\n *\n * A function of the two headings rather than a constant, because the headings are the two words\n * every other name in this component is built out of. \"Available\" and \"Chosen\" are right for a\n * column picker and wrong for most of the screens this ends up on, where the pair is Everyone and\n * Recipients, All permissions and Granted, Inactive and Active, Bench and Line-up. Pass the pair\n * once and every button, every count and every announcement follows — \"Move 3 selected to\n * Granted\", \"Moved 3 to Granted, 9 left in All permissions\" — and `labels` still overrides any one\n * of them, which is also how this gets translated.\n */\nexport function transferListLabels(available = \"Available\", chosen = \"Chosen\"): TransferListLabels {\n  return {\n    available,\n    chosen,\n    moveSelected: (count, to) =>\n      count === 1 ? `Move 1 selected to ${to}` : `Move ${count} selected to ${to}`,\n    moveAll: (count, to) => (count === 1 ? `Move 1 to ${to}` : `Move all ${count} to ${to}`),\n    count: (shown, total) => (shown === total ? `${total}` : `${shown} of ${total}`),\n    selectedCount: (count) => (count === 0 ? \"None selected\" : `${count} selected`),\n    empty: (side) => `${side} is empty`,\n    noMatch: (side) => `Nothing in ${side} matches`,\n    search: (side) => `Filter ${side}`,\n    moved: (count, to, from, remaining) =>\n      `Moved ${count} to ${to}. ${remaining} ${remaining === 1 ? \"row\" : \"rows\"} left in ${from}.`,\n  }\n}\n\nexport const defaultTransferListLabels: TransferListLabels = transferListLabels()\n\n/** Case-insensitive substring over the label, falling back to the value. */\nfunction matching(items: readonly TransferListItem[], query: string): TransferListItem[] {\n  const needle = query.trim().toLowerCase()\n  if (!needle) return [...items]\n  return items.filter(\n    (item) =>\n      item.label.toLowerCase().includes(needle) || item.value.toLowerCase().includes(needle)\n  )\n}\n\n/** A stored cursor position made safe for a list that has changed length under it. */\nfunction clampIndex(index: number, length: number): number {\n  if (length < 1) return 0\n  if (!Number.isFinite(index) || index < 0) return 0\n  return Math.min(Math.floor(index), length - 1)\n}\n\nexport interface TransferListProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"div\">,\n    \"children\" | \"value\" | \"defaultValue\" | \"onChange\"\n  > {\n  /**\n   * Every row, on either side, in the order both sides are shown in.\n   *\n   * The canonical order lives here and nowhere else — see {@link transferListSides}. Sort it however\n   * the screen wants to read; a row moved and moved back lands where it started.\n   */\n  items: readonly TransferListItem[]\n  /** The values on the chosen side, controlled. Pair with `onChange`. */\n  value?: readonly string[]\n  /** What an uncontrolled list starts with on the chosen side. */\n  defaultValue?: readonly string[]\n  /** Told the whole next array and which values went where. */\n  onChange?: (next: string[], change: TransferChange) => void\n  /** A heading for the pair, wired to the group with `aria-labelledby`. */\n  label?: React.ReactNode\n  /** A line under the heading, wired to the group with `aria-describedby`. */\n  description?: React.ReactNode\n  /**\n   * A filter box on each side. On by default.\n   *\n   * On past a dozen rows a transfer list is unusable without one, which is why it is the default\n   * here and `searchable={false}` is the short-list case — the same way round as `multi-select`,\n   * whose search is on unless `hideSearch` turns it off.\n   *\n   * A filter narrows its side for every purpose, and that is the whole of the rule: the count beside\n   * the heading, the count in each move button's name, and what a press actually moves are all the\n   * rows on screen. Nothing you cannot see is ever moved — which is the only way \"Move all 12\" can\n   * be trusted on a list of four hundred. The one thing that outlives a filter is a selection: rows\n   * you picked and then filtered away stay picked and come back when you clear it, because losing a\n   * deliberate selection to a keystroke in a search box is worse than keeping one that is out of\n   * sight, and the counts never claim otherwise.\n   */\n  searchable?: boolean\n  /** Submits the chosen values with an ordinary form POST, as one hidden input per value. */\n  name?: string\n  /**\n   * The id of the form the hidden inputs belong to, when they do not sit inside it.\n   *\n   * Needed for the same reason `masked-input` needs it: what submits here is the hidden inputs, and\n   * a hidden input left without `form` stays with whichever ancestor it happens to be under. A\n   * transfer list rendered in a dialog, a portal or a sidebar — anywhere outside the `<form>` it\n   * belongs to — then has its values submitted by the wrong form or by none, and nothing reports it.\n   */\n  form?: string\n  /** The whole control unavailable. Both listboxes leave the tab order; no move can be made. */\n  disabled?: boolean\n  /** The left-hand heading, and the word every name on that side is built from. Default `\"Available\"`. */\n  availableLabel?: string\n  /** The right-hand heading. Default `\"Chosen\"`. */\n  chosenLabel?: string\n  labels?: Partial<TransferListLabels>\n  className?: string\n  /** Both listboxes — this is where their height comes from. */\n  listClassName?: string\n  optionClassName?: string\n  /** All four move buttons. */\n  buttonClassName?: string\n}\n\n/**\n * Two lists side by side and the controls that move rows between them.\n *\n * For every screen whose question is *which of these*, where the answer is long enough that seeing\n * what is in and what is out at the same time beats a dropdown: permissions, scopes and API-token\n * access on an admin screen, assigning roles or groups to a user, picking the members of a team,\n * project or channel, choosing which columns a table or report shows, building the recipient list of\n * an email, notification or invite, selecting which metrics a dashboard plots, which fields an\n * export or integration syncs, which locations, stores or warehouses a rule covers, which tags or\n * categories hang off a post or product, moving players between a squad and a bench, and the\n * \"applies to\" of any policy, coupon or automation.\n *\n * Both sides are real multi-select listboxes — one tab stop each, arrows to move the cursor, Space\n * to pick, Enter to move what is picked, Home and End, Ctrl/Cmd+A for everything on screen, and a\n * double-click to send one row across on its own. Four buttons in the middle move the selection or\n * everything, in either direction, and each is named by what it will actually move.\n *\n * Five things go wrong in the version everybody writes first, and four of them are invisible to a\n * mouse:\n *\n *   - **Where the cursor goes after a move.** Two different failures wear the same face. Empty a\n *     side with the button you are holding and that button can no longer act: given the `disabled`\n *     attribute it stops being focusable while focused, so the browser drops focus to `<body>` and\n *     the next Tab restarts at the top of the page. Every unavailable control here is\n *     `aria-disabled` instead, so it keeps its tab stop, its focus and its ability to say it is\n *     unavailable. Move from inside a list, though, and focus never left the listbox — what is lost\n *     is `aria-activedescendant`, pointing at an option that is now on the other side. See\n *     {@link nextActiveAfterMove}.\n *   - **The two sides are listboxes, not checkboxes.** `aria-multiselectable` with `aria-selected`\n *     on every option is a list of things of which several are chosen; a column of checkboxes is a\n *     column of independent controls, one tab stop each, announced one at a time with no sense of\n *     how many there are or how much of the list is picked. The pattern also has to be complete to\n *     be worth anything: an `aria-activedescendant` that does not move with the arrows, or options\n *     with no `aria-selected`, is a listbox in name only.\n *   - **The order rows come back in.** Push onto the destination and a row moved right then left\n *     lands at the bottom of the list it came from, so changing your mind rearranges the thing you\n *     were reading. Both sides are `items` filtered — see {@link transferListSides} — and the\n *     emitted array is in that order too, so the same choice is always the same array.\n *   - **The names of the move buttons.** Four arrows announce as four arrows, and two of them are\n *     \"move the three I picked\" and \"move all four hundred\". Each is named for its effect and its\n *     true count — see {@link TransferListLabels.moveSelected}.\n *   - **Both empty states.** An empty side is the one place a transfer list has to keep talking: its\n *     listbox stays in the tab order so it can still be found and still be moved into, and it says\n *     whether it is empty or merely filtered to nothing, which are not the same problem.\n *\n * A move is also announced through a polite live region, because it changes two lists at once and no\n * field's value: without it, the only evidence a press did anything is that two numbers on screen\n * changed. Values in `value` that no `item` claims are carried through a move untouched, so a\n * paginated or lazily loaded `items` does not silently drop choices made on another page.\n *\n * Official shadcn/ui has nothing of this kind — `TransferList`, `dualListbox`, `selectedItems` and\n * `aria-multiselectable` are all zero hits across its 62 fetchable components, and so is\n * `role=\"listbox\"`, Radix owning that markup inside `select` and `combobox`. Within this registry,\n * `multi-select` picks from a list inside one field and `nested-checkbox` selects from a hierarchy;\n * both answer *which* without showing the two sets at once, which is the thing this is for.\n */\nexport function TransferList({\n  items,\n  value,\n  defaultValue,\n  onChange,\n  label,\n  description,\n  searchable = true,\n  name,\n  form,\n  disabled,\n  availableLabel,\n  chosenLabel,\n  labels: labelOverrides,\n  className,\n  listClassName,\n  optionClassName,\n  buttonClassName,\n  ...props\n}: TransferListProps) {\n  const labels = React.useMemo(\n    () => ({ ...transferListLabels(availableLabel, chosenLabel), ...labelOverrides }),\n    [availableLabel, chosenLabel, labelOverrides]\n  )\n  const baseId = React.useId()\n  const labelId = `${baseId}-label`\n  const descriptionId = `${baseId}-description`\n\n  const controlled = value !== undefined\n  const [internal, setInternal] = React.useState<string[]>(() => [...(defaultValue ?? [])])\n  const chosenValues: readonly string[] = controlled ? value : internal\n\n  /**\n   * One selection across both sides, intersected with a side when it is used.\n   *\n   * Not two sets, and not a flag on each item: a row can only be on one side at a time, so which\n   * side a selected value belongs to is already known from `value` and does not need storing beside\n   * it. Two stores would be two things to keep in step every time a row moves, `items` changes, or a\n   * caller rewrites `value` from the outside — and the one that drifts is the one that decides what\n   * a button moves. Moved values are dropped from it, so a row does not arrive on the far side\n   * already picked, pre-arming the button that would send it straight back.\n   */\n  const [selected, setSelected] = React.useState<readonly string[]>([])\n  const [query, setQuery] = React.useState<Record<TransferSide, string>>({\n    available: \"\",\n    chosen: \"\",\n  })\n  const [active, setActive] = React.useState<Record<TransferSide, number>>({\n    available: 0,\n    chosen: 0,\n  })\n  const [message, setMessage] = React.useState(\"\")\n\n  const listRefs = {\n    available: React.useRef<HTMLDivElement | null>(null),\n    chosen: React.useRef<HTMLDivElement | null>(null),\n  }\n\n  const sides = React.useMemo(() => transferListSides(items, chosenValues), [items, chosenValues])\n  const visible: Record<TransferSide, TransferListItem[]> = React.useMemo(\n    () => ({\n      available: matching(sides.available, query.available),\n      chosen: matching(sides.chosen, query.chosen),\n    }),\n    [sides, query]\n  )\n  const selectedSet = React.useMemo(() => new Set(selected), [selected])\n\n  // Clamped at render rather than when the lists change: the stored index is a cursor, and every\n  // filter keystroke, every move and every change to `items` from outside can leave it past the end.\n  // Clamping here means there is one place it can be wrong, instead of one place per cause.\n  const activeIndex: Record<TransferSide, number> = {\n    available: clampIndex(active.available, visible.available.length),\n    chosen: clampIndex(active.chosen, visible.chosen.length),\n  }\n\n  /** What a press on each button moves — and, through its count, what each button is called. */\n  const selectedMovable: Record<TransferSide, string[]> = {\n    available: movableValues(visible.available.filter((item) => selectedSet.has(item.value))),\n    chosen: movableValues(visible.chosen.filter((item) => selectedSet.has(item.value))),\n  }\n  const allMovable: Record<TransferSide, string[]> = {\n    available: movableValues(visible.available),\n    chosen: movableValues(visible.chosen),\n  }\n\n  /**\n   * Keeps the cursor visible when the keyboard moves it.\n   *\n   * Gated on a ref set by the key handler, so arriving at a page with this on it does not scroll\n   * anything: an effect keyed on the active index alone runs on mount, and would pull the viewport\n   * to a list the person has not touched. Found by `data-active` rather than by the option's id,\n   * because `useId` returns a value with colons in it — `#:r1:-available-opt-0` is not a selector\n   * and `querySelector` throws on it rather than returning null.\n   */\n  const scrollWanted = React.useRef(false)\n  const scrollSide = React.useRef<TransferSide>(\"available\")\n  React.useEffect(() => {\n    if (!scrollWanted.current) return\n    scrollWanted.current = false\n    const list = listRefs[scrollSide.current].current\n    const option = list?.querySelector?.('[data-active=\"true\"]') as HTMLElement | null\n    option?.scrollIntoView?.({ block: \"nearest\" })\n  }, [activeIndex.available, activeIndex.chosen])\n\n  const commit = (next: string[], change: TransferChange) => {\n    if (!controlled) setInternal(next)\n    onChange?.(next, change)\n  }\n\n  function move(from: TransferSide, values: readonly string[]) {\n    if (disabled || values.length === 0) return\n    const to: TransferSide = from === \"available\" ? \"chosen\" : \"available\"\n    const moving = new Set(values)\n    commit(transferValues(items, chosenValues, values, to), { to, values: [...values] })\n    setSelected((prev) => prev.filter((value) => !moving.has(value)))\n    // Positions in the rendered list, which is what `aria-activedescendant` indexes into — not\n    // positions in `items`, and not in the unfiltered side.\n    const source = visible[from]\n    const removed: number[] = []\n    source.forEach((item, index) => {\n      if (moving.has(item.value)) removed.push(index)\n    })\n    const at = nextActiveAfterMove(source.length, removed, activeIndex[from])\n    if (at !== null) setActive((prev) => ({ ...prev, [from]: at }))\n    setMessage(\n      labels.moved(values.length, labels[to], labels[from], sides[from].length - values.length)\n    )\n  }\n\n  function toggle(side: TransferSide, item: TransferListItem) {\n    if (disabled || item.disabled) return\n    setSelected((prev) =>\n      prev.includes(item.value) ? prev.filter((value) => value !== item.value) : [...prev, item.value]\n    )\n  }\n\n  function moveCursor(side: TransferSide, index: number) {\n    scrollSide.current = side\n    scrollWanted.current = true\n    setActive((prev) => ({ ...prev, [side]: index }))\n  }\n\n  function onKeyDown(side: TransferSide, event: React.KeyboardEvent<HTMLDivElement>) {\n    if (disabled) return\n    const rows = visible[side]\n    const at = activeIndex[side]\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      if (rows.length === 0) return\n      event.preventDefault()\n      const next = clampIndex(at + (event.key === \"ArrowDown\" ? 1 : -1), rows.length)\n      moveCursor(side, next)\n      // Shift extends by adding, never by removing. A true anchored range has to deselect on the way\n      // back, which needs an anchor that survives every click, filter keystroke and move in between —\n      // and when it does not survive, the thing it silently throws away is a selection somebody made\n      // on purpose. Additive is predictable and loses nothing; Space is how a row comes back off.\n      if (event.shiftKey) {\n        const item = rows[next]\n        if (item && !item.disabled) {\n          setSelected((prev) => (prev.includes(item.value) ? prev : [...prev, item.value]))\n        }\n      }\n      return\n    }\n    if (event.key === \"Home\" || event.key === \"End\") {\n      if (rows.length === 0) return\n      event.preventDefault()\n      moveCursor(side, event.key === \"Home\" ? 0 : rows.length - 1)\n      return\n    }\n    if (event.key === \" \") {\n      if (rows.length === 0) return\n      // Or the page scrolls under the list, which is the one key press a listbox must not let through.\n      event.preventDefault()\n      const item = rows[at]\n      if (item) toggle(side, item)\n      return\n    }\n    if (event.key === \"Enter\") {\n      if (rows.length === 0) return\n      event.preventDefault()\n      const picked = selectedMovable[side]\n      if (picked.length > 0) {\n        move(side, picked)\n        return\n      }\n      // Nothing picked: Enter moves the row under the cursor, so a one-row move is one key press\n      // rather than Space-then-Enter.\n      const item = rows[at]\n      if (item && !item.disabled) move(side, [item.value])\n      return\n    }\n    if ((event.metaKey || event.ctrlKey) && (event.key === \"a\" || event.key === \"A\")) {\n      if (rows.length === 0) return\n      event.preventDefault()\n      const all = allMovable[side]\n      setSelected((prev) => [...prev, ...all.filter((value) => !prev.includes(value))])\n    }\n  }\n\n  function moveButton(from: TransferSide, scope: \"selected\" | \"all\") {\n    const to: TransferSide = from === \"available\" ? \"chosen\" : \"available\"\n    const values = scope === \"selected\" ? selectedMovable[from] : allMovable[from]\n    const can = !disabled && values.length > 0\n    const Icon =\n      scope === \"all\"\n        ? to === \"chosen\"\n          ? ChevronsRight\n          : ChevronsLeft\n        : to === \"chosen\"\n          ? ChevronRight\n          : ChevronLeft\n    return (\n      <button\n        type=\"button\"\n        aria-label={\n          scope === \"selected\"\n            ? labels.moveSelected(values.length, labels[to])\n            : labels.moveAll(values.length, labels[to])\n        }\n        // aria-disabled, never the attribute: this is the control most likely to be holding focus at\n        // the moment it becomes unavailable — emptying a side is what makes it unavailable — and the\n        // attribute would drop that focus to `<body>`. `pointer-events-none` stops the press; the\n        // handler checks too, because a keyboard press is not a pointer event.\n        aria-disabled={!can || undefined}\n        aria-controls={`${baseId}-${from}-list ${baseId}-${to}-list`}\n        // When it cannot act, point at the count on screen that says why.\n        aria-describedby={!can && scope === \"selected\" ? `${baseId}-${from}-count` : undefined}\n        onClick={() => move(from, values)}\n        className={cn(\n          \"inline-flex size-8 flex-none items-center justify-center rounded-md border border-input bg-background text-muted-foreground transition-colors\",\n          \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n          can\n            ? \"hover:bg-accent hover:text-accent-foreground\"\n            : \"pointer-events-none opacity-50\",\n          buttonClassName\n        )}\n      >\n        {/* Stacked on a narrow screen the destination is below or above, not beside: the icons turn\n            with the layout so they keep pointing at it. Decorative either way — the name is the name. */}\n        <Icon aria-hidden=\"true\" className=\"size-4 rotate-90 sm:rotate-0\" />\n      </button>\n    )\n  }\n\n  function renderSide(side: TransferSide) {\n    const rows = visible[side]\n    const sideName = labels[side]\n    const headingId = `${baseId}-${side}-heading`\n    const listId = `${baseId}-${side}-list`\n    const countId = `${baseId}-${side}-count`\n    const emptyId = `${baseId}-${side}-empty`\n    const at = activeIndex[side]\n    const filtered = query[side].trim().length > 0\n    return (\n      <div className=\"flex min-w-0 flex-1 flex-col\">\n        <div className=\"mb-1 flex items-baseline justify-between gap-2\">\n          <span id={headingId} className=\"text-sm font-medium\">\n            {sideName}\n          </span>\n          {/* Shown rather than total while filtered, so \"12\" never sits over three visible rows. */}\n          <span className=\"text-xs tabular-nums text-muted-foreground\">\n            {labels.count(rows.length, sides[side].length)}\n          </span>\n        </div>\n\n        {searchable ? (\n          <div className=\"relative mb-1\">\n            <Search\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground\"\n            />\n            <input\n              type=\"text\"\n              value={query[side]}\n              disabled={disabled}\n              onChange={(event) => {\n                const next = event.target.value\n                setQuery((prev) => ({ ...prev, [side]: next }))\n              }}\n              aria-label={labels.search(sideName)}\n              aria-controls={listId}\n              className={cn(\n                \"h-8 w-full rounded-md border border-input bg-background pl-7 pr-2 text-sm\",\n                \"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                \"disabled:cursor-not-allowed disabled:opacity-50\"\n              )}\n            />\n          </div>\n        ) : null}\n\n        <div\n          ref={listRefs[side]}\n          id={listId}\n          role=\"listbox\"\n          aria-multiselectable=\"true\"\n          aria-labelledby={headingId}\n          aria-describedby={rows.length === 0 ? emptyId : countId}\n          aria-disabled={disabled || undefined}\n          // Kept in the tab order while empty — on purpose. A side that drops out when it empties is\n          // a side that cannot be found again by keyboard, and the empty one is exactly where\n          // somebody is about to move rows to.\n          tabIndex={disabled ? -1 : 0}\n          aria-activedescendant={rows.length > 0 ? `${baseId}-${side}-opt-${at}` : undefined}\n          onKeyDown={(event) => onKeyDown(side, event)}\n          className={cn(\n            \"h-48 overflow-y-auto rounded-md border border-input bg-background p-1\",\n            \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n            disabled && \"opacity-50\",\n            listClassName\n          )}\n        >\n          {rows.length === 0 ? (\n            // Not an option, so it stays out of the listbox's owned children — and the two reasons a\n            // side can be empty are different problems, so they do not share a sentence.\n            <p id={emptyId} role=\"presentation\" className=\"px-2 py-4 text-center text-sm text-muted-foreground\">\n              {filtered ? labels.noMatch(sideName) : labels.empty(sideName)}\n            </p>\n          ) : null}\n          {rows.map((item, index) => {\n            const isSelected = selectedSet.has(item.value)\n            return (\n              <div\n                key={item.value}\n                id={`${baseId}-${side}-opt-${index}`}\n                role=\"option\"\n                // Always present, true or false: in a multiselectable listbox the absence of\n                // aria-selected means \"not selectable\", which is a different list than this one.\n                aria-selected={isSelected}\n                aria-disabled={item.disabled || undefined}\n                data-active={index === at ? \"true\" : undefined}\n                // Focus belongs to the listbox, not to the row: this is the activedescendant pattern,\n                // and a click that left focus where it was would leave the arrow keys dead.\n                onPointerDown={(event) => {\n                  event.preventDefault()\n                  listRefs[side].current?.focus?.()\n                }}\n                onClick={() => {\n                  moveCursor(side, index)\n                  toggle(side, item)\n                }}\n                // The row under the pointer, never the selection — a double-click is a gesture about\n                // one row, and the two clicks inside it cancel out on the way through `toggle`.\n                onDoubleClick={() => {\n                  if (!item.disabled) move(side, [item.value])\n                }}\n                className={cn(\n                  \"flex cursor-pointer select-none items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-sm\",\n                  isSelected && \"bg-accent text-accent-foreground\",\n                  index === at && \"ring-1 ring-inset ring-ring\",\n                  item.disabled && \"cursor-not-allowed opacity-50\",\n                  optionClassName\n                )}\n              >\n                <span className=\"truncate\">{item.label}</span>\n              </div>\n            )\n          })}\n        </div>\n\n        <p id={countId} className=\"mt-1 text-xs text-muted-foreground\">\n          {labels.selectedCount(selectedMovable[side].length)}\n        </p>\n      </div>\n    )\n  }\n\n  const describedBy =\n    [props[\"aria-describedby\"], description ? descriptionId : null].filter(Boolean).join(\" \") ||\n    undefined\n  const labelledBy =\n    [props[\"aria-labelledby\"], label ? labelId : null].filter(Boolean).join(\" \") || undefined\n\n  return (\n    // Spread first, so a caller's aria-describedby is merged above rather than quietly replacing the\n    // description this renders.\n    <div {...props} role=\"group\" aria-labelledby={labelledBy} aria-describedby={describedBy} className={cn(\"min-w-0\", className)}>\n      {label ? (\n        <p id={labelId} className=\"mb-1 text-base font-medium\">\n          {label}\n        </p>\n      ) : null}\n      {description ? (\n        <p id={descriptionId} className=\"mb-3 text-sm text-muted-foreground\">\n          {description}\n        </p>\n      ) : null}\n\n      <div className=\"flex flex-col gap-3 sm:flex-row sm:items-center\">\n        {renderSide(\"available\")}\n        <div className=\"flex flex-row items-center justify-center gap-1 sm:flex-col\">\n          {moveButton(\"available\", \"selected\")}\n          {moveButton(\"available\", \"all\")}\n          {moveButton(\"chosen\", \"all\")}\n          {moveButton(\"chosen\", \"selected\")}\n        </div>\n        {renderSide(\"chosen\")}\n      </div>\n\n      {/*\n        `disabled` is mirrored onto these, because what submits is the hidden inputs and not anything\n        on screen. The browser leaves a disabled control out of the form data; a hidden input that\n        was never told it is disabled is not left out — so a transfer list greyed out precisely\n        because its values are not being collected submits them anyway, and the handler receives a\n        set of columns, permissions or recipients the person could not have chosen. The same bug was\n        found in `masked-input`, which is where the wording comes from.\n      */}\n      {name\n        ? chosenValues.map((chosenValue) => (\n            <input\n              key={chosenValue}\n              type=\"hidden\"\n              name={name}\n              value={chosenValue}\n              disabled={disabled}\n              form={form}\n            />\n          ))\n        : null}\n\n      {/* A move changes two lists and no field's value, so nothing else here announces it. */}\n      <div aria-live=\"polite\" className=\"sr-only\">\n        {message}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
