component — conform
Conform Input OTP
The quebi InputOTP wired to Conform. Binds a one-time-code field's name, required, default, and validity from field metadata and renders inline errors.
- form
- conform
- otp
- code
- validation
01 example
Bound to a Conform form
Submit a partial code to see the validation error wired from field metadata.
source
conform-input-otp.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 type { PropsWithChildren } from "react"
import { cn } from "@/lib/utils"
import { describedBy, Description, Field, FieldError, Label } from "@/components/field"
import { InputOTP } from "@/components/input-otp"
export interface ConformInputOTPProps
extends Omit<
React.ComponentPropsWithoutRef<typeof InputOTP>,
"name" | "form" | "value" | "onChange" | "defaultValue" | "required" | "children" | "render"
> {
/** A one-time code bound to a string form value. */
field: FieldMetadata<string>
label?: string
description?: string
}
/**
* ConformInputOTP — InputOTP wired to Conform.
*
* Binds a string Conform field to the quebi InputOTP: derives name, id, form,
* required, default, and validity from the field metadata and renders inline
* errors. Pass the slot layout (InputOTPGroup + InputOTPSlot) as children.
*
* `input-otp` renders a real `<input>` and its props are the DOM ones, so the
* metadata lands under its own names here — `required` and `aria-invalid`,
* not react-aria's `isRequired`/`isInvalid`.
*/
export function ConformInputOTP({
field,
label,
description,
children,
className,
...props
}: PropsWithChildren<ConformInputOTPProps>) {
const hasErrors = !field.valid && !!field.errors
const isRequired = field.required ?? false
return (
<Field className={cn(className)}>
{label && (
<Label htmlFor={field.id} className={cn(hasErrors && "text-quebi-danger")}>
{label}
{isRequired && <span className="ml-1 text-quebi-fg">*</span>}
</Label>
)}
{/* `InputOTP` renders the `input-otp` package's own container, which
carries no `data-slot`, so this wrapper is what the field stack and
`FieldRow` place. */}
<div data-slot="control">
<InputOTP
{...props}
id={field.id}
name={field.name}
form={field.formId}
defaultValue={(field.initialValue as string) ?? ""}
required={isRequired}
aria-invalid={hasErrors || undefined}
aria-describedby={describedBy(
hasErrors && field.errorId,
description && field.descriptionId,
)}
>
{children}
</InputOTP>
</div>
{/* These ids are ours to set: the control above is not a react-aria
field, so nothing generates them and its aria-describedby is their
only reference. */}
{description && <Description id={field.descriptionId}>{description}</Description>}
{hasErrors && <FieldError id={field.errorId}>{field.errors?.join(", ")}</FieldError>}
</Field>
)
}