{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "field-array",
  "title": "Field Array",
  "description": "A repeatable group of form rows with add, remove and keyboard reorder (Alt+Arrow), min and max limits, stable row keys, focus management and spoken announcements.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "registry/tealmac/field-array.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\n\nexport interface FieldArrayRow<T> {\n  item: T\n  index: number\n  /** Stable across reorders; use it as the key of anything you keep per row. */\n  id: string\n  count: number\n  canRemove: boolean\n  canMoveUp: boolean\n  canMoveDown: boolean\n  remove: () => void\n  moveUp: () => void\n  moveDown: () => void\n  /** Replace this row's item. */\n  update: (next: T) => void\n}\n\nconst RowContext = React.createContext<FieldArrayRow<unknown> | null>(null)\n\n/** The row context, for `FieldArrayRowActions` and your own row controls. */\nexport function useFieldArrayRow<T = unknown>() {\n  const row = React.useContext(RowContext)\n  if (!row)\n    throw new Error(\"useFieldArrayRow must be used inside a FieldArray row\")\n  return row as FieldArrayRow<T>\n}\n\nexport interface FieldArrayProps<T> {\n  value: T[]\n  onChange: (next: T[]) => void\n  /** Creates the item the Add button appends. */\n  createItem: () => T\n  /** Rows that cannot be removed. Default 0. */\n  min?: number\n  /** Add is disabled at this many rows. */\n  max?: number\n  /** Group label, read by screen readers as the legend. */\n  label: React.ReactNode\n  description?: React.ReactNode\n  /** Singular noun for announcements and button names. Default \"item\". */\n  itemName?: string\n  addLabel?: React.ReactNode\n  emptyMessage?: React.ReactNode\n  /** Render one row. Put your fields inside; use `FieldArrayRowActions` for the buttons. */\n  children: (row: FieldArrayRow<T>) => React.ReactNode\n  className?: string\n}\n\nlet counter = 0\nconst nextId = () => `fa-${++counter}`\n\n/**\n * Add, remove and reorder rows. Rows keep a stable id through reorders, so\n * inputs keep their focus and uncontrolled state. Alt+ArrowUp and Alt+ArrowDown\n * move the row that has focus; every change is announced.\n */\nexport function FieldArray<T>({\n  value,\n  onChange,\n  createItem,\n  min = 0,\n  max,\n  label,\n  description,\n  itemName = \"item\",\n  addLabel,\n  emptyMessage = \"Nothing added yet.\",\n  children,\n  className,\n}: FieldArrayProps<T>) {\n  const ids = React.useRef<string[]>([])\n  while (ids.current.length < value.length) ids.current.push(nextId())\n  if (ids.current.length > value.length) ids.current.length = value.length\n\n  const listRef = React.useRef<HTMLUListElement>(null)\n  const addRef = React.useRef<HTMLButtonElement>(null)\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  const pendingFocus = React.useRef<\n    { id: string } | { index: number } | \"add\" | null\n  >(null)\n\n  React.useEffect(() => {\n    const target = pendingFocus.current\n    if (!target) return\n    pendingFocus.current = null\n    if (target === \"add\") return addRef.current?.focus()\n    const rows = Array.from(\n      listRef.current?.querySelectorAll<HTMLElement>(\":scope > li\") ?? []\n    )\n    const row =\n      \"id\" in target\n        ? rows.find((el) => el.dataset.rowId === target.id)\n        : rows[Math.min(target.index, rows.length - 1)]\n    const focusable = row?.querySelector<HTMLElement>(\n      \"input:not([disabled]),select:not([disabled]),textarea:not([disabled]),button:not([disabled])\"\n    )\n    ;(focusable ?? row)?.focus()\n  })\n\n  const name = itemName\n  const atMax = max !== undefined && value.length >= max\n  const atMin = value.length <= min\n\n  function add() {\n    if (atMax) return\n    const id = nextId()\n    ids.current = [...ids.current, id]\n    pendingFocus.current = { id }\n    onChange([...value, createItem()])\n    setAnnouncement(\n      `${name} ${value.length + 1} added. ${value.length + 1} in total.`\n    )\n  }\n  function remove(index: number) {\n    if (atMin) return\n    ids.current = ids.current.filter((_, i) => i !== index)\n    pendingFocus.current =\n      value.length - 1 === 0\n        ? \"add\"\n        : { index: Math.min(index, value.length - 2) }\n    onChange(value.filter((_, i) => i !== index))\n    setAnnouncement(`${name} ${index + 1} removed. ${value.length - 1} left.`)\n  }\n  function move(from: number, to: number) {\n    if (to < 0 || to >= value.length) return\n    const nextIds = [...ids.current]\n    const [movedId] = nextIds.splice(from, 1)\n    nextIds.splice(to, 0, movedId)\n    ids.current = nextIds\n    pendingFocus.current = { id: movedId }\n    const next = [...value]\n    const [item] = next.splice(from, 1)\n    next.splice(to, 0, item)\n    onChange(next)\n    setAnnouncement(`${name} moved to position ${to + 1} of ${value.length}.`)\n  }\n\n  return (\n    <fieldset className={cn(\"flex min-w-0 flex-col gap-3\", className)}>\n      <legend className=\"text-sm leading-snug font-medium\">{label}</legend>\n      {description ? (\n        <p className=\"text-muted-foreground -mt-1 text-sm\">{description}</p>\n      ) : null}\n      {value.length === 0 ? (\n        <p className=\"text-muted-foreground rounded-md border border-dashed px-3 py-4 text-center text-sm\">\n          {emptyMessage}\n        </p>\n      ) : (\n        <ul ref={listRef} className=\"flex flex-col gap-3\">\n          {value.map((item, index) => {\n            const id = ids.current[index]\n            const row: FieldArrayRow<T> = {\n              item,\n              index,\n              id,\n              count: value.length,\n              canRemove: !atMin,\n              canMoveUp: index > 0,\n              canMoveDown: index < value.length - 1,\n              remove: () => remove(index),\n              moveUp: () => move(index, index - 1),\n              moveDown: () => move(index, index + 1),\n              update: (next) =>\n                onChange(value.map((v, i) => (i === index ? next : v))),\n            }\n            return (\n              // The key handler listens for Alt+Arrow bubbling up from the row's own controls.\n              // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n              <li\n                key={id}\n                data-row-id={id}\n                tabIndex={-1}\n                role=\"group\"\n                aria-label={`${name} ${index + 1} of ${value.length}`}\n                onKeyDown={(event) => {\n                  if (!event.altKey || event.defaultPrevented) return\n                  if (event.key === \"ArrowUp\") {\n                    event.preventDefault()\n                    row.moveUp()\n                  } else if (event.key === \"ArrowDown\") {\n                    event.preventDefault()\n                    row.moveDown()\n                  }\n                }}\n                className=\"bg-card focus-visible:ring-ring/50 flex items-start gap-2 rounded-lg border p-3 outline-none focus-visible:ring-[3px]\"\n              >\n                <RowContext.Provider value={row as FieldArrayRow<unknown>}>\n                  {children(row)}\n                </RowContext.Provider>\n              </li>\n            )\n          })}\n        </ul>\n      )}\n      <div>\n        <Button\n          ref={addRef}\n          type=\"button\"\n          variant=\"outline\"\n          size=\"sm\"\n          disabled={atMax}\n          onClick={add}\n        >\n          <PlusIcon aria-hidden=\"true\" />\n          {addLabel ?? `Add ${name}`}\n        </Button>\n        {atMax ? (\n          <span className=\"text-muted-foreground ms-3 text-xs\">\n            Limit of {max} reached.\n          </span>\n        ) : null}\n      </div>\n      <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </div>\n    </fieldset>\n  )\n}\n\n/** Move up, move down and remove buttons for the current row. */\nexport function FieldArrayRowActions({\n  className,\n  reorder = true,\n}: {\n  className?: string\n  /** Show the move buttons. Keep them: Alt+Arrow is not discoverable. */\n  reorder?: boolean\n}) {\n  const row = useFieldArrayRow()\n  const n = row.index + 1\n  return (\n    <div className={cn(\"flex shrink-0 items-center gap-1\", className)}>\n      {reorder ? (\n        <>\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon-sm\"\n            disabled={!row.canMoveUp}\n            onClick={row.moveUp}\n            aria-label={`Move ${n} up`}\n          >\n            <ArrowUpIcon aria-hidden=\"true\" />\n          </Button>\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon-sm\"\n            disabled={!row.canMoveDown}\n            onClick={row.moveDown}\n            aria-label={`Move ${n} down`}\n          >\n            <ArrowDownIcon aria-hidden=\"true\" />\n          </Button>\n        </>\n      ) : null}\n      <Button\n        type=\"button\"\n        variant=\"ghost\"\n        size=\"icon-sm\"\n        disabled={!row.canRemove}\n        onClick={row.remove}\n        aria-label={`Remove ${n}`}\n      >\n        <Trash2Icon aria-hidden=\"true\" />\n      </Button>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}