feat: add form primitives and rhf integration

This commit is contained in:
2026-03-19 17:50:34 +08:00
parent cb15b46b0c
commit b7d17383bf
7 changed files with 627 additions and 2 deletions
+1
View File
@@ -35,6 +35,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"motion": "^12.38.0",
"react-hook-form": "^7.71.2",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
+196
View File
@@ -0,0 +1,196 @@
import { useState } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Controller, useForm } from "react-hook-form";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
import {
Form,
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage
} from "./form";
import { Input } from "./input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "./select";
import { Switch } from "./switch";
type ProfileValues = {
email: string;
marketing: boolean;
role: string;
};
function ExampleForm(props: {
defaultValues?: ProfileValues;
onSubmit?: (values: ProfileValues) => void;
}) {
const form = useForm<ProfileValues>({
defaultValues: props.defaultValues ?? {
email: "",
marketing: false,
role: "editorial"
}
});
return (
<Form {...form}>
<form noValidate onSubmit={form.handleSubmit((values) => props.onSubmit?.(values))}>
<div className="grid gap-5">
<FormItem name="email" required>
<FormLabel>Email address</FormLabel>
<FormControl>
<Input
placeholder="team@cadence.dev"
{...form.register("email", {
required: "Email is required."
})}
/>
</FormControl>
<FormDescription>We send release notes to this address.</FormDescription>
<FormMessage />
</FormItem>
<Controller
control={form.control}
name="role"
rules={{
required: "Choose a review lane."
}}
render={({ field }) => (
<FormItem name="role">
<FormLabel>Review lane</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger aria-label="Review lane">
<SelectValue placeholder="Choose a lane" />
</SelectTrigger>
<SelectContent>
<SelectItem value="editorial">Editorial</SelectItem>
<SelectItem value="design">Design</SelectItem>
<SelectItem value="legal">Legal</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Controller
control={form.control}
name="marketing"
render={({ field }) => (
<FormItem name="marketing">
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<FormLabel>Weekly summary</FormLabel>
<FormDescription>Get a Friday digest of rollout changes.</FormDescription>
</div>
<FormControl className="gap-0">
<Switch
aria-label="Weekly summary"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save settings</Button>
</div>
</form>
</Form>
);
}
describe("Form", () => {
it("wires label, description, invalid state, and message through registered inputs", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ExampleForm onSubmit={onSubmit} />);
await user.click(screen.getByRole("button", { name: "Save settings" }));
const input = screen.getByRole("textbox", { name: "Email address" });
const message = await screen.findByText("Email is required.");
expect(onSubmit).not.toHaveBeenCalled();
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAttribute("aria-describedby", expect.stringContaining(message.id));
expect(input.closest("[data-slot='root']")).toHaveAttribute("data-invalid", "");
});
it("submits updated values from controlled and uncontrolled fields", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ExampleForm onSubmit={onSubmit} />);
await user.type(screen.getByRole("textbox", { name: "Email address" }), "team@cadence.dev");
await user.click(screen.getByRole("combobox", { name: "Review lane" }));
await user.click(await screen.findByRole("option", { name: "Legal" }));
await user.click(screen.getByRole("switch", { name: "Weekly summary" }));
await user.click(screen.getByRole("button", { name: "Save settings" }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: "team@cadence.dev",
marketing: true,
role: "legal"
});
});
});
it("shows custom fallback message content when no field error is present", async () => {
const user = userEvent.setup();
function MessagePreview() {
const form = useForm<{ email: string }>({
defaultValues: {
email: "team@cadence.dev"
}
});
const [saved, setSaved] = useState(false);
return (
<Form {...form}>
<form
noValidate
onSubmit={form.handleSubmit(() => {
setSaved(true);
})}
>
<FormItem name="email">
<FormLabel>Email address</FormLabel>
<FormControl>
<Input {...form.register("email")} />
</FormControl>
<FormMessage>{saved ? "Saved successfully." : null}</FormMessage>
</FormItem>
<Button type="submit">Save</Button>
</form>
</Form>
);
}
render(<MessagePreview />);
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("Saved successfully.")).toBeInTheDocument();
});
});
+210
View File
@@ -0,0 +1,210 @@
import { Slot } from "@radix-ui/react-slot";
import {
createContext,
forwardRef,
useContext,
useId,
type ComponentPropsWithoutRef,
type ReactNode
} from "react";
import {
FormProvider,
useFormContext,
type FieldError,
type FieldValues,
type FormProviderProps,
type UseFormReturn
} from "react-hook-form";
import {
Field,
FieldDescription,
FieldError as BaseFieldError,
FieldLabel,
useFieldContext,
type FieldDescriptionProps,
type FieldErrorProps,
type FieldProps
} from "./field";
import { cn } from "../lib/cn";
import { createDataAttributes } from "../lib/contracts";
type FormItemContextValue = {
name?: string;
};
const FormItemContext = createContext<FormItemContextValue | null>(null);
function mergeIds(...ids: Array<string | undefined>) {
const value = ids.filter(Boolean).join(" ").trim();
return value.length > 0 ? value : undefined;
}
function useSafeFormContext() {
try {
return useFormContext();
} catch {
return null;
}
}
function useFormFieldState(name?: string) {
const form = useSafeFormContext();
if (!form || !name) {
return {
error: undefined as FieldError | undefined,
invalid: false
};
}
// Accessing concrete formState branches subscribes this hook to RHF updates.
void form.formState.errors;
void form.formState.touchedFields;
void form.formState.dirtyFields;
const fieldState = form.getFieldState(name, form.formState);
return {
error: fieldState.error,
invalid: fieldState.invalid
};
}
function getErrorMessage(error?: FieldError) {
if (!error) {
return undefined;
}
if (typeof error.message === "string" && error.message.length > 0) {
return error.message;
}
if (typeof error.type === "string" && error.type.length > 0) {
return error.type;
}
return "Invalid value.";
}
export const Form = FormProvider;
export type FormProps<
TFieldValues extends FieldValues = FieldValues,
TContext = unknown,
TTransformedValues extends FieldValues | undefined = undefined
> = FormProviderProps<TFieldValues, TContext, TTransformedValues>;
export type FormItemProps = Omit<FieldProps, "id" | "invalid"> & {
name?: string;
invalid?: boolean;
};
export const FormItem = forwardRef<HTMLDivElement, FormItemProps>(function FormItem(
{ name, invalid, ...props },
ref
) {
const reactId = useId();
const { invalid: formInvalid } = useFormFieldState(name);
const resolvedInvalid = invalid ?? formInvalid;
return (
<FormItemContext.Provider value={{ name }}>
<Field
{...props}
id={`form-item-${reactId.replace(/:/g, "")}`}
invalid={resolvedInvalid}
ref={ref}
/>
</FormItemContext.Provider>
);
});
export type FormLabelProps = ComponentPropsWithoutRef<typeof FieldLabel>;
export const FormLabel = forwardRef<HTMLLabelElement, FormLabelProps>(function FormLabel(
props,
ref
) {
const { name } = useContext(FormItemContext) ?? {};
const { invalid: formInvalid } = useFormFieldState(name);
return (
<FieldLabel
{...props}
aria-invalid={props["aria-invalid"] ?? (formInvalid || undefined)}
ref={ref}
/>
);
});
export type FormControlProps = ComponentPropsWithoutRef<typeof Slot> & {
className?: string;
};
export const FormControl = forwardRef<HTMLDivElement, FormControlProps>(function FormControl(
{ children, className, ...props },
ref
) {
const { name } = useContext(FormItemContext) ?? {};
const field = useFieldContext();
const { invalid } = useFormFieldState(name);
const describedBy = mergeIds(
typeof props["aria-describedby"] === "string" ? props["aria-describedby"] : undefined,
field?.descriptionId,
invalid ? field?.errorId : undefined
);
const controlId = typeof props.id === "string" ? props.id : field?.inputId;
return (
<div
{...createDataAttributes({
invalid
})}
className={cn("grid gap-2", className)}
data-slot="control"
ref={ref}
>
<Slot
{...props}
aria-describedby={describedBy}
aria-invalid={props["aria-invalid"] ?? (invalid || undefined)}
id={controlId}
>
{children}
</Slot>
</div>
);
});
export type FormDescriptionProps = FieldDescriptionProps;
export const FormDescription = forwardRef<HTMLParagraphElement, FormDescriptionProps>(
function FormDescription(props, ref) {
return <FieldDescription {...props} ref={ref} />;
}
);
export type FormMessageProps = Omit<FieldErrorProps, "children"> & {
children?: ReactNode;
};
export const FormMessage = forwardRef<HTMLParagraphElement, FormMessageProps>(
function FormMessage({ children, ...props }, ref) {
const { name } = useContext(FormItemContext) ?? {};
const { error } = useFormFieldState(name);
const message = getErrorMessage(error) ?? children;
if (!message) {
return null;
}
return (
<BaseFieldError {...props} ref={ref}>
{message}
</BaseFieldError>
);
}
);
export type FormMethods<TFieldValues extends FieldValues = FieldValues> = UseFormReturn<TFieldValues>;
+15 -1
View File
@@ -108,7 +108,6 @@ export {
FieldDescription,
FieldError,
FieldLabel,
FormItem,
useFieldIds,
type FieldControlProps,
type FieldDescriptionProps,
@@ -116,6 +115,21 @@ export {
type FieldProps,
type FieldRenderProps
} from "./components/field";
export {
Form,
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage,
type FormControlProps,
type FormDescriptionProps,
type FormItemProps,
type FormLabelProps,
type FormMessageProps,
type FormMethods,
type FormProps
} from "./components/form";
export { Input, type InputProps } from "./components/input";
export { inputVariants } from "./components/input.variants";
export { Label, type LabelProps } from "./components/label";