component — conform

Conform Day Schedule

The quebi Day Schedule wired to Conform. Submits its spans as JSON through a hidden input and derives name, required, and validity from the field metadata.

  • form
  • conform
  • schedule
  • time
  • range
  • validation

01 example

Bound to a Conform form

The spans submit as JSON. Drag the workshop span longer to push the day past 8 hours and see the validation error.

focus
standup
workshop

source

conform-day-schedule.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 { useRef } from "react"
import { cn } from "@/lib/utils"
import { DaySchedule, type DayScheduleProps, type DaySpan } from "@/components/day-schedule"
import {
  describedBy,
  Description,
  Field,
  FieldError,
  focusFirstControl,
  Label,
} from "@/components/field"

export interface ConformDayScheduleProps
  extends Omit<DayScheduleProps, "spans" | "defaultSpans" | "onSpansChange"> {
  /**
   * A weekly schedule bound to a form value. The spans are an array, so they
   * are submitted as a JSON string through a registered hidden input and the
   * schema parses that JSON back into spans — hence the wire-or-parsed union,
   * the same shape `conform-date-field` and `conform-number-field` use.
   */
  field: FieldMetadata<string | DaySpan[]>
  label?: string
  description?: string
  /** Used when the field has no initial value. */
  defaultSpans?: DaySpan[]
}

/** Parse a serialized span list, tolerating an absent or malformed payload. */
function parseSpans(value: string | undefined, fallback: DaySpan[]): DaySpan[] {
  if (typeof value !== "string" || value.length === 0) return fallback
  try {
    const parsed = JSON.parse(value)
    return Array.isArray(parsed) ? (parsed as DaySpan[]) : fallback
  } catch {
    return fallback
  }
}

/**
 * ConformDaySchedule — DaySchedule wired to Conform.
 *
 * Binds a Conform field to the quebi DaySchedule through a registered hidden
 * input carrying the spans as JSON. DaySchedule is a drag-to-select grid with
 * no native form value of its own, so that input is the whole submitted value.
 *
 * The spans live in Conform's state rather than in `useState`, which is what
 * makes them survive a failed submit and snap back on a form reset — a second
 * copy in component state disagrees with the form the moment either happens.
 */
export function ConformDaySchedule({
  field,
  label,
  description,
  defaultSpans = [],
  className,
  ...props
}: ConformDayScheduleProps) {
  const fieldRef = useRef<HTMLDivElement>(null)
  const control = useControl({
    defaultValue: JSON.stringify(parseSpans(field.initialValue as string | undefined, defaultSpans)),
    // 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
  const isRequired = field.required ?? false
  const spans = parseSpans(control.value, defaultSpans)

  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={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
        data-slot="control"
        className={cn(
          "border p-4 transition-colors duration-150",
          hasErrors ? "border-quebi-danger" : "border-quebi-hairline",
        )}
      >
        <DaySchedule
          {...props}
          spans={spans}
          onSpansChange={(next) => control.change(JSON.stringify(next))}
          aria-invalid={hasErrors || undefined}
          aria-describedby={describedBy(
            hasErrors && field.errorId,
            description && field.descriptionId,
          )}
        />
      </div>

      {/* These ids are ours to set: DaySchedule is not a react-aria field, so
          nothing generates them and the aria-describedby above is their only
          reference. */}
      {description && <Description id={field.descriptionId}>{description}</Description>}
      {hasErrors && <FieldError id={field.errorId}>{field.errors?.join(", ")}</FieldError>}
    </Field>
  )
}