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.
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.
source
conform-choice-box.tsxCopy 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>
)
}