Form
Ecsightreact-hook-form + zod wired into Field; Thai validators in lib/validation.
Motion: Errors slide open; a field shakes once when it turns invalid; focus jumps to the first error.
ติดตั้ง
npx shadcn@latest add @ecsight/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="อีเมล"
* 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 Thai messages. Anything not covered here falls back
* to zod's built-in Thai locale.
*/
z.config(z.locales.th())
/** Required text, trimmed. `required("ชื่อลูกค้า")` → "กรุณากรอกชื่อลูกค้า" */
export const required = (label: string) => z.string().trim().min(1, `กรุณากรอก${label}`)
export const email = z
.string()
.trim()
.min(1, "กรุณากรอกอีเมล")
.pipe(z.email("รูปแบบอีเมลไม่ถูกต้อง"))
/** Thai mobile number. Accepts 081-234-5678, 081 234 5678 or 0812345678; outputs digits only. */
export const thaiMobile = z
.string()
.trim()
.min(1, "กรุณากรอกเบอร์มือถือ")
.transform((v) => v.replace(/[\s-]/g, ""))
.pipe(z.string().regex(/^0[689]\d{8}$/, "เบอร์มือถือต้องขึ้นต้นด้วย 06, 08 หรือ 09 และมี 10 หลัก"))
/** 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])
}
/** เลขประจำตัวผู้เสียภาษี 13 หลัก (เลขบัตรประชาชนหรือนิติบุคคล). Dashes/spaces allowed; outputs digits only. */
export const thaiTaxId = z
.string()
.trim()
.min(1, "กรุณากรอกเลขประจำตัวผู้เสียภาษี")
.transform((v) => v.replace(/[\s-]/g, ""))
.pipe(
z
.string()
.regex(/^\d{13}$/, "เลขประจำตัวผู้เสียภาษีต้องมี 13 หลัก")
.refine(isValidThaiId, "เลขประจำตัวผู้เสียภาษีไม่ถูกต้อง (ตรวจสอบหลักสุดท้าย)")
)
/** 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())