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-form

For validation, install Zod and the resolver:

npm install zod @hookform/resolvers

Preview

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

ComponentAttributeDescription
Form.Fielddata-invalidPresent when the field has a validation error.
data-disabledPresent when the field is disabled.
Form.Labeldata-invalidMatches the field error state.
data-disabledMatches the field disabled state.
Form.Inputdata-invalidMatches the field error state for styling.
data-disabledMatches the field disabled state.
Form.ErrorMessagedata-indexThe zero-based index of the error message.
Edit on GitHub

Last updated on

On this page