{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "collection-field",
  "title": "Collection Field",
  "author": "@fma-ds/ds",
  "description": "A list edited in place inside a form: the user adds an item and fills it right there (payment methods, lots, recipients).\n\nRows, state and validation stay in the app (`useFieldArray`'s `fields`, `append`, `remove`).\n\n- **`CollectionField`**: takes the `label` (a legend), `description`, the list's `error`, `itemCount`, `onAdd` and `addLabel`. `minItems` removes the remove buttons at the minimum, `maxItems` swaps the add button for a note once reached, and an empty list shows `emptyMessage`.\n- **`CollectionFieldItem`**: `label` is numbered (\"Lot 2\") and also names its remove button; `onRemove` removes it. Two layouts: `row` for a few fields side by side on wide screens, with the remove button at the end; `group` for many fields under a visible legend with a static border, so items stay apart when fields stack on small screens.\n- **`CollectionFieldSummary`** with **`CollectionFieldSummaryItem`**: the total, what is allocated and what remains (`isEmphasized`: by size and weight, with the state in words).\n- **Focus**: adding announces the new item and focuses its first field; removing announces it and focuses the add button.\n\nLists filled elsewhere and only shown here (edited in a dialog, listed in a table) are a composition of `data-grid`/`item` and `dialog`, not this block.\n\nBuilt-in text is in English; translate it with `i18n.labels`.",
  "dependencies": [
    "cn@^0.5.0",
    "lucide-react@^1.61.0"
  ],
  "registryDependencies": [
    "@fma-ds/button",
    "@fma-ds/description-list",
    "@fma-ds/empty",
    "@fma-ds/field"
  ],
  "files": [
    {
      "path": "src/blocks/collection-field/collection-field.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"cn\";\nimport { PlusIcon, Trash2Icon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@/core/button/button\";\nimport {\n  DescriptionDetails,\n  DescriptionItem,\n  DescriptionList,\n  DescriptionTerm,\n} from \"@/core/description-list/description-list\";\nimport { Empty, EmptyDescription } from \"@/core/empty/empty\";\nimport { FieldDescription, FieldError, FieldLegend, FieldSet } from \"@/core/field/field\";\n\ntype CollectionFieldLabels = {\n  /** The remove button's accessible name (\"Remove lot 2\"). */\n  remove: (item: string) => string;\n  /** The remove button's visible text in the `group` layout. */\n  removeText: string;\n  added: (item: string) => string;\n  removed: (item: string) => string;\n  /** Shown instead of the add button once `maxItems` is reached. */\n  maxReached: (max: number) => string;\n  /** Shown while the list is empty, unless `emptyMessage` is given. */\n  empty: string;\n};\n\ntype CollectionFieldI18n = { labels?: Partial<CollectionFieldLabels> };\n\nconst defaultCollectionFieldLabels: CollectionFieldLabels = {\n  remove: (item) => `Remove ${item}`,\n  removeText: \"Remove\",\n  added: (item) => `${item} added`,\n  removed: (item) => `${item} removed`,\n  maxReached: (max) => `Up to ${max} items.`,\n  empty: \"Nothing added yet.\",\n};\n\ntype CollectionFieldContextValue = {\n  labels: CollectionFieldLabels;\n  canRemove: boolean;\n  /** Called by an item's remove button, before the app's `onRemove`. */\n  willRemove: (item: string) => void;\n};\n\nconst CollectionFieldContext = React.createContext<CollectionFieldContextValue | null>(null);\n\nfunction useCollectionField(part: string) {\n  const context = React.useContext(CollectionFieldContext);\n  if (!context) throw new Error(`${part} must be used within a CollectionField`);\n  return context;\n}\n\n/** What gets focus in a new item: its first focusable field, not its remove button. */\nfunction firstField(item: Element) {\n  const candidates = item.querySelectorAll<HTMLElement>(\n    \"input, select, textarea, button, [tabindex]\",\n  );\n  return [...candidates].find(\n    (element) =>\n      element.tabIndex >= 0 &&\n      !element.matches(\":disabled, [type=hidden]\") &&\n      !element.closest(\"[aria-hidden=true], [data-slot=collection-field-remove]\"),\n  );\n}\n\ntype CollectionFieldProps = Omit<React.ComponentProps<\"fieldset\">, \"children\"> & {\n  /** Names the list (a legend). */\n  label: React.ReactNode;\n  description?: React.ReactNode;\n  /** An error about the list as a whole (\"The payments must add up to the total\"). */\n  error?: React.ReactNode;\n  /** How many items there are (`fields.length` of a `useFieldArray`). */\n  itemCount: number;\n  /** Items can't be removed below this: their remove buttons go away. */\n  minItems?: number;\n  /** The add button gives way to `labels.maxReached` once there are this many. */\n  maxItems?: number;\n  /** Appends an item (the app's `append`). */\n  onAdd: () => void;\n  /** The add button's text: what is added (\"Add lot\"). */\n  addLabel: React.ReactNode;\n  emptyMessage?: React.ReactNode;\n  /** After the add button: a `CollectionFieldSummary` for lists that add up to a value. */\n  summary?: React.ReactNode;\n  i18n?: CollectionFieldI18n;\n  /** `CollectionFieldItem`s. */\n  children?: React.ReactNode;\n};\n\n/**\n * A list edited in place inside a form: items are added and filled right\n * there. Rows, state and validation stay in the app (`useFieldArray`'s\n * `fields`, `append`, `remove`); this lays them out, adds and removes, and\n * says what happened: adding announces the new item and focuses its first\n * field, removing announces it and focuses the add button.\n */\nfunction CollectionField({\n  label,\n  description,\n  error,\n  itemCount,\n  minItems = 0,\n  maxItems,\n  onAdd,\n  addLabel,\n  emptyMessage,\n  summary,\n  i18n,\n  className,\n  children,\n  ...props\n}: CollectionFieldProps) {\n  const labels = { ...defaultCollectionFieldLabels, ...i18n?.labels };\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const addRef = React.useRef<HTMLButtonElement>(null);\n  const pending = React.useRef<{ type: \"add\" } | { type: \"remove\"; item: string } | null>(null);\n  const previousCount = React.useRef(itemCount);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const canAdd = maxItems === undefined || itemCount < maxItems;\n\n  // Runs after every render: the item appears (or goes) once the app's array changes.\n  React.useLayoutEffect(() => {\n    const change = itemCount - previousCount.current;\n    previousCount.current = itemCount;\n    const action = pending.current;\n\n    if (action?.type === \"add\" && change > 0) {\n      pending.current = null;\n      const items = listRef.current?.querySelectorAll(\"[data-slot=collection-field-item]\");\n      const item = items?.[items.length - 1];\n      if (!item) return;\n      setAnnouncement(labels.added(item.getAttribute(\"data-label\") ?? \"\"));\n      firstField(item)?.focus();\n    } else if (action?.type === \"remove\" && change < 0) {\n      pending.current = null;\n      setAnnouncement(labels.removed(action.item));\n      addRef.current?.focus();\n    }\n  });\n\n  const context: CollectionFieldContextValue = {\n    labels,\n    canRemove: itemCount > minItems,\n    willRemove: (item) => {\n      pending.current = { type: \"remove\", item };\n    },\n  };\n\n  return (\n    <CollectionFieldContext.Provider value={context}>\n      <FieldSet\n        data-slot=\"collection-field\"\n        data-invalid={error ? true : undefined}\n        className={cn(\"gap-3\", className)}\n        {...props}\n      >\n        <FieldLegend variant=\"label\">{label}</FieldLegend>\n        {description && <FieldDescription className=\"-mt-1.5\">{description}</FieldDescription>}\n        <span role=\"status\" className=\"sr-only\">\n          {announcement}\n        </span>\n        {itemCount === 0 ? (\n          <Empty className=\"gap-0 rounded-lg border border-dashed p-4 md:p-4\">\n            <EmptyDescription>{emptyMessage ?? labels.empty}</EmptyDescription>\n          </Empty>\n        ) : (\n          <div ref={listRef} data-slot=\"collection-field-items\" className=\"flex flex-col gap-3\">\n            {children}\n          </div>\n        )}\n        {error && <FieldError>{error}</FieldError>}\n        {canAdd ? (\n          <Button\n            ref={addRef}\n            data-slot=\"collection-field-add\"\n            variant=\"outline\"\n            size=\"sm\"\n            className=\"self-start\"\n            onPress={() => {\n              pending.current = { type: \"add\" };\n              onAdd();\n            }}\n          >\n            <PlusIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n            {addLabel}\n          </Button>\n        ) : (\n          maxItems !== undefined && (\n            <p data-slot=\"collection-field-max\" className=\"text-sm text-muted-foreground\">\n              {labels.maxReached(maxItems)}\n            </p>\n          )\n        )}\n        {summary}\n      </FieldSet>\n    </CollectionFieldContext.Provider>\n  );\n}\n\ntype CollectionFieldItemProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Names the item, numbered (\"Lot 2\"): the group's name, the legend in `group`, and the remove button's. */\n  label: string;\n  /** Removes it (the app's `remove(index)`). */\n  onRemove: () => void;\n  /**\n   * `row`: a few fields side by side on wide screens (method + amount), the\n   * remove button at the end. `group`: many fields under a visible numbered\n   * legend, with a static border so items stay apart when the fields stack.\n   */\n  layout?: \"row\" | \"group\";\n  children: React.ReactNode;\n};\n\n/** One item of a `CollectionField`, with its fields as children. */\nfunction CollectionFieldItem({\n  label,\n  onRemove,\n  layout = \"row\",\n  className,\n  children,\n  ...props\n}: CollectionFieldItemProps) {\n  const { labels, canRemove, willRemove } = useCollectionField(\"CollectionFieldItem\");\n  const remove = () => {\n    willRemove(label);\n    onRemove();\n  };\n\n  if (layout === \"group\") {\n    return (\n      <fieldset\n        data-slot=\"collection-field-item\"\n        data-layout=\"group\"\n        data-label={label}\n        className={cn(\"relative rounded-lg border p-4\", className)}\n        {...(props as React.ComponentProps<\"fieldset\">)}\n      >\n        {/* Floated so it sits inside the border, as a heading, not on it. */}\n        <legend className=\"float-left mb-4 flex min-h-7 items-center pe-24 text-sm font-medium\">\n          {label}\n        </legend>\n        {canRemove && (\n          <Button\n            data-slot=\"collection-field-remove\"\n            variant=\"ghost\"\n            size=\"sm\"\n            aria-label={labels.remove(label)}\n            className=\"absolute end-3 top-4\"\n            onPress={remove}\n          >\n            {labels.removeText}\n          </Button>\n        )}\n        <div className=\"clear-both flex flex-col gap-4\">{children}</div>\n      </fieldset>\n    );\n  }\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={label}\n      data-slot=\"collection-field-item\"\n      data-layout=\"row\"\n      data-label={label}\n      className={cn(\"flex flex-col gap-3 sm:flex-row sm:items-end\", className)}\n      {...props}\n    >\n      {children}\n      {canRemove && (\n        <Button\n          data-slot=\"collection-field-remove\"\n          variant=\"ghost\"\n          size=\"icon\"\n          aria-label={labels.remove(label)}\n          className=\"self-end\"\n          onPress={remove}\n        >\n          <Trash2Icon />\n        </Button>\n      )}\n    </div>\n  );\n}\n\n/**\n * Where a list that adds up to a value stands: the total, what is allocated\n * and what remains. The last item (`isEmphasized`) stands out by size and\n * weight; its value says the state in words (\"R$ 20,00 left\", \"Settled\").\n */\nfunction CollectionFieldSummary({ className, ...props }: React.ComponentProps<\"dl\">) {\n  return (\n    <DescriptionList\n      data-slot=\"collection-field-summary\"\n      className={cn(\"rounded-lg border p-3\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CollectionFieldSummaryItem({\n  label,\n  value,\n  isEmphasized = false,\n  className,\n  ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  label: React.ReactNode;\n  value: React.ReactNode;\n  isEmphasized?: boolean;\n}) {\n  return (\n    <DescriptionItem\n      data-slot=\"collection-field-summary-item\"\n      className={cn(isEmphasized && \"border-t pt-2\", className)}\n      {...props}\n    >\n      <DescriptionTerm className={cn(isEmphasized && \"font-medium text-foreground\")}>\n        {label}\n      </DescriptionTerm>\n      <DescriptionDetails className={cn(\"tabular-nums\", isEmphasized && \"text-base font-semibold\")}>\n        {value}\n      </DescriptionDetails>\n    </DescriptionItem>\n  );\n}\n\nexport type {\n  CollectionFieldI18n,\n  CollectionFieldItemProps,\n  CollectionFieldLabels,\n  CollectionFieldProps,\n};\nexport { CollectionField, CollectionFieldItem, CollectionFieldSummary, CollectionFieldSummaryItem };\n",
      "type": "registry:component",
      "target": "components/fma-ds/collection-field/collection-field.tsx"
    }
  ],
  "meta": {
    "status": "experimental",
    "source": "original",
    "license": "MIT",
    "tags": [
      "block",
      "form",
      "field-array",
      "list",
      "repeater"
    ],
    "contentHash": "485d5b54137a"
  },
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}