Form

Ecsight

react-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.

13 หลัก ใส่ขีดได้ เช่น 0-1055-12345-67-8

ติดตั้ง

npx shadcn@latest add @ecsight/form

Source

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())