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
+2 -1
View File
@@ -12,6 +12,7 @@
"@ai-ui/tokens": "workspace:*",
"@ai-ui/ui": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-hook-form": "^7.71.2"
}
}
+187
View File
@@ -0,0 +1,187 @@
import {
Button,
Form,
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Textarea
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
import { Controller, useForm } from "react-hook-form";
import { useState } from "react";
type LaunchFormValues = {
email: string;
notifications: boolean;
role: string;
summary: string;
};
function LaunchSettingsForm() {
const [submitted, setSubmitted] = useState<LaunchFormValues | null>(null);
const form = useForm<LaunchFormValues>({
defaultValues: {
email: "",
notifications: true,
role: "design",
summary: ""
}
});
return (
<Form {...form}>
<form
className="grid w-[620px] gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]"
noValidate
onSubmit={form.handleSubmit((values) => {
setSubmitted(values);
})}
>
<div className="space-y-2">
<h2 className="m-0 text-xl font-semibold tracking-[var(--tracking-tight)]">
Launch settings
</h2>
<p className="m-0 text-sm leading-6 text-[var(--color-muted-foreground)]">
This story uses <code>react-hook-form</code> with the new form primitives.
</p>
</div>
<FormItem name="email" required>
<FormLabel>Email address</FormLabel>
<FormControl>
<Input
placeholder="team@cadence.dev"
{...form.register("email", {
required: "Email is required.",
pattern: {
value: /\S+@\S+\.\S+/,
message: "Enter a valid email address."
}
})}
/>
</FormControl>
<FormDescription>Release notes and alerts will be sent here.</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="design">Design</SelectItem>
<SelectItem value="engineering">Engineering</SelectItem>
<SelectItem value="legal">Legal</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
The selected team becomes the primary reviewer for this launch.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormItem name="summary">
<FormLabel>Launch summary</FormLabel>
<FormControl>
<Textarea
placeholder="Summarize the rollout, scope, and known risks."
{...form.register("summary", {
minLength: {
value: 24,
message: "Summary must be at least 24 characters."
}
})}
/>
</FormControl>
<FormDescription>Shown in the changelog card and Slack digest.</FormDescription>
<FormMessage />
</FormItem>
<Controller
control={form.control}
name="notifications"
render={({ field }) => (
<FormItem name="notifications">
<div className="flex items-start justify-between gap-4 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div className="space-y-1">
<FormLabel>Weekly digest</FormLabel>
<FormDescription>
Send a Friday summary of all rollout changes to stakeholders.
</FormDescription>
</div>
<FormControl className="gap-0">
<Switch
aria-label="Weekly digest"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => {
form.reset();
setSubmitted(null);
}}
>
Reset
</Button>
<Button type="submit">Save settings</Button>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
<p className="m-0 text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Submitted payload
</p>
<pre className="m-0 mt-3 overflow-x-auto text-sm leading-6 text-[var(--color-foreground)]">
<code>{submitted ? JSON.stringify(submitted, null, 2) : "Submit the form to inspect values."}</code>
</pre>
</div>
</form>
</Form>
);
}
const meta = {
title: "Components/Form",
component: LaunchSettingsForm,
parameters: {
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof LaunchSettingsForm>;
export default meta;
type Story = StoryObj<typeof meta>;
export const LaunchSettings: Story = {};
+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";
+16
View File
@@ -98,6 +98,9 @@ importers:
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-hook-form:
specifier: ^7.71.2
version: 7.71.2(react@18.3.1)
packages/tokens: {}
@@ -157,6 +160,9 @@ importers:
motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-hook-form:
specifier: ^7.71.2
version: 7.71.2(react@18.3.1)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
@@ -2802,6 +2808,12 @@ packages:
peerDependencies:
react: ^19.2.4
react-hook-form@7.71.2:
resolution: {integrity: sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
@@ -5850,6 +5862,10 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
react-hook-form@7.71.2(react@18.3.1):
dependencies:
react: 18.3.1
react-is@17.0.2: {}
react-remove-scroll-bar@2.3.8(@types/react@18.3.28)(react@18.3.1):