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.
source
conform-day-schedule.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 { 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>
)
}