component — conform

Conform Range Calendar

The quebi RangeCalendar wired to Conform. Binds a start/end field through a hidden fieldset, for a range calendar that stays on screen.

  • form
  • conform
  • calendar
  • date
  • range
  • validation

01 example

Bound to a Conform form

Pick a single day and submit to see the error wired from field metadata.

Trip dates, März 2026

23
24
25
26
27
28
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
Submitted as trip.start and trip.end.

source

conform-range-calendar.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 { type CalendarDate, parseDate } from "@internationalized/date"
import { useRef } from "react"
import type { DateValue, RangeCalendarProps, RangeValue } from "react-aria-components"
import { cn } from "@/lib/utils"
import {
  describedBy,
  Description,
  Field,
  FieldError,
  focusFirstControl,
  Label,
} from "@/components/field"
import { RangeCalendar } from "@/components/range-calendar"

/** The wire shape: two ISO `YYYY-MM-DD` strings, submitted as `<name>.start` / `<name>.end`. */
export interface ConformCalendarRange {
  start: string
  end: string
}

export interface ConformRangeCalendarProps
  extends Omit<
    RangeCalendarProps<DateValue>,
    "value" | "defaultValue" | "onChange" | "isInvalid" | "children"
  > {
  /**
   * The wrapper's class. A plain string, unlike the inherited
   * `RangeCalendarProps<DateValue>["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 date range bound to an object form value with `start` and `end` ISO
   * strings. Validate it with `v.object({ start: …, end: … })`.
   */
  field: FieldMetadata<ConformCalendarRange>
  label?: string
  description?: string
}

/** Parse an ISO `YYYY-MM-DD` string into a CalendarDate; null for empty/invalid. */
function toCalendarDate(value: unknown): CalendarDate | null {
  if (typeof value !== "string" || value === "") return null
  try {
    return parseDate(value)
  } catch {
    return null
  }
}

/** Read the control payload as a calendar range, or null while it is incomplete. */
function toRangeValue(
  payload: ConformCalendarRange | null | undefined,
): RangeValue<DateValue> | null {
  const start = toCalendarDate(payload?.start)
  const end = toCalendarDate(payload?.end)
  return start && end ? { start, end } : null
}

/**
 * ConformRangeCalendar — an always-visible RangeCalendar wired to Conform.
 *
 * Binds a start/end Conform field through a registered hidden `<fieldset>`,
 * which renders one hidden input per part: `<name>.start` and `<name>.end`,
 * both ISO `YYYY-MM-DD`. react-aria's RangeCalendar has no `name` and renders
 * no form control, so the fieldset is the whole form value.
 *
 * Reach for `conform-date-range-picker` unless the calendar has to stay on
 * screen.
 */
export function ConformRangeCalendar({
  field,
  label,
  description,
  className,
  ...props
}: ConformRangeCalendarProps) {
  const calendarRef = useRef<HTMLDivElement>(null)
  const control = useControl<ConformCalendarRange, ConformCalendarRange>({
    defaultValue: (field.initialValue as ConformCalendarRange | undefined) ?? undefined,
    parse: (payload) => {
      if (payload === null || typeof payload !== "object" || Array.isArray(payload)) return null
      const { start, end } = payload as Record<string, unknown>
      return {
        start: typeof start === "string" ? start : "",
        end: typeof end === "string" ? end : "",
      }
    },
    // 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(calendarRef.current)
    },
  })

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

  return (
    <Field
      // `w-fit` rather than the `w-full` every other field root wears: this is
      // a grid of fixed-size cells with an intrinsic width, and a field root
      // stretched across a column leaves it stranded at one end. Keeping the
      // calendar's own chrome over its grid is `RangeCalendar`'s job, not this
      // one's — see the note on `Calendar`'s root.
      className={cn("w-fit", 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="fieldset"
        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={calendarRef} data-slot="control">
        <RangeCalendar
          {...props}
          value={toRangeValue(control.payload)}
          onChange={(range) =>
            control.change(
              range ? { start: range.start.toString(), end: range.end.toString() } : null,
            )
          }
          isInvalid={hasErrors}
          aria-label={props["aria-label"] ?? label}
          aria-describedby={describedBy(
            hasErrors && field.errorId,
            description && field.descriptionId,
          )}
        />
      </div>

      {/* These ids are ours to set: react-aria only owns the ids of children
          rendered inside its field, and these are siblings of the calendar
          above, not children of it. The aria-describedby above is the only
          reference they get. */}
      {description && <Description id={field.descriptionId}>{description}</Description>}
      {hasErrors && <FieldError id={field.errorId}>{field.errors?.join(", ")}</FieldError>}
    </Field>
  )
}