Form
Conteneur de validation pour composer libelles, descriptions, controles et messages. Installez Form depuis le registre VLLNT UI avec la CLI shadcn.
Conteneur de validation pour composer libelles, descriptions, controles et messages. Fait partie de la famille form dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.
Installation
Ajoutez Form a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/form.jsonSource
"use client";
import * as React from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
type ControllerProps,
type DefaultValues,
type FieldPath,
type FieldValues,
FormProvider,
type Resolver,
type SubmitErrorHandler,
useForm,
useFormContext,
type UseFormReturn,
} from "react-hook-form";
import { cn } from "../../lib/utils";
import { Label } from "../label/label";
type FormInstance<TFieldValues extends FieldValues> = UseFormReturn<
TFieldValues,
unknown,
TFieldValues
>;
type FormRenderChildren<TFieldValues extends FieldValues> =
| ((form: FormInstance<TFieldValues>) => React.ReactNode)
| React.ReactNode;
type FormSubmitHandler<TFieldValues extends FieldValues> = (
values: TFieldValues,
form: FormInstance<TFieldValues>,
) => Promise<void> | void;
type FormErrorHandler<TFieldValues extends FieldValues> = (
errors: Parameters<SubmitErrorHandler<TFieldValues>>[0],
form: FormInstance<TFieldValues>,
) => Promise<void> | void;
type BaseFormProps<TFieldValues extends FieldValues> = Omit<
React.ComponentPropsWithoutRef<"form">,
"children"
> & {
children?: FormRenderChildren<TFieldValues>;
controlId?: string;
descriptionId?: string;
disabled?: boolean;
invalid?: boolean;
messageId?: string;
onError?: FormErrorHandler<TFieldValues>;
onValidSubmit?: FormSubmitHandler<TFieldValues>;
required?: boolean;
};
type ManagedFormProps<TFieldValues extends FieldValues> = {
defaultValues?: DefaultValues<TFieldValues>;
form?: undefined;
resolver?: Resolver<TFieldValues>;
schema?: Parameters<typeof zodResolver>[0];
values?: TFieldValues;
};
type ProvidedFormProps<TFieldValues extends FieldValues> = {
defaultValues?: never;
form: FormInstance<TFieldValues>;
resolver?: never;
schema?: never;
values?: never;
};
export type FormProps<TFieldValues extends FieldValues = FieldValues> =
BaseFormProps<TFieldValues> &
(ManagedFormProps<TFieldValues> | ProvidedFormProps<TFieldValues>);
type FormNativeSubmitHandler =
React.ComponentPropsWithoutRef<"form">["onSubmit"];
type FormRootContextValue = {
controlId?: string;
descriptionId?: string;
disabled: boolean;
invalid: boolean;
messageId?: string;
required: boolean;
};
type FormFieldContextValue = {
name: string;
};
type FormItemContextValue = {
controlId: string;
descriptionId: string;
disabled: boolean;
hasDescription: boolean;
hasMessage: boolean;
hasMessageSlot: boolean;
id: string;
invalid: boolean;
messageId: string;
required: boolean;
};
const FormRootContext = React.createContext<FormRootContextValue | undefined>(
undefined,
);
const FormFieldContext = React.createContext<FormFieldContextValue | undefined>(
undefined,
);
const FormItemContext = React.createContext<FormItemContextValue | undefined>(
undefined,
);
function useFormRootContext(componentName: string) {
const context = React.use(FormRootContext);
if (context === undefined) {
throw new Error(`${componentName} must be used within Form.`);
}
return context;
}
function useFormItemContext(componentName: string) {
const context = React.use(FormItemContext);
if (context === undefined) {
throw new Error(`${componentName} must be used within FormItem.`);
}
return context;
}
function composeIds(...ids: (string | undefined)[]) {
const value = ids.filter((id) => id !== undefined && id.length > 0).join(" ");
return value.length > 0 ? value : undefined;
}
function resolveItemId(
baseId: string | undefined,
generatedId: string,
suffix: string,
) {
if (baseId === undefined) {
return `${generatedId}-${suffix}`;
}
return baseId.endsWith(`-${suffix}`)
? `${baseId}-${generatedId}`
: `${baseId}-${suffix}-${generatedId}`;
}
function isNamedFormChild(
child: React.ReactNode,
name: "FormDescription" | "FormMessage",
): child is React.ReactElement<{ children?: React.ReactNode }> {
if (!React.isValidElement<{ children?: React.ReactNode }>(child)) {
return false;
}
const { type } = child;
if (typeof type === "string" || typeof type === "symbol") {
return false;
}
return "displayName" in type && type.displayName === name;
}
function hasVisibleContent(children: React.ReactNode): boolean {
return React.Children.toArray(children).some((child) => {
if (child === null || child === undefined || typeof child === "boolean") {
return false;
}
if (typeof child === "string") {
return child.length > 0;
}
if (typeof child === "number") {
return true;
}
if (React.isValidElement<{ children?: React.ReactNode }>(child)) {
const nestedChildren = child.props.children;
return nestedChildren === undefined
? true
: hasVisibleContent(nestedChildren);
}
return true;
});
}
function hasFormChild(
children: React.ReactNode,
name: "FormDescription" | "FormMessage",
): boolean {
return React.Children.toArray(children).some((child) => {
if (isNamedFormChild(child, name)) {
return true;
}
if (React.isValidElement<{ children?: React.ReactNode }>(child)) {
return hasFormChild(child.props.children, name);
}
return false;
});
}
function hasRenderedFormChild(
children: React.ReactNode,
name: "FormDescription" | "FormMessage",
): boolean {
return React.Children.toArray(children).some((child) => {
if (isNamedFormChild(child, name)) {
return name === "FormMessage"
? hasVisibleContent(child.props.children)
: true;
}
if (React.isValidElement<{ children?: React.ReactNode }>(child)) {
return hasRenderedFormChild(child.props.children, name);
}
return false;
});
}
function createManagedSubmitHandler<TFieldValues extends FieldValues>(
form: FormInstance<TFieldValues>,
options: {
onError?: FormErrorHandler<TFieldValues>;
onValidSubmit?: FormSubmitHandler<TFieldValues>;
shouldValidate: boolean;
},
): ReturnType<FormInstance<TFieldValues>["handleSubmit"]> | undefined {
const { onError, onValidSubmit, shouldValidate } = options;
if (!shouldValidate) {
return undefined;
}
return form.handleSubmit(
async (submittedValues) => {
if (onValidSubmit !== undefined) {
await onValidSubmit(submittedValues, form);
}
},
async (errors) => {
if (onError !== undefined) {
await onError(errors, form);
}
},
);
}
function createSubmitHandler(
nativeSubmit: FormNativeSubmitHandler,
handleValidatedSubmit:
| ((event?: React.BaseSyntheticEvent) => Promise<void>)
| undefined,
): FormNativeSubmitHandler {
return async (event) => {
nativeSubmit?.(event);
if (handleValidatedSubmit && !event.defaultPrevented) {
await handleValidatedSubmit(event);
}
};
}
function useFormRootContextValue(
value: FormRootContextValue,
): FormRootContextValue {
const { controlId, descriptionId, disabled, invalid, messageId, required } =
value;
return React.useMemo(
() => ({
controlId,
descriptionId,
disabled,
invalid,
messageId,
required,
}),
[controlId, descriptionId, disabled, invalid, messageId, required],
);
}
type FormMarkupProps<TFieldValues extends FieldValues> = {
children: FormProps<TFieldValues>["children"];
className?: string;
disabled: boolean;
form: FormInstance<TFieldValues>;
formProps: Omit<
React.ComponentPropsWithoutRef<"form">,
"children" | "className" | "onSubmit"
>;
formRef?: React.Ref<HTMLFormElement>;
handleValidatedSubmit?: ReturnType<
FormInstance<TFieldValues>["handleSubmit"]
>;
invalid: boolean;
onSubmit: FormNativeSubmitHandler;
rootContextValue: FormRootContextValue;
};
function FormMarkup<TFieldValues extends FieldValues>({
children,
className,
disabled,
form,
formProps,
formRef,
handleValidatedSubmit,
invalid,
onSubmit,
rootContextValue,
}: FormMarkupProps<TFieldValues>) {
return (
<FormRootContext.Provider value={rootContextValue}>
<FormProvider {...form}>
<form
className={cn("space-y-2", className)}
data-disabled={
disabled || form.formState.isSubmitting ? "true" : undefined
}
data-invalid={invalid ? "true" : undefined}
data-submitting={form.formState.isSubmitting ? "true" : undefined}
onSubmit={
onSubmit === undefined && handleValidatedSubmit === undefined
? undefined
: createSubmitHandler(onSubmit, handleValidatedSubmit)
}
ref={formRef}
{...formProps}
>
{typeof children === "function" ? children(form) : children}
</form>
</FormProvider>
</FormRootContext.Provider>
);
}
function FormInner<TFieldValues extends FieldValues = FieldValues>({
children,
className,
controlId,
defaultValues,
descriptionId,
disabled = false,
form: providedForm,
invalid = false,
messageId,
onError,
onSubmit,
onValidSubmit,
ref,
required = false,
resolver,
schema,
values,
...props
}: FormProps<TFieldValues> & { ref?: React.Ref<HTMLFormElement> }) {
const internalForm = useForm<TFieldValues>({
defaultValues,
resolver:
schema === undefined
? resolver
: (zodResolver(schema) as Resolver<TFieldValues>),
values,
});
const form: FormInstance<TFieldValues> = providedForm ?? internalForm;
const isManagedForm =
providedForm !== undefined ||
resolver !== undefined ||
schema !== undefined;
const rootContextValue = useFormRootContextValue({
controlId,
descriptionId,
disabled,
invalid,
messageId,
required,
});
const handleValidatedSubmit = createManagedSubmitHandler(form, {
onError,
onValidSubmit,
shouldValidate:
isManagedForm || onValidSubmit !== undefined || onError !== undefined,
});
return (
<FormMarkup
className={className}
disabled={disabled}
form={form}
formProps={props}
formRef={ref}
handleValidatedSubmit={handleValidatedSubmit}
invalid={invalid}
onSubmit={onSubmit}
rootContextValue={rootContextValue}
>
{children}
</FormMarkup>
);
}
FormInner.displayName = "Form";
const Form = FormInner;
function FormField<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ ...props }: ControllerProps<TFieldValues, TName>) {
const fieldContextValue = React.useMemo(
() => ({ name: props.name }),
[props.name],
);
return (
<FormFieldContext.Provider value={fieldContextValue}>
<Controller {...props} />
</FormFieldContext.Provider>
);
}
function useFormField() {
const fieldContext = React.use(FormFieldContext);
const itemContext = useFormItemContext("useFormField");
const { formState, getFieldState } = useFormContext();
if (fieldContext === undefined) {
return {
disabled: itemContext.disabled,
error: undefined,
formDescriptionId: itemContext.descriptionId,
formItemId: itemContext.controlId,
formMessageId: itemContext.messageId,
hasDescription: itemContext.hasDescription,
hasMessage: itemContext.hasMessage,
hasMessageSlot: itemContext.hasMessageSlot,
id: itemContext.id,
invalid: itemContext.invalid,
isDirty: false,
isTouched: false,
isValidating: false,
name: "",
required: itemContext.required,
};
}
const fieldState = getFieldState(fieldContext.name, formState);
return {
disabled: itemContext.disabled,
error: fieldState.error,
formDescriptionId: itemContext.descriptionId,
formItemId: itemContext.controlId,
formMessageId: itemContext.messageId,
hasDescription: itemContext.hasDescription,
hasMessage: itemContext.hasMessage,
hasMessageSlot: itemContext.hasMessageSlot,
id: itemContext.id,
invalid: itemContext.invalid || fieldState.invalid,
isDirty: fieldState.isDirty,
isTouched: fieldState.isTouched,
isValidating: fieldState.isValidating,
name: fieldContext.name,
required: itemContext.required,
};
}
const FormItem = ({
children,
className,
disabled: itemDisabled,
invalid: itemInvalid,
ref,
required: itemRequired,
...props
}: React.ComponentPropsWithoutRef<"div"> & {
disabled?: boolean;
invalid?: boolean;
ref?: React.Ref<HTMLDivElement>;
required?: boolean;
}) => {
const {
controlId: controlIdBase,
descriptionId: descriptionIdBase,
disabled,
invalid,
messageId: messageIdBase,
required,
} = useFormRootContext("FormItem");
const generatedId = React.useId();
const hasDescription = hasRenderedFormChild(children, "FormDescription");
const hasMessage = hasRenderedFormChild(children, "FormMessage");
const hasMessageSlot = hasFormChild(children, "FormMessage");
const effectiveDisabled = itemDisabled ?? disabled;
const effectiveInvalid = itemInvalid ?? invalid;
const effectiveRequired = itemRequired ?? required;
const value = React.useMemo<FormItemContextValue>(
() => ({
controlId: resolveItemId(controlIdBase, generatedId, "control"),
descriptionId: resolveItemId(
descriptionIdBase,
generatedId,
"description",
),
disabled: effectiveDisabled,
hasDescription,
hasMessage,
hasMessageSlot,
id: generatedId,
invalid: effectiveInvalid,
messageId: resolveItemId(messageIdBase, generatedId, "message"),
required: effectiveRequired,
}),
[
controlIdBase,
descriptionIdBase,
effectiveDisabled,
effectiveInvalid,
effectiveRequired,
generatedId,
hasDescription,
hasMessage,
hasMessageSlot,
messageIdBase,
],
);
return (
<FormItemContext.Provider value={value}>
<div className={cn("space-y-2", className)} ref={ref} {...props}>
{children}
</div>
</FormItemContext.Provider>
);
};
FormItem.displayName = "FormItem";
const FormLabel = ({
className,
htmlFor,
ref,
...props
}: React.ComponentPropsWithoutRef<typeof Label> & {
ref?: React.Ref<React.ComponentRef<typeof Label>>;
}) => {
const { formItemId, invalid } = useFormField();
return (
<Label
className={cn(invalid && "text-destructive", className)}
data-invalid={invalid ? "true" : undefined}
htmlFor={htmlFor ?? formItemId}
ref={ref}
{...props}
/>
);
};
FormLabel.displayName = "FormLabel";
type FormControlProps = React.ComponentPropsWithoutRef<typeof Slot> & {
disabled?: boolean;
required?: boolean;
};
const FormControl = ({
disabled: controlDisabled,
id: _id,
ref,
required: controlRequired,
...props
}: FormControlProps & { ref?: React.Ref<HTMLElement> }) => {
const {
disabled,
error,
formDescriptionId,
formItemId,
formMessageId,
hasDescription,
hasMessage,
hasMessageSlot,
invalid,
required,
} = useFormField();
const { formState } = useFormContext();
const hasErrorMessage = hasVisibleContent(error?.message);
const describedBy = composeIds(
props["aria-describedby"],
hasDescription ? formDescriptionId : undefined,
error === undefined
? hasMessage && invalid
? formMessageId
: undefined
: hasMessageSlot && hasErrorMessage
? formMessageId
: undefined,
);
const effectiveDisabled =
controlDisabled ?? (disabled || formState.isSubmitting);
const effectiveRequired = controlRequired ?? required;
const nativeConstraintProps: {
disabled?: boolean;
required?: boolean;
} = {
disabled: effectiveDisabled || undefined,
required: effectiveRequired || undefined,
};
return (
<Slot
{...props}
{...nativeConstraintProps}
aria-describedby={describedBy}
aria-disabled={props["aria-disabled"] ?? (effectiveDisabled || undefined)}
aria-invalid={props["aria-invalid"] ?? (invalid || undefined)}
aria-required={props["aria-required"] ?? (effectiveRequired || undefined)}
data-disabled={effectiveDisabled ? "true" : undefined}
data-invalid={invalid ? "true" : undefined}
id={formItemId}
ref={ref}
/>
);
};
FormControl.displayName = "FormControl";
const FormDescription = ({
className,
id: _id,
ref,
...props
}: React.ComponentPropsWithoutRef<"p"> & {
ref?: React.Ref<HTMLParagraphElement>;
}) => {
const { formDescriptionId } = useFormField();
return (
<p
{...props}
className={cn("text-sm text-muted-foreground", className)}
id={formDescriptionId}
ref={ref}
/>
);
};
FormDescription.displayName = "FormDescription";
const FormMessage = ({
children,
className,
id: _id,
ref,
...props
}: React.ComponentPropsWithoutRef<"p"> & {
ref?: React.Ref<HTMLParagraphElement>;
}) => {
const { error, formMessageId, invalid } = useFormField();
const body = error?.message ?? children;
if (!hasVisibleContent(body)) {
return null;
}
return (
<p
{...props}
className={cn(
"text-sm font-medium",
invalid || error !== undefined ? "text-destructive" : "text-foreground",
className,
)}
id={formMessageId}
ref={ref}
role={invalid || error !== undefined ? "alert" : undefined}
>
{body}
</p>
);
};
FormMessage.displayName = "FormMessage";
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
};
Stories
Explorez chaque variante et etat dans le Storybook interactif :
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.