{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "repeatable-fields",
  "title": "Repeatable Fields",
  "description": "A set of identical field rows with an \"add another\" button under them and a remove button on each: the caller renders one row's fields, this owns the list, the identity of each row, and where focus goes when the list changes shape. Reach for it wherever a form takes a list rather than a value: work history and education on a CV or profile, the line items of an invoice, quote or expense claim, environment variables and their values in a deploy or settings screen, several emails, phone numbers or addresses on one contact, the headers, query parameters or body fields of an API request builder, a product's variants and their prices, the answer options of a survey or poll being written, team members being invited by email, redirect URIs and allowed origins on an OAuth app, webhook endpoints, ingredients and steps in a recipe, authors on a paper, guests on a booking, and the \"add another\" row of any admin screen that edits a one-to-many relation. Common asks it answers: \"repeatable fields react\", \"repeating form fields\", \"dynamic form fields react\", \"add more fields button react\", \"add another row form\", \"add remove input rows react\", \"field array react\", \"useFieldArray without react-hook-form\", \"useFieldArray alternative\", \"react-hook-form field array shadcn\", \"shadcn repeatable fields\", \"shadcn dynamic form rows\", \"shadcn add another field\", \"shadcn field array\", \"shadcn invoice line items\", \"multiple email inputs react\", \"add multiple phone numbers form\", \"key value pair input react\", \"environment variables input react\", \"http headers input react\", \"invoice line items component react\", \"work experience form repeatable\", \"education fields add more\", \"product variants form react\", \"survey options editor react\", \"add remove list of inputs\", \"duplicate form row react\", \"variable length form react\", \"array of objects form react\", \"nested form array react\", \"deleting a row shows the wrong value\", \"removing an item keeps the value of the next one\", \"react list delete wrong item\", \"input value jumps to another row after delete\", \"wrong row deleted react\", \"deleted row value appears in the row below\", \"why not to use index as key\", \"react key index bug form inputs\", \"stable keys for dynamic form rows\", \"uncontrolled input loses value on remove\", \"focus new row after adding react\", \"autofocus newly added input react\", \"focus first field of new row\", \"focus lost after removing a row\", \"focus jumps to body after delete react\", \"tab starts from top of page after removing a row\", \"where should focus go after deleting an item\", \"manage focus when removing an element react\", \"screen reader does not announce added row\", \"announce row added aria-live\", \"remove buttons all say remove\", \"accessible name for delete row button\", \"label each remove button by its row\", \"aria-label for repeated delete buttons\", \"fieldset legend repeated fields\", \"group repeated form fields accessibility\", \"minimum one row required form\", \"limit number of rows form react\", \"disable add button at max rows\", \"keep at least one field react\", \"可変長フォーム react\", \"入力欄を動的に追加\", \"もう1つ追加 ボタン\", \"入力行の追加と削除\", \"明細行の追加 フォーム\", \"環境変数の入力欄\", \"配列のフォーム react\", \"index を key にすると壊れる\", \"行を削除したら下の行の値がずれる\", \"追加した行にフォーカスを移す\", \"削除後のフォーカス移動\", \"削除ボタンの読み上げ名\". Official shadcn/ui has the shell and none of the behaviour, and the measurement says so precisely. Across its whole registry today — 63 items in the catalogue, 62 fetchable, `questionnaire` indexed and 404 on both style tracks, 223,287 bytes of `files[].content` concatenated — `useFieldArray`, `fieldArray`, `repeatable`, `Add another`, `appendField`, `removeField` and `duplicateRow` are every one of them a zero hit. What official does ship is `field`, and it is worth being exact about what that gives you: `FieldSet` is a styled `<fieldset>`, `FieldLegend` a styled `<legend>`, `FieldGroup` a flex column, and it owns every one of the 2 `fieldset` and 7 `legend` occurrences in the registry. In its 6,170 bytes the words `add` and `remove` do not appear once. So the grouping is solved and the repetition is not. `form` is the other near-miss: 3,758 bytes wiring one field to its label, description and message — `FormField` occurs 17 times in it — and a field array is not in its vocabulary at all. This component is deliberately not built on either of them: the shell is nine lines of flexbox, and a project that has installed `field` can hand this component's `className` the same classes and the two sit together. Five things go wrong in the thirty-line version, and every one of them is invisible to a mouse. The first is the famous one, and the usual summary of it — \"don't use the index as a key\" — names the fix rather than the failure. React uses the key to decide which previously rendered row a row corresponds to. Keyed by position, deleting the second of three rows means the row that was third is now second, so React matches it to the second row's DOM nodes and keeps them — and an `<input>`'s text is DOM state, not something React re-renders. The value typed into row 3 is then sitting in row 2's box: nothing throws, the row count is right, every box has text in it, and what the person sees is the row they deleted having eaten the row below it. Uncontrolled inputs lose the value outright; controlled ones recover on the next render, but the caret, the selection, the scroll position and anything a ref is holding do not. So identity has to come from somewhere other than the position, and this mints a key per row and keeps it — rather than requiring an `id` on each row, which would be wrong here because these rows are form values that get submitted, and a synthetic id the caller adds on the way in and strips on the way out is the kind of chore that gets skipped. Keys survive the caller replacing the array too, matched by object identity and consumed as they are claimed, because `rows.filter(...)`, a reorder and an append all preserve the identity of the rows they keep. Second, focus after adding: left on the add button, the new row's first field is one Tab per field in every row above it, so a long form punishes the keyboard for every row it gains. Focus moves into the new row's first field here, which is also why the remove button is rendered after the row's fields — `querySelector` answers in document order, and the selector matches a button too, so putting it first would open every fresh row with focus on the control that deletes it. Third, focus after removing: the button just pressed no longer exists, and a browser does not move focus when the node holding it is removed — it drops focus to `<body>`, so the next Tab starts again at the top of the page and a screen reader says nothing. The rule here is the row that took the vacated slot, else the row now last, else the add button, which means walking a list of six down to one is six presses in one place rather than six presses and five hunts for the button. Fourth, the remove buttons: n of them called \"Remove\" tell a screen-reader user how many rows there are and nothing about which button takes which, so each is named for its row — \"Remove variable 2 of 3\" — and each row is a `role=\"group\"` named the same way, so entering one says where you are. Fifth, the floor and the ceiling: `min` and `max` are `aria-disabled` rather than the `disabled` attribute, so the control keeps its tab stop and announces itself as unavailable instead of silently vanishing or sitting there doing nothing, and the reason is on screen and wired up with `aria-describedby`. Adding and removing are also announced through a polite live region, because neither changes any field's value — without it the only evidence a row appeared is that focus moved, which says where you are and not what happened. What you get: `RepeatableFields`, generic over your row type, controlled with `value` + `onChange` (handed the whole next array and a `{ type, index }` saying what changed) or uncontrolled with `defaultValue`; `newRow`, the factory for a blank row; `children` as a render function receiving `{ row, index, total, rowId, update, remove, canRemove }`, where `rowId` is the stable minted key handed over so a label in row 2 can point at row 2's input; `legend` and `description` in a real `<fieldset>`/`<legend>`; `emptyMessage` for a set with nothing in it; `min` and `max`; `noun`, one prop that rewrites every accessible name at once (\"Add another recipient\", \"Remove recipient 2 of 3\"); `disabled`, which goes on the fieldset so the browser disables the caller's fields too without a prop being threaded down; `labels` for translating any of it; and per-part `className` for the set, a row, and each button. `nextFocusAfterRemove` and `alignRowKeys` are exported as pure functions, so the two decisions that are actually hard — where focus lands and which row keeps which key — can be asserted on their own. One file, two lucide icons, no `react-hook-form` and no form library; the array drops straight into `react-hook-form`'s `Controller` or any controlled state, and the fields are yours, so their `name` attributes and validation stay where they were. Distinct from the neighbours, and the pair worth knowing is `tag-input`: that one holds a `string[]` of short free-text values as chips inside a single field, this one repeats a whole row of several fields. `multi-select` picks from a fixed set of options you supply, and `nested-checkbox` selects from a hierarchy — both choose among things that already exist, where this one makes new ones. `sortable-list` reorders rows that already carry their own `id` and does not add or remove them. `upload-list` and `filter-chips` show a collection the person did not type. `form-error-summary` collects what went wrong after a submit, and `weekly-hours` is a fixed seven rows rather than a list that grows.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/repeatable-fields.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Plus, Trash2 } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Where focus goes after a row is removed.\n *\n * `count` is how many rows there were *before* the removal, `removed` the position that went. The\n * answer is a position rather than an element because the element the person was standing on is the\n * one that no longer exists: a browser does not move focus when the node holding it is removed, it\n * drops focus to `<body>`, and from there the next Tab starts again at the top of the page and a\n * screen reader says nothing at all. Every other component in this registry that removes the thing\n * under the cursor has the same rule — `split-button` restores to the half that opened the menu,\n * `sortable-list` re-focuses the handle it just moved — and this is that rule for a list that got\n * shorter.\n *\n * Three cases, and the middle one is the only one people get right by accident:\n *\n *   - rows remain and a row took the vacated slot → that row, so the cursor stays where it was on\n *     screen and the next press removes the next one. Walking a list of six down to one is then six\n *     presses in one place rather than six presses and five hunts for the button.\n *   - the last row went, so nothing moved up → the row now last, which is the one above it.\n *   - nothing is left → the add button, the only control still on screen.\n *\n * Exported because it is the part worth asserting on its own: the same decision in a browser is\n * three lines of DOM calls, and a test of those can only say that *something* was focused.\n */\nexport type RepeatableFocusTarget = { kind: \"row\"; index: number } | { kind: \"add\" }\n\nexport function nextFocusAfterRemove(count: number, removed: number): RepeatableFocusTarget {\n  const total = Number.isFinite(count) ? Math.floor(count) : 0\n  const at = Number.isFinite(removed) ? Math.floor(removed) : 0\n  const remaining = total - 1\n  if (remaining < 1) return { kind: \"add\" }\n  // Clamped on both sides: `removed` arrives from a handler on a row that is already gone, and a\n  // position past the end would point the focus effect at a ref holding nothing.\n  if (at < 0) return { kind: \"row\", index: 0 }\n  if (at >= remaining) return { kind: \"row\", index: remaining - 1 }\n  return { kind: \"row\", index: at }\n}\n\n/**\n * Keeps one key per row across changes the component did not make, matching rows by identity.\n *\n * This is the whole of the bug the pattern is famous for, and it is worth being precise about what\n * the bug *is*, because the usual summary — \"don't use the index as a key\" — describes the fix and\n * not the failure. React uses the key to decide which previously rendered row a row corresponds to.\n * Keyed by position, deleting the second of three rows means the row that was third is now second,\n * so React matches it to the second row's DOM nodes and keeps them: the `<input>` elements are\n * reused in place, and an input's text is DOM state, not something React re-renders. The value the\n * person typed in row 3 is then sitting in row 2's box. Nothing throws and nothing looks broken —\n * the row count is right, every box has text in it — and what the person sees is **the row they\n * deleted having eaten the row below it**. Uncontrolled inputs lose the value outright; controlled\n * ones recover on the next render, but the caret, the selection, the scroll position, the validation\n * state and anything a `ref` is holding do not.\n *\n * So identity has to come from somewhere that is not the position, and the component mints it rather\n * than asking for it. Requiring an `id` on each row — which is what `sortable-list` does, and right\n * for it — would be wrong here: these rows are form values, they get submitted, and a synthetic id\n * the caller has to add on the way in and strip on the way out is the kind of chore that gets\n * skipped. `react-hook-form`'s `useFieldArray` keeps its `id` outside the field values for exactly\n * this reason.\n *\n * Minted keys then have to survive the caller replacing the array, which is the case this function\n * exists for. Matching is by object identity, consuming each previous row as it is claimed, because\n * the ordinary ways a caller rewrites a list — `rows.filter(...)`, a reorder, an append — all\n * preserve the identity of the rows they keep. A row that matches nothing is new and gets a fresh\n * key. The one thing that defeats it is a caller that rebuilds every row object on every render\n * (`rows.map((r) => ({ ...r }))`), and nothing could have helped there: every row is new by then, so\n * the information needed to pair them up no longer exists.\n *\n * Returns `prevKeys` itself when nothing changed, which is not an optimisation but the thing that\n * stops the render-time reconcile below from looping forever on a caller who passes a freshly built\n * array of the same rows every render.\n */\nexport function alignRowKeys<T>(\n  rows: readonly T[],\n  prevRows: readonly T[],\n  prevKeys: readonly string[],\n  mint: () => string\n): readonly string[] {\n  const claimed = new Array<boolean>(prevRows.length).fill(false)\n  const next: string[] = []\n  let changed = rows.length !== prevKeys.length\n  for (let i = 0; i < rows.length; i++) {\n    let found = -1\n    for (let j = 0; j < prevRows.length; j++) {\n      if (!claimed[j] && prevRows[j] === rows[i]) {\n        found = j\n        break\n      }\n    }\n    if (found >= 0 && found < prevKeys.length) {\n      claimed[found] = true\n      next.push(prevKeys[found])\n      if (prevKeys[found] !== prevKeys[i]) changed = true\n    } else {\n      next.push(mint())\n      changed = true\n    }\n  }\n  return changed ? next : prevKeys\n}\n\n/**\n * The selector for \"the field a person would start typing in\".\n *\n * Read with `querySelector`, so it answers with the first match in document order — which is why the\n * remove button is rendered *after* the row's fields rather than before them. Put it first and the\n * row a person just added would open with focus on the button that deletes it.\n *\n * `:not([disabled])` matters on each entry rather than once: a row whose first field is conditionally\n * unavailable should hand focus to the second, and a disabled element cannot take focus anyway, so\n * matching one means asking for a focus that silently does not happen.\n */\nexport const REPEATABLE_FIELD_SELECTOR =\n  'input:not([type=\"hidden\"]):not([disabled]), select:not([disabled]), ' +\n  'textarea:not([disabled]), button:not([disabled]), [href], ' +\n  '[contenteditable=\"true\"], [tabindex]:not([tabindex=\"-1\"])'\n\nexport interface RepeatableFieldsLabels {\n  /** The add button's visible text, and its accessible name. */\n  add: string\n  /**\n   * A row's remove button, named by the row it removes.\n   *\n   * `(position, total)` rather than a fixed string because n rows means n remove buttons, and a\n   * screen reader reading them out gives \"Remove, Remove, Remove\" — which says how many there are\n   * and nothing about which is which. Naming them \"Remove row 2 of 3\" is the difference between a\n   * list you can edit without looking and one you cannot.\n   */\n  remove: (position: number, total: number) => string\n  /** A row's own accessible name, announced on entering it. */\n  row: (position: number, total: number) => string\n  /** Announced after a row is added. `total` counts the new row. */\n  added: (position: number, total: number) => string\n  /** Announced after a row is removed. `total` is what is left. */\n  removed: (position: number, total: number) => string\n  /** Shown and announced when no more rows may be added. */\n  atMax: (max: number) => string\n  /** Shown and announced when no more rows may be removed. */\n  atMin: (min: number) => string\n}\n\n/**\n * The labels, built around what one row is called.\n *\n * A function of `noun` rather than a constant because the accessible names are the whole\n * accessibility story of this component and \"row\" is almost never the word: the thing being repeated\n * is a variable, a recipient, a line item, a phone number. One prop moves every name at once —\n * \"Add another variable\", \"Remove variable 2 of 3\" — and `labels` still overrides any of them\n * individually, which is also how this gets translated.\n */\nexport function repeatableFieldsLabels(noun = \"row\"): RepeatableFieldsLabels {\n  const Noun = noun.charAt(0).toUpperCase() + noun.slice(1)\n  return {\n    add: `Add another ${noun}`,\n    remove: (position, total) => `Remove ${noun} ${position} of ${total}`,\n    row: (position, total) => `${Noun} ${position} of ${total}`,\n    added: (position, total) => `Added ${noun} ${position} of ${total}`,\n    removed: (position, total) =>\n      total > 0 ? `Removed ${noun} ${position}, ${total} remaining` : `Removed the last ${noun}`,\n    atMax: (max) => `Maximum of ${max}`,\n    atMin: (min) => (min === 1 ? `At least one ${noun} is required` : `At least ${min} required`),\n  }\n}\n\nexport const defaultRepeatableFieldsLabels: RepeatableFieldsLabels = repeatableFieldsLabels()\n\n/** What changed, handed to `onChange` beside the new array. */\nexport type RepeatableChange =\n  | { type: \"add\"; index: number }\n  | { type: \"remove\"; index: number }\n  | { type: \"update\"; index: number }\n\nexport interface RepeatableRowRenderArgs<T> {\n  /** This row's value. */\n  row: T\n  /** Its position, from 0. */\n  index: number\n  /** How many rows there are. */\n  total: number\n  /**\n   * A stable, unique id for this row — the same string for as long as the row exists, and never\n   * reused.\n   *\n   * It is the component's row key, handed over because a row of fields needs per-row DOM ids of its\n   * own: `<label htmlFor={`${rowId}-key`}>` is how a label in row 2 points at the input in row 2\n   * rather than at row 1's. Built from `useId`, so it is stable across server and client renders.\n   */\n  rowId: string\n  /** Replace this row's value. */\n  update: (next: T) => void\n  /** Remove this row. A no-op at `min`. */\n  remove: () => void\n  /** Whether removing is allowed right now — `false` at `min`, or when the set is disabled. */\n  canRemove: boolean\n}\n\nexport interface RepeatableFieldsProps<T>\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"fieldset\">,\n    \"children\" | \"value\" | \"defaultValue\" | \"onChange\"\n  > {\n  /** The rows, controlled. Pair with `onChange`. */\n  value?: readonly T[]\n  /** The rows a new, uncontrolled set starts with. */\n  defaultValue?: readonly T[]\n  /** Told the whole new array, and which row changed. */\n  onChange?: (next: T[], change: RepeatableChange) => void\n  /**\n   * Makes a blank row.\n   *\n   * Called once per press of the add button. Return a fresh object each time — returning one shared\n   * object makes every row the same object, and then editing any of them edits all of them.\n   */\n  newRow: () => T\n  /** Renders one row's fields. The remove button is added after whatever this returns. */\n  children: (args: RepeatableRowRenderArgs<T>) => React.ReactNode\n  /** The set's visible heading, in a real `<legend>`. */\n  legend?: React.ReactNode\n  /** A line under the legend, wired to the group with `aria-describedby`. */\n  description?: React.ReactNode\n  /** Shown in place of the rows when there are none. */\n  emptyMessage?: React.ReactNode\n  /**\n   * The fewest rows that may remain. Gates the remove button; it does not add rows to reach the\n   * floor — pass a `defaultValue` of the length you want to start at.\n   */\n  min?: number\n  /** The most rows that may be added. */\n  max?: number\n  /**\n   * The whole set unavailable.\n   *\n   * Goes on the `<fieldset>`, so the browser disables every control inside it — the caller's fields\n   * included, without them having to thread a prop down.\n   */\n  disabled?: boolean\n  /** What one row is called, used to build every accessible name. Default `\"row\"`. */\n  noun?: string\n  /** Overrides `labels.add` alone, which is the one most often reworded. */\n  addLabel?: React.ReactNode\n  labels?: Partial<RepeatableFieldsLabels>\n  className?: string\n  rowClassName?: string\n  addButtonClassName?: string\n  removeButtonClassName?: string\n}\n\n/**\n * A set of identical field rows, with \"add another\" and a remove button on each.\n *\n * For every form that takes a list rather than a value: work history and education, the line items\n * of an invoice or quote, environment variables and their values, several emails, phone numbers or\n * addresses on a contact, the headers or query parameters of an API request, a product's variants,\n * the options of a question being written, team members being invited, redirect URIs, webhook\n * endpoints, ingredients in a recipe, authors on a paper.\n *\n * The caller renders the fields; this owns the list. Which means it owns the five things that go\n * wrong, every one of which is invisible to a pointer and obvious to a keyboard:\n *\n *   - **Focus after adding.** A new row whose fields nobody is standing in is a row the person has\n *     to go and find: focus stays on the add button, and the way to the new row is one Tab per field\n *     in every row above it. Focus moves to the new row's first field here, so adding and typing are\n *     one gesture — see {@link REPEATABLE_FIELD_SELECTOR} for how the field is found, and why the\n *     remove button is rendered last.\n *   - **Focus after removing.** The button that was just pressed no longer exists, and a browser\n *     drops focus to `<body>` rather than moving it. See {@link nextFocusAfterRemove}.\n *   - **Row identity.** Keyed by position, deleting a row makes the row below it appear to inherit\n *     its values. See {@link alignRowKeys}; this mints a key per row and keeps it.\n *   - **Naming the remove buttons.** n of them called \"Remove\" say nothing about which row they\n *     take. See {@link RepeatableFieldsLabels.remove}.\n *   - **The floor and the ceiling.** `min` and `max` are `aria-disabled` rather than the `disabled`\n *     attribute, so the control keeps its place in the tab order and a person arriving at it is told\n *     it is unavailable — rather than finding that the button has silently gone, or worse, that it is\n *     there and does nothing. The reason is on screen too, wired up with `aria-describedby`.\n *\n * Adding and removing are also announced through a polite live region, because neither is a change\n * to any field's value: without it the only evidence a row appeared is that focus moved, which says\n * where you are and not what happened.\n *\n * The outer element is a real `<fieldset>` with a real `<legend>`. Official shadcn/ui's `field`\n * ships `FieldSet`, `FieldLegend` and `FieldGroup` and they are exactly this shell — a styled\n * `<fieldset>`, a styled `<legend>`, a flex column — with no notion of a row being added or taken\n * away (`add` and `remove` are zero hits in its source). So the shell is the part that already\n * exists, and it is deliberately not a dependency: it is nine lines of flexbox, and this component\n * is the behaviour that goes inside it. A project that has installed `field` can hand this\n * component's `className` the same classes and the two sit side by side.\n */\nexport function RepeatableFields<T>({\n  value,\n  defaultValue,\n  onChange,\n  newRow,\n  children,\n  legend,\n  description,\n  emptyMessage,\n  min = 0,\n  max,\n  disabled,\n  noun = \"row\",\n  addLabel,\n  labels: labelOverrides,\n  className,\n  rowClassName,\n  addButtonClassName,\n  removeButtonClassName,\n  ...props\n}: RepeatableFieldsProps<T>) {\n  const labels = React.useMemo(\n    () => ({ ...repeatableFieldsLabels(noun), ...labelOverrides }),\n    [noun, labelOverrides]\n  )\n  const baseId = React.useId()\n  const descriptionId = `${baseId}-description`\n  const maxHintId = `${baseId}-max`\n  const minHintId = `${baseId}-min`\n\n  const controlled = value !== undefined\n  const [internal, setInternal] = React.useState<T[]>(() => [...(defaultValue ?? [])])\n  const rows: readonly T[] = controlled ? value : internal\n\n  // Monotonic, so a key is never reused even after the row that had it is gone: React matching a new\n  // row to a removed row's nodes is the same bug as matching it by position.\n  const keySeed = React.useRef(0)\n  const mint = React.useCallback(() => `${baseId}-r${keySeed.current++}`, [baseId])\n\n  /**\n   * One key per row, reconciled during render rather than in an effect.\n   *\n   * An effect runs after the commit, so the render in which a row first appears would have no key\n   * for it — and the only thing available to key it by at that moment is its position, which is the\n   * bug. Adjusting state during render is React's own answer for state that has to agree with props\n   * (it re-renders immediately, before anything is shown), and the guard is `alignRowKeys` returning\n   * its input unchanged: without that, a caller passing a freshly built array every render would set\n   * state on every render forever.\n   *\n   * The previous rows live in a ref rather than in state for the same reason — it is a note of what\n   * was last seen, not something the UI is derived from, and putting it in state would make every\n   * new array reference a re-render whether or not any key changed.\n   */\n  const [keys, setKeys] = React.useState<readonly string[]>(() => rows.map(() => mint()))\n  const seenRows = React.useRef(rows)\n  let rowKeys = keys\n  if (seenRows.current !== rows) {\n    rowKeys = alignRowKeys(rows, seenRows.current, keys, mint)\n    seenRows.current = rows\n    if (rowKeys !== keys) setKeys(rowKeys)\n  }\n\n  const total = rows.length\n  const atMax = max !== undefined && total >= max\n  const atMin = total <= min\n  const canAdd = !disabled && !atMax\n  const canRemove = !disabled && !atMin\n\n  const [message, setMessage] = React.useState(\"\")\n\n  const rowRefs = React.useRef<Array<React.RefObject<HTMLDivElement | null>>>([])\n  const rowRef = (index: number) => {\n    while (rowRefs.current.length <= index) {\n      rowRefs.current.push(React.createRef<HTMLDivElement>())\n    }\n    return rowRefs.current[index]\n  }\n  const addRef = React.useRef<HTMLButtonElement | null>(null)\n\n  /**\n   * Where focus is owed, if anywhere.\n   *\n   * State rather than a call inside the click handler, because the row to focus does not exist yet\n   * when the handler runs: adding sets the array and the row is rendered by the commit that follows.\n   * Cleared as soon as it is spent, so an unrelated re-render — a parent's state, a validation\n   * message arriving — does not pull focus back into the list.\n   */\n  const [pendingFocus, setPendingFocus] = React.useState<RepeatableFocusTarget | null>(null)\n\n  React.useEffect(() => {\n    if (!pendingFocus) return\n    setPendingFocus(null)\n    if (pendingFocus.kind === \"add\") {\n      addRef.current?.focus?.()\n      return\n    }\n    // Bounded by the live count: `value` is a prop and can shrink again before this runs, and a ref\n    // past the end holds nothing.\n    if (pendingFocus.index < 0 || pendingFocus.index >= total) return\n    const node = rowRefs.current[pendingFocus.index]?.current\n    if (!node) return\n    // The row's own first field, else the row itself — which is why the row carries tabIndex={-1}.\n    // A row whose fields are all disabled, or which renders no field at all, still has somewhere for\n    // focus to land, and landing on the row announces which row it is.\n    const field = node.querySelector?.(REPEATABLE_FIELD_SELECTOR) as HTMLElement | null\n    ;(field ?? node).focus?.()\n  }, [pendingFocus, total])\n\n  const commit = (next: T[], change: RepeatableChange) => {\n    if (!controlled) setInternal(next)\n    onChange?.(next, change)\n  }\n\n  function add() {\n    if (!canAdd) return\n    const next = [...rows, newRow()]\n    commit(next, { type: \"add\", index: next.length - 1 })\n    setPendingFocus({ kind: \"row\", index: next.length - 1 })\n    setMessage(labels.added(next.length, next.length))\n  }\n\n  function remove(index: number) {\n    if (!canRemove) return\n    const next = rows.filter((_, i) => i !== index)\n    commit(next, { type: \"remove\", index })\n    setPendingFocus(nextFocusAfterRemove(total, index))\n    setMessage(labels.removed(index + 1, next.length))\n  }\n\n  function update(index: number, row: T) {\n    const next = rows.map((current, i) => (i === index ? row : current))\n    commit(next, { type: \"update\", index })\n  }\n\n  const describedBy =\n    [props[\"aria-describedby\"], description ? descriptionId : null].filter(Boolean).join(\" \") ||\n    undefined\n\n  return (\n    <fieldset\n      {...props}\n      disabled={disabled}\n      aria-describedby={describedBy}\n      // min-w-0 because a fieldset's default min-width is its content, which makes a long row stretch\n      // the whole form instead of scrolling or wrapping inside it.\n      className={cn(\"min-w-0\", className)}\n    >\n      {legend ? <legend className=\"mb-1 text-base font-medium\">{legend}</legend> : null}\n      {description ? (\n        <p id={descriptionId} className=\"mb-3 text-sm text-muted-foreground\">\n          {description}\n        </p>\n      ) : null}\n\n      {total === 0 && emptyMessage ? (\n        <p className=\"mb-3 rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground\">\n          {emptyMessage}\n        </p>\n      ) : null}\n\n      {total > 0 ? (\n        <div className=\"space-y-2\">\n          {rows.map((row, index) => (\n            <div\n              // Minted, never the position — see alignRowKeys.\n              key={rowKeys[index]}\n              ref={rowRef(index)}\n              role=\"group\"\n              aria-label={labels.row(index + 1, total)}\n              // Focusable only on purpose, as the fallback target of the focus effect above.\n              tabIndex={-1}\n              // A ring rather than a bare `outline-none`: this is the one element here that can hold\n              // focus without being in the tab order, so suppressing the outline unconditionally\n              // would leave a sighted keyboard user looking at a row with nothing to say focus is on\n              // it. Scoped to focus-visible so a pointer press on the row does not light it up.\n              className={cn(\n                \"flex items-start gap-2 rounded-md\",\n                \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                rowClassName\n              )}\n            >\n              <div className=\"min-w-0 flex-1\">\n                {children({\n                  row,\n                  index,\n                  total,\n                  rowId: rowKeys[index],\n                  update: (next) => update(index, next),\n                  remove: () => remove(index),\n                  canRemove,\n                })}\n              </div>\n              <button\n                type=\"button\"\n                // aria-disabled, not disabled: at the floor the button keeps its tab stop and\n                // announces itself as unavailable, rather than vanishing from under the cursor or\n                // sitting there looking live. `pointer-events-none` stops the press; the handler\n                // checks too, because a keyboard press does not go through pointer events.\n                aria-disabled={!canRemove || undefined}\n                aria-label={labels.remove(index + 1, total)}\n                aria-describedby={atMin && min > 0 ? minHintId : undefined}\n                onClick={() => remove(index)}\n                className={cn(\n                  \"inline-flex size-9 flex-none items-center justify-center rounded-md text-muted-foreground transition-colors\",\n                  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                  \"disabled:pointer-events-none disabled:opacity-50\",\n                  canRemove\n                    ? \"hover:bg-destructive/10 hover:text-destructive\"\n                    : \"pointer-events-none opacity-50\",\n                  removeButtonClassName\n                )}\n              >\n                <Trash2 aria-hidden=\"true\" className=\"size-4\" />\n              </button>\n            </div>\n          ))}\n        </div>\n      ) : null}\n\n      <div className=\"mt-3 flex items-center gap-3\">\n        <button\n          ref={addRef}\n          type=\"button\"\n          aria-disabled={!canAdd || undefined}\n          aria-describedby={atMax ? maxHintId : undefined}\n          onClick={add}\n          className={cn(\n            \"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium transition-colors\",\n            \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n            \"disabled:pointer-events-none disabled:opacity-50\",\n            canAdd\n              ? \"hover:bg-accent hover:text-accent-foreground\"\n              : \"pointer-events-none opacity-50\",\n            addButtonClassName\n          )}\n        >\n          <Plus aria-hidden=\"true\" className=\"size-4\" />\n          {addLabel ?? labels.add}\n        </button>\n        {atMax && max !== undefined ? (\n          <p id={maxHintId} className=\"text-sm text-muted-foreground\">\n            {labels.atMax(max)}\n          </p>\n        ) : null}\n        {atMin && min > 0 ? (\n          <p id={minHintId} className=\"text-sm text-muted-foreground\">\n            {labels.atMin(min)}\n          </p>\n        ) : null}\n      </div>\n\n      {/* Adding and removing change no field's value, so nothing else here is announced at all. */}\n      <div aria-live=\"polite\" className=\"sr-only\">\n        {message}\n      </div>\n    </fieldset>\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"
}
