Array frame
The frame around every array, with its items, their buttons and the errors about the list.
Installation
npx shadcn@latest add @formdef/array-frameRegister 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:
| Prop | Meaning |
|---|---|
label | The legend of the fieldset. |
description | A hint below the legend. |
min | Disables the remove buttons while the list has this many items or fewer. |
max | Disables 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>
)
}