feat: add form primitives and rhf integration
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user