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.

How many units to order (1–99).

source

conform-number-field.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 { 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>
  )
}