# บทนำ

> ค้นหาที่อยู่ไทยแบบ fuzzy ด้วยตำบล อำเภอ จังหวัด หรือรหัสไปรษณีย์ ทั้งภาษาไทยและอังกฤษ ไม่มี runtime dependency

Source: https://thai-zip.vercel.app/docs

`thaizip` เป็นไลบรารีค้นหาที่อยู่ไทยแบบ headless มีชุดข้อมูลที่อยู่ทั้งประเทศมาในตัว ค้นหาได้ทั้งบนเบราว์เซอร์และเซิร์ฟเวอร์ และใช้กับเฟรมเวิร์กใดก็ได้

| แพ็กเกจ | ใช้เมื่อ |
| --- | --- |
| `thaizip` | ต้องการ API ค้นหา หรือสร้าง UI เอง (หน้าในส่วนนี้) |
| [`react-thaizip`](https://thai-zip.vercel.app/docs/react) | ต้องการคอมโพเนนต์ React พร้อมใช้ (autocomplete, cascade select, ฟอร์มที่อยู่) ที่คัดลอกลงโปรเจกต์ |

## ติดตั้ง

```npm
npm install thaizip
```

ต้องใช้ Node.js 18 ขึ้นไป React เป็น peer dependency แบบ optional ใช้เฉพาะเมื่อ import `thaizip/react`

| Import | เนื้อหา |
| --- | --- |
| `thaizip` | ฟังก์ชันและ type หลัก (4.4 KB gzip) |
| `thaizip/react` | hook [`useThaiAddressAutocomplete`](https://thai-zip.vercel.app/docs/react-hook) |
| `thaizip/data` | ตัวโหลดชุดข้อมูลที่มากับแพ็กเกจ (2.3 KB gzip และก้อนข้อมูล 114 KB gzip ที่ดึงตอนโหลดครั้งแรก) |

## เริ่มใช้งาน

```ts
import { loadDefaultIndex } from 'thaizip/data'
import { searchThaiAddress, resolveThaiAddress } from 'thaizip'

const index = await loadDefaultIndex()

const [record] = searchThaiAddress(index, 'ลาดพร้าว') // หรือ 'bang rak' หรือ '10500'
const address = resolveThaiAddress(record)
// { subdistrict: 'ลาดพร้าว', district: 'เขตลาดพร้าว', province: 'กรุงเทพมหานคร', postalCode: '10230', … }
```

`loadDefaultIndex()` สร้าง index ครั้งเดียว (ราว 40 ms) แล้ว cache ไว้ ควรเรียกตอนโหลดหน้าหรือ route ไม่ใช่ตอนผู้ใช้กดคีย์แรก

## ใช้กับ AI

ทุกหน้ามีเวอร์ชัน Markdown: เติม `.md` ท้าย URL หรือกดปุ่ม Copy Markdown ด้านบน [`/llms.txt`](https://thai-zip.vercel.app/llms.txt) เป็นสารบัญทุกหน้า และ [`/llms-full.txt`](https://thai-zip.vercel.app/llms-full.txt) รวมทุกหน้าไว้ในไฟล์เดียว

---

# การค้นหา

> searchThaiAddress และ lookupByZipCode ตัวเลือก การจัดอันดับ รหัสไปรษณีย์ และการค้นด้วยภาษาอังกฤษ

Source: https://thai-zip.vercel.app/docs/search

## `searchThaiAddress`

```ts
function searchThaiAddress(index: TrigramIndex, query: string, options?: SearchOptions): ThaiAddressRecord[]
```

ค้นหาด้วยชื่อตำบล อำเภอ หรือจังหวัด ทั้งภาษาไทยและอังกฤษ หรือด้วยรหัสไปรษณีย์ ถ้าไม่พบจะคืน `[]` และไม่ throw

```ts
searchThaiAddress(index, 'ลาดพร้าว', { limit: 5 })
searchThaiAddress(index, 'bang rak')
searchThaiAddress(index, '10500') // ตัวเลขล้วน: ค้นด้วยรหัสไปรษณีย์
```

| ตัวเลือก | Type | ค่าเริ่มต้น | รายละเอียด |
| --- | --- | --- | --- |
| `limit` | `number` | `10` | จำนวนผลลัพธ์สูงสุดของการค้นด้วยข้อความ |
| `threshold` | `number` | `0.4` | คะแนนขั้นต่ำ (0–1) ของการค้นด้วยข้อความ |
| `zipLimit` | `number` | ดู[ด้านล่าง](#รหัสไปรษณีย์) | จำนวนผลลัพธ์สูงสุดเมื่อ query เป็นตัวเลขล้วน |
| `romanizationAliases` | `boolean` | `true` | แปลงคำสะกดภาษาอังกฤษที่ไม่ใช่ RTGS ก่อนค้นหา |

ผลลัพธ์แต่ละรายการเป็น `ThaiAddressRecord` หนึ่งรายการต่อหนึ่งตำบล:

```ts
type ThaiAddressRecord = {
  provinceId: number
  provinceNameTh: string
  provinceNameEn: string
  amphureId: number
  amphureNameTh: string
  amphureNameEn: string
  tambonId: number
  tambonNameTh: string
  tambonNameEn: string
  zipCode: string
}
```

แปลง record เป็น label สำหรับ dropdown หรือเป็นที่อยู่สำหรับบันทึกได้ด้วยฟังก์ชันใน[จัดรูปแบบและแปลงที่อยู่](https://thai-zip.vercel.app/docs/format)

### หลักการจับคู่

query จะถูก normalize ก่อน: ตัดคำนำหน้าที่อยู่ (`จังหวัด`, `อำเภอ`, `ตำบล`, `แขวง`, `เขต` และ `จ.` `อ.` `ต.` `ข.`) และวรรณยุกต์ออก จากนั้นจับคู่แบบ trigram (ชิ้นตัวอักษรละ 3 ตัวที่ซ้อนกัน) จึงยังเจอแม้พิมพ์ผิดหรือพิมพ์ไม่ครบ

ผลลัพธ์เรียงตามคะแนน trigram ก่อน แล้วดูว่า query ตรงกับชื่อของตัวตำบลเองแค่ไหน (ตรงทั้งคำ ขึ้นต้นด้วย แล้วจึงมีอยู่ในชื่อ) สุดท้ายเรียงตามตัวอักษรไทย เกณฑ์ข้อสองทำให้ตำบลลาดพร้าวอยู่เหนือตำบลอื่นในเขตลาดพร้าว

query ที่สั้นกว่า 3 ตัวอักษรจะได้ `[]` และยังไม่รองรับชื่อกับรหัสไปรษณีย์ใน query เดียว (`ลาดพร้าว 10900`) ให้ค้นแยกกัน

### ค้นด้วยภาษาอังกฤษ

ชื่อภาษาอังกฤษในชุดข้อมูลสะกดแบบ RTGS ดังนั้น `bang rak` หรือ `chatuchak` เจอได้ทันที นอกจากนี้มี alias ที่คัดไว้ 87 คำสำหรับคำสะกดที่คนนิยมแต่ไม่ใช่ RTGS เช่น `lardprao` → `lat phrao` และ `krungthep` → `bangkok` เป็นพจนานุกรม ไม่ใช่ตัวแปลงอักษรอัตโนมัติ คำสะกดที่ไม่อยู่ในรายการจะไม่เจอ ปิดได้ด้วย `romanizationAliases: false`

## รหัสไปรษณีย์

query ที่เป็นตัวเลขล้วนตั้งแต่ 2 หลักขึ้นไปจะค้นด้วยรหัสไปรษณีย์ รหัสที่ตรงทั้งหมดขึ้นก่อน ตามด้วยรหัสที่ขึ้นต้นตรงกัน เรียงจากน้อยไปมาก

รหัสไปรษณีย์เดียวอาจครอบคลุมหลายตำบล (`45000` มี 33 ตำบล) รหัสครบ 5 หลักจึง**ไม่**ถูกจำกัดจำนวนตามค่าเริ่มต้น ส่วน prefix 2–4 หลักจะจำกัดที่ `limit` เพราะ `30` อย่างเดียวตรงกับราว 290 ตำบล ส่ง `zipLimit` เพื่อกำหนดเอง หรือ `Infinity` เพื่อเอาทั้งหมด

### `lookupByZipCode`

```ts
function lookupByZipCode(index: TrigramIndex, zip: string, options?: SearchOptions): ThaiAddressRecord[]
```

ค้นด้วยรหัสไปรษณีย์อย่างเดียว ใช้เฉพาะ `zipLimit` ซึ่งค่าเริ่มต้นคือไม่จำกัด รวมถึงกรณี prefix ด้วย

```ts
lookupByZipCode(index, '45000') // ทั้ง 33 ตำบล
lookupByZipCode(index, '450', { zipLimit: 10 })
```

## ฟังก์ชันช่วย

ทั้งสองฟังก์ชันทำงานอยู่ใน `searchThaiAddress` และ export ให้เรียกใช้แยกได้

| ฟังก์ชัน | รายละเอียด |
| --- | --- |
| `normalizeThaiAddressText(input)` | ตัดคำนำหน้าที่อยู่และวรรณยุกต์ แล้วแปลงเป็นตัวพิมพ์เล็ก แบบเดียวกับตอนค้นหา |
| `applyRomanizationAliases(normalized)` | แปลงคำสะกดที่ไม่ใช่ RTGS ที่รู้จักเป็นคำสะกดในชุดข้อมูล เช่น `'lardprao'` → `'lat phrao'` ข้อความภาษาไทยจะคืนค่าเดิม |

---

# รายการแบบลำดับขั้น

> listProvinces, listAmphures และ listTambons สำหรับ dropdown จังหวัด → อำเภอ → ตำบล

Source: https://thai-zip.vercel.app/docs/cascade

ฟังก์ชันชุดนี้แสดงรายการที่อยู่ทีละระดับ สำหรับ dropdown ที่ให้เลือกจังหวัด แล้วอำเภอ แล้วตำบล ข้อมูลอ่านจากการจัดกลุ่มที่สร้างไว้พร้อม index จึงเรียกทุกครั้งที่ค่าเปลี่ยนได้โดยไม่กระทบ performance

```ts
import { listProvinces, listAmphures, listTambons } from 'thaizip'

const provinces = listProvinces(index)            // ทั้ง 77 จังหวัด
const districts = listAmphures(index, provinceId)
const subdistricts = listTambons(index, amphureId) // แต่ละตำบลมี zipCode
```

| ฟังก์ชัน | คืนค่า |
| --- | --- |
| `listProvinces(index)` | `ProvinceSummary[]` เรียงตามชื่อไทย |
| `listAmphures(index, provinceId)` | `AmphureSummary[]` ในจังหวัดนั้น เรียงตามชื่อไทย |
| `listTambons(index, amphureId)` | `TambonSummary[]` ในอำเภอนั้น เรียงตามชื่อไทย |

```ts
type ProvinceSummary = { id: number; nameTh: string; nameEn: string }
type AmphureSummary = { id: number; nameTh: string; nameEn: string; provinceId: number }
type TambonSummary = { id: number; nameTh: string; nameEn: string; amphureId: number; zipCode: string }
```

id ที่ไม่มีอยู่ เช่น `0` ก่อนเลือกอะไร จะได้ `[]` แทนการ throw เมื่อเปลี่ยนตัวเลือกระดับบน ให้ reset id ระดับล่าง แล้วรายการจะว่างเอง

ถ้าต้องการคอมโพเนนต์สำเร็จรูปที่ใช้ฟังก์ชันเหล่านี้ ดู [cascade select](https://thai-zip.vercel.app/docs/react/cascade-select)

---

# จัดรูปแบบและแปลงที่อยู่

> แปลงผลการค้นหาเป็น label สำหรับ dropdown หรือเป็นที่อยู่แบบมีโครงสร้างสำหรับบันทึก

Source: https://thai-zip.vercel.app/docs/format

## `formatThaiAddressSuggestion`

```ts
function formatThaiAddressSuggestion(record: ThaiAddressRecord, options?: { locale?: 'th' | 'en' }): ThaiAddressSuggestion
```

สร้าง label พร้อมแสดงสำหรับรายการใน dropdown

```ts
const suggestions = searchThaiAddress(index, 'ลาดพร้าว').map((r) => formatThaiAddressSuggestion(r))
suggestions[0].label // 'ลาดพร้าว > เขตลาดพร้าว > กรุงเทพมหานคร 10230'

formatThaiAddressSuggestion(record, { locale: 'en' }).label // 'Lat Phrao > Khet Lat Phrao > Bangkok 10230'
```

```ts
type ThaiAddressSuggestion = {
  id: string // String(record.tambonId)
  label: string // labelTh หรือ labelEn เมื่อ locale เป็น 'en'
  labelTh: string
  labelEn: string
  tambon: string
  tambonEn: string
  amphure: string
  amphureEn: string
  province: string
  provinceEn: string
  zipCode: string
}
```

## `resolveThaiAddress`

```ts
function resolveThaiAddress(record: ThaiAddressRecord): ResolvedThaiAddress
```

สร้าง object ที่อยู่สำหรับบันทึก มีค่าเดียวกันในชื่อฟิลด์สองแบบ เลือกแบบที่เข้ากับ schema ของคุณ

```ts
type ResolvedThaiAddress = {
  // แบบไทย
  tambon: string
  tambonEn: string
  amphure: string
  amphureEn: string
  province: string
  provinceEn: string
  zipCode: string
  // แบบอังกฤษ ค่าเดียวกัน
  subdistrict: string
  subdistrictEn: string
  district: string
  districtEn: string
  postalCode: string
}
```

[React hook](https://thai-zip.vercel.app/docs/react-hook) คืนค่านี้ และ[คอมโพเนนต์ของ react-thaizip](https://thai-zip.vercel.app/docs/react) ก็ใช้ค่านี้เป็น value

---

# React hook

> useThaiAddressAutocomplete จาก thaizip/react จัดการ state การค้นหาแบบ debounce สำหรับ UI autocomplete ของคุณเอง

Source: https://thai-zip.vercel.app/docs/react-hook

`useThaiAddressAutocomplete` ครอบ `searchThaiAddress` ด้วย debounce และจัดการ state ของ query กับ suggestions ให้ แนบ `"use client"` มาแล้ว จึงใช้ใน Next.js App Router ได้ทันที ถ้าต้องการคอมโพเนนต์สำเร็จรูป ดู [autocomplete](https://thai-zip.vercel.app/docs/react/autocomplete)

```tsx
import { useState } from 'react'
import { useThaiAddressAutocomplete } from 'thaizip/react'
import type { ResolvedThaiAddress, TrigramIndex } from 'thaizip'

function AddressAutocomplete({ index }: { index: TrigramIndex }) {
  const [selected, setSelected] = useState<ResolvedThaiAddress | null>(null)
  const { query, setQuery, setQuerySilent, suggestions, isOpen, selectSuggestion } =
    useThaiAddressAutocomplete({ index, onSelect: setSelected })

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {isOpen && (
        <ul>
          {suggestions.map((s) => (
            <li key={s.id} onClick={() => selectSuggestion(s) && setQuerySilent(s.label)}>
              {s.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}
```

`index` ต้องไม่เป็น null และเรียก hook แบบมีเงื่อนไขไม่ได้ ถ้าโหลด index แบบ async ให้ render คอมโพเนนต์นี้หลังจาก `loadDefaultIndex()` เสร็จแล้วเท่านั้น

## ตัวเลือก

| ตัวเลือก | Type | ค่าเริ่มต้น | รายละเอียด |
| --- | --- | --- | --- |
| `index` | `TrigramIndex` | ต้องระบุ | index ที่ใช้ค้นหา |
| `debounce` | `number` | `200` | มิลลิวินาทีที่รอหลังหยุดพิมพ์ |
| `limit` | `number` | `10` | ส่งต่อให้ `searchThaiAddress` |
| `threshold` | `number` | `0.4` | ส่งต่อให้ `searchThaiAddress` |
| `zipLimit` | `number` | — | ส่งต่อให้ `searchThaiAddress` |
| `locale` | `'th' \| 'en'` | `'th'` | ภาษาของ `label` ในแต่ละ suggestion |
| `initialQuery` | `string` | `''` | ข้อความเริ่มต้นในช่อง ไม่ทำให้เกิดการค้นหา |
| `onSelect` | `(address: ResolvedThaiAddress) => void` | — | เรียกเมื่อ `selectSuggestion` ได้ที่อยู่ |

## ค่าที่คืน

| ค่า | รายละเอียด |
| --- | --- |
| `query` | ข้อความปัจจุบันในช่อง |
| `setQuery(value)` | เปลี่ยนข้อความแล้วค้นหาหลัง debounce |
| `setQuerySilent(value)` | เปลี่ยนข้อความโดยไม่ค้นหาและไม่เปิด dropdown เช่น ใช้แสดง label ที่เลือก |
| `suggestions` | `ThaiAddressSuggestion[]` จากการค้นหาล่าสุด |
| `isOpen` | `true` เมื่อมี query และมี suggestion อย่างน้อยหนึ่งรายการ |
| `selectSuggestion(item)` | แปลง suggestion เป็น `ResolvedThaiAddress` เรียก `onSelect` แล้วคืนค่านั้น (หรือ `null` ถ้า item เก่าแล้ว) ไม่เปลี่ยน `query` |
| `clear()` | ล้าง `query` และ `suggestions` |

---

# ข้อมูลและ index

> โหลดชุดข้อมูลที่มากับแพ็กเกจ หรือสร้าง index จากข้อมูลของคุณเอง

Source: https://thai-zip.vercel.app/docs/data

ทุกฟังก์ชันรับ `TrigramIndex` สร้างได้จากชุดข้อมูลที่มากับแพ็กเกจ หรือจากตารางข้อมูลของคุณเอง ให้มองเป็นค่าทึบ ไม่ต้องสร้างหรือแก้ไขเอง

## ชุดข้อมูลที่มากับแพ็กเกจ

`thaizip/data` มีชุดข้อมูลทั้งประเทศ: 77 จังหวัด พร้อมอำเภอ ตำบล และรหัสไปรษณีย์ ก้อนข้อมูลจะถูกดาวน์โหลดเมื่อเรียก `loadDefaultIndex()` ครั้งแรกเท่านั้น สำหรับ bundler ที่แยก chunk ตาม dynamic import (webpack, Turbopack, Vite)

```ts
import { loadDefaultIndex, getDefaultIndexIfLoaded, clearDefaultIndex } from 'thaizip/data'
```

| ฟังก์ชัน | รายละเอียด |
| --- | --- |
| `loadDefaultIndex()` | คืน `Promise<TrigramIndex>` ครั้งแรกจะสร้าง index (ราว 40 ms) ครั้งต่อไปและการเรียกพร้อมกันจะได้ index เดียวกันที่ cache ไว้ |
| `getDefaultIndexIfLoaded()` | คืน index ถ้าสร้างเสร็จแล้ว ไม่เช่นนั้นคืน `null` เป็นแบบ synchronous UI จึงข้ามสถานะกำลังโหลดได้เมื่อ index พร้อมอยู่แล้ว ไม่เริ่มการโหลดเอง |
| `clearDefaultIndex()` | ล้าง index ที่ cache ไว้ เช่น ระหว่างเทสต์ |

## ข้อมูลของคุณเอง

ใช้ `buildThaiAddressIndex` เมื่อมีชุดข้อมูลที่ใหม่กว่า ต้องการแค่บางจังหวัด หรือต้องการ record ของคุณเอง

```ts
import { buildThaiAddressIndex } from 'thaizip'

const index = buildThaiAddressIndex(
  { provinces, amphures, tambons },
  { onSkip: (tambon) => console.warn('skipped', tambon.name_th) },
)
```

ตารางใช้ฟิลด์แบบ snake_case ตามข้อมูลต้นทางของภาครัฐ:

```ts
type RawData = {
  provinces: { id: number; name_th: string; name_en: string; geography_id: number; deleted_at: string | null }[]
  amphures: { id: number; name_th: string; name_en: string; province_id: number; deleted_at: string | null }[]
  tambons: { id: number; zip_code: number | string; name_th: string; name_en: string; amphure_id: number; deleted_at: string | null }[]
  geographies?: { id: number; name: string; deleted_at: string | null }[] // ไม่ได้ใช้
}
```

แถวที่ `deleted_at` ไม่เป็น null จะถูกข้าม ตำบลที่อำเภอหรือจังหวัดไม่มีอยู่หรือถูกลบจะถูกข้ามด้วย และส่งให้ `onSkip`

| ตัวเลือก | ค่าเริ่มต้น | รายละเอียด |
| --- | --- | --- |
| `onSkip` | — | เรียกสำหรับแต่ละตำบลที่ถูกข้าม |
| `validate` | `true` | ตรวจชนิดของฟิลด์ก่อนสร้าง แทบไม่เสียเวลา ควรเปิดไว้สำหรับข้อมูลที่ไม่ได้สร้างเอง |

ถ้าต้องการตรวจข้อมูลแยกต่างหาก เช่น ใน build step ให้เรียก `validateRawData(data)` ซึ่งจะ throw `TypeError` ที่ระบุตาราง id ของแถว ฟิลด์ และชนิดที่เจอจริง เช่น `[thaizip] province 12: expected string for name_th, got number`

---

# เริ่มต้นใช้งาน

> Scaffold คอมโพเนนต์ที่อยู่ไทยพร้อมใช้ลงในโปรเจกต์ React หรือ Next.js แบบเดียวกับ shadcn/ui

Source: https://thai-zip.vercel.app/docs/react

react-thaizip เป็น CLI ไม่ใช่ไลบรารีคอมโพเนนต์ที่ติดตั้งเป็น dependency มันคัดลอกซอร์สของคอมโพเนนต์ลงในโปรเจกต์ของคุณแบบเดียวกับ [shadcn/ui](https://ui.shadcn.com/) ไฟล์ที่ได้จึงเป็นโค้ดของคุณและแก้ได้เต็มที่

## สิ่งที่ต้องมี

Node.js 18 ขึ้นไป และโปรเจกต์ React หรือ Next.js ที่ติดตั้ง Tailwind CSS v3 หรือ v4 ไว้แล้ว `init` ตรวจหา Tailwind แต่จะไม่ติดตั้งให้

## ตั้งค่า

```npm
npx react-thaizip init
```

`init` ตรวจโครงสร้างโปรเจกต์ package manager, TypeScript, Tailwind และ shadcn/ui เพิ่ม design tokens ลง global stylesheet ติดตั้ง `thaizip` แล้วสร้าง `thaizip.config.json` ถ้ามี `components.json` ของ shadcn/ui คอมโพเนนต์จะประกอบจาก primitives ของคุณเอง (Base UI, Radix หรือ React Aria) ถ้าไม่มีจะได้แบบ vanilla Base UI สำหรับ Tailwind v3 จะพิมพ์ `theme.extend` snippet ให้นำไปเพิ่มเอง

## เพิ่มคอมโพเนนต์

```npm
npx react-thaizip add autocomplete
```

```text
components/
  thai-address-autocomplete.tsx
hooks/
  use-thai-address-index.ts
lib/
  utils.ts
```

`add` ติดตั้ง dependency ที่ขาดและพิมพ์ import path ให้ ใช้ path นั้น ตัวอย่างในเอกสารนี้ใช้ alias `@/components/...`

```tsx
import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export function AddressField() {
  return <ThaiAddressAutocomplete onValueChange={(address) => console.log(address)} />
}
```

## คอมโพเนนต์

<Cards>
  <Card title="ค้นหาที่อยู่ (autocomplete)" href="https://thai-zip.vercel.app/docs/react/autocomplete">
    พิมพ์ค้นหาด้วยตำบล อำเภอ จังหวัด หรือรหัสไปรษณีย์
  </Card>
  <Card title="เลือกที่อยู่ตามลำดับ (cascade select)" href="https://thai-zip.vercel.app/docs/react/cascade-select">
    เลือกจังหวัด → อำเภอ → ตำบล ตามลำดับ
  </Card>
  <Card title="ฟอร์มที่อยู่เต็ม" href="https://thai-zip.vercel.app/docs/react/address-form">
    บ้านเลขที่ หมู่ ซอย ถนน พร้อมลำดับที่อยู่
  </Card>
  <Card title="แสดงที่อยู่" href="https://thai-zip.vercel.app/docs/react/address-display">
    แสดงที่อยู่ที่บันทึกไว้แบบอ่านอย่างเดียว
  </Card>
  <Card title="ฟิลด์สำหรับ react-hook-form" href="https://thai-zip.vercel.app/docs/react/address-form-field">
    cascade select ที่ผูกกับ react-hook-form
  </Card>
</Cards>

---

# ค้นหาที่อยู่แบบ autocomplete

> ค้นหาที่อยู่ไทยจากข้อความที่พิมพ์ พร้อมค่าที่อยู่แบบมีโครงสร้าง

Source: https://thai-zip.vercel.app/docs/react/autocomplete

`ThaiAddressAutocomplete` เหมาะกับหน้าฟอร์มที่ผู้ใช้ต้องการค้นหาที่อยู่ด้วยตำบล อำเภอ/เขต จังหวัด หรือรหัสไปรษณีย์

```tsx title="autocomplete-demo.tsx"
'use client'

import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteDemo() {
  return <ThaiAddressAutocomplete onValueChange={(address) => console.log(address)} />
}
```

## ติดตั้ง

**CLI**

```bash
npx react-thaizip add autocomplete
```

CLI เลือกไฟล์ให้ตาม style ที่ `init` บันทึกไว้: โปรเจกต์ที่มี `components.json` ของ shadcn/ui จะได้ไฟล์ที่ประกอบจาก primitives ของ shadcn (Base UI, Radix หรือ React Aria) นอกนั้นได้แบบ vanilla

**ติดตั้งเอง — Vanilla**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip @base-ui/react`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `components/thai-address-autocomplete.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/thai-address-autocomplete.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)
1. แบบ vanilla ต้องมี design tokens ใน global stylesheet ด้วย ดู [การปรับแต่ง](https://thai-zip.vercel.app/docs/react/customization#design-tokens)

**ติดตั้งเอง — shadcn/ui · Base UI**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip`
1. เพิ่ม shadcn/ui primitives ที่คอมโพเนนต์ใช้: `npx shadcn@latest add combobox input-group input button`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `components/thai-address-autocomplete.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/base/thai-address-autocomplete.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)

**ติดตั้งเอง — shadcn/ui · Radix**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip`
1. เพิ่ม shadcn/ui primitives ที่คอมโพเนนต์ใช้: `npx shadcn@latest add combobox input-group input button`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `components/thai-address-autocomplete.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/radix/thai-address-autocomplete.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)

**ติดตั้งเอง — shadcn/ui · React Aria**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip`
1. เพิ่ม shadcn/ui primitives ที่คอมโพเนนต์ใช้: `npx shadcn@latest add combobox input-group input button`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `components/thai-address-autocomplete.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/aria/thai-address-autocomplete.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)

## วิธีใช้

```tsx
import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'
```

```tsx
<ThaiAddressAutocomplete onValueChange={(address) => console.log(address)} />
```

ค่าใน `onValueChange` คือ `ResolvedThaiAddress | null` เมื่อผู้ใช้เลือกคำแนะนำจะได้ที่อยู่แบบมีโครงสร้าง และการพิมพ์ให้ข้อความว่างจะล้างที่อยู่ที่เลือกอยู่แล้วด้วยค่า `null`

## ตัวอย่าง

### ค้นหาภาษาอังกฤษ

ใช้ `locale="en"` เพื่อแสดงป้ายกำกับ คำใบ้ และผลลัพธ์เป็นภาษาอังกฤษ ข้อมูลที่ส่งกลับยังมีชื่อทั้งไทยและอังกฤษ

```tsx title="autocomplete-en.tsx"
'use client'

import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteEn() {
  return <ThaiAddressAutocomplete locale="en" onValueChange={(address) => console.log(address)} />
}
```

### ป้ายกำกับ

คอมโพเนนต์ไม่มีป้ายกำกับในตัว ครอบด้วย `<label>` เพื่อให้ช่องมีชื่อที่ screen reader อ่านได้ และเพิ่มคำอธิบายไว้ด้านล่าง

```tsx title="autocomplete-label.tsx"
import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteLabel() {
  return (
    <div className="flex flex-col gap-2">
      <label className="flex flex-col gap-2 text-sm font-medium">
        ที่อยู่จัดส่ง
        <ThaiAddressAutocomplete />
      </label>
      <p className="text-sm text-muted-foreground">พิมพ์ตำบล อำเภอ หรือรหัสไปรษณีย์ แล้วเลือกจากรายการ</p>
    </div>
  )
}
```

### ควบคุมค่าจากภายนอก

ส่ง `value` เพื่อควบคุมค่า หรือส่ง `defaultValue` เพื่อตั้งค่าเริ่มต้นสำหรับโหมด uncontrolled ปุ่มในตัวอย่างนี้ล้างค่าที่ควบคุมอยู่และแสดงผลลัพธ์ที่ callback ส่งกลับมา

```tsx title="autocomplete-controlled.tsx"
'use client'

import { useState } from 'react'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteControlled() {
  const [address, setAddress] = useState<ResolvedThaiAddress | null>(null)

  return (
    <div className="flex flex-col gap-3">
      <ThaiAddressAutocomplete value={address} onValueChange={setAddress} />
      <div>
        <button type="button" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent" onClick={() => setAddress(null)}>
          ล้างค่า
        </button>
      </div>
      <p className="text-sm font-medium">ค่าปัจจุบัน</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(address, null, 2)}</pre>
    </div>
  )
}
```

### ปิดใช้งาน

ส่ง `disabled` เพื่อปิดช่องค้นหา

```tsx title="autocomplete-disabled.tsx"
'use client'

import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteDisabled() {
  return <ThaiAddressAutocomplete disabled />
}
```

### ไม่ผ่านการตรวจสอบ

ส่ง `aria-invalid` เพื่อแสดงว่าช่องนี้ไม่ผ่านการตรวจสอบ เช่นหลังตรวจฟอร์มไม่ผ่าน

```tsx title="autocomplete-invalid.tsx"
'use client'

import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteInvalid() {
  return <ThaiAddressAutocomplete aria-invalid />
}
```

### ปรับข้อความ

ใช้ `texts` เพื่อแทนที่ placeholder และข้อความตอนโหลด/ผิดพลาด/ไม่พบผลลัพธ์ คีย์ที่ไม่ได้ส่งจะใช้ค่าเริ่มต้นตาม `locale`

```tsx title="autocomplete-texts.tsx"
import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteTexts() {
  return (
    <ThaiAddressAutocomplete
      texts={{
        placeholder: 'ค้นหาจากรหัสไปรษณีย์ เช่น 10110',
        emptyText: 'ไม่พบที่อยู่นี้ ลองพิมพ์ชื่ออำเภอแทน',
      }}
    />
  )
}
```

### ปรับสไตล์

แต่ละส่วนของคอมโพเนนต์มี class prop ของตัวเอง (ดู [API Reference](#api-reference)) class ที่ส่งจะรวมด้วย `cn` จึงทับค่าเริ่มต้นได้

```tsx title="autocomplete-styled.tsx"
import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteStyled() {
  return (
    <ThaiAddressAutocomplete
      inputClassName="rounded-full"
      popupClassName="rounded-2xl"
      itemClassName="rounded-xl"
    />
  )
}
```

### ใช้ในฟอร์ม

ส่ง `name` เพื่อส่งที่อยู่ที่เลือกไปกับฟอร์มปกติ คอมโพเนนต์จะสร้าง hidden inputs สี่ช่อง (`address-subdistrict`, `address-district`, `address-province`, `address-zipcode`) และ `required` จะกันไม่ให้ส่งฟอร์มจนกว่าจะเลือกที่อยู่

```tsx title="form-autocomplete.tsx"
'use client'

import { useRef, useState, type FormEvent } from 'react'
import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function FormAutocomplete() {
  const ref = useRef<HTMLInputElement>(null)
  const [payload, setPayload] = useState<Record<string, FormDataEntryValue> | null>(null)

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setPayload(Object.fromEntries(new FormData(event.currentTarget)))
  }

  return (
    <form className="flex flex-col gap-3" onSubmit={handleSubmit}>
      <ThaiAddressAutocomplete
        ref={ref}
        name="address"
        required
      />
      <div className="flex gap-2">
        <button type="button" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent" onClick={() => ref.current?.focus()}>
          โฟกัสช่องที่อยู่
        </button>
        <button type="submit" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent">
          ส่งฟอร์ม
        </button>
      </div>
      <p className="text-sm font-medium">FormData ที่ส่ง</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(payload, null, 2)}</pre>
    </form>
  )
}
```

ชื่อพื้นที่ใน hidden inputs เป็นภาษาไทยเสมอ แม้ UI จะแสดงภาษาอังกฤษ ถ้า `disabled` hidden inputs จะถูก disabled ด้วยและไม่อยู่ใน `FormData` ถ้าใช้ไลบรารีฟอร์ม ให้เก็บ `ResolvedThaiAddress | null` ไว้ใน state ของฟอร์มแล้วส่งกลับผ่าน `value` เพื่อให้ validation, reset และ submit ใช้ค่าเดียวกัน

## API Reference

| Prop | Type | ค่าเริ่มต้น | รายละเอียด |
| --- | --- | --- | --- |
| `value` | `ResolvedThaiAddress \| null` | — | ค่าที่ควบคุมอยู่; ส่ง `null` เพื่อล้าง |
| `defaultValue` | `ResolvedThaiAddress \| null` | — | ค่าเริ่มต้นของโหมด uncontrolled และเติมข้อความค้นหาเริ่มต้น |
| `onValueChange` | `(address: ResolvedThaiAddress \| null) => void` | — | เรียกเมื่อเลือกหรือล้างที่อยู่ |
| `name` | `string` | — | สร้าง hidden inputs สี่ช่อง: `<name>-subdistrict`, `-district`, `-province`, `-zipcode` |
| `locale` | `'th' \| 'en'` | `'th'` | ภาษาของผลลัพธ์และข้อความเริ่มต้น |
| `texts` | `Partial<ThaiAddressAutocompleteTexts>` | — | แทนที่ `placeholder`, `clearAriaLabel`, `loadingText`, `errorText`, `retryLabel`, `emptyText` |
| `indexLoad` | `'mount' \| 'visible'` | `'mount'` | โหลดข้อมูลที่อยู่ตอน mount หรือรอจนช่องเลื่อนเข้ามาในจอ |
| `limit` | `number` | `10` | จำนวนคำแนะนำสูงสุด |
| `debounce` | `number` | `200` | ระยะหน่วงก่อนค้นหา (มิลลิวินาที) |
| `threshold` | `number` | `0.4` | คะแนนความใกล้เคียงขั้นต่ำ (0–1) ที่คำแนะนำจะถูกแสดง |
| `disabled` | `boolean` | `false` | ปิดใช้งานช่องค้นหา |
| `required` | `boolean` | — | กำหนดให้ช่องต้องมีค่า |
| `aria-invalid` | `React.AriaAttributes['aria-invalid']` | — | ระบุสถานะไม่ผ่านการตรวจสอบ |
| `onBlur` | `React.FocusEventHandler<HTMLInputElement>` | — | ทำงานเมื่อ input สูญเสียโฟกัส |
| `onError` | `(error: Error) => void` | — | ทำงานเมื่อโหลดดัชนีที่อยู่ไม่สำเร็จ |
| `className` | `string` | — | wrapper หลัก |
| `inputClassName` | `string` | — | ช่องพิมพ์ค้นหา |
| `popupClassName` | `string` | — | กล่องผลลัพธ์ |
| `itemClassName` | `string` | — | แต่ละผลลัพธ์ |
| `ref` | `React.Ref<HTMLInputElement>` | — | ส่งต่อไปยัง input |

`limit`, `debounce` และ `threshold` ส่งต่อตรงไปยัง hook `useThaiAddressAutocomplete` ของ thaizip

---

# เลือกที่อยู่แบบลำดับชั้น

> เลือกจังหวัด อำเภอหรือเขต และตำบลหรือแขวงตามลำดับ

Source: https://thai-zip.vercel.app/docs/react/cascade-select

`ThaiAddressCascadeSelect` เหมาะกับฟอร์มที่ต้องการให้ผู้ใช้เลือกที่อยู่ตามลำดับ จังหวัด → อำเภอ/เขต → ตำบล/แขวง

```tsx title="cascade-select-demo.tsx"
'use client'

import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'

export default function CascadeSelectDemo() {
  return <ThaiAddressCascadeSelect onValueChange={(address) => console.log(address)} />
}
```

## ติดตั้ง

**CLI**

```bash
npx react-thaizip add cascade-select
```

CLI เลือกไฟล์ให้ตาม style ที่ `init` บันทึกไว้: โปรเจกต์ที่มี `components.json` ของ shadcn/ui จะได้ไฟล์ที่ประกอบจาก primitives ของ shadcn (Base UI, Radix หรือ React Aria) นอกนั้นได้แบบ vanilla

**ติดตั้งเอง — Vanilla**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip @base-ui/react`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/thai-address-cascade-select.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)
1. แบบ vanilla ต้องมี design tokens ใน global stylesheet ด้วย ดู [การปรับแต่ง](https://thai-zip.vercel.app/docs/react/customization#design-tokens)

**ติดตั้งเอง — shadcn/ui · Base UI**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip`
1. เพิ่ม shadcn/ui primitives ที่คอมโพเนนต์ใช้: `npx shadcn@latest add select label button input`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/base/thai-address-cascade-select.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)

**ติดตั้งเอง — shadcn/ui · Radix**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip`
1. เพิ่ม shadcn/ui primitives ที่คอมโพเนนต์ใช้: `npx shadcn@latest add select label button input`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/radix/thai-address-cascade-select.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)

**ติดตั้งเอง — shadcn/ui · React Aria**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip`
1. เพิ่ม shadcn/ui primitives ที่คอมโพเนนต์ใช้: `npx shadcn@latest add select label button input`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/aria/thai-address-cascade-select.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)

## วิธีใช้

```tsx
import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'
```

```tsx
<ThaiAddressCascadeSelect onValueChange={(address) => console.log(address)} />
```

`onValueChange` จะส่ง `ResolvedThaiAddress | null` เมื่อเลือกครบทั้งสามระดับ

### เมื่อเปลี่ยนตัวเลือกหลัก

เมื่อเลือกที่อยู่ครบแล้ว การเปลี่ยนจังหวัดหรืออำเภอ/เขตจะเรียก `onValueChange(null)` และล้างตัวเลือกปลายทางที่ไม่สอดคล้องกัน ผู้ใช้จึงต้องเลือกตัวเลือกที่อยู่ถัดไปใหม่

## ตัวอย่าง

### เลือกข้อมูลภาษาอังกฤษ

ใช้ `locale="en"` เพื่อแสดงป้ายและตัวเลือกเป็นภาษาอังกฤษ

```tsx title="cascade-select-en.tsx"
'use client'

import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'

export default function CascadeSelectEn() {
  return <ThaiAddressCascadeSelect locale="en" onValueChange={(address) => console.log(address)} />
}
```

### ควบคุมค่าจากภายนอก

ส่ง `value` เพื่อควบคุมค่าทั้งชุด หรือ `defaultValue` เพื่อตั้งลำดับที่เลือกไว้ตอนเริ่มต้น ตัวอย่างนี้แสดงค่า callback และมีปุ่มล้างค่า

```tsx title="cascade-select-controlled.tsx"
'use client'

import { useState } from 'react'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'

export default function CascadeSelectControlled() {
  const [address, setAddress] = useState<ResolvedThaiAddress | null>(null)

  return (
    <div className="flex flex-col gap-3">
      <ThaiAddressCascadeSelect value={address} onValueChange={setAddress} />
      <div>
        <button type="button" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent" onClick={() => setAddress(null)}>
          ล้างค่า
        </button>
      </div>
      <p className="text-sm font-medium">ค่าปัจจุบัน</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(address, null, 2)}</pre>
    </div>
  )
}
```

### ปิดใช้งาน

ส่ง `disabled` เพื่อปิดทุก select

```tsx title="cascade-select-disabled.tsx"
'use client'

import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'

export default function CascadeSelectDisabled() {
  return <ThaiAddressCascadeSelect disabled />
}
```

### ไม่ผ่านการตรวจสอบ

ส่ง `aria-invalid` เพื่อทำเครื่องหมายว่าไม่ผ่านการตรวจสอบทั้งสาม trigger

```tsx title="cascade-select-invalid.tsx"
'use client'

import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'

export default function CascadeSelectInvalid() {
  return <ThaiAddressCascadeSelect aria-invalid />
}
```

### ปรับข้อความ

ใช้ `texts` เพื่อแทนที่ป้ายกำกับและ placeholder เช่นให้ตรงกับคำว่าเขต/แขวงของกรุงเทพฯ คีย์ที่ไม่ได้ส่งจะใช้ค่าเริ่มต้นตาม `locale`

```tsx title="cascade-select-texts.tsx"
import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'

export default function CascadeSelectTexts() {
  return (
    <ThaiAddressCascadeSelect
      texts={{
        provinceLabel: 'จังหวัดที่จัดส่ง',
        provincePlaceholder: 'เลือกจังหวัด',
        districtLabel: 'เขต/อำเภอ',
        subdistrictLabel: 'แขวง/ตำบล',
      }}
    />
  )
}
```

### ใช้ในฟอร์ม

ส่ง `name` เพื่อส่งค่าที่เลือกไปกับฟอร์มปกติ คอมโพเนนต์จะสร้าง hidden inputs สี่ช่อง (`address-subdistrict`, `address-district`, `address-province`, `address-zipcode`) และ `required` จะกันไม่ให้ส่งฟอร์มจนกว่าจะเลือกครบทั้งสามระดับ

```tsx title="form-cascade-select.tsx"
'use client'

import { useRef, useState, type FormEvent } from 'react'
import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'

export default function FormCascadeSelect() {
  const ref = useRef<HTMLButtonElement>(null)
  const [payload, setPayload] = useState<Record<string, FormDataEntryValue> | null>(null)

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setPayload(Object.fromEntries(new FormData(event.currentTarget)))
  }

  return (
    <form className="flex flex-col gap-3" onSubmit={handleSubmit}>
      <ThaiAddressCascadeSelect
        ref={ref}
        name="address"
        required
      />
      <div className="flex gap-2">
        <button type="button" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent" onClick={() => ref.current?.focus()}>
          โฟกัสช่องที่อยู่
        </button>
        <button type="submit" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent">
          ส่งฟอร์ม
        </button>
      </div>
      <p className="text-sm font-medium">FormData ที่ส่ง</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(payload, null, 2)}</pre>
    </form>
  )
}
```

cascade select จะไม่ส่ง resolved value จนกว่าจะเลือกครบทั้งสามระดับ ระหว่างที่เลือกจังหวัด/อำเภอไว้บางส่วน คอมโพเนนต์เก็บสถานะนั้นไว้เอง การส่ง `value={null}` จะล้าง selection ที่ครบแล้ว แต่คงการเลือกบางส่วนไว้ ถ้าการ reset ฟอร์มต้องล้างการเลือกบางส่วนด้วย ให้ remount ด้วย key:

```tsx
<ThaiAddressCascadeSelect key={resetVersion} name="address" value={address} onValueChange={setAddress} />
```

## API Reference

| Prop | Type | ค่าเริ่มต้น | รายละเอียด |
| --- | --- | --- | --- |
| `value` | `ResolvedThaiAddress \| null` | — | ค่าที่ควบคุมอยู่; ส่ง `null` เพื่อล้าง |
| `defaultValue` | `ResolvedThaiAddress \| null` | — | ค่าเริ่มต้นของโหมด uncontrolled และเลือกลำดับที่อยู่ให้ครบตั้งแต่เริ่ม |
| `onValueChange` | `(address: ResolvedThaiAddress \| null) => void` | — | เรียกเมื่อเลือกครบหรือเมื่อค่าถูกล้าง |
| `name` | `string` | — | สร้าง hidden inputs สี่ช่อง: `<name>-subdistrict`, `-district`, `-province`, `-zipcode` |
| `locale` | `'th' \| 'en'` | `'th'` | ภาษาของป้ายและตัวเลือก |
| `texts` | `Partial<ThaiAddressCascadeSelectTexts>` | — | แทนที่ข้อความ `*Label`/`*Placeholder` ของแต่ละระดับ รวมถึง `loadingText`, `errorText`, `retryLabel` |
| `indexLoad` | `'mount' \| 'visible'` | `'mount'` | โหลดข้อมูลที่อยู่ตอน mount หรือรอจนช่องเลื่อนเข้ามาในจอ |
| `disabled` | `boolean` | `false` | ปิดใช้งานทุก select |
| `required` | `boolean` | — | กำหนดให้ทั้งสาม select ต้องมีค่า |
| `aria-invalid` | `React.AriaAttributes['aria-invalid']` | — | ทำเครื่องหมายว่าไม่ผ่านการตรวจสอบทั้งสาม trigger |
| `onBlur` | `React.FocusEventHandler<HTMLButtonElement>` | — | ทำงานเมื่อ trigger ของจังหวัดสูญเสียโฟกัส |
| `onError` | `(error: Error) => void` | — | ทำงานเมื่อโหลดดัชนีที่อยู่ไม่สำเร็จ |
| `className` | `string` | — | grid wrapper หลัก |
| `labelClassName` | `string` | — | ป้ายกำกับของทุกช่อง |
| `triggerClassName` | `string` | — | trigger ของ select และช่องรหัสไปรษณีย์ |
| `popupClassName` | `string` | — | กล่องตัวเลือก |
| `itemClassName` | `string` | — | แต่ละตัวเลือก |
| `ref` | `React.Ref<HTMLButtonElement>` | — | ส่งต่อไปยัง trigger ของจังหวัด ซึ่งเป็นตัวควบคุมหลักของ cascade |

ทั้ง `ref` และ `onBlur` ผูกกับ trigger ของจังหวัด ไม่ใช่ trigger ของอำเภอหรือตำบล

---

# ฟอร์มที่อยู่แบบเต็ม

> กรอกบ้านเลขที่ หมู่ ซอย ถนน พร้อมเลือกที่อยู่ตามลำดับชั้นในฟอร์มเดียว

Source: https://thai-zip.vercel.app/docs/react/address-form

`ThaiAddressForm` เหมาะกับฟอร์มที่ต้องการที่อยู่แบบเต็ม คือบ้านเลขที่/หมู่/ซอย/ถนน บวกกับตำบล อำเภอ/เขต และจังหวัด โดยฝัง [cascade select](https://thai-zip.vercel.app/docs/react/cascade-select) ไว้ภายในให้แล้ว

```tsx title="address-form-demo.tsx"
'use client'

import { ThaiAddressForm } from '@/components/thai-address-form'

export default function AddressFormDemo() {
  return <ThaiAddressForm onValueChange={(address) => console.log(address)} />
}
```

## ติดตั้ง

**CLI**

```bash
npx react-thaizip add address-form
```

CLI เลือกไฟล์ให้ตาม style ที่ `init` บันทึกไว้: โปรเจกต์ที่มี `components.json` ของ shadcn/ui จะได้ไฟล์ที่ประกอบจาก primitives ของ shadcn (Base UI, Radix หรือ React Aria) นอกนั้นได้แบบ vanilla

**ติดตั้งเอง — Vanilla**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip @base-ui/react`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/thai-address-cascade-select.tsx)
   - `components/thai-address-form.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/thai-address-form.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)
1. แบบ vanilla ต้องมี design tokens ใน global stylesheet ด้วย ดู [การปรับแต่ง](https://thai-zip.vercel.app/docs/react/customization#design-tokens)

**ติดตั้งเอง — shadcn/ui · Base UI**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip`
1. เพิ่ม shadcn/ui primitives ที่คอมโพเนนต์ใช้: `npx shadcn@latest add select label button input`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/base/thai-address-cascade-select.tsx)
   - `components/thai-address-form.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/base/thai-address-form.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)

**ติดตั้งเอง — shadcn/ui · Radix**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip`
1. เพิ่ม shadcn/ui primitives ที่คอมโพเนนต์ใช้: `npx shadcn@latest add select label button input`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/radix/thai-address-cascade-select.tsx)
   - `components/thai-address-form.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/radix/thai-address-form.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)

**ติดตั้งเอง — shadcn/ui · React Aria**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip`
1. เพิ่ม shadcn/ui primitives ที่คอมโพเนนต์ใช้: `npx shadcn@latest add select label button input`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/aria/thai-address-cascade-select.tsx)
   - `components/thai-address-form.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/aria/thai-address-form.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)

## วิธีใช้

```tsx
import { ThaiAddressForm } from '@/components/thai-address-form'
```

```tsx
<ThaiAddressForm onValueChange={(address) => console.log(address)} />
```

ค่าใน `onValueChange` คือ `FullThaiAddress | null` จะไม่เป็น `null` ก็ต่อเมื่อกรอกบ้านเลขที่แล้วและเลือกที่อยู่ตามลำดับชั้นครบทั้งสามระดับ

## ตัวอย่าง

### กรอกข้อมูลภาษาอังกฤษ

ใช้ `locale="en"` เพื่อแสดงป้ายกำกับและตัวเลือกเป็นภาษาอังกฤษ ทั้งฟิลด์ข้อความของตัวเองและ cascade select ที่ฝังอยู่

```tsx title="address-form-en.tsx"
'use client'

import { ThaiAddressForm } from '@/components/thai-address-form'

export default function AddressFormEn() {
  return <ThaiAddressForm locale="en" onValueChange={(address) => console.log(address)} />
}
```

### ควบคุมค่าจากภายนอก

ส่ง `value` เพื่อควบคุมค่าทั้งชุด หรือ `defaultValue` เพื่อตั้งค่าเริ่มต้น การเลือกที่อยู่ตามลำดับชั้นที่ยังเลือกไม่ครบจะไม่ถูกล้าง แม้ `value` ที่ควบคุมอยู่จะเป็น `null`

```tsx title="address-form-controlled.tsx"
'use client'

import { useState } from 'react'
import { ThaiAddressForm, type FullThaiAddress } from '@/components/thai-address-form'

export default function AddressFormControlled() {
  const [address, setAddress] = useState<FullThaiAddress | null>(null)

  return (
    <div className="flex flex-col gap-3">
      <ThaiAddressForm value={address} onValueChange={setAddress} />
      <div>
        <button type="button" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent" onClick={() => setAddress(null)}>
          ล้างค่า
        </button>
      </div>
      <p className="text-sm font-medium">ค่าปัจจุบัน</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(address, null, 2)}</pre>
    </div>
  )
}
```

### ปิดใช้งาน

ส่ง `disabled` เพื่อปิดทุกช่องกรอกและทุก select

```tsx title="address-form-disabled.tsx"
'use client'

import { ThaiAddressForm } from '@/components/thai-address-form'

export default function AddressFormDisabled() {
  return <ThaiAddressForm disabled />
}
```

### ไม่ผ่านการตรวจสอบ

ส่ง `aria-invalid` เพื่อทำเครื่องหมายว่าช่องกรอกทั้งสี่และ trigger ของ cascade ไม่ผ่านการตรวจสอบ

```tsx title="address-form-invalid.tsx"
'use client'

import { ThaiAddressForm } from '@/components/thai-address-form'

export default function AddressFormInvalid() {
  return <ThaiAddressForm aria-invalid />
}
```

### ปรับข้อความ

ใช้ `texts` สำหรับป้ายกำกับและ placeholder ของคอมโพเนนต์นี้ และ `cascadeTexts` สำหรับ cascade select ที่ฝังอยู่

```tsx title="address-form-texts.tsx"
import { ThaiAddressForm } from '@/components/thai-address-form'

export default function AddressFormTexts() {
  return (
    <ThaiAddressForm
      texts={{
        houseNoLabel: 'บ้านเลขที่ / ห้อง',
        houseNoPlaceholder: 'เช่น 99/9 ห้อง 1203',
        streetLabel: 'อาคาร / ถนน',
      }}
    />
  )
}
```

### ใช้ในฟอร์ม

ฟอร์มแบบหน้าชำระเงิน: มีช่องชื่อผู้รับและเบอร์โทรคู่กับที่อยู่ พร้อมปุ่มยกเลิกและบันทึก การส่ง `name` จะสร้าง hidden inputs แปดช่อง `FormData` ธรรมดาจึงได้ที่อยู่ครบทั้งชุด

```tsx title="address-form-submit.tsx"
'use client'

import { useState, type FormEvent } from 'react'
import { ThaiAddressForm } from '@/components/thai-address-form'

export default function AddressFormSubmit() {
  const [payload, setPayload] = useState<Record<string, FormDataEntryValue> | null>(null)

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setPayload(Object.fromEntries(new FormData(event.currentTarget)))
  }

  return (
    <form className="flex flex-col gap-4" onSubmit={handleSubmit} onReset={() => setPayload(null)}>
      <div className="grid gap-4 sm:grid-cols-2">
        <label className="flex flex-col gap-1.5 text-sm font-medium">
          ชื่อผู้รับ
          <input name="recipient" required className="h-9 rounded-md border border-input bg-background px-3 text-sm" />
        </label>
        <label className="flex flex-col gap-1.5 text-sm font-medium">
          เบอร์โทรศัพท์
          <input name="phone" type="tel" required className="h-9 rounded-md border border-input bg-background px-3 text-sm" />
        </label>
      </div>
      <ThaiAddressForm name="address" required />
      <div className="flex justify-end gap-2">
        <button type="reset" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent">
          ยกเลิก
        </button>
        <button type="submit" className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90">
          บันทึกที่อยู่
        </button>
      </div>
      <p className="text-sm font-medium">FormData ที่ส่ง</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(payload, null, 2)}</pre>
    </form>
  )
}
```

## API Reference

| Prop | Type | ค่าเริ่มต้น | รายละเอียด |
| --- | --- | --- | --- |
| `value` | `FullThaiAddress \| null` | — | ค่าที่ควบคุมอยู่; ส่ง `null` เพื่อล้าง |
| `defaultValue` | `FullThaiAddress \| null` | — | ค่าเริ่มต้นของโหมด uncontrolled เติมทั้งช่องข้อความและ cascade ที่ฝังอยู่ |
| `onValueChange` | `(address: FullThaiAddress \| null) => void` | — | เรียกเมื่อบ้านเลขที่ไม่ว่างและเลือกครบทั้งสามระดับ หรือเมื่อค่ากลับมาไม่ครบอีกครั้ง |
| `name` | `string` | — | สร้าง hidden inputs 8 ช่อง: 4 ช่องของ cascade (`-subdistrict`/`-district`/`-province`/`-zipcode`) และ 4 ช่องของคอมโพเนนต์นี้ (`-houseno`/`-moo`/`-soi`/`-street`) |
| `locale` | `'th' \| 'en'` | `'th'` | ภาษาของข้อความและป้ายกำกับ ทั้งของคอมโพเนนต์นี้และ cascade ที่ฝังอยู่ |
| `texts` | `Partial<ThaiAddressFormTexts>` | — | แทนที่ป้ายกำกับและ placeholder ของบ้านเลขที่/หมู่/ซอย/ถนน |
| `cascadeTexts` | `Partial<ThaiAddressCascadeSelectTexts>` | — | ส่งต่อเป็น `texts` ของ cascade select ที่ฝังอยู่ |
| `indexLoad` | `'mount' \| 'visible'` | `'mount'` | ส่งต่อไปยังตัวโหลดดัชนีที่อยู่ของ cascade ที่ฝังอยู่ |
| `disabled` | `boolean` | `false` | ปิดใช้งานทุกช่องข้อความและ cascade ที่ฝังอยู่ |
| `required` | `boolean` | `false` | กำหนดให้ช่องบ้านเลขที่และ cascade ต้องมีค่า (หมู่/ซอย/ถนนยังไม่บังคับ) |
| `aria-invalid` | `React.AriaAttributes['aria-invalid']` | — | ทำเครื่องหมายว่าช่องข้อความทั้งสี่และ trigger ของ cascade ไม่ผ่านการตรวจสอบ |
| `onBlur` | `React.FocusEventHandler<HTMLInputElement>` | — | ทำงานเฉพาะเมื่อช่องบ้านเลขที่สูญเสียโฟกัส |
| `onError` | `(error: Error) => void` | — | ทำงานเมื่อโหลดดัชนีที่อยู่ไม่สำเร็จ (จัดการโดย cascade ที่ฝังอยู่) |
| `className` | `string` | — | wrapper หลัก |
| `labelClassName` | `string` | — | ป้ายกำกับของช่องข้อความและ cascade |
| `inputClassName` | `string` | — | ช่องบ้านเลขที่/หมู่/ซอย/ถนน |
| `triggerClassName` | `string` | — | trigger ของ select และช่องรหัสไปรษณีย์ใน cascade |
| `popupClassName` | `string` | — | กล่องตัวเลือกของ cascade |
| `itemClassName` | `string` | — | แต่ละตัวเลือกของ cascade |
| `ref` | `React.Ref<HTMLInputElement>` | — | ส่งต่อไปยังช่องบ้านเลขที่ |

---

# แสดงที่อยู่แบบอ่านอย่างเดียว

> จัดรูปแบบที่อยู่ไทยที่เลือกไว้แล้วให้เป็นข้อความอ่านง่าย ไม่ต้องโหลดดัชนีที่อยู่

Source: https://thai-zip.vercel.app/docs/react/address-display

`ThaiAddressDisplay` เหมาะกับหน้าที่ต้องแสดงที่อยู่ที่บันทึกไว้แล้ว เช่นหน้าสรุปคำสั่งซื้อหรือโปรไฟล์ผู้ใช้ เป็นคอมโพเนนต์อ่านอย่างเดียว ไม่มี Base UI และไม่โหลดดัชนีที่อยู่ จึงมี style เดียว

```tsx title="address-display-demo.tsx"
import { ThaiAddressDisplay, type ThaiAddressDisplayValue } from '@/components/thai-address-display'

const address: ThaiAddressDisplayValue = {
  tambon: 'คลองตันเหนือ',
  tambonEn: 'Khlong Tan Nuea',
  amphure: 'วัฒนา',
  amphureEn: 'Watthana',
  province: 'กรุงเทพมหานคร',
  provinceEn: 'Bangkok',
  zipCode: '10110',
  subdistrict: 'คลองตันเหนือ',
  subdistrictEn: 'Khlong Tan Nuea',
  district: 'วัฒนา',
  districtEn: 'Watthana',
  postalCode: '10110',
  houseNo: '99/9',
  moo: '4',
  soi: '2',
  street: 'สุขุมวิท',
}

export default function AddressDisplayDemo() {
  return <ThaiAddressDisplay value={address} />
}
```

## ติดตั้ง

**CLI**

```bash
npx react-thaizip add address-display
```

CLI เลือกไฟล์ให้ตาม style ที่ `init` บันทึกไว้: โปรเจกต์ที่มี `components.json` ของ shadcn/ui จะได้ไฟล์ที่ประกอบจาก primitives ของ shadcn (Base UI, Radix หรือ React Aria) นอกนั้นได้แบบ vanilla

**ติดตั้งเอง — Vanilla**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `components/thai-address-display.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/thai-address-display.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)
1. แบบ vanilla ต้องมี design tokens ใน global stylesheet ด้วย ดู [การปรับแต่ง](https://thai-zip.vercel.app/docs/react/customization#design-tokens)

## วิธีใช้

```tsx
import { ThaiAddressDisplay } from '@/components/thai-address-display'
```

```tsx
<ThaiAddressDisplay value={address} />
```

`value` รับที่อยู่ที่ resolve ไว้แล้ว (`ResolvedThaiAddress` บวก `houseNo`/`moo`/`soi`/`street` แบบ optional มี type ชื่อ `ThaiAddressDisplayValue`) หรือ `null` เมื่อยังไม่มีที่อยู่ ซึ่งจะแสดง `emptyText` แทน

## ตัวอย่าง

### หลายบรรทัด

ใช้ `mode="multi-line"` เพื่อแยกส่วนบ้านเลขที่/หมู่/ซอย/ถนน กับส่วนตำบล-อำเภอ-จังหวัดออกเป็นคนละบรรทัด

```tsx title="address-display-multi-line.tsx"
import { ThaiAddressDisplay, type ThaiAddressDisplayValue } from '@/components/thai-address-display'

const address: ThaiAddressDisplayValue = {
  tambon: 'คลองตันเหนือ',
  tambonEn: 'Khlong Tan Nuea',
  amphure: 'วัฒนา',
  amphureEn: 'Watthana',
  province: 'กรุงเทพมหานคร',
  provinceEn: 'Bangkok',
  zipCode: '10110',
  subdistrict: 'คลองตันเหนือ',
  subdistrictEn: 'Khlong Tan Nuea',
  district: 'วัฒนา',
  districtEn: 'Watthana',
  postalCode: '10110',
  houseNo: '99/9',
  moo: '4',
  soi: '2',
  street: 'สุขุมวิท',
}

export default function AddressDisplayMultiLine() {
  return <ThaiAddressDisplay value={address} mode="multi-line" />
}
```

### แสดงเป็นภาษาอังกฤษ

ใช้ `locale="en"` เพื่อแสดงชื่อภาษาอังกฤษ คำว่า "หมู่"/"ซอย" จะเปลี่ยนเป็นคำทับศัพท์มาตรฐาน "Moo"/"Soi" โดยอัตโนมัติ ส่วนบ้านเลขที่และชื่อถนนแสดงตามที่กรอกไว้เสมอ

```tsx title="address-display-en.tsx"
import { ThaiAddressDisplay, type ThaiAddressDisplayValue } from '@/components/thai-address-display'

const address: ThaiAddressDisplayValue = {
  tambon: 'คลองตันเหนือ',
  tambonEn: 'Khlong Tan Nuea',
  amphure: 'วัฒนา',
  amphureEn: 'Watthana',
  province: 'กรุงเทพมหานคร',
  provinceEn: 'Bangkok',
  zipCode: '10110',
  subdistrict: 'คลองตันเหนือ',
  subdistrictEn: 'Khlong Tan Nuea',
  district: 'วัฒนา',
  districtEn: 'Watthana',
  postalCode: '10110',
  houseNo: '99/9',
  moo: '4',
  soi: '2',
  street: 'สุขุมวิท',
}

export default function AddressDisplayEn() {
  return <ThaiAddressDisplay value={address} locale="en" />
}
```

### ไม่มีบ้านเลขที่

เมื่อ `value` ไม่มี `houseNo`/`moo`/`soi`/`street` เลย จะแสดงเฉพาะตำบล-อำเภอ-จังหวัด

```tsx title="address-display-minimal.tsx"
import { ThaiAddressDisplay, type ThaiAddressDisplayValue } from '@/components/thai-address-display'

const address: ThaiAddressDisplayValue = {
  tambon: 'คลองตันเหนือ',
  tambonEn: 'Khlong Tan Nuea',
  amphure: 'วัฒนา',
  amphureEn: 'Watthana',
  province: 'กรุงเทพมหานคร',
  provinceEn: 'Bangkok',
  zipCode: '10110',
  subdistrict: 'คลองตันเหนือ',
  subdistrictEn: 'Khlong Tan Nuea',
  district: 'วัฒนา',
  districtEn: 'Watthana',
  postalCode: '10110',
}

export default function AddressDisplayMinimal() {
  return <ThaiAddressDisplay value={address} />
}
```

### ยังไม่มีที่อยู่

ส่ง `value={null}` เพื่อแสดง `emptyText` แทน (ค่าเริ่มต้นคือ "ไม่มีที่อยู่" หรือ "No address" ตาม `locale`)

```tsx title="address-display-empty.tsx"
import { ThaiAddressDisplay } from '@/components/thai-address-display'

export default function AddressDisplayEmpty() {
  return <ThaiAddressDisplay value={null} />
}
```

### ปรับสไตล์

ใช้ `className` กับ `<address>` และ `lineClassName` กับแต่ละบรรทัดในโหมดหลายบรรทัด ตัวอย่างนี้ทำเป็นการ์ดและเน้นบรรทัดแรก

```tsx title="address-display-styled.tsx"
import { ThaiAddressDisplay, type ThaiAddressDisplayValue } from '@/components/thai-address-display'

const address: ThaiAddressDisplayValue = {
  tambon: 'คลองตันเหนือ',
  tambonEn: 'Khlong Tan Nuea',
  amphure: 'วัฒนา',
  amphureEn: 'Watthana',
  province: 'กรุงเทพมหานคร',
  provinceEn: 'Bangkok',
  zipCode: '10110',
  subdistrict: 'คลองตันเหนือ',
  subdistrictEn: 'Khlong Tan Nuea',
  district: 'วัฒนา',
  districtEn: 'Watthana',
  postalCode: '10110',
  houseNo: '99/9',
  moo: '4',
  soi: '2',
  street: 'สุขุมวิท',
}

export default function AddressDisplayStyled() {
  return (
    <ThaiAddressDisplay
      value={address}
      mode="multi-line"
      className="rounded-lg border p-4 not-italic"
      lineClassName="first:font-medium"
    />
  )
}
```

## API Reference

| Prop | Type | ค่าเริ่มต้น | รายละเอียด |
| --- | --- | --- | --- |
| `value` | `ThaiAddressDisplayValue \| null` | — | **จำเป็น** ที่อยู่ที่จะแสดง; `null` จะแสดง `emptyText` แทน |
| `locale` | `'th' \| 'en'` | `'th'` | กำหนดภาษาของชื่อพื้นที่และ `emptyText` เริ่มต้น |
| `mode` | `'single-line' \| 'multi-line'` | `'single-line'` | `'single-line'` ต่อส่วนถนนกับพื้นที่ด้วย `', '` ส่วน `'multi-line'` แสดงเป็นสองบรรทัด |
| `emptyText` | `string` | `'ไม่มีที่อยู่'` / `'No address'` | ข้อความเมื่อ `value` เป็น `null` ตาม `locale` |
| `className` | `string` | — | `<address>` หลัก |
| `lineClassName` | `string` | — | แต่ละบรรทัดในโหมด `'multi-line'` |
| `ref` | `React.Ref<HTMLElement>` | — | ส่งต่อไปยัง `<address>` |

---

# ฟิลด์ที่อยู่สำหรับ react-hook-form

> ครอบ cascade select ด้วย Controller ของ react-hook-form เพื่อผูกกับ validation และ state ของฟอร์มที่มีอยู่แล้ว

Source: https://thai-zip.vercel.app/docs/react/address-form-field

`ThaiAddressFormField` เหมาะกับฟอร์มที่ใช้ [react-hook-form](https://react-hook-form.com/) อยู่แล้วและต้องการผูกที่อยู่เข้ากับ state ของฟอร์มโดยตรง แทนที่จะอ่านค่าจาก hidden inputs รูปแบบ prop ต่างจากคอมโพเนนต์อื่นโดยตั้งใจ: ส่ง `control`/`name`/`rules` เหมือนกับ `useController` ของ react-hook-form เอง และไม่มี `value`/`onValueChange`/hidden inputs

```tsx title="address-form-field-demo.tsx"
'use client'

import { useState } from 'react'
import { useForm } from 'react-hook-form'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressFormField } from '@/components/thai-address-form-field'

type FormValues = {
  address: ResolvedThaiAddress | null
}

export default function AddressFormFieldDemo() {
  const { control, handleSubmit } = useForm<FormValues>({ defaultValues: { address: null } })
  const [submitted, setSubmitted] = useState<ResolvedThaiAddress | null>(null)

  return (
    <form className="flex flex-col gap-3" onSubmit={handleSubmit((data) => setSubmitted(data.address))}>
      <ThaiAddressFormField
        control={control}
        name="address"
      />
      <div>
        <button type="submit" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent">
          ส่งฟอร์ม
        </button>
      </div>
      <p className="text-sm font-medium">ค่าที่ส่ง</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(submitted, null, 2)}</pre>
    </form>
  )
}
```

## ติดตั้ง

**CLI**

```bash
npx react-thaizip add address-form-field
```

CLI เลือกไฟล์ให้ตาม style ที่ `init` บันทึกไว้: โปรเจกต์ที่มี `components.json` ของ shadcn/ui จะได้ไฟล์ที่ประกอบจาก primitives ของ shadcn (Base UI, Radix หรือ React Aria) นอกนั้นได้แบบ vanilla

**ติดตั้งเอง — Vanilla**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip @base-ui/react react-hook-form`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/thai-address-cascade-select.tsx)
   - `components/thai-address-form-field.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/thai-address-form-field.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)
1. แบบ vanilla ต้องมี design tokens ใน global stylesheet ด้วย ดู [การปรับแต่ง](https://thai-zip.vercel.app/docs/react/customization#design-tokens)

**ติดตั้งเอง — shadcn/ui · Base UI**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip react-hook-form`
1. เพิ่ม shadcn/ui primitives ที่คอมโพเนนต์ใช้: `npx shadcn@latest add select label button input`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/base/thai-address-cascade-select.tsx)
   - `components/thai-address-form-field.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/base/thai-address-form-field.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)

**ติดตั้งเอง — shadcn/ui · Radix**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip react-hook-form`
1. เพิ่ม shadcn/ui primitives ที่คอมโพเนนต์ใช้: `npx shadcn@latest add select label button input`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/radix/thai-address-cascade-select.tsx)
   - `components/thai-address-form-field.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/radix/thai-address-form-field.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)

**ติดตั้งเอง — shadcn/ui · React Aria**

1. ติดตั้ง dependencies: `npm install clsx tailwind-merge thaizip react-hook-form`
1. เพิ่ม shadcn/ui primitives ที่คอมโพเนนต์ใช้: `npx shadcn@latest add select label button input`
1. คัดลอกไฟล์ต่อไปนี้ลงในโปรเจกต์:
   - `lib/utils.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/aria/thai-address-cascade-select.tsx)
   - `components/thai-address-form-field.tsx` — [ซอร์ส](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/aria/thai-address-form-field.tsx)
1. แก้ import `@/lib/...` และ `@/hooks/...` ให้ตรงกับตำแหน่งไฟล์ในโปรเจกต์ (CLI แก้ให้อัตโนมัติ)

## วิธีใช้

```tsx
import { useForm } from 'react-hook-form'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressFormField } from '@/components/thai-address-form-field'
```

```tsx
const { control, handleSubmit } = useForm<{ address: ResolvedThaiAddress | null }>({
  defaultValues: { address: null },
})
```

```tsx
<ThaiAddressFormField control={control} name="address" />
```

ฟิลด์ `name` ที่ระบุ (`"address"` ในตัวอย่าง) ต้องมีชนิดค่าเป็น `ResolvedThaiAddress | null` ใน type ของฟอร์ม `handleSubmit` ของ react-hook-form อ่านค่าได้โดยตรง คอมโพเนนต์นี้ไม่ render hidden inputs

## ตัวอย่าง

### กรอกข้อมูลภาษาอังกฤษ

ใช้ `locale="en"` เพื่อแสดงป้ายและตัวเลือกของ cascade select ที่ฝังอยู่เป็นภาษาอังกฤษ

```tsx title="address-form-field-en.tsx"
'use client'

import { useState } from 'react'
import { useForm } from 'react-hook-form'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressFormField } from '@/components/thai-address-form-field'

type FormValues = {
  address: ResolvedThaiAddress | null
}

export default function AddressFormFieldEn() {
  const { control, handleSubmit } = useForm<FormValues>({ defaultValues: { address: null } })
  const [submitted, setSubmitted] = useState<ResolvedThaiAddress | null>(null)

  return (
    <form className="flex flex-col gap-3" onSubmit={handleSubmit((data) => setSubmitted(data.address))}>
      <ThaiAddressFormField
        control={control}
        name="address"
        locale="en"
      />
      <div>
        <button type="submit" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent">
          Submit
        </button>
      </div>
      <p className="text-sm font-medium">Submitted value</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(submitted, null, 2)}</pre>
    </form>
  )
}
```

### ตรวจสอบด้วย validation rule

ส่ง `rules` แบบเดียวกับที่ส่งให้ `Controller`/`useController` ของ react-hook-form ตัวอย่างนี้กำหนดให้ต้องเลือกที่อยู่ครบก่อนส่งฟอร์ม ข้อความ error จะแสดงเป็น `<p role="alert">` ใต้ cascade select

```tsx title="address-form-field-validation.tsx"
'use client'

import { useState } from 'react'
import { useForm } from 'react-hook-form'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressFormField } from '@/components/thai-address-form-field'

type FormValues = {
  address: ResolvedThaiAddress | null
}

export default function AddressFormFieldValidation() {
  const { control, handleSubmit } = useForm<FormValues>({ defaultValues: { address: null } })
  const [submitted, setSubmitted] = useState<ResolvedThaiAddress | null>(null)

  return (
    <form className="flex flex-col gap-3" onSubmit={handleSubmit((data) => setSubmitted(data.address))}>
      <ThaiAddressFormField
        control={control}
        name="address"
        rules={{ validate: (value) => value !== null || 'กรุณาเลือกที่อยู่ให้ครบ' }}
      />
      <div>
        <button type="submit" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent">
          ส่งฟอร์ม
        </button>
      </div>
      <p className="text-sm font-medium">ค่าที่ส่ง</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(submitted, null, 2)}</pre>
    </form>
  )
}
```

### ปิดใช้งาน

ส่ง `disabled` เพื่อปิดทุก select ใน cascade select ที่ฝังอยู่

```tsx title="address-form-field-disabled.tsx"
'use client'

import { useForm } from 'react-hook-form'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressFormField } from '@/components/thai-address-form-field'

type FormValues = {
  address: ResolvedThaiAddress | null
}

export default function AddressFormFieldDisabled() {
  const { control } = useForm<FormValues>({ defaultValues: { address: null } })

  return <ThaiAddressFormField control={control} name="address" disabled />
}
```

### ใช้ในฟอร์ม

ฟิลด์ที่อยู่คู่กับ input ทั่วไปที่ใช้ `register` ในฟอร์มเดียวกัน ทั้งสองช่องตรวจสอบตอนส่ง และ `handleSubmit` ได้ค่าทั้งหมดพร้อมกัน

```tsx title="address-form-field-full.tsx"
'use client'

import { useState } from 'react'
import { useForm } from 'react-hook-form'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressFormField } from '@/components/thai-address-form-field'

type FormValues = {
  name: string
  address: ResolvedThaiAddress | null
}

export default function AddressFormFieldFull() {
  const { control, register, handleSubmit, formState: { errors } } = useForm<FormValues>({
    defaultValues: { name: '', address: null },
  })
  const [submitted, setSubmitted] = useState<FormValues | null>(null)

  return (
    <form className="flex flex-col gap-4" onSubmit={handleSubmit(setSubmitted)}>
      <label className="flex flex-col gap-1.5 text-sm font-medium">
        ชื่อ-นามสกุล
        <input
          {...register('name', { required: 'กรุณากรอกชื่อ' })}
          aria-invalid={errors.name ? true : undefined}
          className="h-9 rounded-md border border-input bg-background px-3 text-sm"
        />
        {errors.name && <span role="alert" className="text-sm font-normal text-destructive">{errors.name.message}</span>}
      </label>
      <ThaiAddressFormField
        control={control}
        name="address"
        rules={{ validate: (value) => value !== null || 'กรุณาเลือกที่อยู่ให้ครบ' }}
      />
      <div>
        <button type="submit" className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90">
          สมัครสมาชิก
        </button>
      </div>
      <p className="text-sm font-medium">ค่าที่ส่ง</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(submitted, null, 2)}</pre>
    </form>
  )
}
```

## API Reference

| Prop | Type | ค่าเริ่มต้น | รายละเอียด |
| --- | --- | --- | --- |
| `control` | `Control<TFieldValues>` | — | **จำเป็น** ค่า `control` จาก `useForm()` ของ react-hook-form |
| `name` | `FieldPath<TFieldValues>` | — | **จำเป็น** path ของฟิลด์ในฟอร์ม ชนิดค่าต้องเป็น `ResolvedThaiAddress \| null` |
| `rules` | `Omit<RegisterOptions<…>, 'valueAsNumber' \| 'valueAsDate' \| 'setValueAs' \| 'disabled'>` | — | ส่งต่อไปยัง `Controller` ของ react-hook-form เพื่อตรวจสอบค่า |
| `locale` | `'th' \| 'en'` | `'th'` | ภาษาของป้ายและตัวเลือกใน cascade select ที่ฝังอยู่ |
| `texts` | `Partial<ThaiAddressCascadeSelectTexts>` | — | ส่งต่อเป็น `texts` ของ cascade select ที่ฝังอยู่ |
| `indexLoad` | `'mount' \| 'visible'` | `'mount'` | ส่งต่อไปยังตัวโหลดดัชนีที่อยู่ของ cascade ที่ฝังอยู่ |
| `disabled` | `boolean` | — | ปิดใช้งานทุก select ใน cascade select ที่ฝังอยู่ |
| `className` | `string` | — | `<div>` ที่ครอบ cascade select และข้อความ validation |
| `labelClassName` | `string` | — | ป้ายกำกับของ cascade select |
| `triggerClassName` | `string` | — | trigger และช่องรหัสไปรษณีย์ของ cascade select |
| `popupClassName` | `string` | — | กล่องตัวเลือกของ cascade select |
| `itemClassName` | `string` | — | แต่ละตัวเลือกของ cascade select |
| `errorClassName` | `string` | — | ข้อความ validation `role="alert"` ที่แสดงเมื่อตรวจสอบไม่ผ่าน |

คอมโพเนนต์นี้ไม่รับ `value`/`onValueChange`/`ref`/`name` (สำหรับ hidden input) แบบคอมโพเนนต์อื่น react-hook-form อ่านและเขียนค่าผ่าน `control`/`name` ที่ส่งให้แทน

---

# การปรับแต่ง

> ปรับธีมคอมโพเนนต์ที่ scaffold ด้วย design tokens หรือแก้ซอร์สโดยตรง

Source: https://thai-zip.vercel.app/docs/react/customization

props ด้านข้อความและ class มีอธิบายในหน้าของแต่ละคอมโพเนนต์แล้ว (`locale`, `texts` และ slot `*ClassName`) หน้านี้อธิบาย design tokens และกรณีที่ควรแก้ซอร์สเอง

## Design tokens

`init` เพิ่ม token block ลง global stylesheet คอมโพเนนต์อ่าน CSS custom properties เหล่านี้: `--background`, `--foreground`, `--popover`, `--primary`, `--muted`, `--accent`, `--destructive`, `--border`, `--input`, `--ring` และ `--radius`

```css
:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --border: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
  --radius: 0.625rem;
  /* … */
}
```

`.dark` ใน block เดียวกันจะ override สีเหล่านี้ แก้ค่าเพื่อเปลี่ยนธีมของทุกคอมโพเนนต์ โปรเจกต์ shadcn/ui มี tokens เหล่านี้อยู่แล้ว

## Tailwind v4

block นี้มี `@theme inline` ที่ map properties ไปเป็น classes อย่าง `bg-background`, `border-input` และ `ring-ring` ให้แล้ว ไม่ต้องทำอะไรเพิ่ม

## Tailwind v3

Tailwind v3 ไม่มี `@theme inline` ดังนั้น `init` จะพิมพ์ snippet นี้ให้เพิ่มเองใต้ `theme.extend` ใน `tailwind.config.{js,ts}` ถ้ายังไม่เพิ่ม token classes จะไม่มี style:

```ts
colors: {
  background: 'hsl(var(--background))',
  foreground: 'hsl(var(--foreground))',
  popover: { DEFAULT: 'hsl(var(--popover))', foreground: 'hsl(var(--popover-foreground))' },
  primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
  muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' },
  accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))' },
  destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' },
  border: 'hsl(var(--border))',
  input: 'hsl(var(--input))',
  ring: 'hsl(var(--ring))',
},
borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)' },
```

## แก้ซอร์ส

ถ้าต้องการเปลี่ยน layout, markup หรือพฤติกรรม ให้แก้ไฟล์คอมโพเนนต์ที่ `add` สร้างไว้โดยตรง คง import ของ `utils` และ hooks ไว้ การรัน `add` ซ้ำจะไม่เขียนทับสิ่งที่แก้ เว้นแต่ส่ง `--overwrite`

---

# CLI

> คำสั่ง flags, thaizip.config.json และวิธีแก้ปัญหาที่พบบ่อย

Source: https://thai-zip.vercel.app/docs/react/cli

รัน CLI จากรากของโปรเจกต์ที่ต้องการ scaffold

```text
react-thaizip init [--yes]
react-thaizip add [component...] [--yes] [--overwrite]
react-thaizip --help | --version
```

## `init`

ตรวจโปรเจกต์แล้วพิมพ์สรุปก่อนถามคำถามใด ๆ:

```text
Detected project settings:
  Components directory: app/components
  Lib directory: lib
  Hooks directory: hooks
  Package manager: npm
  Language: TypeScript
  Tailwind: v4 (app/globals.css)
  Component style: vanilla Tailwind (no components.json found)
```

แก้ directory ของคอมโพเนนต์ได้ที่ prompt จากนั้น `init` เพิ่ม design tokens ลง global stylesheet เสนอติดตั้ง `thaizip` และสร้าง `thaizip.config.json` ขั้นตอนที่ต้องทำเองจะพิมพ์รวมไว้ท้ายสุดใต้ `=== Manual steps required ===`

## `add`

```npm
npx react-thaizip add autocomplete cascade-select
```

ถ้าไม่ระบุชื่อ `add` จะแสดง multiselect ชื่อไม่แยกตัวพิมพ์เล็ก-ใหญ่:

| คอมโพเนนต์ | ชื่อที่ใช้ได้ |
| --- | --- |
| `ThaiAddressAutocomplete` | `autocomplete`, `thai-address-autocomplete` |
| `ThaiAddressCascadeSelect` | `cascade`, `cascade-select`, `thai-address-cascade-select` |
| `ThaiAddressForm` | `address-form`, `thai-address-form` |
| `ThaiAddressDisplay` | `address-display`, `thai-address-display` |
| `ThaiAddressFormField` | `address-form-field`, `thai-address-form-field` |

ใช้ชื่อคอมโพเนนต์แบบ PascalCase ได้ด้วย ไฟล์ที่ใช้ร่วมกัน (`lib/utils.ts` และ hooks) จะถูกเพิ่มให้อัตโนมัติ `add` ต้องการ `thaizip` 0.7.5 ขึ้นไป ติดตั้ง dependency ที่ขาด และในโปรเจกต์ shadcn/ui จะเรียก shadcn CLI เพื่อเพิ่ม primitives ที่ยังไม่มี

## Flags

| Flag | ผล |
| --- | --- |
| `--yes`, `-y` | ข้าม prompt และใช้คำตอบเริ่มต้น คำตอบเริ่มต้นของการเขียนทับคือ **ไม่** |
| `--overwrite` | เขียนทับไฟล์ที่มีอยู่โดยไม่ถาม รวมถึงไฟล์ `lib`/hook ที่ปกติได้รับการป้องกัน ใช้กับ `add` เท่านั้น |
| `--help`, `-h` | แสดง help วางหลัง `init` หรือ `add` เพื่อดูเฉพาะคำสั่ง ชนะ `--version` |
| `--version`, `-v` | แสดงเวอร์ชันของ CLI |

## `thaizip.config.json`

```json
{
  "typescript": true,
  "componentDir": "app/components",
  "libDir": "lib",
  "hooksDir": "hooks",
  "packageManager": "npm",
  "tailwind": { "version": 4, "css": "app/globals.css" },
  "style": "vanilla",
  "shadcnBase": "",
  "shadcnUiAlias": "",
  "shadcnUiDir": "",
  "registryVersion": "0.3.7"
}
```

path ทั้งหมดเทียบจากรากโปรเจกต์ `typescript: false` ทำให้ `add` เขียนไฟล์ `.jsx`/`.js` ส่วน `style` เป็น `vanilla` หรือ `shadcn` โดย `shadcnBase` เป็น `base`, `radix` หรือ `aria` เมื่อใช้ shadcn ถ้าต้องการเปลี่ยน style ให้แก้ไฟล์แล้วรัน `add --overwrite` ใหม่ config เวอร์ชันเก่าจะถูกแปลงให้อัตโนมัติ

## การแก้ปัญหา

**`init` ตรวจไม่พบ Tailwind** ติดตั้ง Tailwind ตาม framework ก่อน แล้วรัน `init` ใหม่

**`No global CSS file was found`** คัดลอก token block ที่พิมพ์ออกมาไปไว้ใน stylesheet ที่แอปโหลดจริง แล้วตั้ง path ใน `tailwind.css`

**classes อย่าง `bg-background` ไม่มี style (Tailwind v3)** เพิ่ม `theme.extend` snippet ที่ `init` พิมพ์ไว้ลงใน `tailwind.config` แล้ว restart dev server ดู[การปรับแต่ง](https://thai-zip.vercel.app/docs/react/customization#tailwind-v3)

**`thaizip` ต่ำกว่า 0.7.5** รัน `npm i thaizip@latest` แล้วรัน `add` อีกครั้ง

**`Failed to install`** ติดตั้งรายการแพ็กเกจที่พิมพ์ออกมาด้วย package manager ของโปรเจกต์ แล้วรันคำสั่งเดิมอีกครั้ง

**ไฟล์ถูกข้าม** คอมโพเนนต์ที่มีอยู่จะไม่ถูกเขียนทับจนกว่าจะยืนยันหรือส่ง `--overwrite` ส่วนไฟล์ `lib`/hook จะถูกเก็บไว้เสมอแม้ใช้ `--yes` ถ้าต้องการไฟล์ใหม่ให้รัน `npx react-thaizip add utils --overwrite` ถ้า `add` เตือนว่าไฟล์เก่ากว่า registry ปัจจุบัน ให้รันคำสั่งที่มันพิมพ์ให้

**import path ไม่ถูก** ตรวจ directory ทั้งสามใน `thaizip.config.json` แล้ว `cd` ไปที่รากโปรเจกต์ และรัน `add <component> --overwrite` ใหม่

---

# Introduction

> Fast fuzzy search for Thai addresses (sub-district, district, province, postal code) in Thai or English, with no runtime dependencies

Source: https://thai-zip.vercel.app/en/docs

`thaizip` is a headless Thai address search library. It bundles the national address dataset, searches it in the browser or on the server, and works with any framework.

| Package | Use it when |
| --- | --- |
| `thaizip` | You want the search API, or you are building your own UI. These pages. |
| [`react-thaizip`](https://thai-zip.vercel.app/en/docs/react) | You want ready-made React components (autocomplete, cascade select, address form) copied into your project. |

## Install

```npm
npm install thaizip
```

Node.js 18 or later. React is an optional peer dependency, needed only for `thaizip/react`.

| Import | Contents |
| --- | --- |
| `thaizip` | Core functions and types (4.4 KB gzip) |
| `thaizip/react` | The [`useThaiAddressAutocomplete`](https://thai-zip.vercel.app/en/docs/react-hook) hook |
| `thaizip/data` | The bundled dataset loader (2.3 KB gzip, plus a 114 KB gzip data chunk fetched on first load) |

## Quick start

```ts
import { loadDefaultIndex } from 'thaizip/data'
import { searchThaiAddress, resolveThaiAddress } from 'thaizip'

const index = await loadDefaultIndex()

const [record] = searchThaiAddress(index, 'ลาดพร้าว') // also 'bang rak' or '10500'
const address = resolveThaiAddress(record)
// { subdistrict: 'ลาดพร้าว', district: 'เขตลาดพร้าว', province: 'กรุงเทพมหานคร', postalCode: '10230', … }
```

`loadDefaultIndex()` builds the search index once (about 40 ms) and caches it. Call it when the page or route loads, not on the first keystroke.

## Use with AI

Every page is available as Markdown: append `.md` to its URL, or use the Copy Markdown button at the top. [`/llms.txt`](https://thai-zip.vercel.app/llms.txt) indexes every page and [`/llms-full.txt`](https://thai-zip.vercel.app/llms-full.txt) has them all in one file.

---

# Search

> searchThaiAddress and lookupByZipCode — options, ranking, postal codes, and English input

Source: https://thai-zip.vercel.app/en/docs/search

## `searchThaiAddress`

```ts
function searchThaiAddress(index: TrigramIndex, query: string, options?: SearchOptions): ThaiAddressRecord[]
```

Searches by sub-district, district, or province name in Thai or English, or by postal code. Returns `[]` when nothing matches; it never throws.

```ts
searchThaiAddress(index, 'ลาดพร้าว', { limit: 5 })
searchThaiAddress(index, 'bang rak')
searchThaiAddress(index, '10500') // all digits: postal-code lookup
```

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `limit` | `number` | `10` | Maximum results for a text query. |
| `threshold` | `number` | `0.4` | Minimum match score (0–1) for a text query. |
| `zipLimit` | `number` | see [below](#postal-codes) | Maximum results for an all-digit query. |
| `romanizationAliases` | `boolean` | `true` | Rewrite common non-RTGS spellings before searching. |

Each result is a `ThaiAddressRecord`, one per sub-district:

```ts
type ThaiAddressRecord = {
  provinceId: number
  provinceNameTh: string
  provinceNameEn: string
  amphureId: number
  amphureNameTh: string
  amphureNameEn: string
  tambonId: number
  tambonNameTh: string
  tambonNameEn: string
  zipCode: string
}
```

Turn a record into a dropdown label or a saved address with the [format and resolve](https://thai-zip.vercel.app/en/docs/format) functions.

### How matching works

The query is normalized first: Thai address prefixes (`จังหวัด`, `อำเภอ`, `ตำบล`, `แขวง`, `เขต`, and `จ.` `อ.` `ต.` `ข.`) and tone marks are removed. It is then matched by trigrams (overlapping three-character chunks), so typos and partial names still match.

Results are ranked by trigram score, then by how well the query matches the sub-district's own name (exact, prefix, then substring), then alphabetically in Thai. That second key puts ตำบลลาดพร้าว above the other sub-districts inside เขตลาดพร้าว.

Queries shorter than three characters return `[]`. A name and a postal code in one query (`ลาดพร้าว 10900`) is not supported; search them separately.

### English input

English names follow the RTGS romanization used in the dataset, so `bang rak` or `chatuchak` match directly. A curated list of 87 aliases also maps common non-RTGS spellings, for example `lardprao` → `lat phrao` and `krungthep` → `bangkok`. It is a dictionary, not a transliterator: spellings not on the list won't match. Pass `romanizationAliases: false` to turn it off.

## Postal codes

An all-digit query of two or more digits is a postal-code lookup. Exact matches come first, then prefix matches in ascending order.

One postal code can cover many sub-districts (`45000` covers 33), so a complete five-digit code is **not** capped by default. A two-to-four-digit prefix is capped at `limit`, since `30` alone matches about 290. Pass `zipLimit` to set your own cap, or `Infinity` for every match.

### `lookupByZipCode`

```ts
function lookupByZipCode(index: TrigramIndex, zip: string, options?: SearchOptions): ThaiAddressRecord[]
```

The postal-code lookup on its own. Only `zipLimit` applies, and it defaults to no cap, for prefixes too.

```ts
lookupByZipCode(index, '45000') // all 33 sub-districts
lookupByZipCode(index, '450', { zipLimit: 10 })
```

## Helpers

Both run inside `searchThaiAddress` and are exported for use on their own.

| Function | Description |
| --- | --- |
| `normalizeThaiAddressText(input)` | Strips address prefixes and tone marks and lowercases, as the search does. |
| `applyRomanizationAliases(normalized)` | Rewrites a known non-RTGS spelling to the dataset's spelling, e.g. `'lardprao'` → `'lat phrao'`. Thai text is returned unchanged. |

---

# Cascade lists

> listProvinces, listAmphures, and listTambons for province → district → sub-district dropdowns

Source: https://thai-zip.vercel.app/en/docs/cascade

These functions list addresses level by level, for dropdowns where people pick a province, then a district, then a sub-district. They read groupings built with the index, so they are cheap to call on every change.

```ts
import { listProvinces, listAmphures, listTambons } from 'thaizip'

const provinces = listProvinces(index)            // all 77
const districts = listAmphures(index, provinceId)
const subdistricts = listTambons(index, amphureId) // each with its zipCode
```

| Function | Returns |
| --- | --- |
| `listProvinces(index)` | `ProvinceSummary[]`, sorted by Thai name |
| `listAmphures(index, provinceId)` | `AmphureSummary[]` in that province, sorted by Thai name |
| `listTambons(index, amphureId)` | `TambonSummary[]` in that district, sorted by Thai name |

```ts
type ProvinceSummary = { id: number; nameTh: string; nameEn: string }
type AmphureSummary = { id: number; nameTh: string; nameEn: string; provinceId: number }
type TambonSummary = { id: number; nameTh: string; nameEn: string; amphureId: number; zipCode: string }
```

An unknown id, such as a `0` placeholder before anything is selected, returns `[]` instead of throwing. When the parent selection changes, reset the child ids and the lists empty themselves.

For a ready-made component built on these functions, see the [cascade select](https://thai-zip.vercel.app/en/docs/react/cascade-select).

---

# Format and resolve

> Turn a search result into a dropdown label or a structured address to save

Source: https://thai-zip.vercel.app/en/docs/format

## `formatThaiAddressSuggestion`

```ts
function formatThaiAddressSuggestion(record: ThaiAddressRecord, options?: { locale?: 'th' | 'en' }): ThaiAddressSuggestion
```

Builds a ready-to-render label for a dropdown item.

```ts
const suggestions = searchThaiAddress(index, 'ลาดพร้าว').map((r) => formatThaiAddressSuggestion(r))
suggestions[0].label // 'ลาดพร้าว > เขตลาดพร้าว > กรุงเทพมหานคร 10230'

formatThaiAddressSuggestion(record, { locale: 'en' }).label // 'Lat Phrao > Khet Lat Phrao > Bangkok 10230'
```

```ts
type ThaiAddressSuggestion = {
  id: string // String(record.tambonId)
  label: string // labelTh, or labelEn when locale is 'en'
  labelTh: string
  labelEn: string
  tambon: string
  tambonEn: string
  amphure: string
  amphureEn: string
  province: string
  provinceEn: string
  zipCode: string
}
```

## `resolveThaiAddress`

```ts
function resolveThaiAddress(record: ThaiAddressRecord): ResolvedThaiAddress
```

Builds the address object to store. It carries the same values under two naming conventions, so you can pick the one that fits your schema.

```ts
type ResolvedThaiAddress = {
  // Thai convention
  tambon: string
  tambonEn: string
  amphure: string
  amphureEn: string
  province: string
  provinceEn: string
  zipCode: string
  // English convention, same values
  subdistrict: string
  subdistrictEn: string
  district: string
  districtEn: string
  postalCode: string
}
```

The [React hook](https://thai-zip.vercel.app/en/docs/react-hook) returns this value, and the [react-thaizip components](https://thai-zip.vercel.app/en/docs/react) use it as theirs.

---

# React hook

> useThaiAddressAutocomplete from thaizip/react — debounced search state for your own autocomplete UI

Source: https://thai-zip.vercel.app/en/docs/react-hook

`useThaiAddressAutocomplete` wraps `searchThaiAddress` with a debounce and the query/suggestions state. It ships with `"use client"`, so it works in the Next.js App Router as is. For a finished component instead, see the [autocomplete](https://thai-zip.vercel.app/en/docs/react/autocomplete).

```tsx
import { useState } from 'react'
import { useThaiAddressAutocomplete } from 'thaizip/react'
import type { ResolvedThaiAddress, TrigramIndex } from 'thaizip'

function AddressAutocomplete({ index }: { index: TrigramIndex }) {
  const [selected, setSelected] = useState<ResolvedThaiAddress | null>(null)
  const { query, setQuery, setQuerySilent, suggestions, isOpen, selectSuggestion } =
    useThaiAddressAutocomplete({ index, onSelect: setSelected })

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {isOpen && (
        <ul>
          {suggestions.map((s) => (
            <li key={s.id} onClick={() => selectSuggestion(s) && setQuerySilent(s.label)}>
              {s.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}
```

`index` must not be null, and hooks can't be called conditionally. If you load the index asynchronously, render this component only once `loadDefaultIndex()` has resolved.

## Options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `index` | `TrigramIndex` | required | The index to search. |
| `debounce` | `number` | `200` | Milliseconds to wait after typing stops. |
| `limit` | `number` | `10` | Passed to `searchThaiAddress`. |
| `threshold` | `number` | `0.4` | Passed to `searchThaiAddress`. |
| `zipLimit` | `number` | — | Passed to `searchThaiAddress`. |
| `locale` | `'th' \| 'en'` | `'th'` | Language of each suggestion's `label`. |
| `initialQuery` | `string` | `''` | Initial input text. Does not trigger a search. |
| `onSelect` | `(address: ResolvedThaiAddress) => void` | — | Called when `selectSuggestion` resolves an address. |

## Returns

| Value | Description |
| --- | --- |
| `query` | Current input text. |
| `setQuery(value)` | Update the text and search after the debounce. |
| `setQuerySilent(value)` | Update the text without searching or reopening, e.g. to show the selected label. |
| `suggestions` | `ThaiAddressSuggestion[]` from the latest search. |
| `isOpen` | `true` when there is a query and at least one suggestion. |
| `selectSuggestion(item)` | Resolve a suggestion, call `onSelect`, and return the `ResolvedThaiAddress` (or `null` for a stale item). It does not change `query`. |
| `clear()` | Reset `query` and `suggestions`. |

---

# Data and index

> Load the bundled dataset, or build an index from your own data

Source: https://thai-zip.vercel.app/en/docs/data

Every function takes a `TrigramIndex`. Get one from the bundled dataset, or build it from your own tables. Treat it as opaque; don't construct or mutate one by hand.

## Bundled dataset

`thaizip/data` holds the national dataset: 77 provinces and their districts and sub-districts with postal codes. The data chunk is downloaded only when `loadDefaultIndex()` first runs, in bundlers that split dynamic imports (webpack, Turbopack, Vite).

```ts
import { loadDefaultIndex, getDefaultIndexIfLoaded, clearDefaultIndex } from 'thaizip/data'
```

| Function | Description |
| --- | --- |
| `loadDefaultIndex()` | Returns `Promise<TrigramIndex>`. The first call builds the index (about 40 ms); later and concurrent calls share the same cached index. |
| `getDefaultIndexIfLoaded()` | Returns the index if it is already built, otherwise `null`. Synchronous, so UI can skip a loading state on a warm page. Never starts a load. |
| `clearDefaultIndex()` | Drops the cached index, for example between tests. |

## Your own data

Use `buildThaiAddressIndex` when you have a newer dataset, need only some provinces, or want your own records.

```ts
import { buildThaiAddressIndex } from 'thaizip'

const index = buildThaiAddressIndex(
  { provinces, amphures, tambons },
  { onSkip: (tambon) => console.warn('skipped', tambon.name_th) },
)
```

The tables use snake_case fields, as in the government source data:

```ts
type RawData = {
  provinces: { id: number; name_th: string; name_en: string; geography_id: number; deleted_at: string | null }[]
  amphures: { id: number; name_th: string; name_en: string; province_id: number; deleted_at: string | null }[]
  tambons: { id: number; zip_code: number | string; name_th: string; name_en: string; amphure_id: number; deleted_at: string | null }[]
  geographies?: { id: number; name: string; deleted_at: string | null }[] // unused
}
```

Rows with a non-null `deleted_at` are skipped. A sub-district whose district or province is missing or deleted is skipped too, and passed to `onSkip`.

| Option | Default | Description |
| --- | --- | --- |
| `onSkip` | — | Called for each skipped sub-district. |
| `validate` | `true` | Check field types before building. It costs nothing measurable, so leave it on for data you didn't generate. |

To check data on its own, for example in a build step, call `validateRawData(data)`. It throws a `TypeError` that names the table, row id, field, and actual type, such as `[thaizip] province 12: expected string for name_th, got number`.

---

# Get started

> Scaffold ready-made Thai address components into a React or Next.js project, shadcn/ui-style

Source: https://thai-zip.vercel.app/en/docs/react

react-thaizip is a CLI, not a component library you install as a dependency. It copies component source into your project, the same way [shadcn/ui](https://ui.shadcn.com/) does, so the files are yours to change.

## Prerequisites

Node.js 18 or later, and a React or Next.js project with Tailwind CSS v3 or v4 already installed. `init` detects Tailwind; it does not install it.

## Set up

```npm
npx react-thaizip init
```

`init` detects the project layout, package manager, TypeScript, Tailwind, and shadcn/ui, adds design tokens to your global stylesheet, installs `thaizip`, and writes `thaizip.config.json`. With a shadcn/ui `components.json`, components are composed from your own primitives (Base UI, Radix, or React Aria); otherwise you get the vanilla Base UI style. With Tailwind v3 it also prints a `theme.extend` snippet to add by hand.

## Add a component

```npm
npx react-thaizip add autocomplete
```

```text
components/
  thai-address-autocomplete.tsx
hooks/
  use-thai-address-index.ts
lib/
  utils.ts
```

`add` also installs missing dependencies and prints the import path. Use that path: examples in these docs use the `@/components/...` alias.

```tsx
import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export function AddressField() {
  return <ThaiAddressAutocomplete onValueChange={(address) => console.log(address)} />
}
```

## Components

<Cards>
  <Card title="Autocomplete" href="https://thai-zip.vercel.app/en/docs/react/autocomplete">
    Type to search by sub-district, district, province, or postal code.
  </Card>
  <Card title="Cascade select" href="https://thai-zip.vercel.app/en/docs/react/cascade-select">
    Pick province → district → sub-district in order.
  </Card>
  <Card title="Address form" href="https://thai-zip.vercel.app/en/docs/react/address-form">
    House number, moo, soi, and street plus the address hierarchy.
  </Card>
  <Card title="Address display" href="https://thai-zip.vercel.app/en/docs/react/address-display">
    Show a saved address read-only.
  </Card>
  <Card title="Address form field" href="https://thai-zip.vercel.app/en/docs/react/address-form-field">
    The cascade select bound to react-hook-form.
  </Card>
</Cards>

---

# Address autocomplete

> Search Thai addresses from typed text and receive a structured address value

Source: https://thai-zip.vercel.app/en/docs/react/autocomplete

`ThaiAddressAutocomplete` is for forms where people can search an address by sub-district, district, province, or postal code.

```tsx title="autocomplete-en.tsx"
'use client'

import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteEn() {
  return <ThaiAddressAutocomplete locale="en" onValueChange={(address) => console.log(address)} />
}
```

## Installation

**CLI**

```bash
npx react-thaizip add autocomplete
```

The CLI picks files based on the style `init` recorded: projects with a shadcn/ui `components.json` get files composed from shadcn primitives (Base UI, Radix, or React Aria); everything else gets the vanilla files.

**Manual — Vanilla**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip @base-ui/react`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `components/thai-address-autocomplete.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/thai-address-autocomplete.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).
1. The vanilla style also needs the design tokens in your global stylesheet — see [Customization](https://thai-zip.vercel.app/en/docs/react/customization#design-tokens).

**Manual — shadcn/ui · Base UI**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip`
1. Add the shadcn/ui primitives the component uses: `npx shadcn@latest add combobox input-group input button`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `components/thai-address-autocomplete.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/base/thai-address-autocomplete.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).

**Manual — shadcn/ui · Radix**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip`
1. Add the shadcn/ui primitives the component uses: `npx shadcn@latest add combobox input-group input button`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `components/thai-address-autocomplete.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/radix/thai-address-autocomplete.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).

**Manual — shadcn/ui · React Aria**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip`
1. Add the shadcn/ui primitives the component uses: `npx shadcn@latest add combobox input-group input button`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `components/thai-address-autocomplete.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/aria/thai-address-autocomplete.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).

## Usage

```tsx
import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'
```

```tsx
<ThaiAddressAutocomplete onValueChange={(address) => console.log(address)} />
```

`onValueChange` receives `ResolvedThaiAddress | null`. Selecting a suggestion yields a structured address; typing an empty query clears an existing resolved selection with `null`.

## Examples

### Thai search

Without `locale`, labels, the placeholder, and suggestions are in Thai (the default `locale="th"`). The returned data includes both Thai and English names either way.

```tsx title="autocomplete-demo.tsx"
'use client'

import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteDemo() {
  return <ThaiAddressAutocomplete onValueChange={(address) => console.log(address)} />
}
```

### Label

The component renders no label of its own. Wrap it in a `<label>` to give the field an accessible name, and add a description underneath.

```tsx title="autocomplete-label-en.tsx"
import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteLabelEn() {
  return (
    <div className="flex flex-col gap-2">
      <label className="flex flex-col gap-2 text-sm font-medium">
        Shipping address
        <ThaiAddressAutocomplete locale="en" />
      </label>
      <p className="text-sm text-muted-foreground">Type a sub-district, district, or postal code, then pick a result.</p>
    </div>
  )
}
```

### Controlled

Pass `value` to control the selection, or `defaultValue` to seed uncontrolled state. This example includes a clear button for the controlled value and shows the callback output.

```tsx title="autocomplete-controlled-en.tsx"
'use client'

import { useState } from 'react'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteControlledEn() {
  const [address, setAddress] = useState<ResolvedThaiAddress | null>(null)

  return (
    <div className="flex flex-col gap-3">
      <ThaiAddressAutocomplete locale="en" value={address} onValueChange={setAddress} />
      <div>
        <button type="button" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent" onClick={() => setAddress(null)}>
          Clear
        </button>
      </div>
      <p className="text-sm font-medium">Current value</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(address, null, 2)}</pre>
    </div>
  )
}
```

### Disabled

Pass `disabled` to turn off the search field.

```tsx title="autocomplete-disabled.tsx"
'use client'

import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteDisabled() {
  return <ThaiAddressAutocomplete disabled />
}
```

### Invalid

Pass `aria-invalid` to mark the field invalid, for example after failed form validation.

```tsx title="autocomplete-invalid.tsx"
'use client'

import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteInvalid() {
  return <ThaiAddressAutocomplete aria-invalid />
}
```

### Custom text

Use `texts` to override the placeholder and the loading/error/empty messages. Keys you don't pass keep the `locale` defaults.

```tsx title="autocomplete-texts-en.tsx"
import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteTextsEn() {
  return (
    <ThaiAddressAutocomplete
      locale="en"
      texts={{
        placeholder: 'Search by postal code, e.g. 10110',
        emptyText: 'No match. Try a district name instead.',
      }}
    />
  )
}
```

### Custom styling

Each part of the component has its own class prop (see [API Reference](#api-reference)). Classes are merged with `cn`, so they override the defaults.

```tsx title="autocomplete-styled-en.tsx"
import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function AutocompleteStyledEn() {
  return (
    <ThaiAddressAutocomplete
      locale="en"
      inputClassName="rounded-full"
      popupClassName="rounded-2xl"
      itemClassName="rounded-xl"
    />
  )
}
```

### Form

Pass `name` to submit the selected address with a regular form: the component renders four hidden inputs (`address-subdistrict`, `address-district`, `address-province`, `address-zipcode`). `required` blocks submission until an address is selected.

```tsx title="form-autocomplete-en.tsx"
'use client'

import { useRef, useState, type FormEvent } from 'react'
import { ThaiAddressAutocomplete } from '@/components/thai-address-autocomplete'

export default function FormAutocompleteEn() {
  const ref = useRef<HTMLInputElement>(null)
  const [payload, setPayload] = useState<Record<string, FormDataEntryValue> | null>(null)

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setPayload(Object.fromEntries(new FormData(event.currentTarget)))
  }

  return (
    <form className="flex flex-col gap-3" onSubmit={handleSubmit}>
      <ThaiAddressAutocomplete
        ref={ref}
        name="address"
        required
        locale="en"
      />
      <div className="flex gap-2">
        <button type="button" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent" onClick={() => ref.current?.focus()}>
          Focus address
        </button>
        <button type="submit" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent">
          Submit
        </button>
      </div>
      <p className="text-sm font-medium">Submitted FormData</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(payload, null, 2)}</pre>
    </form>
  )
}
```

Area names in the hidden inputs are always Thai, even when the UI is in English. With `disabled`, the hidden inputs are disabled too and left out of `FormData`. With a form library, keep `ResolvedThaiAddress | null` in form state and pass it back through `value`, so validation, reset, and submit share one value.

## API Reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `ResolvedThaiAddress \| null` | — | Controlled value; pass `null` to clear it. |
| `defaultValue` | `ResolvedThaiAddress \| null` | — | Uncontrolled seed value; also pre-fills the initial search text. |
| `onValueChange` | `(address: ResolvedThaiAddress \| null) => void` | — | Called when an address is selected or cleared. |
| `name` | `string` | — | Renders four hidden inputs: `<name>-subdistrict`, `-district`, `-province`, `-zipcode`. |
| `locale` | `'th' \| 'en'` | `'th'` | Locale of suggestions and default text. |
| `texts` | `Partial<ThaiAddressAutocompleteTexts>` | — | Overrides `placeholder`, `clearAriaLabel`, `loadingText`, `errorText`, `retryLabel`, `emptyText`. |
| `indexLoad` | `'mount' \| 'visible'` | `'mount'` | Load the address data on mount, or only once the field scrolls into view. |
| `limit` | `number` | `10` | Maximum number of suggestions. |
| `debounce` | `number` | `200` | Delay before searching, in milliseconds. |
| `threshold` | `number` | `0.4` | Minimum match score (0–1) for a suggestion to be shown. |
| `disabled` | `boolean` | `false` | Disables the field. |
| `required` | `boolean` | — | Marks the field as required. |
| `aria-invalid` | `React.AriaAttributes['aria-invalid']` | — | Marks the field invalid. |
| `onBlur` | `React.FocusEventHandler<HTMLInputElement>` | — | Fires when the input loses focus. |
| `onError` | `(error: Error) => void` | — | Runs if the address index cannot load. |
| `className` | `string` | — | Root wrapper. |
| `inputClassName` | `string` | — | Search input. |
| `popupClassName` | `string` | — | Results popup. |
| `itemClassName` | `string` | — | Each result item. |
| `ref` | `React.Ref<HTMLInputElement>` | — | Forwarded to the input. |

`limit`, `debounce`, and `threshold` are passed straight to thaizip's `useThaiAddressAutocomplete` hook.

---

# Address cascade select

> Choose province, district, and sub-district in order

Source: https://thai-zip.vercel.app/en/docs/react/cascade-select

`ThaiAddressCascadeSelect` is for forms where people choose an address in the order province → district → sub-district.

```tsx title="cascade-select-en.tsx"
'use client'

import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'

export default function CascadeSelectEn() {
  return <ThaiAddressCascadeSelect locale="en" onValueChange={(address) => console.log(address)} />
}
```

## Installation

**CLI**

```bash
npx react-thaizip add cascade-select
```

The CLI picks files based on the style `init` recorded: projects with a shadcn/ui `components.json` get files composed from shadcn primitives (Base UI, Radix, or React Aria); everything else gets the vanilla files.

**Manual — Vanilla**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip @base-ui/react`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/thai-address-cascade-select.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).
1. The vanilla style also needs the design tokens in your global stylesheet — see [Customization](https://thai-zip.vercel.app/en/docs/react/customization#design-tokens).

**Manual — shadcn/ui · Base UI**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip`
1. Add the shadcn/ui primitives the component uses: `npx shadcn@latest add select label button input`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/base/thai-address-cascade-select.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).

**Manual — shadcn/ui · Radix**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip`
1. Add the shadcn/ui primitives the component uses: `npx shadcn@latest add select label button input`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/radix/thai-address-cascade-select.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).

**Manual — shadcn/ui · React Aria**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip`
1. Add the shadcn/ui primitives the component uses: `npx shadcn@latest add select label button input`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/aria/thai-address-cascade-select.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).

## Usage

```tsx
import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'
```

```tsx
<ThaiAddressCascadeSelect onValueChange={(address) => console.log(address)} />
```

`onValueChange` receives `ResolvedThaiAddress | null` once all three levels have been chosen.

### Changing a parent choice

After a complete selection, changing the province or district emits `onValueChange(null)` and clears downstream selections that no longer match. The person then chooses the remaining address levels again.

## Examples

### Thai choices

Without `locale`, labels and options are in Thai (the default `locale="th"`).

```tsx title="cascade-select-demo.tsx"
'use client'

import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'

export default function CascadeSelectDemo() {
  return <ThaiAddressCascadeSelect onValueChange={(address) => console.log(address)} />
}
```

### Controlled

Pass `value` to control the full selection, or `defaultValue` to seed the selected address. This example includes a clear button and shows the callback value.

```tsx title="cascade-select-controlled-en.tsx"
'use client'

import { useState } from 'react'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'

export default function CascadeSelectControlledEn() {
  const [address, setAddress] = useState<ResolvedThaiAddress | null>(null)

  return (
    <div className="flex flex-col gap-3">
      <ThaiAddressCascadeSelect locale="en" value={address} onValueChange={setAddress} />
      <div>
        <button type="button" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent" onClick={() => setAddress(null)}>
          Clear
        </button>
      </div>
      <p className="text-sm font-medium">Current value</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(address, null, 2)}</pre>
    </div>
  )
}
```

### Disabled

Pass `disabled` to disable every select.

```tsx title="cascade-select-disabled.tsx"
'use client'

import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'

export default function CascadeSelectDisabled() {
  return <ThaiAddressCascadeSelect disabled />
}
```

### Invalid

Pass `aria-invalid` to mark all three triggers invalid.

```tsx title="cascade-select-invalid.tsx"
'use client'

import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'

export default function CascadeSelectInvalid() {
  return <ThaiAddressCascadeSelect aria-invalid />
}
```

### Custom text

Use `texts` to override labels and placeholders, for example to match Bangkok's khet/khwaeng wording. Keys you don't pass keep the `locale` defaults.

```tsx title="cascade-select-texts-en.tsx"
import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'

export default function CascadeSelectTextsEn() {
  return (
    <ThaiAddressCascadeSelect
      locale="en"
      texts={{
        provinceLabel: 'Delivery province',
        provincePlaceholder: 'Choose a province',
        districtLabel: 'District / khet',
        subdistrictLabel: 'Sub-district / khwaeng',
      }}
    />
  )
}
```

### Form

Pass `name` to submit the selection with a regular form: the component renders four hidden inputs (`address-subdistrict`, `address-district`, `address-province`, `address-zipcode`). `required` blocks submission until all three levels are chosen.

```tsx title="form-cascade-select-en.tsx"
'use client'

import { useRef, useState, type FormEvent } from 'react'
import { ThaiAddressCascadeSelect } from '@/components/thai-address-cascade-select'

export default function FormCascadeSelectEn() {
  const ref = useRef<HTMLButtonElement>(null)
  const [payload, setPayload] = useState<Record<string, FormDataEntryValue> | null>(null)

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setPayload(Object.fromEntries(new FormData(event.currentTarget)))
  }

  return (
    <form className="flex flex-col gap-3" onSubmit={handleSubmit}>
      <ThaiAddressCascadeSelect
        ref={ref}
        name="address"
        required
        locale="en"
      />
      <div className="flex gap-2">
        <button type="button" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent" onClick={() => ref.current?.focus()}>
          Focus address
        </button>
        <button type="submit" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent">
          Submit
        </button>
      </div>
      <p className="text-sm font-medium">Submitted FormData</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(payload, null, 2)}</pre>
    </form>
  )
}
```

The cascade does not emit a resolved value until all three levels are selected. While a province or district is only partly chosen, the component keeps that state itself: `value={null}` clears a complete selection but keeps a partial one. If a form reset must also clear a partial selection, remount it with a key:

```tsx
<ThaiAddressCascadeSelect key={resetVersion} name="address" value={address} onValueChange={setAddress} />
```

## API Reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `ResolvedThaiAddress \| null` | — | Controlled value; pass `null` to clear it. |
| `defaultValue` | `ResolvedThaiAddress \| null` | — | Uncontrolled seed value; pre-selects the complete address chain. |
| `onValueChange` | `(address: ResolvedThaiAddress \| null) => void` | — | Called when an address is completed or cleared. |
| `name` | `string` | — | Renders four hidden inputs: `<name>-subdistrict`, `-district`, `-province`, `-zipcode`. |
| `locale` | `'th' \| 'en'` | `'th'` | Label and option locale. |
| `texts` | `Partial<ThaiAddressCascadeSelectTexts>` | — | Overrides the `*Label`/`*Placeholder` text of each level plus `loadingText`, `errorText`, `retryLabel`. |
| `indexLoad` | `'mount' \| 'visible'` | `'mount'` | Load the address data on mount, or only once the field scrolls into view. |
| `disabled` | `boolean` | `false` | Disables all selects. |
| `required` | `boolean` | — | Marks all three selects as required. |
| `aria-invalid` | `React.AriaAttributes['aria-invalid']` | — | Marks all three triggers invalid. |
| `onBlur` | `React.FocusEventHandler<HTMLButtonElement>` | — | Runs when the province trigger loses focus. |
| `onError` | `(error: Error) => void` | — | Runs if the address index cannot load. |
| `className` | `string` | — | Root grid wrapper. |
| `labelClassName` | `string` | — | Every field label. |
| `triggerClassName` | `string` | — | Select triggers and the postal-code input. |
| `popupClassName` | `string` | — | Option popups. |
| `itemClassName` | `string` | — | Each option item. |
| `ref` | `React.Ref<HTMLButtonElement>` | — | Forwarded to the province trigger, the cascade's primary control. |

Both `ref` and `onBlur` target the province trigger, not the district or sub-district trigger.

---

# Full address form

> Capture house number, moo, soi, and street alongside an ordered address selection in one form

Source: https://thai-zip.vercel.app/en/docs/react/address-form

`ThaiAddressForm` is for forms that need a full address: house number/moo/soi/street plus sub-district, district, and province. It embeds a [cascade select](https://thai-zip.vercel.app/en/docs/react/cascade-select) internally.

```tsx title="address-form-en.tsx"
'use client'

import { ThaiAddressForm } from '@/components/thai-address-form'

export default function AddressFormEn() {
  return <ThaiAddressForm locale="en" onValueChange={(address) => console.log(address)} />
}
```

## Installation

**CLI**

```bash
npx react-thaizip add address-form
```

The CLI picks files based on the style `init` recorded: projects with a shadcn/ui `components.json` get files composed from shadcn primitives (Base UI, Radix, or React Aria); everything else gets the vanilla files.

**Manual — Vanilla**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip @base-ui/react`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/thai-address-cascade-select.tsx)
   - `components/thai-address-form.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/thai-address-form.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).
1. The vanilla style also needs the design tokens in your global stylesheet — see [Customization](https://thai-zip.vercel.app/en/docs/react/customization#design-tokens).

**Manual — shadcn/ui · Base UI**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip`
1. Add the shadcn/ui primitives the component uses: `npx shadcn@latest add select label button input`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/base/thai-address-cascade-select.tsx)
   - `components/thai-address-form.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/base/thai-address-form.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).

**Manual — shadcn/ui · Radix**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip`
1. Add the shadcn/ui primitives the component uses: `npx shadcn@latest add select label button input`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/radix/thai-address-cascade-select.tsx)
   - `components/thai-address-form.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/radix/thai-address-form.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).

**Manual — shadcn/ui · React Aria**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip`
1. Add the shadcn/ui primitives the component uses: `npx shadcn@latest add select label button input`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/aria/thai-address-cascade-select.tsx)
   - `components/thai-address-form.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/aria/thai-address-form.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).

## Usage

```tsx
import { ThaiAddressForm } from '@/components/thai-address-form'
```

```tsx
<ThaiAddressForm onValueChange={(address) => console.log(address)} />
```

`onValueChange` receives `FullThaiAddress | null`. It is only non-null once the house number is filled in and all three cascade levels are selected.

## Examples

### Thai input

Without `locale`, labels and options are in Thai (the default `locale="th"`), for both the free-text fields and the embedded cascade select.

```tsx title="address-form-demo.tsx"
'use client'

import { ThaiAddressForm } from '@/components/thai-address-form'

export default function AddressFormDemo() {
  return <ThaiAddressForm onValueChange={(address) => console.log(address)} />
}
```

### Controlled

Pass `value` to control the whole address, or `defaultValue` to seed it. An in-progress, not-yet-complete cascade selection is never cleared, even while the controlled `value` is `null`.

```tsx title="address-form-controlled-en.tsx"
'use client'

import { useState } from 'react'
import { ThaiAddressForm, type FullThaiAddress } from '@/components/thai-address-form'

export default function AddressFormControlledEn() {
  const [address, setAddress] = useState<FullThaiAddress | null>(null)

  return (
    <div className="flex flex-col gap-3">
      <ThaiAddressForm locale="en" value={address} onValueChange={setAddress} />
      <div>
        <button type="button" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent" onClick={() => setAddress(null)}>
          Clear
        </button>
      </div>
      <p className="text-sm font-medium">Current value</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(address, null, 2)}</pre>
    </div>
  )
}
```

### Disabled

Pass `disabled` to disable every text field and select.

```tsx title="address-form-disabled.tsx"
'use client'

import { ThaiAddressForm } from '@/components/thai-address-form'

export default function AddressFormDisabled() {
  return <ThaiAddressForm disabled />
}
```

### Invalid

Pass `aria-invalid` to mark the four text inputs and the cascade's triggers invalid.

```tsx title="address-form-invalid.tsx"
'use client'

import { ThaiAddressForm } from '@/components/thai-address-form'

export default function AddressFormInvalid() {
  return <ThaiAddressForm aria-invalid />
}
```

### Custom text

Use `texts` for this component's own labels and placeholders, and `cascadeTexts` for the embedded cascade select's.

```tsx title="address-form-texts-en.tsx"
import { ThaiAddressForm } from '@/components/thai-address-form'

export default function AddressFormTextsEn() {
  return (
    <ThaiAddressForm
      locale="en"
      texts={{
        houseNoLabel: 'House / unit no.',
        houseNoPlaceholder: 'e.g. 99/9, unit 1203',
        streetLabel: 'Building / road',
      }}
    />
  )
}
```

### Form

A complete checkout-style form: recipient and phone fields next to the address, with Cancel and Submit buttons. Passing `name` renders eight hidden inputs, so a plain `FormData` holds the whole address.

```tsx title="address-form-submit-en.tsx"
'use client'

import { useState, type FormEvent } from 'react'
import { ThaiAddressForm } from '@/components/thai-address-form'

export default function AddressFormSubmitEn() {
  const [payload, setPayload] = useState<Record<string, FormDataEntryValue> | null>(null)

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setPayload(Object.fromEntries(new FormData(event.currentTarget)))
  }

  return (
    <form className="flex flex-col gap-4" onSubmit={handleSubmit} onReset={() => setPayload(null)}>
      <div className="grid gap-4 sm:grid-cols-2">
        <label className="flex flex-col gap-1.5 text-sm font-medium">
          Recipient name
          <input name="recipient" required className="h-9 rounded-md border border-input bg-background px-3 text-sm" />
        </label>
        <label className="flex flex-col gap-1.5 text-sm font-medium">
          Phone
          <input name="phone" type="tel" required className="h-9 rounded-md border border-input bg-background px-3 text-sm" />
        </label>
      </div>
      <ThaiAddressForm name="address" required locale="en" />
      <div className="flex justify-end gap-2">
        <button type="reset" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent">
          Cancel
        </button>
        <button type="submit" className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90">
          Save address
        </button>
      </div>
      <p className="text-sm font-medium">Submitted FormData</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(payload, null, 2)}</pre>
    </form>
  )
}
```

## API Reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `FullThaiAddress \| null` | — | Controlled value; pass `null` to clear it. |
| `defaultValue` | `FullThaiAddress \| null` | — | Uncontrolled seed value; pre-fills both the text fields and the embedded cascade. |
| `onValueChange` | `(address: FullThaiAddress \| null) => void` | — | Called once the house number is non-blank and all three levels are selected, or when the value becomes incomplete again. |
| `name` | `string` | — | Renders 8 hidden inputs: the cascade's own 4 (`-subdistrict`/`-district`/`-province`/`-zipcode`) plus this component's own 4 (`-houseno`/`-moo`/`-soi`/`-street`). |
| `locale` | `'th' \| 'en'` | `'th'` | Text and label locale for both this component and the embedded cascade. |
| `texts` | `Partial<ThaiAddressFormTexts>` | — | Overrides the house-number/moo/soi/street labels and placeholders. |
| `cascadeTexts` | `Partial<ThaiAddressCascadeSelectTexts>` | — | Forwarded as the embedded cascade select's own `texts` prop. |
| `indexLoad` | `'mount' \| 'visible'` | `'mount'` | Forwarded to the embedded cascade's address-index loader. |
| `disabled` | `boolean` | `false` | Disables every text field and the embedded cascade. |
| `required` | `boolean` | `false` | Marks the house-number field and the embedded cascade as required (moo/soi/street stay optional). |
| `aria-invalid` | `React.AriaAttributes['aria-invalid']` | — | Marks the four text inputs and the cascade's triggers invalid. |
| `onBlur` | `React.FocusEventHandler<HTMLInputElement>` | — | Runs only when the house-number input loses focus. |
| `onError` | `(error: Error) => void` | — | Runs if the address index cannot load (handled by the embedded cascade). |
| `className` | `string` | — | Root wrapper. |
| `labelClassName` | `string` | — | Labels of the text fields and the cascade. |
| `inputClassName` | `string` | — | The house-number/moo/soi/street inputs. |
| `triggerClassName` | `string` | — | The cascade's select triggers and postal-code input. |
| `popupClassName` | `string` | — | The cascade's option popups. |
| `itemClassName` | `string` | — | Each of the cascade's option items. |
| `ref` | `React.Ref<HTMLInputElement>` | — | Forwarded to the house-number input. |

---

# Read-only address display

> Format an already-resolved Thai address as readable text, with no address-index loading

Source: https://thai-zip.vercel.app/en/docs/react/address-display

`ThaiAddressDisplay` is for pages that show an address you already have, such as an order summary or a user profile. It is purely presentational — no Base UI, and no address-index loading — so it ships in a single style.

```tsx title="address-display-en.tsx"
import { ThaiAddressDisplay, type ThaiAddressDisplayValue } from '@/components/thai-address-display'

const address: ThaiAddressDisplayValue = {
  tambon: 'คลองตันเหนือ',
  tambonEn: 'Khlong Tan Nuea',
  amphure: 'วัฒนา',
  amphureEn: 'Watthana',
  province: 'กรุงเทพมหานคร',
  provinceEn: 'Bangkok',
  zipCode: '10110',
  subdistrict: 'คลองตันเหนือ',
  subdistrictEn: 'Khlong Tan Nuea',
  district: 'วัฒนา',
  districtEn: 'Watthana',
  postalCode: '10110',
  houseNo: '99/9',
  moo: '4',
  soi: '2',
  street: 'สุขุมวิท',
}

export default function AddressDisplayEn() {
  return <ThaiAddressDisplay value={address} locale="en" />
}
```

## Installation

**CLI**

```bash
npx react-thaizip add address-display
```

The CLI picks files based on the style `init` recorded: projects with a shadcn/ui `components.json` get files composed from shadcn primitives (Base UI, Radix, or React Aria); everything else gets the vanilla files.

**Manual — Vanilla**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `components/thai-address-display.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/thai-address-display.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).
1. The vanilla style also needs the design tokens in your global stylesheet — see [Customization](https://thai-zip.vercel.app/en/docs/react/customization#design-tokens).

## Usage

```tsx
import { ThaiAddressDisplay } from '@/components/thai-address-display'
```

```tsx
<ThaiAddressDisplay value={address} />
```

`value` takes an already-resolved address (`ResolvedThaiAddress` plus optional `houseNo`/`moo`/`soi`/`street`, typed as `ThaiAddressDisplayValue`), or `null` when there is no address yet, which renders `emptyText` instead.

## Examples

### Multi-line

Use `mode="multi-line"` to render the house-number/moo/soi/street portion and the sub-district/district/province portion as separate rows.

```tsx title="address-display-multi-line-en.tsx"
import { ThaiAddressDisplay, type ThaiAddressDisplayValue } from '@/components/thai-address-display'

const address: ThaiAddressDisplayValue = {
  tambon: 'คลองตันเหนือ',
  tambonEn: 'Khlong Tan Nuea',
  amphure: 'วัฒนา',
  amphureEn: 'Watthana',
  province: 'กรุงเทพมหานคร',
  provinceEn: 'Bangkok',
  zipCode: '10110',
  subdistrict: 'คลองตันเหนือ',
  subdistrictEn: 'Khlong Tan Nuea',
  district: 'วัฒนา',
  districtEn: 'Watthana',
  postalCode: '10110',
  houseNo: '99/9',
  moo: '4',
  soi: '2',
  street: 'สุขุมวิท',
}

export default function AddressDisplayMultiLineEn() {
  return <ThaiAddressDisplay value={address} locale="en" mode="multi-line" />
}
```

### Thai output

Use `locale="th"` for Thai names. "หมู่"/"ซอย" automatically become the standard transliterations "Moo"/"Soi" only under `locale="en"` — the house number and street name always render exactly as supplied.

```tsx title="address-display-demo.tsx"
import { ThaiAddressDisplay, type ThaiAddressDisplayValue } from '@/components/thai-address-display'

const address: ThaiAddressDisplayValue = {
  tambon: 'คลองตันเหนือ',
  tambonEn: 'Khlong Tan Nuea',
  amphure: 'วัฒนา',
  amphureEn: 'Watthana',
  province: 'กรุงเทพมหานคร',
  provinceEn: 'Bangkok',
  zipCode: '10110',
  subdistrict: 'คลองตันเหนือ',
  subdistrictEn: 'Khlong Tan Nuea',
  district: 'วัฒนา',
  districtEn: 'Watthana',
  postalCode: '10110',
  houseNo: '99/9',
  moo: '4',
  soi: '2',
  street: 'สุขุมวิท',
}

export default function AddressDisplayDemo() {
  return <ThaiAddressDisplay value={address} />
}
```

### No house-level fields

When `value` has none of `houseNo`/`moo`/`soi`/`street`, only the sub-district/district/province portion is rendered.

```tsx title="address-display-minimal-en.tsx"
import { ThaiAddressDisplay, type ThaiAddressDisplayValue } from '@/components/thai-address-display'

const address: ThaiAddressDisplayValue = {
  tambon: 'คลองตันเหนือ',
  tambonEn: 'Khlong Tan Nuea',
  amphure: 'วัฒนา',
  amphureEn: 'Watthana',
  province: 'กรุงเทพมหานคร',
  provinceEn: 'Bangkok',
  zipCode: '10110',
  subdistrict: 'คลองตันเหนือ',
  subdistrictEn: 'Khlong Tan Nuea',
  district: 'วัฒนา',
  districtEn: 'Watthana',
  postalCode: '10110',
}

export default function AddressDisplayMinimalEn() {
  return <ThaiAddressDisplay value={address} locale="en" />
}
```

### Empty

Pass `value={null}` to render `emptyText` instead (the default is "No address" or "ไม่มีที่อยู่", based on `locale`).

```tsx title="address-display-empty-en.tsx"
import { ThaiAddressDisplay } from '@/components/thai-address-display'

export default function AddressDisplayEmptyEn() {
  return <ThaiAddressDisplay value={null} locale="en" />
}
```

### Custom styling

Use `className` for the `<address>` element and `lineClassName` for each row in multi-line mode — here, a card with an emphasized first line.

```tsx title="address-display-styled-en.tsx"
import { ThaiAddressDisplay, type ThaiAddressDisplayValue } from '@/components/thai-address-display'

const address: ThaiAddressDisplayValue = {
  tambon: 'คลองตันเหนือ',
  tambonEn: 'Khlong Tan Nuea',
  amphure: 'วัฒนา',
  amphureEn: 'Watthana',
  province: 'กรุงเทพมหานคร',
  provinceEn: 'Bangkok',
  zipCode: '10110',
  subdistrict: 'คลองตันเหนือ',
  subdistrictEn: 'Khlong Tan Nuea',
  district: 'วัฒนา',
  districtEn: 'Watthana',
  postalCode: '10110',
  houseNo: '99/9',
  moo: '4',
  soi: '2',
  street: 'สุขุมวิท',
}

export default function AddressDisplayStyledEn() {
  return (
    <ThaiAddressDisplay
      value={address}
      locale="en"
      mode="multi-line"
      className="rounded-lg border p-4 not-italic"
      lineClassName="first:font-medium"
    />
  )
}
```

## API Reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `ThaiAddressDisplayValue \| null` | — | **Required.** The address to render; `null` renders `emptyText` instead. |
| `locale` | `'th' \| 'en'` | `'th'` | Drives the locality label and the default `emptyText`. |
| `mode` | `'single-line' \| 'multi-line'` | `'single-line'` | `'single-line'` joins the street portion and the locality with `', '`; `'multi-line'` renders them as two rows. |
| `emptyText` | `string` | `'ไม่มีที่อยู่'` / `'No address'` | Replacement text when `value` is `null`, based on `locale`. |
| `className` | `string` | — | The root `<address>`. |
| `lineClassName` | `string` | — | Each row in `'multi-line'` mode. |
| `ref` | `React.Ref<HTMLElement>` | — | Forwarded to the `<address>` element. |

---

# react-hook-form address field

> Wrap the cascade select in react-hook-form's Controller to bind it to an existing form's validation and state

Source: https://thai-zip.vercel.app/en/docs/react/address-form-field

`ThaiAddressFormField` is for forms that already use [react-hook-form](https://react-hook-form.com/) and want the address bound directly into form state, instead of reading it from hidden inputs. Its prop shape is deliberately different from the other components: pass `control`/`name`/`rules` the same way you would to react-hook-form's own `useController`, and there is no `value`/`onValueChange`/hidden inputs.

```tsx title="address-form-field-en.tsx"
'use client'

import { useState } from 'react'
import { useForm } from 'react-hook-form'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressFormField } from '@/components/thai-address-form-field'

type FormValues = {
  address: ResolvedThaiAddress | null
}

export default function AddressFormFieldEn() {
  const { control, handleSubmit } = useForm<FormValues>({ defaultValues: { address: null } })
  const [submitted, setSubmitted] = useState<ResolvedThaiAddress | null>(null)

  return (
    <form className="flex flex-col gap-3" onSubmit={handleSubmit((data) => setSubmitted(data.address))}>
      <ThaiAddressFormField
        control={control}
        name="address"
        locale="en"
      />
      <div>
        <button type="submit" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent">
          Submit
        </button>
      </div>
      <p className="text-sm font-medium">Submitted value</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(submitted, null, 2)}</pre>
    </form>
  )
}
```

## Installation

**CLI**

```bash
npx react-thaizip add address-form-field
```

The CLI picks files based on the style `init` recorded: projects with a shadcn/ui `components.json` get files composed from shadcn primitives (Base UI, Radix, or React Aria); everything else gets the vanilla files.

**Manual — Vanilla**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip @base-ui/react react-hook-form`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/thai-address-cascade-select.tsx)
   - `components/thai-address-form-field.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/thai-address-form-field.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).
1. The vanilla style also needs the design tokens in your global stylesheet — see [Customization](https://thai-zip.vercel.app/en/docs/react/customization#design-tokens).

**Manual — shadcn/ui · Base UI**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip react-hook-form`
1. Add the shadcn/ui primitives the component uses: `npx shadcn@latest add select label button input`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/base/thai-address-cascade-select.tsx)
   - `components/thai-address-form-field.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/base/thai-address-form-field.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).

**Manual — shadcn/ui · Radix**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip react-hook-form`
1. Add the shadcn/ui primitives the component uses: `npx shadcn@latest add select label button input`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/radix/thai-address-cascade-select.tsx)
   - `components/thai-address-form-field.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/radix/thai-address-form-field.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).

**Manual — shadcn/ui · React Aria**

1. Install the dependencies: `npm install clsx tailwind-merge thaizip react-hook-form`
1. Add the shadcn/ui primitives the component uses: `npx shadcn@latest add select label button input`
1. Copy these files into your project:
   - `lib/utils.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/lib/utils.ts)
   - `hooks/use-thai-address-index.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-index.ts)
   - `hooks/use-thai-address-cascade.ts` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/hooks/use-thai-address-cascade.ts)
   - `components/thai-address-cascade-select.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/aria/thai-address-cascade-select.tsx)
   - `components/thai-address-form-field.tsx` — [source](https://github.com/naay99999/react-thai-zip/blob/react-thaizip-v0.4.1/templates/react/ts/shadcn/aria/thai-address-form-field.tsx)
1. Update the `@/lib/...` and `@/hooks/...` imports to match where the files live in your project (the CLI does this for you).

## Usage

```tsx
import { useForm } from 'react-hook-form'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressFormField } from '@/components/thai-address-form-field'
```

```tsx
const { control, handleSubmit } = useForm<{ address: ResolvedThaiAddress | null }>({
  defaultValues: { address: null },
})
```

```tsx
<ThaiAddressFormField control={control} name="address" />
```

The `name` you pass (`"address"` above) must have type `ResolvedThaiAddress | null` in the form's value type. react-hook-form's own `handleSubmit` reads the value directly — this component renders no hidden inputs.

## Examples

### Thai input

Without `locale`, labels and options of the embedded cascade select are in Thai (the default `locale="th"`).

```tsx title="address-form-field-demo.tsx"
'use client'

import { useState } from 'react'
import { useForm } from 'react-hook-form'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressFormField } from '@/components/thai-address-form-field'

type FormValues = {
  address: ResolvedThaiAddress | null
}

export default function AddressFormFieldDemo() {
  const { control, handleSubmit } = useForm<FormValues>({ defaultValues: { address: null } })
  const [submitted, setSubmitted] = useState<ResolvedThaiAddress | null>(null)

  return (
    <form className="flex flex-col gap-3" onSubmit={handleSubmit((data) => setSubmitted(data.address))}>
      <ThaiAddressFormField
        control={control}
        name="address"
      />
      <div>
        <button type="submit" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent">
          ส่งฟอร์ม
        </button>
      </div>
      <p className="text-sm font-medium">ค่าที่ส่ง</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(submitted, null, 2)}</pre>
    </form>
  )
}
```

### Validation

Pass `rules` the same way you would to react-hook-form's `Controller`/`useController`. This example requires a complete address before the form can submit; the error message renders as a `<p role="alert">` below the cascade select.

```tsx title="address-form-field-validation-en.tsx"
'use client'

import { useState } from 'react'
import { useForm } from 'react-hook-form'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressFormField } from '@/components/thai-address-form-field'

type FormValues = {
  address: ResolvedThaiAddress | null
}

export default function AddressFormFieldValidationEn() {
  const { control, handleSubmit } = useForm<FormValues>({ defaultValues: { address: null } })
  const [submitted, setSubmitted] = useState<ResolvedThaiAddress | null>(null)

  return (
    <form className="flex flex-col gap-3" onSubmit={handleSubmit((data) => setSubmitted(data.address))}>
      <ThaiAddressFormField
        control={control}
        name="address"
        locale="en"
        rules={{ validate: (value) => value !== null || 'Please select a complete address.' }}
      />
      <div>
        <button type="submit" className="rounded-md border border-input bg-background px-3 py-1.5 text-sm hover:bg-accent">
          Submit
        </button>
      </div>
      <p className="text-sm font-medium">Submitted value</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(submitted, null, 2)}</pre>
    </form>
  )
}
```

### Disabled

Pass `disabled` to disable every select in the embedded cascade.

```tsx title="address-form-field-disabled.tsx"
'use client'

import { useForm } from 'react-hook-form'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressFormField } from '@/components/thai-address-form-field'

type FormValues = {
  address: ResolvedThaiAddress | null
}

export default function AddressFormFieldDisabled() {
  const { control } = useForm<FormValues>({ defaultValues: { address: null } })

  return <ThaiAddressFormField control={control} name="address" disabled />
}
```

### Form

The address field alongside an ordinary `register`ed input in the same form: both validate on submit, and `handleSubmit` receives every value at once.

```tsx title="address-form-field-full-en.tsx"
'use client'

import { useState } from 'react'
import { useForm } from 'react-hook-form'
import type { ResolvedThaiAddress } from 'thaizip'
import { ThaiAddressFormField } from '@/components/thai-address-form-field'

type FormValues = {
  name: string
  address: ResolvedThaiAddress | null
}

export default function AddressFormFieldFullEn() {
  const { control, register, handleSubmit, formState: { errors } } = useForm<FormValues>({
    defaultValues: { name: '', address: null },
  })
  const [submitted, setSubmitted] = useState<FormValues | null>(null)

  return (
    <form className="flex flex-col gap-4" onSubmit={handleSubmit(setSubmitted)}>
      <label className="flex flex-col gap-1.5 text-sm font-medium">
        Full name
        <input
          {...register('name', { required: 'Please enter your name.' })}
          aria-invalid={errors.name ? true : undefined}
          className="h-9 rounded-md border border-input bg-background px-3 text-sm"
        />
        {errors.name && <span role="alert" className="text-sm font-normal text-destructive">{errors.name.message}</span>}
      </label>
      <ThaiAddressFormField
        control={control}
        name="address"
        locale="en"
        rules={{ validate: (value) => value !== null || 'Please select a complete address.' }}
      />
      <div>
        <button type="submit" className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90">
          Sign up
        </button>
      </div>
      <p className="text-sm font-medium">Submitted value</p>
      <pre className="max-h-72 overflow-auto rounded-md border bg-muted p-3 text-xs">{JSON.stringify(submitted, null, 2)}</pre>
    </form>
  )
}
```

## API Reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `control` | `Control<TFieldValues>` | — | **Required.** The `control` value from react-hook-form's `useForm()`. |
| `name` | `FieldPath<TFieldValues>` | — | **Required.** The form field's path; its value type must be `ResolvedThaiAddress \| null`. |
| `rules` | `Omit<RegisterOptions<…>, 'valueAsNumber' \| 'valueAsDate' \| 'setValueAs' \| 'disabled'>` | — | Forwarded to react-hook-form's `Controller` for validation. |
| `locale` | `'th' \| 'en'` | `'th'` | Label and option locale for the embedded cascade select. |
| `texts` | `Partial<ThaiAddressCascadeSelectTexts>` | — | Forwarded as the embedded cascade select's own `texts` prop. |
| `indexLoad` | `'mount' \| 'visible'` | `'mount'` | Forwarded to the embedded cascade's address-index loader. |
| `disabled` | `boolean` | — | Disables every select in the embedded cascade select. |
| `className` | `string` | — | The `<div>` wrapping the cascade select and its validation message. |
| `labelClassName` | `string` | — | The cascade select's labels. |
| `triggerClassName` | `string` | — | The cascade select's triggers and postal-code input. |
| `popupClassName` | `string` | — | The cascade select's option popups. |
| `itemClassName` | `string` | — | Each of the cascade select's option items. |
| `errorClassName` | `string` | — | The `role="alert"` validation message shown when validation fails. |

This component accepts no `value`/`onValueChange`/`ref`/`name` (for a hidden input) like the other components do — react-hook-form reads and writes the value through the `control`/`name` you pass instead.

---

# Customization

> Theme the scaffolded components with design tokens, or edit their source

Source: https://thai-zip.vercel.app/en/docs/react/customization

Text and class props are covered on each component page (`locale`, `texts`, and the `*ClassName` slots). This page covers the theme tokens and when to edit the source.

## Design tokens

`init` adds a token block to your global stylesheet. The components read these CSS custom properties: `--background`, `--foreground`, `--popover`, `--primary`, `--muted`, `--accent`, `--destructive`, `--border`, `--input`, `--ring`, and `--radius`.

```css
:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --border: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
  --radius: 0.625rem;
  /* … */
}
```

`.dark` overrides the colors in the same block. Change the values to retheme every component. A shadcn/ui project already has these tokens.

## Tailwind v4

The block includes `@theme inline`, which maps the properties to classes such as `bg-background`, `border-input`, and `ring-ring`. Nothing else to do.

## Tailwind v3

Tailwind v3 has no `@theme inline`, so `init` prints this snippet for `theme.extend` in `tailwind.config.{js,ts}`. The token classes have no styles until you add it:

```ts
colors: {
  background: 'hsl(var(--background))',
  foreground: 'hsl(var(--foreground))',
  popover: { DEFAULT: 'hsl(var(--popover))', foreground: 'hsl(var(--popover-foreground))' },
  primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
  muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' },
  accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))' },
  destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' },
  border: 'hsl(var(--border))',
  input: 'hsl(var(--input))',
  ring: 'hsl(var(--ring))',
},
borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)' },
```

## Edit the source

For layout, markup, or behavior changes, edit the component file `add` generated. Keep its imports of `utils` and the hooks. Rerunning `add` won't overwrite your edits unless you pass `--overwrite`.

---

# CLI

> Commands, flags, thaizip.config.json, and fixes for common problems

Source: https://thai-zip.vercel.app/en/docs/react/cli

Run the CLI from the root of the project you want to scaffold.

```text
react-thaizip init [--yes]
react-thaizip add [component...] [--yes] [--overwrite]
react-thaizip --help | --version
```

## `init`

Detects the project, then prints a summary before asking anything:

```text
Detected project settings:
  Components directory: app/components
  Lib directory: lib
  Hooks directory: hooks
  Package manager: npm
  Language: TypeScript
  Tailwind: v4 (app/globals.css)
  Component style: vanilla Tailwind (no components.json found)
```

You can change the component directory at the prompt. `init` then adds design tokens to the global stylesheet, offers to install `thaizip`, and writes `thaizip.config.json`. Any steps you must do by hand are printed together at the end under `=== Manual steps required ===`.

## `add`

```npm
npx react-thaizip add autocomplete cascade-select
```

With no names, `add` opens a multiselect. Names are case-insensitive:

| Component | Accepted names |
| --- | --- |
| `ThaiAddressAutocomplete` | `autocomplete`, `thai-address-autocomplete` |
| `ThaiAddressCascadeSelect` | `cascade`, `cascade-select`, `thai-address-cascade-select` |
| `ThaiAddressForm` | `address-form`, `thai-address-form` |
| `ThaiAddressDisplay` | `address-display`, `thai-address-display` |
| `ThaiAddressFormField` | `address-form-field`, `thai-address-form-field` |

The PascalCase component name works too. Shared files (`lib/utils.ts` and the hooks) are added automatically. `add` requires `thaizip` 0.7.5 or later, installs missing dependencies, and, in a shadcn/ui project, runs the shadcn CLI for any missing primitives.

## Flags

| Flag | Effect |
| --- | --- |
| `--yes`, `-y` | Skip prompts and take each prompt's default. The default for overwriting is **no**. |
| `--overwrite` | Replace existing files without asking, including the protected `lib`/hook files. `add` only. |
| `--help`, `-h` | Print help; after `init` or `add` for that command. Wins over `--version`. |
| `--version`, `-v` | Print the CLI version. |

## `thaizip.config.json`

```json
{
  "typescript": true,
  "componentDir": "app/components",
  "libDir": "lib",
  "hooksDir": "hooks",
  "packageManager": "npm",
  "tailwind": { "version": 4, "css": "app/globals.css" },
  "style": "vanilla",
  "shadcnBase": "",
  "shadcnUiAlias": "",
  "shadcnUiDir": "",
  "registryVersion": "0.3.7"
}
```

Directories are relative to the project root. `typescript: false` makes `add` write `.jsx`/`.js`. `style` is `vanilla` or `shadcn`, with `shadcnBase` set to `base`, `radix`, or `aria` for shadcn. To switch style, edit the file and rerun `add --overwrite`. Older config versions are migrated automatically.

## Troubleshooting

**`init` cannot detect Tailwind.** Install Tailwind for your framework first, then rerun `init`.

**`No global CSS file was found`.** Copy the printed token block into the stylesheet your app loads, and set its path in `tailwind.css`.

**Token classes like `bg-background` have no styles (Tailwind v3).** Add the `theme.extend` snippet `init` printed to `tailwind.config`, then restart the dev server. See [Customization](https://thai-zip.vercel.app/en/docs/react/customization#tailwind-v3).

**`thaizip` is older than 0.7.5.** Run `npm i thaizip@latest`, then rerun `add`.

**`Failed to install`.** Install the printed package list with your package manager, then rerun the command.

**A file was skipped.** Existing components are kept unless you confirm or pass `--overwrite`. `lib`/hook files are always kept, even with `--yes`; refresh one with `npx react-thaizip add utils --overwrite`. If `add` warns that a file predates the current registry, run the command it prints.

**Import paths look wrong.** Check the three directories in `thaizip.config.json`, `cd` to the project root, and rerun `add <component> --overwrite`.
