component — conform

Conform Time Field

The quebi TimeField wired to Conform through a hidden input — react-aria renders no form control for a TimeField, so a bare name submits nothing.

  • form
  • conform
  • time
  • validation

01 example

Bound to a Conform form

Set the time before 08:00 and submit to see the error wired from field metadata.

Opens at
900
Submitted through a hidden input — a named TimeField submits nothing on its own.

source

conform-time-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 { BaseControl, useControl } from "@conform-to/react/future"
import { parseTime, type Time } from "@internationalized/date"
import { useRef } from "react"
import { composeRenderProps } from "react-aria-components"
import type { TimeFieldProps, TimeValue } from "react-aria-components"
import { cn } from "@/lib/utils"
import { Description, FieldError, fieldStyles, focusFirstControl, Label } from "@/components/field"
import { TimeField, TimeInput } from "@/components/time-field"

export interface ConformTimeFieldProps
  extends Omit<
    TimeFieldProps<TimeValue>,
    "name" | "form" | "value" | "defaultValue" | "onChange" | "isRequired" | "isInvalid" | "children"
  > {
  /** A time bound to a string form value — `HH:MM[:SS]`, what `Time#toString()` produces. */
  field: FieldMetadata<string>
  label?: string
  description?: string
}

/** Parse the wire value (`"09:30"` / `"09:30:00"`) into a Time, tolerating junk. */
function toTime(value: string | undefined): Time | null {
  if (!value) return null
  try {
    return parseTime(value)
  } catch {
    return null
  }
}

/**
 * ConformTimeField — TimeField wired to Conform.
 *
 * Binds a string Conform field to the quebi TimeField, through a registered
 * hidden input that carries `Time#toString()`.
 *
 * The hidden input is not belt-and-braces — it is the whole form value.
 * `AriaTimeFieldProps extends InputDOMProps`, so `name` type-checks on a
 * TimeField, but react-aria renders a hidden date input for DateField only:
 * a named TimeField submits nothing at all.
 *
 * Three rules govern the registered input, all silent when broken:
 *
 * 1. It is never `type="hidden"`. `register()` strips the type once; React
 *    re-applies it on the next commit and then keeps forcing the value back to
 *    `defaultValue`, so every change is lost.
 * 2. It never takes a React `value` prop. `control.change()` writes the DOM
 *    value and React would revert it on the next commit, leaving FormData with
 *    the React-owned value.
 * 3. It is hidden by CSS (`sr-only` + `tabIndex={-1}`), never by the `hidden`
 *    attribute — which is what `BaseControl` renders by default, so every one
 *    of these variants passes `hidden={false}`. After a failed submit Conform's
 *    v1 `useForm` focuses the first errored field with a bare
 *    `element.focus()`, and a real browser no-ops that on a `hidden` element:
 *    the focus lands nowhere, `useControl` never sees a `focusin`, and
 *    `onFocus` below never runs. jsdom and happy-dom both focus hidden
 *    elements happily, so a DOM test agrees with whatever you wrote — this one
 *    was settled in Chrome (task #11).
 *
 * `BaseControl` is what enforces 1 and 2, which is why the input is rendered
 * through it rather than by hand.
 */
export function ConformTimeField({
  field,
  label,
  description,
  className,
  ...props
}: ConformTimeFieldProps) {
  const inputRef = useRef<HTMLDivElement>(null)
  const control = useControl({
    defaultValue: (field.initialValue as string) ?? "",
    // Conform focuses the first errored field after a failed submit; that is
    // the registered input, which nobody can see — hand it to the segments.
    onFocus() {
      focusFirstControl(inputRef.current)
    },
  })

  const hasErrors = !field.valid && !!field.errors
  const isRequired = field.required ?? false

  return (
    <TimeField
      {...props}
      value={toTime(control.value)}
      onChange={(value) => control.change(value ? value.toString() : "")}
      onBlur={() => control.blur()}
      isRequired={isRequired}
      isInvalid={hasErrors}
      className={composeRenderProps(className, (resolved) => cn(fieldStyles, resolved))}
    >
      {/* 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={control.register}
        defaultValue={control.defaultValue ?? ""}
        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>
      )}
      <div ref={inputRef} data-slot="control">
        <TimeInput />
      </div>
      {/* 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>}
    </TimeField>
  )
}