feat: add form primitives and rhf integration
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
"@ai-ui/tokens": "workspace:*",
|
"@ai-ui/tokens": "workspace:*",
|
||||||
"@ai-ui/ui": "workspace:*",
|
"@ai-ui/ui": "workspace:*",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.71.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {};
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
|
"react-hook-form": "^7.71.2",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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,
|
FieldDescription,
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
FormItem,
|
|
||||||
useFieldIds,
|
useFieldIds,
|
||||||
type FieldControlProps,
|
type FieldControlProps,
|
||||||
type FieldDescriptionProps,
|
type FieldDescriptionProps,
|
||||||
@@ -116,6 +115,21 @@ export {
|
|||||||
type FieldProps,
|
type FieldProps,
|
||||||
type FieldRenderProps
|
type FieldRenderProps
|
||||||
} from "./components/field";
|
} 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 { Input, type InputProps } from "./components/input";
|
||||||
export { inputVariants } from "./components/input.variants";
|
export { inputVariants } from "./components/input.variants";
|
||||||
export { Label, type LabelProps } from "./components/label";
|
export { Label, type LabelProps } from "./components/label";
|
||||||
|
|||||||
Generated
+16
@@ -98,6 +98,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1(react@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: {}
|
packages/tokens: {}
|
||||||
|
|
||||||
@@ -157,6 +160,9 @@ importers:
|
|||||||
motion:
|
motion:
|
||||||
specifier: ^12.38.0
|
specifier: ^12.38.0
|
||||||
version: 12.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
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:
|
tailwind-merge:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
@@ -2802,6 +2808,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.4
|
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:
|
react-is@17.0.2:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
@@ -5850,6 +5862,10 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
scheduler: 0.27.0
|
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-is@17.0.2: {}
|
||||||
|
|
||||||
react-remove-scroll-bar@2.3.8(@types/react@18.3.28)(react@18.3.1):
|
react-remove-scroll-bar@2.3.8(@types/react@18.3.28)(react@18.3.1):
|
||||||
|
|||||||
Reference in New Issue
Block a user