Form
Form component system built on React Hook Form with validation and accessible controls.
A declarative form system built on top of React Hook Form. It coordinates state between your input fields and validation schemas, automatically applying the correct accessible attributes and error states.
Installation
npm install @zayne-labs/ui-react react-hook-formFor validation, install Zod and the resolver:
npm install zod @hookform/resolversPreview
Loading...
Basic Usage
import { zodResolver } from "@hookform/resolvers/zod";
import { Form } from "@zayne-labs/ui-react/ui/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
const schema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
function LoginForm() {
const form = useForm({
defaultValues: {
email: "",
password: "",
},
resolver: zodResolver(schema),
});
return (
<Form.Root form={form} onSubmit={form.handleSubmit((data) => console.log(data))}>
<Form.Field control={form.control} name="email">
<Form.Label>Email</Form.Label>
<Form.Input type="email" />
<Form.ErrorMessage />
</Form.Field>
<Form.Field control={form.control} name="password">
<Form.Label>Password</Form.Label>
<Form.Input type="password" />
<Form.ErrorMessage />
</Form.Field>
<Form.Submit>Sign In</Form.Submit>
</Form.Root>
);
}Control Usage & Type Safety
While Form.Root shares the internal context required to make the control and field-inherited props optional, it is highly recommended to pass them explicitly.
Providing these props directly allows TypeScript to link your components to your specific form schema, enabling full type safety and robust auto-completion for every field path. Note that while sub-components can inherit the name from their parent Form.Field, the field itself always requires a name prop to function.
// Recommended: schema-aware name prop
<Form.Field control={form.control} name="email">
<Form.Label>Email</Form.Label>
<Form.Input />
</Form.Field>// Optional: "name" falls back to generic string
<Form.Field name="email">
<Form.Label>Email</Form.Label>
<Form.Input />
</Form.Field>Controlled Components
To integrate third-party components that don't expose a native ref (such as a Combobox or Switch), use the controlled component variants. These wrap React Hook Form's Controller while remaining fully integrated with the form's accessibility and error handling system.
Form.FieldBoundController
This is the recommended approach for most controlled components. It must be nested within a Form.Field, which allows it to automatically bind to the parent's name and control.
<Form.Field control={form.control} name="country">
<Form.Label>Country</Form.Label>
<Form.FieldBoundController
render={({ field }) => (
<Combobox options={countries} value={field.value} onChange={field.onChange} />
)}
/>
<Form.ErrorMessage />
</Form.Field>Form.FieldWithController
Use this variant if you need a standalone controller that doesn't require a parent Form.Field. It provides its own field context, making it useful for specialized layouts or direct usage under Form.Root.
<Form.FieldWithController
control={form.control}
name="notifications"
render={({ field }) => <Switch checked={field.value} onCheckedChange={field.onChange} />}
/>Reactive State
To avoid unnecessary full-form re-renders, use the Form.Watch and Form.StateSubscribe components. They isolate re-renders by subscribing only to the specific field values or form states you need.
<Form.Root form={form} onSubmit={onSubmit}>
<Form.Watch control={form.control} name="accountType">
{(type) =>
type === "business" && (
<Form.Field control={form.control} name="taxId">
<Form.Label>Tax ID</Form.Label>
<Form.Input />
</Form.Field>
)
}
</Form.Watch>
<Form.StateSubscribe>
{({ isSubmitting }) => (
<Form.Submit disabled={isSubmitting}>{isSubmitting ? "Processing..." : "Continue"}</Form.Submit>
)}
</Form.StateSubscribe>
</Form.Root>Component Source
"use client";import { dataAttr, on, toArray } from "@zayne-labs/toolkit-core";import { ContextError, useCallbackRef, useCompareValue, useToggle } from "@zayne-labs/toolkit-react";import { composeRefs, composeTwoEventHandlers, getMultipleSlots, type DiscriminatedRenderItemProps, type DiscriminatedRenderProps, type InferProps, type PolymorphicPropsStrict,} from "@zayne-labs/toolkit-react/utils";import { defineEnum, isFunction, type AnyString } from "@zayne-labs/toolkit-type-helpers";import { Fragment as ReactFragment, useEffect, useId, useMemo, useRef } from "react";import { Controller, FormProvider as HookFormProvider, useFormState, useWatch, type Control, type ControllerProps, type FieldPath, type FieldValues, type RegisterOptions, type FormStateSubscribeProps as StateSubscribeProps, type UseFormReturn, type WatchProps,} from "react-hook-form";import { ForWithWrapper } from "@/components/common/for";import { Slot } from "@/components/common/slot";import { cnMerge } from "@/lib/utils/cn";import { LaxFormFieldProvider, LaxFormRootProvider, StrictFormFieldProvider, useFormMethodsContext, useLaxFormFieldContext, useLaxFormFieldState, useLaxFormRootContext, useStrictFormFieldContext, type FieldContextType, type FieldState, type FormRootContextType,} from "./form-context";import { getEyeIcon, getFieldErrorMessage } from "./utils";export type FormRootProps<TFieldValues extends FieldValues, TTransformedValues> = InferProps<"form"> & Partial<FormRootContextType> & { children: React.ReactNode; form: UseFormReturn<TFieldValues, unknown, TTransformedValues>; };export function FormRoot<TFieldValues extends FieldValues, TTransformedValues = TFieldValues>( props: FormRootProps<TFieldValues, TTransformedValues>) { const { children, className, form, withEyeIcon, ...restOfProps } = props; const shallowedComparedWithEyeIcon = useCompareValue(withEyeIcon); const formContextValue = useMemo( () => ({ withEyeIcon: shallowedComparedWithEyeIcon }), [shallowedComparedWithEyeIcon] ); return ( <HookFormProvider {...form}> <LaxFormRootProvider value={formContextValue}> <form className={cnMerge("flex flex-col", className)} {...restOfProps} data-scope="form" data-part="root" data-slot="form-root" > {children} </form> </LaxFormRootProvider> </HookFormProvider> );}export type FormFieldProps< TControl, TFieldValues extends FieldValues, TTransformedValues,> = (TControl extends Control<infer TValues> ? { control?: never; name: FieldPath<TValues>; }: { control?: Control<TFieldValues, unknown, TTransformedValues>; name: FieldPath<TFieldValues>; }) & ( | (InferProps<"div"> & { withWrapper?: true }) | { children: React.ReactNode; className?: never; withWrapper: false } );export function FormField< TControl, TFieldValues extends FieldValues = FieldValues, TTransformedValues = TFieldValues,>(props: FormFieldProps<TControl, TFieldValues, TTransformedValues>) { const { children, className, control, name, withWrapper = true } = props; const { isDisabled, isInvalid } = useLaxFormFieldState({ control, name }); const uniqueId = useId(); const fieldContextValue = useMemo( () => ({ formDescriptionId: `${name}-(${uniqueId})-form-item-description`, formItemId: `${name}-(${uniqueId})-form-item`, formMessageId: `${name}-(${uniqueId})-form-item-message`, name, }) satisfies FieldContextType, [name, uniqueId] ); const WrapperElement = withWrapper ? "div" : ReactFragment; const wrapperElementProps = withWrapper && { className: cnMerge("flex flex-col gap-2", className), "data-part": "field", "data-scope": "form", "data-slot": "form-field", /* eslint-disable perfectionist/sort-objects -- order of attributes does not matter */ "data-disabled": dataAttr(isDisabled), "data-invalid": dataAttr(isInvalid), /* eslint-enable perfectionist/sort-objects -- order of attributes does not matter */ }; return ( <StrictFormFieldProvider value={fieldContextValue}> <LaxFormFieldProvider value={fieldContextValue}> <WrapperElement {...wrapperElementProps}>{children}</WrapperElement> </LaxFormFieldProvider> </StrictFormFieldProvider> );}export type FormFieldWithControllerProps< TFieldValues extends FieldValues, TName extends FieldPath<TFieldValues>, TTransformedValues = TFieldValues,> = ControllerProps<TFieldValues, TName, TTransformedValues>;export function FormFieldWithController< TFieldValues extends FieldValues, TName extends FieldPath<TFieldValues>, TTransformedValues = TFieldValues,>(props: FormFieldWithControllerProps<TFieldValues, TName, TTransformedValues>) { const methodsContextValue = useFormMethodsContext({ strict: false }); const { control, name, render, ...restOfProps } = props; const uniqueId = useId(); const fieldContextValue = useMemo( () => ({ formDescriptionId: `${name}-(${uniqueId})-form-item-description`, formItemId: `${name}-(${uniqueId})-form-item`, formMessageId: `${name}-(${uniqueId})-form-item-message`, name, }) satisfies FieldContextType, [name, uniqueId] ); const resolvedControl = control ?? methodsContextValue?.control; if (!resolvedControl) { throw new ContextError( "<Form.FormFieldWithController> must be provided with an explicit 'control' prop or used within <Form.Root>" ); } return ( <StrictFormFieldProvider value={fieldContextValue}> <LaxFormFieldProvider value={fieldContextValue}> <Controller control={resolvedControl as never} name={name} render={render as never} {...(restOfProps as object)} /> </LaxFormFieldProvider> </StrictFormFieldProvider> );}export type FormFieldBoundControllerProps<TFieldValues extends FieldValues, TTransformedValues> = Omit< ControllerProps<TFieldValues, never, TTransformedValues>, "control" | "name">;export function FormFieldBoundController< TFieldValues extends FieldValues = Record<string, never>, TTransformedValues = TFieldValues,>(props: FormFieldBoundControllerProps<TFieldValues, TTransformedValues>) { const { control } = useFormMethodsContext(); const { name } = useStrictFormFieldContext(); const { render, ...restOfProps } = props; return ( <Controller name={name} control={control} render={render as never} {...(restOfProps as object)} /> );}export type FormFieldContextProps = DiscriminatedRenderProps< (contextValue: FieldContextType) => React.ReactNode>;export function FormFieldContext(props: FormFieldContextProps) { const { children, render } = props; const fieldContextValues = useStrictFormFieldContext(); if (typeof children === "function") { return children(fieldContextValues); } return render(fieldContextValues);}export type FormLabelProps = InferProps<"label">;export function FormLabel(props: FormLabelProps) { const fieldContextValues = useStrictFormFieldContext(); const { children, htmlFor, ...restOfProps } = props; const { isDisabled, isInvalid } = useLaxFormFieldState({ name: fieldContextValues.name }); return ( <label data-scope="form" data-part="label" data-slot="form-label" data-disabled={dataAttr(isDisabled)} data-invalid={dataAttr(isInvalid)} htmlFor={Object.hasOwn(props, "htmlFor") ? htmlFor : fieldContextValues.formItemId} {...restOfProps} > {children} </label> );}export type FormInputGroupProps = InferProps<"div">;export function FormInputGroup(props: FormInputGroupProps) { const { children, className, ...restOfProps } = props; const { isDisabled, isInvalid } = useLaxFormFieldState(); const { regularChildren, slots: [leftItemSlot, rightItemSlot], } = getMultipleSlots(children, [FormInputLeftItem, FormInputRightItem]); return ( <div data-scope="form" data-part="input-group" data-slot="form-input-group" data-invalid={dataAttr(isInvalid)} data-disabled={dataAttr(isDisabled)} className={cnMerge("flex items-center justify-between gap-2", className)} {...restOfProps} > {leftItemSlot} {regularChildren} {rightItemSlot} </div> );}export type FormSideItemProps = { children?: React.ReactNode; className?: string;};export function FormInputLeftItem<TElement extends React.ElementType = "span">( props: PolymorphicPropsStrict<TElement, FormSideItemProps>) { const { as: Element = "span", children, className, ...restOfProps } = props; return ( <Element data-scope="form" data-part="left-item" data-slot="form-left-item" className={cnMerge("inline-flex items-center justify-center", className)} {...restOfProps} > {children} </Element> );}FormInputLeftItem.slotSymbol = Symbol("input-left-item");export function FormInputRightItem<TElement extends React.ElementType = "span">( props: PolymorphicPropsStrict<TElement, FormSideItemProps>) { const { as: Element = "span", children, className, ...restOfProps } = props; return ( <Element data-scope="form" data-part="right-item" data-slot="form-right-item" className={cnMerge("inline-flex items-center justify-center", className)} {...restOfProps} > {children} </Element> );}FormInputRightItem.slotSymbol = Symbol("input-right-item");type RulesProp = { rules?: RegisterOptions;};export type FormInputPrimitiveProps<TFieldValues extends FieldValues> = Omit< InferProps<"input">, "children"> & RulesProp & { classNames?: { error?: string; eyeIcon?: string; input?: string; inputGroup?: string }; control?: Control<TFieldValues>; fieldState?: FieldState; name?: FieldPath<TFieldValues>; withEyeIcon?: FormRootContextType["withEyeIcon"]; };export type FormTextAreaPrimitiveProps<TFieldValues extends FieldValues> = InferProps<"textarea"> & RulesProp & { classNames?: { base?: string; error?: string }; control?: Control<TFieldValues>; fieldState?: FieldState; name?: FieldPath<TFieldValues>; };export type FormSelectPrimitiveProps<TFieldValues extends FieldValues> = InferProps<"select"> & RulesProp & { classNames?: { base?: string; error?: string }; control?: Control<TFieldValues>; fieldState?: FieldState; name?: FieldPath<TFieldValues>; };const inputTypesWithoutFullWidth = new Set<React.HTMLInputTypeAttribute>(["checkbox", "radio"]);export function FormInputPrimitive<TFieldValues extends FieldValues>( props: FormInputPrimitiveProps<TFieldValues>) { const fieldContextValues = useLaxFormFieldContext(); const formRootContextValues = useLaxFormRootContext(); const { className, classNames, control, fieldState, id, name, rules, type = "text", withEyeIcon, ...restOfProps } = props; const resolvedWithEyeIcon = withEyeIcon ?? formRootContextValues?.withEyeIcon ?? true; const fieldStateFromLaxFormField = useLaxFormFieldState({ control, name }); const { isDisabled, isInvalid } = fieldState ?? fieldStateFromLaxFormField; const [isPasswordVisible, toggleIsPasswordVisible] = useToggle(false); const shouldHaveEyeIcon = Boolean(resolvedWithEyeIcon) && type === "password"; const WrapperElement = shouldHaveEyeIcon ? FormInputGroup : ReactFragment; const wrapperElementProps = shouldHaveEyeIcon && ({ className: cnMerge("w-full", classNames?.inputGroup, isInvalid && classNames?.error), } satisfies InferProps<typeof FormInputGroup>); const { register } = useFormMethodsContext({ strict: false }) ?? {}; const eyeIcon = getEyeIcon({ classNames, iconType: isPasswordVisible ? "closed" : "open", renderIconProps: { isPasswordVisible }, withEyeIcon: resolvedWithEyeIcon, }); return ( <WrapperElement {...wrapperElementProps}> <input data-slot="form-input" data-scope="form" data-part="input" aria-describedby={ !isInvalid ? fieldContextValues?.formDescriptionId : `${fieldContextValues?.formDescriptionId} ${fieldContextValues?.formMessageId}` } aria-invalid={dataAttr(isInvalid)} data-invalid={dataAttr(isInvalid)} data-disabled={dataAttr(isDisabled)} id={Object.hasOwn(props, "id") ? id : fieldContextValues?.formItemId} name={Object.hasOwn(props, "name") ? name : fieldContextValues?.name} type={type === "password" && isPasswordVisible ? "text" : type} className={cnMerge( !inputTypesWithoutFullWidth.has(type) && "w-full min-w-0", `bg-transparent text-sm outline-hidden transition-[color,box-shadow] selection:bg-zu-primary selection:text-zu-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-zu-foreground placeholder:text-zu-muted-foreground focus-visible:outline-hidden disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50`, className, classNames?.input, type !== "password" && isInvalid && classNames?.error )} {...(Boolean(name) && register?.(name, rules))} {...restOfProps} /> {shouldHaveEyeIcon && ( <FormInputRightItem as="button" type="button" onClick={toggleIsPasswordVisible} className="size-5 shrink-0 lg:size-6" > {eyeIcon} </FormInputRightItem> )} </WrapperElement> );}export function FormTextAreaPrimitive<TFieldValues extends FieldValues>( props: FormTextAreaPrimitiveProps<TFieldValues>) { const fieldContextValues = useLaxFormFieldContext(); const { className, classNames, control, fieldState, id, name, rules, ...restOfProps } = props; const fieldStateFromLaxFormField = useLaxFormFieldState({ control, name }); const { isDisabled, isInvalid } = fieldState ?? fieldStateFromLaxFormField; const { register } = useFormMethodsContext({ strict: false }) ?? {}; return ( <textarea data-slot="form-textarea" data-scope="form" data-part="textarea" aria-describedby={ !isInvalid ? fieldContextValues?.formDescriptionId : `${fieldContextValues?.formDescriptionId} ${fieldContextValues?.formMessageId}` } aria-invalid={dataAttr(isInvalid)} data-disabled={dataAttr(isDisabled)} data-invalid={dataAttr(isInvalid)} id={Object.hasOwn(props, "id") ? id : fieldContextValues?.formItemId} name={Object.hasOwn(props, "name") ? name : fieldContextValues?.name} className={cnMerge( `w-full bg-transparent text-sm placeholder:text-zu-muted-foreground focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50`, className, classNames?.base, isInvalid && classNames?.error )} {...(Boolean(name) && register?.(name, rules))} {...restOfProps} /> );}export function FormSelectPrimitive<TFieldValues extends FieldValues>( props: FormSelectPrimitiveProps<TFieldValues>) { const fieldContextValues = useLaxFormFieldContext(); const { className, classNames, control, fieldState, id, name, rules, ...restOfProps } = props; const fieldStateFromLaxFormField = useLaxFormFieldState({ control, name }); const { isDisabled, isInvalid } = fieldState ?? fieldStateFromLaxFormField; const { register } = useFormMethodsContext({ strict: false }) ?? {}; return ( <select defaultValue="" data-slot="form-select" data-scope="form" data-part="select" aria-describedby={ !isInvalid ? fieldContextValues?.formDescriptionId : `${fieldContextValues?.formDescriptionId} ${fieldContextValues?.formMessageId}` } aria-invalid={dataAttr(isInvalid)} data-disabled={dataAttr(isDisabled)} data-invalid={dataAttr(isInvalid)} id={id ?? fieldContextValues?.formItemId} name={name ?? fieldContextValues?.name} className={cnMerge( `w-full bg-transparent text-sm placeholder:text-zu-muted-foreground focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50`, className, classNames?.base, isInvalid && classNames?.error )} {...(Boolean(name) && register?.(name, rules))} {...restOfProps} /> );}type PrimitivePropsToOmit = "control" | "formState" | "name";export type FormInputProps = Omit<FormInputPrimitiveProps<FieldValues>, PrimitivePropsToOmit>;export type FormTextAreaProps = Omit<FormTextAreaPrimitiveProps<FieldValues>, PrimitivePropsToOmit>;export type FormSelectProps = Omit<FormSelectPrimitiveProps<FieldValues>, PrimitivePropsToOmit>;export type FormInputCombinedProps = | (FormInputProps & { type?: FormInputProps["type"] }) | (FormSelectProps & { type: "select" }) | (FormTextAreaProps & { type: "textarea" });const InputTypeMap = defineEnum({ select: FormSelectPrimitive, textarea: FormTextAreaPrimitive,});export function FormInput(props: FormInputCombinedProps) { const { onBlur, onChange, ref, rules, type, ...restOfProps } = props; const { name } = useStrictFormFieldContext(); const { register } = useFormMethodsContext(); const SelectedInput = type === "textarea" || type === "select" ? InputTypeMap[type as Exclude<typeof type, AnyString>] : FormInputPrimitive; const registerProps = name ? register(name, rules) : null; return ( <SelectedInput type={type} name={name} {...registerProps} {...(restOfProps as NonNullable<unknown>)} // eslint-disable-next-line react/refs -- Ignore ref={composeRefs(registerProps?.ref, ref)} onChange={composeTwoEventHandlers(registerProps?.onChange, onChange)} onBlur={composeTwoEventHandlers(registerProps?.onBlur, onBlur)} /> );}export function FormTextArea(props: FormTextAreaProps) { return <FormInput {...props} type="textarea" />;}export function FormSelect(props: FormSelectProps) { return <FormInput {...props} type="select" />;}export type FormDescriptionProps = InferProps<"p">;export function FormDescription<TElement extends React.ElementType = "p">( props: PolymorphicPropsStrict<TElement, FormDescriptionProps>) { const { as: Element = "p", className, ...restOfProps } = props; const { formDescriptionId } = useLaxFormFieldContext() ?? {}; return ( <Element data-slot="form-description" data-scope="form" data-part="description" id={formDescriptionId} className={cnMerge("text-[12px]", className)} {...restOfProps} /> );}type ErrorMessageRenderProps = { className: string; "data-index": number; "data-part": "error-message"; "data-scope": "form"; "data-slot": "form-error-message"; id: string | undefined;};type ErrorMessageRenderState = { errorMessage: string; errorMessageArray: string[]; index: number;};type ErrorMessageRenderFn = (context: { props: ErrorMessageRenderProps; state: ErrorMessageRenderState;}) => React.ReactNode;export type FormErrorMessagePrimitiveProps<TFieldValues extends FieldValues> = DiscriminatedRenderItemProps<ErrorMessageRenderFn> & { className?: string; classNames?: { container?: string; errorMessage?: string; errorMessageAnimation?: string; }; control?: Control<TFieldValues>; // == Here for type inference of errorField prop disableErrorAnimation?: boolean; disableScrollToErrorField?: boolean; } & ( | { fieldName: FieldPath<TFieldValues>; type?: "regular"; } | { fieldName: string; type: "root"; } );type FormErrorMessagePrimitiveOverloadType = { <TFieldValues extends FieldValues>( props: Extract<FormErrorMessagePrimitiveProps<TFieldValues>, { type?: "regular" }> ): React.ReactNode; <TFieldValues extends FieldValues>( // eslint-disable-next-line ts-eslint/unified-signatures -- Using overloads are better because it gives better error messages props: Extract<FormErrorMessagePrimitiveProps<TFieldValues>, { type: "root" }> ): React.ReactNode;};export const FormErrorMessagePrimitive: FormErrorMessagePrimitiveOverloadType = (props) => { const methodsContextValues = useFormMethodsContext({ strict: false }); const { children, className, classNames, control, disableErrorAnimation = false, disableScrollToErrorField = false, fieldName, renderItem, type = "regular", } = props; const resolvedControl = control ?? methodsContextValues?.control; if (!resolvedControl) { throw new ContextError( "<Form.ErrorMessagePrimitive> must be provided with an explicit 'control' prop or used within <Form.Root>" ); } const { errors } = useLaxFormFieldState({ control: resolvedControl as never, name: fieldName, }); const { formMessageId } = useLaxFormFieldContext() ?? {}; const containerRef = useRef<HTMLUListElement>(null); const errorAnimationClass = classNames?.errorMessageAnimation ?? "animate-shake"; const getErrorElements = useCallbackRef(() => containerRef.current?.children ?? []); useEffect(() => { if (disableErrorAnimation) return; if (!errors || Object.keys(errors).length === 0) return; const errorMessageElements = getErrorElements(); if (errorMessageElements.length === 0) return; const controller = new AbortController(); for (const element of errorMessageElements) { element.classList.add(errorAnimationClass); const onAnimationEnd = () => element.classList.remove(errorAnimationClass); on(element, "animationend", onAnimationEnd, { once: true, signal: controller.signal }); } return () => { controller.abort(); }; }, [disableErrorAnimation, errorAnimationClass, errors, getErrorElements]); useEffect(() => { if (disableScrollToErrorField) return; if (!errors || Object.keys(errors).length === 0) return; const errorMessageElements = getErrorElements(); if (errorMessageElements.length === 0) return; const firstErrorElement = errorMessageElements[0]; if (!firstErrorElement) return; // == Find the input field associated with this error const inputField = document.querySelector(`[name='${fieldName}']`); const isFocusableInput = inputField?.matches( ":is(input, select, textarea, [contenteditable='true'])" ); // == Return early if the input field is focusable (Only scrollIntoView for non-focusable fields) if (isFocusableInput) return; // == Get the element's position and scroll in one frame const frameID = requestAnimationFrame(() => { const elementRect = firstErrorElement.getBoundingClientRect(); if (elementRect.top === 0) return; const topWithOffset = elementRect.top - 100; window.scrollTo({ behavior: "smooth", top: window.scrollY + topWithOffset, }); }); return () => { cancelAnimationFrame(frameID); }; }, [disableScrollToErrorField, fieldName, errors, getErrorElements]); const fieldErrorMessage = getFieldErrorMessage({ errors, fieldName, type }); if (!fieldErrorMessage) { return null; } const errorMessageArray = toArray(fieldErrorMessage); if (errorMessageArray.length === 0) { return null; } const getRenderProps = (options: { index: number }): ErrorMessageRenderProps => { const { index } = options; return { className: cnMerge(className, classNames?.errorMessage), /* eslint-disable perfectionist/sort-objects -- Ignore */ "data-slot": "form-error-message", "data-part": "error-message", "data-scope": "form", "data-index": index, /* eslint-enable perfectionist/sort-objects -- Ignore */ id: formMessageId, }; }; const getRenderState = (options: { errorMessage: string; index: number }): ErrorMessageRenderState => { const { errorMessage, index } = options; return { errorMessage, errorMessageArray, index, }; }; const selectedChildren = typeof children === "function" ? children : renderItem; return ( <ForWithWrapper ref={containerRef} className={cnMerge("flex flex-col", classNames?.container)} data-slot="form-error-message-container" data-scope="form" data-part="error-message-container" each={errorMessageArray} renderItem={(errorMessage, index) => { return selectedChildren({ props: getRenderProps({ index }), state: getRenderState({ errorMessage, index }), }); }} /> );};export type FormErrorMessageProps<TControl, TFieldValues extends FieldValues, TTransformedValues> = | (TControl extends Control<infer TValues> ? { className?: string; control?: never; errorField?: FieldPath<TValues>; type?: "regular"; } : { className?: string; control?: Control<TFieldValues, unknown, TTransformedValues>; // == Here for type inference of errorField prop errorField?: FieldPath<TFieldValues>; type?: "regular"; }) | { className?: string; errorField: string; type: "root"; };export function FormErrorMessage< TControl, TFieldValues extends FieldValues = FieldValues, TTransformedValues = TFieldValues,>(props: FormErrorMessageProps<TControl, TFieldValues, TTransformedValues>) { const fieldContextValues = useLaxFormFieldContext(); const { className, errorField, type = "regular" } = props; const { control } = useFormMethodsContext(); return ( <FormErrorMessagePrimitive type={type as "root"} control={control} fieldName={errorField ?? (fieldContextValues?.name as NonNullable<typeof errorField>)} renderItem={({ props: renderProps, state }) => ( <li key={state.errorMessage} {...renderProps} className={cnMerge( "text-[13px] text-zu-destructive", "data-[index=0]:mt-1", renderProps.className, className )} > {state.errorMessage} </li> )} /> );}export type FormSubmitProps<TFieldValues extends FieldValues, TTransformedValues> = Omit< InferProps<"button">, "children"> & { asChild?: boolean;} & ( | { children: React.ReactNode; control?: never; } | { children: StateSubscribeProps<TFieldValues, TTransformedValues>["render"]; control?: Control<TFieldValues, unknown, TTransformedValues>; } );export function FormSubmit< TElement extends React.ElementType = "button", TFieldValues extends FieldValues = Record<string, unknown>, TTransformedValues = TFieldValues,>(props: PolymorphicPropsStrict<TElement, FormSubmitProps<TFieldValues, TTransformedValues>>) { const { as: Element = "button", asChild, children, control, type = "submit", ...restOfProps } = props; const Component = asChild ? Slot.Root : Element; return ( <Component data-slot="form-submit" data-part="submit" data-scope="form" type={type} {...restOfProps}> {isFunction(children) ? <FormStateSubscribe control={control} render={children} /> : children} </Component> );}export type FormWatchProps< TFieldValues extends FieldValues, TFieldName extends | Array<FieldPath<TFieldValues>> | FieldPath<TFieldValues> | ReadonlyArray<FieldPath<TFieldValues>> | undefined, TTransformedValues, TComputeValue, TComputedProps extends Omit< WatchProps<TFieldName, TFieldValues, unknown, TTransformedValues, TComputeValue>, "names" > = Omit<WatchProps<TFieldName, TFieldValues, unknown, TTransformedValues, TComputeValue>, "names">,> = DiscriminatedRenderProps<TComputedProps["render"]> & Omit<TComputedProps, "render">;export function FormWatch< TFieldValues extends FieldValues = Record<string, unknown>, const TFieldName extends | Array<FieldPath<TFieldValues>> | FieldPath<TFieldValues> | ReadonlyArray<FieldPath<TFieldValues>> | undefined = FieldPath<TFieldValues>, TTransformedValues = TFieldValues, TComputeValue = undefined,>(props: FormWatchProps<TFieldValues, TFieldName, TTransformedValues, TComputeValue>) { const fieldContextValue = useLaxFormFieldContext(); const { children, compute, control, defaultValue, disabled, exact, name, render } = props; const methodsContextValue = useFormMethodsContext({ strict: false }); const resolvedControl = control ?? methodsContextValue?.control; if (!resolvedControl) { throw new ContextError( "<Form.Watch> must be provided with an explicit 'control' prop or used within <Form.Root>" ); } const formValue = useWatch({ compute: compute as never, control: resolvedControl as never, defaultValue, disabled, exact, name: (name ?? fieldContextValue?.name) as string, }) as unknown; const selectedChildren = typeof children === "function" ? children : render; const resolvedChildren = selectedChildren(formValue as never); return resolvedChildren;}export type FormStateSubscribeProps< TFieldValues extends FieldValues, TTransformedValues, TComputedProps extends StateSubscribeProps<TFieldValues, TTransformedValues> = StateSubscribeProps< TFieldValues, TTransformedValues >,> = DiscriminatedRenderProps<TComputedProps["render"]> & Omit<TComputedProps, "render">;export function FormStateSubscribe< TFieldValues extends FieldValues = Record<string, unknown>, TTransformedValues = TFieldValues,>(props: FormStateSubscribeProps<TFieldValues, TTransformedValues>) { const fieldContextValues = useLaxFormFieldContext(); const { children, control, disabled, exact, name, render } = props; const methodsContextValue = useFormMethodsContext({ strict: false }); const resolvedControl = control ?? methodsContextValue?.control; if (!resolvedControl) { throw new ContextError( "<Form.StateSubscribe> must be provided with an explicit 'control' prop or used within <Form.Root>" ); } const formState = useFormState({ control: resolvedControl as never, disabled, exact, name: name ?? (fieldContextValues?.name as never), }); const selectedChildren = typeof children === "function" ? children : render; const resolvedChildren = selectedChildren(formState); return resolvedChildren;}<FormStateSubscribe>{(formState) => <p>{formState.isValid ? "Valid" : "Invalid"}</p>}</FormStateSubscribe>;Component API
Data Attributes
| Component | Attribute | Description |
|---|---|---|
Form.Field | data-invalid | Present when the field has a validation error. |
data-disabled | Present when the field is disabled. | |
Form.Label | data-invalid | Matches the field error state. |
data-disabled | Matches the field disabled state. | |
Form.Input | data-invalid | Matches the field error state for styling. |
data-disabled | Matches the field disabled state. | |
Form.ErrorMessage | data-index | The zero-based index of the error message. |
Last updated on