component — conform

Conform Choice Box

The quebi ChoiceBox wired to Conform. Binds a card-style selection through a hidden select, so a GridList with no native form value submits its keys.

  • form
  • conform
  • choice
  • selection
  • validation

02 examples

Bound to a Conform form

Submit without choosing to see the validation error wired from field metadata.

StarterOne project, community support.
GrowthTen projects, email support.
ScaleUnlimited projects, a phone number.
Cards, but a real form value.

Multiple selection

The same binding in multiple-selection mode: the parsed value is an array, and an empty submit reports the schema's own minLength message.

Nightly backupsThirty days of restore points.
SSOSAML and SCIM for your directory.
Audit logEvery change, exportable.
Submits the name once per selected card.

source

conform-choice-box.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import type { FieldMetadata } from "@conform-to/react"
import { BaseControl, useControl } from "@conform-to/react/future"
import { useRef } from "react"
import { cn } from "@/lib/utils"
import { ChoiceBox, type ChoiceBoxProps } from "@/components/choice-box"
import {
  describedBy,
  Description,
  Field,
  FieldError,
  focusFirstControl,
  Label,
} from "@/components/field"

export interface ConformChoiceBoxProps<T extends object>
  extends Omit<
    ChoiceBoxProps<T>,
    "selectedKeys" | "defaultSelectedKeys" | "onSelectionChange"
  > {
  /**
   * The wrapper's class. A plain string, unlike the inherited
   * `ChoiceBoxProps<T>["className"]`: this lands on the `Field` div around the
   * control, which has no render state to compose a function against. Keeping
   * the wider type would let a caller pass a render prop that clsx drops in
   * silence.
   */
  className?: string
  /**
   * A choice box bound to a form value: one key with `selectionMode="single"`,
   * a key per selected card with `"multiple"` — which submits the name once per
   * key, so the parsed value is an array.
   */
  field: FieldMetadata<string | string[]>
  label?: string
  description?: string
  /**
   * Every selectable key, in order. Needed only so a select-all (Ctrl+A in
   * multiple-selection mode) can be turned into an explicit list — react-aria
   * reports it as the sentinel `"all"`, which carries no keys. Without it a
   * select-all leaves the submitted value unchanged.
   */
  keys?: string[]
}

/** Read the field's initial value as a key list, accepting either wire shape. */
function toDefaultKeys(initialValue: unknown): string[] {
  if (Array.isArray(initialValue)) return initialValue.map(String)
  if (typeof initialValue === "string" && initialValue !== "") return [initialValue]
  return []
}

/**
 * ConformChoiceBox — ChoiceBox wired to Conform.
 *
 * Binds a Conform field to the quebi ChoiceBox through a registered hidden
 * `<select>`, which is where the selection becomes a form value.
 *
 * ChoiceBox is a react-aria GridList: it has no `name`, renders no native form
 * control, and its value is a `Set<Key>` rather than a string — so without the
 * hidden control it submits nothing. It is hidden by CSS rather than by the
 * `hidden` attribute, and carries no React `value` prop; both matter and both
 * fail silently — see `conform-time-field`.
 *
 * The registered control mirrors the selection mode, because that is what
 * decides whether an *empty* selection still submits the field:
 *
 * - `"multiple"` registers a `<select multiple>`. With nothing selected it has
 *   no selected option and submits nothing, which is right: Conform parses an
 *   absent key for an array field as `[]`, so a `v.array(...)` schema reports
 *   the author's own `minLength` message.
 * - `"single"` (and `"none"`) registers a plain `<select>`, whose empty state
 *   is an `<option value="">` — the same trick react-aria's `HiddenSelect`
 *   uses. It matters: a `<select multiple>` with nothing selected leaves the
 *   key out of `FormData` entirely, and for a string field that is not the
 *   author's "Pick a plan" but valibot's internal `Invalid key: Expected
 *   "plan" but received undefined`. Submitting `""` puts the key back, and the
 *   message the consumer wrote is the one that shows.
 */
export function ConformChoiceBox<T extends object>({
  field,
  label,
  description,
  keys,
  children,
  className,
  selectionMode = "single",
  ...props
}: ConformChoiceBoxProps<T>) {
  const isMultiple = selectionMode === "multiple"
  const defaultKeys = toDefaultKeys(field.initialValue)
  const fieldRef = useRef<HTMLDivElement>(null)
  const control = useControl<string | string[]>({
    defaultValue: isMultiple ? defaultKeys : (defaultKeys[0] ?? ""),
    // Conform focuses the first errored field after a failed submit; that is
    // the registered control, which nobody can see — hand it to the visible one.
    onFocus() {
      focusFirstControl(fieldRef.current)
    },
  })
  const hasErrors = !field.valid && !!field.errors
  const isRequired = field.required ?? false
  const selected = isMultiple
    ? (control.options ?? [])
    : control.value
      ? [control.value]
      : []

  return (
    <Field ref={fieldRef} className={cn(className)}>
      {/* First, before the label: the stack is spaced with `label + control`
          selectors, and an `sr-only` element between the two is still an
          element the selector cannot see past. */}
      <BaseControl
        type="select"
        multiple={isMultiple}
        name={field.name}
        form={field.formId}
        ref={control.register}
        defaultValue={control.defaultValue ?? (isMultiple ? [] : "")}
        hidden={false}
        tabIndex={-1}
        className="sr-only"
      />

      {label && (
        <Label className={cn(hasErrors && "text-quebi-danger")}>
          {label}
          {isRequired && <span className="ml-1 text-quebi-fg">*</span>}
        </Label>
      )}

      {/* The grid list is the control; it marks itself `data-slot="choice-box"`,
          so this wrapper is what the field stack and `FieldRow` place. */}
      <div data-slot="control">
        <ChoiceBox<T>
          {...props}
          selectionMode={selectionMode}
          selectedKeys={new Set(selected)}
          onSelectionChange={(selection) => {
            if (selection === "all") {
              // Only reachable in multiple-selection mode; react-aria never
              // reports the sentinel for a single selection.
              if (keys) control.change(keys)
              return
            }
            const next = Array.from(selection).map(String)
            control.change(isMultiple ? next : (next[0] ?? ""))
          }}
          aria-label={props["aria-label"] ?? label}
          aria-describedby={describedBy(
            hasErrors && field.errorId,
            description && field.descriptionId,
          )}
        >
          {children}
        </ChoiceBox>
      </div>

      {/* These ids are ours to set: the control above is not a react-aria
          field, so nothing generates them and its aria-describedby is their
          only reference. */}
      {description && <Description id={field.descriptionId}>{description}</Description>}
      {hasErrors && <FieldError id={field.errorId}>{field.errors?.join(", ")}</FieldError>}
    </Field>
  )
}