shadimage
buzzform
uiarraysource: ok · hit

ArrayField

Repeatable array field for BuzzForm v2.

npx shadcn add "https://form.buildnbuzz.com/r/array.json"
free

Where it goes

data-table

Use cases

dashboard

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@dnd-kit/core@dnd-kit/sortable@dnd-kit/utilities

Registry dependencies

@buzzform/initcardcollapsiblebadgealert-dialog

Files1 · source cached 10/8/2026, 4:05:13 PM

registry/shadcn/fields/array.tsx
"use client";

import * as React from "react";
import type { ArrayFieldDef, CoreField } from "@buildnbuzz/form-react";
import {
  useDataField,
  RenderFields,
  useNestedErrorCount,
} from "@buildnbuzz/form-react";
import { resolveExpr } from "@buildnbuzz/form-react";
import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  type DragEndEvent,
} from "@dnd-kit/core";
import {
  SortableContext,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
  useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { IconPlaceholder } from "@/components/icon-placeholder";
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLegend,
  FieldSet,
} from "@/components/ui/field";
import { cn } from "@/lib/utils";

// ---------------------------------------------------------------------------
// UI options
// ---------------------------------------------------------------------------

interface ArrayUi {
  /** Visual style for the array container. Defaults to `"default"`. */
  variant?: "default" | "minimal";
  /** Row header label — use `{ $data: "fieldName" }` to show a field value. Falls back to `"Item #N"`. */
  rowLabel?: unknown;
  /** Enable drag-to-reorder. Defaults to `true`. */
  isSortable?: boolean;
  /** Show error-count badge in the container header. Defaults to `true`. */
  showErrorBadge?: boolean;
  /** Show a duplicate button per row. Defaults to `false`. */
  allowDuplicate?: boolean;
  /** Require confirmation before deleting all items. Defaults to `true`. */
  confirmDelete?: boolean;
  /** Text and label overrides. */
  labels?: {
    /** Label for the add button. Defaults to `"Add Item"`. */
    add?: React.ReactNode;
    /** Empty state message. Defaults to `"No items added yet."`. */
    empty?: React.ReactNode;
    /** Fallback row label when dynamic path is missing or empty. Defaults to `"Item #N"`. */
    rowFallback?: string;
    /** Title for the delete confirmation dialog. */
    confirmDeleteTitle?: React.ReactNode;
    /** Description for the delete confirmation dialog. */
    confirmDeleteDescription?: React.ReactNode;
    /** Label for the cancel button in delete confirmation. */
    confirmDeleteCancelLabel?: string;
    /** Label for the confirm button in delete confirmation. */
    confirmDeleteActionLabel?: string;
  };
  /** Controls required asterisk visibility. */
  asterisk?: boolean;
}

// ---------------------------------------------------------------------------
// ArrayItem props type
// ---------------------------------------------------------------------------

interface ArrayItemProps {
  id: string;
  index: number;
  field: ArrayFieldDef;
  form: ReturnType<typeof useDataField>["form"];
  fieldApiName: string;
  rowLabel: unknown;
  rowFallback?: string;
  contextData: unknown;
  isSortable: boolean;
  isDisabled: boolean;
  isReadOnly: boolean;
  canRemove: boolean;
  canDuplicate: boolean;
  allowDuplicate: boolean;
  onRemove: (index: number) => void;
  onDuplicate: (index: number) => void;
}

// ---------------------------------------------------------------------------
// ArrayItem
// ---------------------------------------------------------------------------

function ArrayItem({
  id,
  index,
  field,
  form,
  fieldApiName,
  rowLabel,
  rowFallback,
  contextData,
  isSortable,
  isDisabled,
  isReadOnly,
  canRemove,
  canDuplicate,
  allowDuplicate,
  onRemove,
  onDuplicate,
}: ArrayItemProps) {
  const [isOpen, setIsOpen] = React.useState(true);
  const rowPath = `${fieldApiName}.${index}`;

  const rowData = form.getFieldValue(rowPath as never);
  const rowFormData =
    rowData && typeof rowData === "object"
      ? (rowData as Record<string, unknown>)
      : { value: rowData };

  const defaultRowLabel = rowFallback ?? `Item ${index + 1}`;

  const resolvedLabel = rowLabel
    ? String(
        resolveExpr(
          rowLabel as string,
          { data: rowFormData, context: (contextData ?? {}) as Record<string, unknown> },
        ) ?? "",
      ).trim() || defaultRowLabel
    : field.primitive === true && rowData !== undefined && rowData !== null
      ? String(rowData)
      : defaultRowLabel;

  const errorCount = useNestedErrorCount(field.fields as CoreField[], rowPath);

  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id, disabled: !isSortable || isDisabled });

  return (
    <div
      ref={setNodeRef}
      style={{
        transform: CSS.Transform.toString(transform),
        transition,
        opacity: isDragging ? 0.5 : 1,
      }}
    >
      <Collapsible open={isOpen} onOpenChange={setIsOpen}>
        <div className="bg-card border rounded-lg overflow-hidden">
          <CardHeader
            className={cn(
              "flex flex-row items-center gap-2 px-3 py-2 bg-muted/50",
              isOpen && "border-b",
            )}
          >
            {isSortable && !isDisabled && (
              <span
                {...attributes}
                {...listeners}
                className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground shrink-0"
              >
                <IconPlaceholder
                  lucide="GripVertical"
                  hugeicons="DragDropIcon"
                  tabler="IconGripVertical"
                  phosphor="DotsSixVertical"
                  remixicon="RiDraggable"
                  className="size-4"
                />
              </span>
            )}

            <CollapsibleTrigger className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer">
              <IconPlaceholder
                lucide="ChevronDown"
                hugeicons="ArrowDown01Icon"
                tabler="IconChevronDown"
                phosphor="CaretDown"
                remixicon="RiArrowDownSLine"
                className={cn(
                  "size-3.5 text-muted-foreground transition-transform duration-200 shrink-0",
                  !isOpen && "-rotate-90",
                )}
              />
              <span className="text-sm font-medium truncate">
                {resolvedLabel}
              </span>
              {errorCount > 0 && (
                <Badge
                  variant="destructive"
                  className="h-5 px-1.5 text-xs ml-1 shrink-0"
                >
                  {errorCount}
                </Badge>
              )}
            </CollapsibleTrigger>

            {!isReadOnly && (
              <div className="flex items-center gap-0.5 shrink-0">
                {allowDuplicate && (
                  <Button
                    type="button"
                    variant="ghost"
                    size="icon"
                    className="h-7 w-7"
                    onClick={() => onDuplicate(index)}
                    disabled={!canDuplicate || isDisabled}
                    title="Duplicate"
                  >
                    <IconPlaceholder
                      lucide="Copy"
                      hugeicons="Copy01Icon"
                      tabler="IconCopy"
                      phosphor="Copy"
                      remixicon="RiFileCopyLine"
                      className="size-3.5"
                    />
                  </Button>
                )}
                <Button
                  type="button"
                  variant="ghost"
                  size="icon"
                  className="h-7 w-7 text-muted-foreground hover:text-destructive"
                  onClick={() => onRemove(index)}
                  disabled={!canRemove || isDisabled}
                  title="Remove"
                >
                  <IconPlaceholder
                    lucide="Trash2"
                    hugeicons="Delete02Icon"
                    tabler="IconTrash"
                    phosphor="Trash"
                    remixicon="RiDeleteBinLine"
                    className="size-3.5"
                  />
                </Button>
              </div>
            )}
          </CardHeader>

          <CollapsibleContent>
            <div className="p-4 flex flex-col gap-4">
              <RenderFields
                fields={field.fields as CoreField[]}
                form={form}
                basePath={rowPath}
              />
            </div>
          </CollapsibleContent>
        </div>
      </Collapsible>
    </div>
  );
}

function MinimalArrayItem({
  id,
  index,
  field,
  form,
  fieldApiName,
  rowLabel,
  rowFallback,
  contextData,
  isSortable,
  isDisabled,
  isReadOnly,
  canRemove,
  canDuplicate,
  allowDuplicate,
  onRemove,
  onDuplicate,
}: ArrayItemProps) {
  const rowPath = `${fieldApiName}.${index}`;
  const rowData = form.getFieldValue(rowPath as never);
  const rowFormData =
    rowData && typeof rowData === "object"
      ? (rowData as Record<string, unknown>)
      : { value: rowData };

  const defaultRowLabel = rowFallback ?? `Item ${index + 1}`;

  const resolvedLabel = rowLabel
    ? String(
        resolveExpr(
          rowLabel as string,
          { data: rowFormData, context: (contextData ?? {}) as Record<string, unknown> },
        ) ?? "",
      ).trim() || defaultRowLabel
    : "";

  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id, disabled: !isSortable || isDisabled });

  return (
    <div
      ref={setNodeRef}
      style={{
        transform: CSS.Transform.toString(transform),
        transition,
        opacity: isDragging ? 0.5 : 1,
      }}
      className="flex w-full items-start gap-3 py-1 group/array-item"
    >
      <div className="flex-1 min-w-0">
        {resolvedLabel && field.primitive !== true && (
          <p className="text-xs text-muted-foreground mb-2">{resolvedLabel}</p>
        )}
        <div
          className={cn(
            "flex flex-col gap-4 w-full",
            field.primitive === true &&
              "**:data-[slot=field-label]:hidden **:data-[slot=field-description]:hidden",
          )}
        >
          <RenderFields
            fields={field.fields as CoreField[]}
            form={form}
            basePath={rowPath}
          />
        </div>
      </div>

      {!isReadOnly && (
        <div
          className={cn(
            "flex items-center gap-1 shrink-0",
            field.primitive === true ? "mt-0.5" : "mt-7",
          )}
        >
          {isSortable && !isDisabled && (
            <Button
              type="button"
              variant="ghost"
              size="icon"
              className="h-7 w-7"
              {...attributes}
              {...listeners}
              title="Drag"
            >
              <IconPlaceholder
                lucide="GripVertical"
                hugeicons="DragDropIcon"
                tabler="IconGripVertical"
                phosphor="DotsSixVertical"
                remixicon="RiDraggable"
                className="size-3.5"
              />
            </Button>
          )}
          {allowDuplicate && (
            <Button
              type="button"
              variant="ghost"
              size="icon"
              className="h-7 w-7"
              onClick={() => onDuplicate(index)}
              disabled={!canDuplicate || isDisabled}
              title="Duplicate"
            >
              <IconPlaceholder
                lucide="Copy"
                hugeicons="Copy01Icon"
                tabler="IconCopy"
                phosphor="Copy"
                remixicon="RiFileCopyLine"
                className="size-3.5"
              />
            </Button>
          )}
          <Button
            type="button"
            variant="ghost"
            size="icon"
            className="h-7 w-7 text-muted-foreground hover:text-destructive"
            onClick={() => onRemove(index)}
            disabled={!canRemove || isDisabled}
            title="Remove"
          >
            <IconPlaceholder
              lucide="Trash2"
              hugeicons="Delete02Icon"
              tabler="IconTrash"
              phosphor="Trash"
              remixicon="RiDeleteBinLine"
              className="size-3.5"
            />
          </Button>
        </div>
      )}
    </div>
  );
}

interface ArrayFieldState {
  fieldApi: ReturnType<typeof useDataField>["fieldApi"];
  field: ArrayFieldDef;
  form: ReturnType<typeof useDataField>["form"];
  isDisabled: boolean;
  isReadOnly: boolean;
  isRequired: boolean;
  label?: React.ReactNode;
  description?: React.ReactNode;
  errors: ReturnType<typeof useDataField>["errors"];
  isInvalid: boolean;
  descriptionId?: string;
  errorId?: string;
  contextData: unknown;
}

function DefaultArrayField(state: ArrayFieldState) {
  const {
    fieldApi,
    field,
    form,
    isDisabled,
    isReadOnly,
    isRequired,
    label,
    description,
    errors,
    isInvalid,
    descriptionId,
    errorId,
    contextData,
  } = state;

  const ui = field.ui as ArrayUi | undefined;
  const labels = ui?.labels;

  const addLabel = labels?.add ?? "Add Item";
  const emptyMessage = labels?.empty ?? "No items added yet.";
  const isSortable = ui?.isSortable !== false;
  const showErrorBadge = ui?.showErrorBadge !== false;
  const allowDuplicate = ui?.allowDuplicate ?? false;
  const confirmDelete = ui?.confirmDelete !== false;

  const items = Array.isArray(fieldApi.state.value) ? fieldApi.state.value : [];

  const confirmDeleteTitle = labels?.confirmDeleteTitle ?? "Delete all items?";
  const confirmDeleteDescription =
    labels?.confirmDeleteDescription ??
    (items.length === 1
      ? "This will remove the item. This cannot be undone."
      : `This will remove all ${items.length} items. This cannot be undone.`);
  const confirmDeleteCancelLabel = labels?.confirmDeleteCancelLabel ?? "Cancel";
  const confirmDeleteActionLabel =
    labels?.confirmDeleteActionLabel ?? "Delete All";

  const [showDeleteAllDialog, setShowDeleteAllDialog] = React.useState(false);

  const canAddMore =
    field.maxItems === undefined || items.length < field.maxItems;
  const canRemoveAny =
    field.minItems === undefined || items.length > field.minItems;

  // Stable DnD IDs keyed by position — sufficient since we reorder in-place
  const dndId = React.useId();
  const itemIds = items.map((_, i) => `${fieldApi.name}-${i}`);

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    }),
  );

  const handleDragEnd = ({ active, over }: DragEndEvent) => {
    if (!over || active.id === over.id) return;
    const from = itemIds.indexOf(active.id as string);
    const to = itemIds.indexOf(over.id as string);
    if (from !== -1 && to !== -1) fieldApi.moveValue(from, to);
  };

  const handleAdd = () => {
    if (!isDisabled && !isReadOnly && canAddMore)
      fieldApi.pushValue(undefined as never);
  };

  const handleRemove = (index: number) => fieldApi.removeValue(index);

  const handleDuplicate = (index: number) => {
    if (canAddMore) fieldApi.insertValue(index + 1, items[index] as never);
  };

  const handleDeleteAll = () => {
    if (confirmDelete) {
      setShowDeleteAllDialog(true);
    } else {
      for (let i = items.length - 1; i >= 0; i--) fieldApi.removeValue(i);
    }
  };

  const totalErrorCount = useNestedErrorCount(
    field.fields as CoreField[],
    fieldApi.name,
  );
  const isEffectivelyRequired =
    isRequired || (typeof field.minItems === "number" && field.minItems >= 1);

  return (
    <>
      <AlertDialog
        open={showDeleteAllDialog}
        onOpenChange={setShowDeleteAllDialog}
      >
        <AlertDialogContent size="sm">
          <AlertDialogHeader>
            <AlertDialogTitle>{confirmDeleteTitle}</AlertDialogTitle>
            <AlertDialogDescription>
              {confirmDeleteDescription}
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>{confirmDeleteCancelLabel}</AlertDialogCancel>
            <AlertDialogAction
              variant="destructive"
              onClick={() => {
                for (let i = items.length - 1; i >= 0; i--)
                  fieldApi.removeValue(i);
                setShowDeleteAllDialog(false);
              }}
            >
              {confirmDeleteActionLabel}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>

      <FieldGroup data-field={fieldApi.name}>
        <Field data-invalid={isInvalid} data-disabled={isDisabled}>
          <FieldSet disabled={isDisabled} className="contents">
            <Card className="py-0 gap-0">
              {/* Header */}
              <CardHeader className="px-4 py-3 flex flex-row items-center justify-between bg-muted/50 border-b gap-3">
                <div className="flex items-center gap-2 min-w-0 flex-1">
                  {label && (
                    <FieldLegend className="text-sm font-semibold truncate mb-0">
                      {label}
                      {isEffectivelyRequired && ui?.asterisk !== false && (
                        <span className="text-destructive ml-1">*</span>
                      )}
                    </FieldLegend>
                  )}
                  {items.length > 0 && (
                    <Badge
                      variant="secondary"
                      className="h-5 px-1.5 text-xs shrink-0"
                    >
                      {items.length}
                    </Badge>
                  )}
                  {showErrorBadge && totalErrorCount > 0 && (
                    <Badge
                      variant="destructive"
                      className="h-5 px-1.5 text-xs shrink-0"
                    >
                      {totalErrorCount}
                    </Badge>
                  )}
                </div>

                {!isReadOnly && (
                  <div className="flex items-center gap-1 shrink-0">
                    {items.length > 0 && canRemoveAny && (
                      <Button
                        type="button"
                        variant="ghost"
                        size="icon"
                        className="h-7 w-7 text-muted-foreground hover:text-destructive"
                        onClick={handleDeleteAll}
                        disabled={isDisabled}
                        title="Delete all"
                      >
                        <IconPlaceholder
                          lucide="Trash2"
                          hugeicons="Delete02Icon"
                          tabler="IconTrash"
                          phosphor="Trash"
                          remixicon="RiDeleteBinLine"
                          className="size-4"
                        />
                      </Button>
                    )}
                    <Button
                      type="button"
                      variant="ghost"
                      size="icon"
                      className="h-7 w-7"
                      onClick={handleAdd}
                      disabled={!canAddMore || isDisabled}
                      title={typeof addLabel === "string" ? addLabel : "Add"}
                    >
                      <IconPlaceholder
                        lucide="Plus"
                        hugeicons="Add01Icon"
                        tabler="IconPlus"
                        phosphor="Plus"
                        remixicon="RiAddLine"
                        className="size-4"
                      />
                    </Button>
                  </div>
                )}
              </CardHeader>

              <CardContent className="p-4">
                {description && !isInvalid && (
                  <FieldDescription id={descriptionId} className="mb-4">
                    {description}
                  </FieldDescription>
                )}
                {isInvalid && (
                  <FieldError id={errorId} errors={errors} className="mb-4" />
                )}

                {items.length === 0 ? (
                  <div className="flex flex-col items-center justify-center py-10 border-2 border-dashed rounded-lg">
                    <p className="text-sm text-muted-foreground mb-3">
                      {emptyMessage}
                    </p>
                    {!isReadOnly && (
                      <Button
                        type="button"
                        variant="outline"
                        size="sm"
                        onClick={handleAdd}
                        disabled={!canAddMore || isDisabled}
                      >
                        <IconPlaceholder
                          lucide="Plus"
                          hugeicons="Add01Icon"
                          tabler="IconPlus"
                          phosphor="Plus"
                          remixicon="RiAddLine"
                          className="size-4 mr-1.5"
                        />
                        {addLabel}
                      </Button>
                    )}
                  </div>
                ) : (
                  <>
                    <DndContext
                      id={dndId}
                      sensors={sensors}
                      collisionDetection={closestCenter}
                      onDragEnd={handleDragEnd}
                    >
                      <SortableContext
                        items={itemIds}
                        strategy={verticalListSortingStrategy}
                      >
                        <div className="flex flex-col gap-3">
                          {items.map((_, index) => (
                            <ArrayItem
                              key={itemIds[index]}
                              id={itemIds[index]!}
                              index={index}
                              field={field}
                              form={form}
                              fieldApiName={fieldApi.name}
                              rowLabel={ui?.rowLabel}
                              rowFallback={labels?.rowFallback}
                              contextData={contextData}
                              isSortable={isSortable}
                              isDisabled={isDisabled}
                              isReadOnly={isReadOnly}
                              canRemove={canRemoveAny}
                              canDuplicate={canAddMore}
                              allowDuplicate={allowDuplicate}
                              onRemove={handleRemove}
                              onDuplicate={handleDuplicate}
                            />
                          ))}
                        </div>
                      </SortableContext>
                    </DndContext>

                    {!isReadOnly && (
                      <Button
                        type="button"
                        variant="outline"
                        className="w-full mt-4 border-dashed text-muted-foreground hover:text-foreground"
                        onClick={handleAdd}
                        disabled={!canAddMore || isDisabled}
                      >
                        <IconPlaceholder
                          lucide="Plus"
                          hugeicons="Add01Icon"
                          tabler="IconPlus"
                          phosphor="Plus"
                          remixicon="RiAddLine"
                          className="size-4 mr-2"
                        />
                        {addLabel}
                      </Button>
                    )}
                  </>
                )}
              </CardContent>
            </Card>
          </FieldSet>
        </Field>
      </FieldGroup>
    </>
  );
}

function MinimalArrayField(state: ArrayFieldState) {
  const {
    fieldApi,
    field,
    form,
    isDisabled,
    isReadOnly,
    isRequired,
    label,
    description,
    errors,
    isInvalid,
    descriptionId,
    errorId,
    contextData,
  } = state;

  const ui = field.ui as ArrayUi | undefined;
  const labels = ui?.labels;

  const addLabel = labels?.add ?? "Add Item";
  const emptyMessage = labels?.empty ?? "No items added yet.";
  const isSortable = ui?.isSortable !== false;
  const showErrorBadge = ui?.showErrorBadge !== false;
  const allowDuplicate = ui?.allowDuplicate ?? false;

  const items = Array.isArray(fieldApi.state.value) ? fieldApi.state.value : [];

  const canAddMore =
    field.maxItems === undefined || items.length < field.maxItems;
  const canRemoveAny =
    field.minItems === undefined || items.length > field.minItems;

  // Stable DnD IDs keyed by position — sufficient since we reorder in-place
  const dndId = React.useId();
  const itemIds = items.map((_, i) => `${fieldApi.name}-${i}`);

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    }),
  );

  const handleDragEnd = ({ active, over }: DragEndEvent) => {
    if (!over || active.id === over.id) return;
    const from = itemIds.indexOf(active.id as string);
    const to = itemIds.indexOf(over.id as string);
    if (from !== -1 && to !== -1) fieldApi.moveValue(from, to);
  };

  const handleAdd = () => {
    if (!isDisabled && !isReadOnly && canAddMore)
      fieldApi.pushValue(undefined as never);
  };

  const handleRemove = (index: number) => fieldApi.removeValue(index);

  const handleDuplicate = (index: number) => {
    if (canAddMore) fieldApi.insertValue(index + 1, items[index] as never);
  };

  const totalErrorCount = useNestedErrorCount(
    field.fields as CoreField[],
    fieldApi.name,
  );
  const isEffectivelyRequired =
    isRequired || (typeof field.minItems === "number" && field.minItems >= 1);

  return (
    <FieldGroup data-field={fieldApi.name}>
      <Field data-invalid={isInvalid} data-disabled={isDisabled}>
        <FieldSet disabled={isDisabled} className="flex flex-col gap-4">
          <div className="flex items-center gap-2">
            {label && (
              <FieldLegend className="text-sm font-semibold mb-0">
                {label}
                {isEffectivelyRequired && ui?.asterisk !== false && (
                  <span className="text-destructive ml-1">*</span>
                )}
              </FieldLegend>
            )}
            {items.length > 0 && (
              <Badge variant="secondary" className="h-5 px-1.5 text-xs">
                {items.length}
              </Badge>
            )}
            {showErrorBadge && totalErrorCount > 0 && (
              <Badge variant="destructive" className="h-5 px-1.5 text-xs">
                {totalErrorCount}
              </Badge>
            )}
            {!isReadOnly && (
              <div className="ml-auto flex items-center gap-1">
                <Button
                  type="button"
                  variant="ghost"
                  size="icon"
                  className="h-7 w-7"
                  onClick={handleAdd}
                  disabled={!canAddMore || isDisabled}
                  title={typeof addLabel === "string" ? addLabel : "Add Item"}
                >
                  <IconPlaceholder
                    lucide="Plus"
                    hugeicons="Add01Icon"
                    tabler="IconPlus"
                    phosphor="Plus"
                    remixicon="RiAddLine"
                    className="size-4"
                  />
                </Button>
              </div>
            )}
          </div>

          {description && !isInvalid && (
            <FieldDescription id={descriptionId}>
              {description}
            </FieldDescription>
          )}
          {isInvalid && <FieldError id={errorId} errors={errors} />}

          {items.length === 0 ? (
            <div className="flex items-center justify-between border border-dashed rounded-lg px-3 py-3">
              <p className="text-sm text-muted-foreground">{emptyMessage}</p>
              {!isReadOnly && (
                <Button
                  type="button"
                  variant="outline"
                  size="sm"
                  onClick={handleAdd}
                  disabled={!canAddMore || isDisabled}
                >
                  <IconPlaceholder
                    lucide="Plus"
                    hugeicons="Add01Icon"
                    tabler="IconPlus"
                    phosphor="Plus"
                    remixicon="RiAddLine"
                    className="size-4 mr-1.5"
                  />
                  {addLabel}
                </Button>
              )}
            </div>
          ) : (
            <DndContext
              id={dndId}
              sensors={sensors}
              collisionDetection={closestCenter}
              onDragEnd={handleDragEnd}
            >
              <SortableContext
                items={itemIds}
                strategy={verticalListSortingStrategy}
              >
                <div className="flex flex-col gap-3">
                  {items.map((_, index) => (
                    <MinimalArrayItem
                      key={itemIds[index]}
                      id={itemIds[index]!}
                      index={index}
                      field={field}
                      form={form}
                      fieldApiName={fieldApi.name}
                      rowLabel={ui?.rowLabel}
                      rowFallback={labels?.rowFallback}
                      contextData={contextData}
                      isSortable={isSortable}
                      isDisabled={isDisabled}
                      isReadOnly={isReadOnly}
                      canRemove={canRemoveAny}
                      canDuplicate={canAddMore}
                      allowDuplicate={allowDuplicate}
                      onRemove={handleRemove}
                      onDuplicate={handleDuplicate}
                    />
                  ))}
                </div>
              </SortableContext>
            </DndContext>
          )}
        </FieldSet>
      </Field>
    </FieldGroup>
  );
}

export function ArrayField() {
  const {
    fieldApi,
    field,
    form,
    isDisabled,
    isReadOnly,
    isRequired,
    label,
    description,
    errors,
    isInvalid,
    descriptionId,
    errorId,
    contextData,
  } = useDataField<ArrayFieldDef>();

  const state: ArrayFieldState = {
    fieldApi,
    field,
    form,
    isDisabled,
    isReadOnly,
    isRequired,
    label,
    description,
    errors,
    isInvalid,
    descriptionId,
    errorId,
    contextData,
  };

  const ui = field.ui as ArrayUi | undefined;
  const variant = ui?.variant ?? "default";

  if (variant === "minimal") return <MinimalArrayField {...state} />;
  return <DefaultArrayField {...state} />;
}

More from buzzform

Browse all components in this library →