Checkbox group field

Several choices among a few options.

Topics

What we write to you about.

Installation

npx shadcn@latest add @formdef/checkbox-group-field

Register it under widgets in lib/formdef/index.tsx, as widgets: { checkboxes: CheckboxGroupField }. The widget serves string[] values.

Usage

export const  = createForm({
  : .({ : .(.()).(1, 'Choose at least one topic') }),
  : { topics: [] },
  fields: {
    topics: {
      : 'checkboxes',
      : {
        : 'Topics',
        : [
          { : 'releases', : 'Releases' },
          { : 'events', : 'Events' },
        ],
      },
    },
  },
})

The options render as a <fieldset> whose <legend> is the label, each with a value, a label and optionally a description and disabled. The value holds the values of the ticked options.

Values the options do not carry stay in the form, as with the select field.

Props

Prop

Type

Source

import { type FieldOf, fieldParts } from '@formdef/core'
import type { ComponentProps } from 'react'

import { FieldNotes } from '@/components/formdef/field-notes'
import { Checkbox } from '@/components/ui/checkbox'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from '@/components/ui/field'
import { blurLeaving } from '@/lib/formdef/blur-leaving'
import type { FieldLabels } from '@/lib/formdef/field-labels'
import { type Option, optionId } from '@/lib/formdef/options'

type Attrs = Pick<ComponentProps<typeof Checkbox>, 'className' | 'disabled' | 'readOnly'>

type CheckboxGroupFieldProps = FieldOf<string[]> &
  Omit<FieldLabels, 'labelAside'> &
  Attrs & {
    options: Option[]
  }

const LEGEND_SUFFIX = '-legend'
const DESCRIPTION_SUFFIX = '-description'

export const CheckboxGroupField = ({
  field,
  label,
  description,
  options,
  ...attrs
}: CheckboxGroupFieldProps) => {
  const parts = fieldParts(field, { description })
  const {
    name: _carriedByTheForm,
    onBlur,
    'aria-invalid': ariaInvalid,
    'aria-errormessage': ariaErrorMessage,
    ...group
  } = parts.control
  const legendId = `${group.id}${LEGEND_SUFFIX}`
  const values = field.state.value ?? []
  const toggle = (value: string, isChecked: boolean) =>
    field.handleChange(
      isChecked ? [...values, value] : values.filter((existing) => existing !== value),
    )

  return (
    <FieldSet
      {...group}
      aria-labelledby={label === undefined ? undefined : legendId}
      onBlur={blurLeaving(onBlur)}
    >
      {label !== undefined && <FieldLegend id={legendId}>{label}</FieldLegend>}
      <FieldGroup data-slot="checkbox-group">
        {options.map((option) => {
          const id = optionId(group.id, option.value)
          const descriptionId = `${id}${DESCRIPTION_SUFFIX}`
          const hasDescription = option.description !== undefined && option.description !== ''

          return (
            <Field
              key={option.value}
              orientation="horizontal"
              data-invalid={parts.isErrorDue}
              data-disabled={attrs.disabled === true || option.disabled === true}
            >
              <Checkbox
                {...attrs}
                id={id}
                disabled={attrs.disabled === true || option.disabled === true}
                aria-invalid={ariaInvalid}
                aria-errormessage={ariaErrorMessage}
                aria-describedby={hasDescription ? descriptionId : undefined}
                checked={values.includes(option.value)}
                onCheckedChange={(isChecked) => toggle(option.value, isChecked)}
              />
              <FieldContent>
                <FieldLabel htmlFor={id}>{option.label}</FieldLabel>
                {hasDescription && (
                  <FieldDescription id={descriptionId}>{option.description}</FieldDescription>
                )}
              </FieldContent>
            </Field>
          )
        })}
      </FieldGroup>
      <FieldNotes parts={parts} description={description} />
    </FieldSet>
  )
}