component — conform
Conform Number Field
Number field wired to a Conform field — binds name, default, required, and errors from field metadata and renders inline validation.
- form
- conform
- number
- input
- field
- validation
01 example
Bound to a Conform form
Submit out of range to see the validation error wired from field metadata.
source
conform-number-field.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 { composeRenderProps } from "react-aria-components"
import type { NumberFieldProps } from "react-aria-components"
import { cn } from "@/lib/utils"
import { Description, FieldError, Label } from "@/components/field"
import {
NumberField,
NumberInput,
type NumberInputSize,
type NumberInputStepper,
} from "@/components/number-field"
export interface ConformNumberFieldProps
extends Omit<
NumberFieldProps,
| "name"
| "form"
| "value"
| "defaultValue"
| "minValue"
| "maxValue"
| "isRequired"
| "isInvalid"
> {
/**
* A numeric field: the wire value is a string that Conform coerces to a
* number, so the metadata carries `number | string`.
*/
field: FieldMetadata<number | string>
label?: string
description?: string
/*
* `onChange` is forwarded rather than omitted with `value` / `defaultValue`.
* Those are bound — the field metadata owns them, and setting one by hand is
* the bug the conform-* variants exist to prevent. A change callback binds
* nothing: it is how a caller watches a field it does not control, which is
* what `FilterPanel`'s live-apply mode needs to commit a range as it is
* typed. The form value still comes from the input, not from the callback.
*/
/**
* Hide the increment / decrement steppers. Forwarded to `NumberInput`,
* because a narrow field has no room for them: they are ~74px wide and the
* input gives up its own width before they give up theirs.
*/
hideStepper?: boolean
/** Control height. Matches `Input`'s scale and `Button`'s `xs` / `sm`. */
size?: NumberInputSize
/**
* Which glyphs the steppers draw: `plus-minus` (the default), `chevron` or
* `arrow`. Forwarded to `NumberInput`; it changes the icons only, so it
* cannot change the control's size.
*/
stepper?: NumberInputStepper
}
/** Read a Conform constraint, which arrives as a string on the wire, as a number. */
function toNumber(value: number | string | undefined): number | undefined {
if (value === undefined || value === "") return undefined
const parsed = Number.parseFloat(String(value))
return Number.isNaN(parsed) ? undefined : parsed
}
/**
* ConformNumberField — NumberField wired to Conform.
*
* Binds a numeric Conform field to the quebi NumberField: derives name, id,
* form, required, default, the schema's min/max/step, and validity from the
* field metadata and renders inline errors.
*
* The constraints are mapped by hand on purpose. Conform names them `min` and
* `max` (the DOM attributes); react-aria's NumberField takes `minValue` and
* `maxValue` and ignores the DOM pair — passing them through silently discards
* the schema's bounds, so a `max: 10` field happily holds 99.
*/
export function ConformNumberField({
field,
label,
description,
hideStepper,
size,
stepper,
className,
...props
}: ConformNumberFieldProps) {
const hasErrors = !field.valid && !!field.errors
const isRequired = field.required ?? false
return (
<NumberField
{...props}
id={field.id}
name={field.name}
form={field.formId}
defaultValue={toNumber(field.initialValue as number | string | undefined)}
minValue={toNumber(field.min)}
maxValue={toNumber(field.max)}
step={toNumber(field.step)}
isRequired={isRequired}
isInvalid={hasErrors}
className={composeRenderProps(className, (resolved) => cn(resolved))}
>
{label && (
<Label className={cn(hasErrors && "text-quebi-danger")}>
{label}
{isRequired && <span className="ml-1 text-quebi-fg">*</span>}
</Label>
)}
<NumberInput hideStepper={hideStepper} size={size} stepper={stepper} />
{/* No ids and no aria-describedby here: this is a react-aria field, so it
generates the description and error ids and already points the control
at them. Setting id={field.errorId} would not break that — on mount
react-aria re-points the control at whatever id the element actually
carries — it would just duplicate wiring that is already correct.
Outside a react-aria field the ids are yours: see ConformSwitch. */}
{description && <Description>{description}</Description>}
{hasErrors && <FieldError>{field.errors?.join(", ")}</FieldError>}
</NumberField>
)
}