component — conform
Conform Storage Picker
A multi-select chip group of device storage sizes wired to Conform. The selected sizes are submitted as one comma-joined value through a registered control, so they repopulate after a failed submit and reset with the form; an optional react-stately list mirrors them as removable tags.
- form
- conform
- storage
- multi-select
- chips
- validation
03 examples
Bound to a Conform form
The form declares 128GB as the default. Toggle chips to add or remove sizes; Reset snaps the selection back, because the value lives in the field rather than beside it.
Required validation
Starts empty — submit without picking a size to see the field-derived error.
Mirrored into a tag list
Pass an optional react-stately list to surface the selection elsewhere. Removing a tag writes the field; Reset re-seeds the list from the form's default.
source
conform-storage-picker.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 } from "@conform-to/react/future"
import { useRef } from "react"
import { Button } from "react-aria-components"
import type { ListData } from "react-stately"
import { type ConformListItem, useConformListControl } from "@/lib/conform-list-control"
import { cn } from "@/lib/utils"
import {
describedBy,
Description,
Field,
FieldError,
focusFirstControl,
Label,
} from "@/components/field"
/**
* Device storage helpers (inlined to keep this component self-contained).
*
* Storage is tracked internally in gigabytes. `DEVICE_STORAGE_OPTIONS` is the
* canonical set of sizes offered; `normalizeStorageValue` parses a free-form
* label (e.g. "1TB", "512 gb") back into a GB number; `formatStorageDisplay`
* renders a GB number back into a human label (e.g. 1024 → "1TB").
*/
export const DEVICE_STORAGE_OPTIONS = [32, 64, 128, 256, 512, 1024, 2048] as const
/** Render a GB amount as a human label, collapsing whole TB values. */
export function formatStorageDisplay(storageGb: number): string {
if (storageGb >= 1024 && storageGb % 1024 === 0) {
return `${storageGb / 1024}TB`
}
return `${storageGb}GB`
}
/** Parse a free-form storage label into a GB number. */
export function normalizeStorageValue(value: string | number): number {
if (typeof value === "number") return value
const match = value.trim().match(/([\d.]+)\s*(tb|gb)?/i)
if (!match) return 0
const amount = Number.parseFloat(match[1])
if (Number.isNaN(amount)) return 0
const unit = (match[2] ?? "gb").toLowerCase()
return unit === "tb" ? Math.round(amount * 1024) : Math.round(amount)
}
/** The label a free-form storage value is stored and submitted under. */
const canonicalStorageLabel = (name: string) =>
formatStorageDisplay(normalizeStorageValue(name))
export interface ConformStoragePickerProps {
// A list-backed field: the storage labels surface as a comma-joined string on
// the wire (`string`) or an array after parsing (`string[]`); only
// name/default/required/errors/valid are read off the metadata.
field: FieldMetadata<string | string[]>
label?: string
/**
* Optional react-stately list mirroring the selection, so the same sizes can
* be rendered and removed as tags elsewhere on the page. The picker owns the
* value either way: removing an item pushes into the field, and a reset or a
* `form.update()` re-seeds the list.
*/
list?: ListData<ConformListItem>
description?: string
className?: string
}
/**
* ConformStoragePicker — a multi-select chip group of device storage sizes,
* wired to Conform.
*
* The selected sizes are submitted as one comma-joined string (e.g.
* `"128GB,1TB"`) through a registered hidden control, so they repopulate after
* a failed submit and reset with the form like every other variant. Validity
* and the error message come from the field.
*
* `list` is optional and is a projection of that value rather than its home —
* see `@/lib/conform-list-control` for why. The default selection is the
* field's, not the list's: seed `useForm({ defaultValue: { storage: "128GB" } })`
* and the list follows, which is also what a reset goes back to.
*/
export function ConformStoragePicker({
field,
label,
list,
description,
className,
}: ConformStoragePickerProps) {
const fieldRef = useRef<HTMLDivElement>(null)
const selection = useConformListControl({
initialValue: field.initialValue,
list,
canonicalize: canonicalStorageLabel,
// 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
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
name={field.name}
form={field.formId}
ref={selection.register}
defaultValue={selection.defaultValue}
hidden={false}
tabIndex={-1}
className="sr-only"
/>
{label && (
<Label className={cn(hasErrors && "text-quebi-danger")}>
{label}
{field.required && <span className="ml-1 text-quebi-fg">*</span>}
</Label>
)}
{/* These ids are ours to set: a chip group is not a react-aria field, so
nothing generates them and the aria-describedby below is their only
reference. */}
{description && <Description id={field.descriptionId}>{description}</Description>}
{/* A <fieldset> rather than a div wearing role="group": these chips are a
group of form controls, which is the one thing the element is for.
`min-w-0` undoes the UA `min-inline-size: min-content` Tailwind's
preflight leaves in place, so the group wraps exactly as the div did. */}
<fieldset
data-slot="control"
className="flex min-w-0 flex-wrap gap-2"
aria-label={label ?? "Storage"}
aria-invalid={hasErrors || undefined}
aria-describedby={describedBy(
hasErrors && field.errorId,
description && field.descriptionId,
)}
>
{DEVICE_STORAGE_OPTIONS.map((storageGb) => {
const value = formatStorageDisplay(storageGb)
const isSelected = selection.isSelected(value)
return (
<Button
key={storageGb}
aria-pressed={isSelected}
onPress={() => selection.toggle(value)}
className={cn(
"rounded-(--q-radius-control) border px-3 py-1.5 font-medium text-sm transition-colors duration-150",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-quebi-focus focus-visible:ring-offset-2 focus-visible:ring-offset-quebi-bg",
isSelected
? "border-quebi-hairline bg-quebi-selected text-quebi-signal"
: "border-quebi-hairline bg-transparent text-quebi-fg hover:bg-quebi-raised",
)}
>
{value}
</Button>
)
})}
</fieldset>
{hasErrors && <FieldError id={field.errorId}>{field.errors?.join(", ")}</FieldError>}
</Field>
)
}