Components
Loading preview...
A form wrapper component that simplifies validation and submission with flex column layout and gap between fields.
npx shadcn@latest add https://21st.dev/r/coss.com/form"use client";
import type { FormEvent } from "react";
import { useState } from "react";
import { z } from "zod";
import { Form } from "@/components/ui/component";
import { Field as FieldPrimitive } from "@base-ui/react/field";
import { Input as InputPrimitive } from "@base-ui/react/input";
import { cn } from "@/lib/utils";
import { Loader2Icon } from "lucide-react";
function Field({
className,
...props
}: FieldPrimitive.Root.Props) {
return (
<FieldPrimitive.Root
className={cn("flex flex-col items-start gap-2", className)}
data-slot="field"
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: FieldPrimitive.Label.Props) {
return (
<FieldPrimitive.Label
className={cn(
"inline-flex items-center gap-2 font-medium text-base/4.5 text-foreground sm:text-sm/4",
className,
)}
data-slot="field-label"
{...props}
/>
);
}
function FieldError({
className,
...props
}: FieldPrimitive.Error.Props) {
return (
<FieldPrimitive.Error
className={cn("text-destructive-foreground text-xs", className)}
data-slot="field-error"
{...props}
/>
);
}
function Input({
className,
...props
}: React.ComponentProps<typeof InputPrimitive>) {
return (
<span
className={cn(
"relative inline-flex w-full rounded-lg border border-input bg-background not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] has-focus-visible:has-aria-invalid:border-destructive/64 has-focus-visible:has-aria-invalid:ring-destructive/16 has-aria-invalid:border-destructive/36 has-focus-visible:border-ring has-autofill:bg-foreground/4 has-disabled:opacity-64 has-[:disabled,:focus-visible,[aria-invalid]]:shadow-none has-focus-visible:ring-[3px] sm:text-sm dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-aria-invalid:ring-destructive/24 dark:not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)]",
className,
)}
data-slot="input-control"
>
<InputPrimitive
className="h-8.5 w-full min-w-0 rounded-[inherit] px-[calc(--spacing(3)-1px)] leading-8.5 outline-none [transition:background-color_5000000s_ease-in-out_0s] placeholder:text-muted-foreground/72 sm:h-7.5 sm:leading-7.5"
data-slot="input"
{...props}
/>
</span>
);
}
function Button({
className,
children,
loading = false,
disabled,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean }) {
return (
<button
className={cn(
"relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-medium text-base outline-none transition-shadow sm:text-sm",
"not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-primary bg-primary text-primary-foreground shadow-primary/24 shadow-xs hover:bg-primary/90",
"h-9 px-[calc(--spacing(3)-1px)] sm:h-8",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64",
loading && "select-none text-transparent",
className,
)}
disabled={loading || disabled}
{...props}
>
{loading ? (
<>
<span className="invisible inline-flex items-center gap-2">{children}</span>
<Loader2Icon
aria-label="Loading"
className="pointer-events-none absolute animate-spin text-primary-foreground"
role="status"
/>
</>
) : (
children
)}
</button>
);
}
const schema = z.object({
age: z.coerce
.number({ message: "Please enter a number." })
.positive({ message: "Number must be positive." }),
name: z.string().min(1, { message: "Please enter a name." }),
});
type Errors = Record<string, string | string[]>;
async function submitForm(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
const { fieldErrors } = z.flattenError(result.error);
return { errors: fieldErrors as Errors };
}
return {
errors: {} as Errors,
};
}
export default function FormWithZod() {
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
setLoading(true);
const response = await submitForm(event);
await new Promise((r) => setTimeout(r, 800));
setErrors(response.errors);
setLoading(false);
if (Object.keys(response.errors).length === 0) {
alert(
`Name: ${String(formData.get("name") || "")}\nAge: ${String(
formData.get("age") || "",
)}`,
);
}
};
return (
<div className="flex items-center justify-center w-full min-h-screen bg-background p-8">
<Form className="max-w-64" errors={errors} onSubmit={onSubmit}>
<Field name="name">
<FieldLabel>Name</FieldLabel>
<Input placeholder="Enter name" />
<FieldError />
</Field>
<Field name="age">
<FieldLabel>Age</FieldLabel>
<Input placeholder="Enter age" />
<FieldError />
</Field>
<Button loading={loading} type="submit">
Submit
</Button>
</Form>
</div>
);
}