Array frame

The frame around every array, with its items, their buttons and the errors about the list.

Team

Up to four members.

Installation

npx shadcn@latest add @formdef/array-frame

Register it in the arrayFrame structure slot in lib/formdef/index.tsx, as structure: { arrayFrame: ArrayFrame }. It then frames every array entry of every form built on that registry.

Usage

export const  = createForm({
  : .({ : .(.()).(1, 'Add at least one member') }),
  : { members: [''] },
  fields: {
    members: {
      : () => '',
      : 'Add member',
      : () => `Remove ${ || 'member'}`,
      : { : 'Team', : 1, : 10 },
      : { : 'text', : { : 'Name' } },
    },
  },
})

The frame renders a <fieldset> with the label as its <legend>, each item with a remove button beside it, and an add button below. Its own props arrive through the array entry's props:

PropMeaning
labelThe legend of the fieldset.
descriptionA hint below the legend.
minDisables the remove buttons while the list has this many items or fewer.
maxDisables the add button once the list has this many items.
variant'label' sets the legend in the size of a field label.

min and max only steer the buttons. The rule itself belongs in the schema, and its message, such as the min(1) above, shows below the list.

After a remove, focus moves to the remove button that took the removed item's place, or to the add button when the last item went.

Arrays describes the array entry, and how to write a frame of your own.

Props

Source

'use client'

import { type ArrayFrameProps, fieldParts } from '@formdef/core'
import { XIcon } from 'lucide-react'
import { type ComponentProps, useEffect, useRef } from 'react'

import { FieldNotes } from '@/components/formdef/field-notes'
import { Button } from '@/components/ui/button'
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@/components/ui/field'

type LegendVariant = NonNullable<ComponentProps<typeof FieldLegend>['variant']>

type ArrayFrameFrameProps = ArrayFrameProps<unknown> &
  Pick<ComponentProps<typeof FieldSet>, 'className'> & {
    label?: string | undefined
    description?: string | undefined
    variant?: LegendVariant | undefined
    min?: number | undefined
    max?: number | undefined
  }

export const ArrayFrame = ({
  field,
  keys,
  renderItem,
  add,
  remove,
  addLabel,
  removeLabels,
  label,
  description,
  variant = 'legend',
  min,
  max,
  className,
}: ArrayFrameFrameProps) => {
  const parts = fieldParts(field)
  const removeButtons = useRef<(HTMLButtonElement | null)[]>([])
  const addButton = useRef<HTMLButtonElement | null>(null)
  const removedAt = useRef<number | undefined>(undefined)
  const isAtMin = min !== undefined && keys.length <= min
  const isAtMax = max !== undefined && keys.length >= max
  const hasDescription = description !== undefined && description !== ''
  const describedBy = [
    ...(hasDescription ? [parts.descriptionId] : []),
    ...(parts.isErrorDue && parts.errors.length > 0 ? [parts.errorId] : []),
  ].join(' ')

  useEffect(() => {
    const index = removedAt.current
    if (index === undefined) {
      return
    }
    removedAt.current = undefined
    const next = removeButtons.current[index] ?? addButton.current
    next?.focus()
  })

  return (
    <FieldSet
      className={className}
      aria-invalid={parts.control['aria-invalid']}
      aria-describedby={describedBy === '' ? undefined : describedBy}
    >
      {label !== undefined && <FieldLegend variant={variant}>{label}</FieldLegend>}
      {hasDescription && (
        <FieldDescription id={parts.descriptionId}>{description}</FieldDescription>
      )}
      <FieldGroup>
        {keys.map((key, index) => (
          <div key={key} data-slot="array-item" className="flex items-start gap-2">
            <div className="min-w-0 flex-1">{renderItem(index)}</div>
            <Button
              ref={(button) => {
                removeButtons.current[index] = button
              }}
              type="button"
              variant="ghost"
              size="icon"
              aria-label={removeLabels[index]}
              disabled={isAtMin}
              onClick={() => {
                removedAt.current = index
                remove(index)
              }}
            >
              <XIcon />
            </Button>
          </div>
        ))}
        <Button
          ref={addButton}
          type="button"
          variant="outline"
          size="sm"
          disabled={isAtMax}
          onClick={add}
        >
          {addLabel}
        </Button>
      </FieldGroup>
      <FieldNotes parts={parts} />
    </FieldSet>
  )
}