Form
Craftreact-hook-form + zod wired into Field; validators (incl. Thai mobile, national ID and tax ID) in lib/validation.
Motion: Errors slide open; a field shakes once when it turns invalid; focus jumps to the first error.
Installation
npx shadcn@latest add @craft/formSource
components/ui/form.tsx
"use client"
import * as React from "react"
import {
Controller,
type ControllerFieldState,
type ControllerProps,
type ControllerRenderProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
/** Spread onto the control so label, description and error are wired for assistive tech */
export type FormControlProps = {
id: string
"aria-invalid": boolean
"aria-describedby"?: string
}
/**
* react-hook-form ↔ Field. Renders label, description and the animated
* FieldError, sets `data-invalid` (red label + one shake) and hands the
* control everything it needs:
*
* ```tsx
* <FormField control={form.control} name="email" label="Email"
* render={({ field, control }) => <Input type="email" {...field} {...control} />} />
* ```
*
* Validation timing is RHF's default: errors appear on submit, then update
* as the user types. Focus moves to the first invalid field on submit.
*/
function FormField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
// the schema's output type when a resolver transforms values (e.g. zod `.transform`)
TTransformed = TValues,
>({
label,
description,
orientation,
className,
render,
...controller
}: Omit<ControllerProps<TValues, TName, TTransformed>, "render"> & {
label?: React.ReactNode
description?: React.ReactNode
orientation?: React.ComponentProps<typeof Field>["orientation"]
className?: string
render: (props: {
field: ControllerRenderProps<TValues, TName>
fieldState: ControllerFieldState
control: FormControlProps
}) => React.ReactElement
}) {
const id = React.useId()
const descriptionId = `${id}-description`
const errorId = `${id}-error`
return (
<Controller
{...controller}
render={({ field, fieldState }) => {
const describedBy = [description && descriptionId, fieldState.error && errorId].filter(Boolean).join(" ")
return (
<Field data-invalid={fieldState.invalid} orientation={orientation} className={className}>
{label && <FieldLabel htmlFor={id}>{label}</FieldLabel>}
{render({
field,
fieldState,
control: { id, "aria-invalid": fieldState.invalid, "aria-describedby": describedBy || undefined },
})}
{description && <FieldDescription id={descriptionId}>{description}</FieldDescription>}
<FieldError id={errorId}>{fieldState.error?.message}</FieldError>
</Field>
)
}}
/>
)
}
export { FormField }
lib/validation.ts
import { z } from "zod"
/**
* Shared zod schemas with English messages. Anything not covered here falls back
* to zod's built-in English locale.
*/
z.config(z.locales.en())
/** Required text, trimmed. `required("Customer name")` → "Customer name is required" */
export const required = (label: string) => z.string().trim().min(1, `${label} is required`)
export const email = z
.string()
.trim()
.min(1, "Enter an email")
.pipe(z.email("Enter a valid email"))
/** Thai mobile number. Accepts 081-234-5678, 081 234 5678 or 0812345678; outputs digits only. */
export const thaiMobile = z
.string()
.trim()
.min(1, "Enter a mobile number")
.transform((v) => v.replace(/[\s-]/g, ""))
.pipe(z.string().regex(/^0[689]\d{8}$/, "Mobile number must start with 06, 08 or 09 and have 10 digits"))
/** True when a 13-digit Thai national/tax ID passes its mod-11 check digit */
export function isValidThaiId(id: string) {
if (!/^\d{13}$/.test(id)) return false
const sum = [...id.slice(0, 12)].reduce((acc, d, i) => acc + Number(d) * (13 - i), 0)
return (11 - (sum % 11)) % 10 === Number(id[12])
}
/** Thai 13-digit tax ID (national ID or juristic person number). Dashes/spaces allowed; outputs digits only. */
export const thaiTaxId = z
.string()
.trim()
.min(1, "Enter a tax ID")
.transform((v) => v.replace(/[\s-]/g, ""))
.pipe(
z
.string()
.regex(/^\d{13}$/, "Tax ID must have 13 digits")
.refine(isValidThaiId, "Enter a valid tax ID (check digit doesn't match)")
)
/** Wrap a string schema so an empty input becomes `undefined` — for optional fields */
export const optional = <T extends z.ZodType<unknown, string>>(schema: T) =>
z
.string()
.transform((v) => (v.trim() === "" ? undefined : v))
.pipe(schema.optional())