thaizip 0.6.0 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.cjs CHANGED
@@ -1,15 +1,19 @@
1
+ "use client";
1
2
  'use strict';
2
3
 
3
4
  var react = require('react');
4
5
 
5
- // src/react/useThaiAddressAutocomplete.ts
6
-
7
6
  // src/core/normalizer.ts
8
7
  var TONE_MARKS = /[่-๋็์]/g;
9
8
  var ADDRESS_PREFIXES = /^(จังหวัด|อำเภอ|ตำบล|แขวง|เขต|จ\.|อ\.|ต\.|ข\.)/;
10
9
  function normalizeThaiAddressText(input) {
11
- if (!input) return "";
12
- let text = input.trim();
10
+ if (typeof input !== "string") {
11
+ throw new TypeError(
12
+ `[thaizip] normalizeThaiAddressText expected string, got ${input === null ? "null" : typeof input}`
13
+ );
14
+ }
15
+ if (input.length === 0) return "";
16
+ let text = input.normalize("NFC").trim();
13
17
  text = text.replace(ADDRESS_PREFIXES, "").trim();
14
18
  text = text.replace(TONE_MARKS, "");
15
19
  text = text.toLowerCase();
@@ -30,61 +34,252 @@ function extractTrigramsNormalized(text) {
30
34
  return result;
31
35
  }
32
36
 
37
+ // src/core/romanize.ts
38
+ var ALIASES = /* @__PURE__ */ new Map([
39
+ // --- Bangkok khet (districts) — concatenated ("no space") forms ---------
40
+ ["phranakhon", "phra nakhon"],
41
+ ["nongchok", "nong chok"],
42
+ ["bangrak", "bang rak"],
43
+ ["bangkhen", "bang khen"],
44
+ ["bangkapi", "bang kapi"],
45
+ ["pathumwan", "pathum wan"],
46
+ ["patumwan", "pathum wan"],
47
+ ["pomprap", "pom prap sattru phai"],
48
+ ["phrakhanong", "phra khanong"],
49
+ ["minburi", "min buri"],
50
+ ["latkrabang", "lat krabang"],
51
+ ["ladkrabang", "lat krabang"],
52
+ ["yannawa", "yan nawa"],
53
+ ["phayathai", "phaya thai"],
54
+ ["thonburi", "thon buri"],
55
+ ["bangkokyai", "bangkok yai"],
56
+ ["huaikhwang", "huai khwang"],
57
+ ["huaykhwang", "huai khwang"],
58
+ ["huaykwang", "huai khwang"],
59
+ ["khlongsan", "khlong san"],
60
+ ["klongsan", "khlong san"],
61
+ ["talingchan", "taling chan"],
62
+ ["bangkoknoi", "bangkok noi"],
63
+ ["bangkhunthian", "bang khun thian"],
64
+ ["phasicharoen", "phasi charoen"],
65
+ ["nongkhaem", "nong khaem"],
66
+ ["ratburana", "rat burana"],
67
+ ["bangphlat", "bang phlat"],
68
+ ["dindaeng", "din daeng"],
69
+ ["buengkum", "bueng kum"],
70
+ ["bangsue", "bang sue"],
71
+ ["bangkholaem", "bang kho laem"],
72
+ ["khlongtoei", "khlong toei"],
73
+ ["klongtoei", "khlong toei"],
74
+ ["klongtoey", "khlong toei"],
75
+ ["khlongtoey", "khlong toei"],
76
+ ["suanluang", "suan luang"],
77
+ ["chomthong", "chom thong"],
78
+ ["donmueang", "don mueang"],
79
+ ["donmuang", "don mueang"],
80
+ ["latphrao", "lat phrao"],
81
+ ["ladprao", "lat phrao"],
82
+ ["lardprao", "lat phrao"],
83
+ ["lat prao", "lat phrao"],
84
+ ["bangkhae", "bang khae"],
85
+ ["laksi", "lak si"],
86
+ ["saimai", "sai mai"],
87
+ ["khannayao", "khan na yao"],
88
+ ["saphansung", "saphan sung"],
89
+ ["wangthonglang", "wang thonglang"],
90
+ ["khlongsamwa", "khlong sam wa"],
91
+ ["klongsamwa", "khlong sam wa"],
92
+ ["bangna", "bang na"],
93
+ ["thawiwatthana", "thawi watthana"],
94
+ ["thungkhru", "thung khru"],
95
+ ["bangbon", "bang bon"],
96
+ // --- Bangkok area / street nicknames with a common misspelling ----------
97
+ ["sathorn", "sathon"],
98
+ ["wattana", "watthana"],
99
+ ["silom", "si lom"],
100
+ // --- Provinces and other high-traffic areas ------------------------------
101
+ ["krungthep", "bangkok"],
102
+ ["korat", "nakhon ratchasima"],
103
+ ["nakornratchasima", "nakhon ratchasima"],
104
+ ["chiangmai", "chiang mai"],
105
+ ["chiangrai", "chiang rai"],
106
+ ["pathumthani", "pathum thani"],
107
+ ["patumthani", "pathum thani"],
108
+ ["samutprakan", "samut prakan"],
109
+ ["samutsakhon", "samut sakhon"],
110
+ ["samutsongkhram", "samut songkhram"],
111
+ ["nakhonpathom", "nakhon pathom"],
112
+ ["nakhonsawan", "nakhon sawan"],
113
+ ["nakhonsithammarat", "nakhon si thammarat"],
114
+ ["prachuapkhirikhan", "prachuap khiri khan"],
115
+ ["huahin", "hua hin"],
116
+ ["hatyai", "hat yai"],
117
+ ["hadyai", "hat yai"],
118
+ ["had yai", "hat yai"],
119
+ ["lopburi", "loburi"],
120
+ ["lop buri", "loburi"],
121
+ ["buengkan", "buogkan"],
122
+ ["bungkan", "buogkan"],
123
+ ["bung kan", "buogkan"],
124
+ ["udonthani", "udon thani"],
125
+ ["khonkaen", "khon kaen"],
126
+ ["suphanburi", "suphan buri"],
127
+ ["nongkhai", "nong khai"],
128
+ ["buriram", "buri ram"]
129
+ ]);
130
+ var NOISE_WORDS_RE = /\b(?:sub[- ]?district|district|province|changwat|amphoe|amphur)\b/g;
131
+ var WHITESPACE_RE = /\s+/g;
132
+ function cleanup(text) {
133
+ const stripped = text.replace(NOISE_WORDS_RE, " ").replace(WHITESPACE_RE, " ").trim();
134
+ return stripped.length > 0 ? stripped : text;
135
+ }
136
+ function applyRomanizationAliases(normalized) {
137
+ if (!normalized) return normalized;
138
+ const direct = ALIASES.get(normalized);
139
+ if (direct !== void 0) return direct;
140
+ const cleaned = cleanup(normalized);
141
+ if (cleaned === normalized) return normalized;
142
+ const aliasedCleaned = ALIASES.get(cleaned);
143
+ return aliasedCleaned !== void 0 ? aliasedCleaned : cleaned;
144
+ }
145
+
33
146
  // src/core/search.ts
34
147
  var ZIP_CODE_RE = /^\d+$/;
148
+ var HAS_LATIN_RE = /[a-z]/i;
149
+ var TH_COLLATOR = new Intl.Collator("th");
150
+ function compareZip(a, b) {
151
+ if (a < b) return -1;
152
+ if (a > b) return 1;
153
+ return 0;
154
+ }
155
+ function sortZipMatches(matches, exactZip) {
156
+ matches.sort((a, b) => {
157
+ if (a.zipCode === exactZip && b.zipCode !== exactZip) return -1;
158
+ if (b.zipCode === exactZip && a.zipCode !== exactZip) return 1;
159
+ return compareZip(a.zipCode, b.zipCode);
160
+ });
161
+ return matches;
162
+ }
163
+ function normalizeLimit(value) {
164
+ if (Number.isNaN(value) || value === -Infinity) return 0;
165
+ if (value === Infinity) return value;
166
+ return Math.max(0, Math.floor(value));
167
+ }
168
+ function lookupByZipCode(index, zip, options) {
169
+ if (!index || !index.zipIndex || !index.records || typeof zip !== "string") return [];
170
+ const normalized = zip.trim();
171
+ if (!ZIP_CODE_RE.test(normalized) || normalized.length < 2) return [];
172
+ const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
173
+ const matches = [];
174
+ const keys = index.sortedZipKeys;
175
+ const postings = index.sortedZipPostings;
176
+ if (keys !== void 0 && postings !== void 0) {
177
+ let lo = 0;
178
+ let hi = keys.length;
179
+ while (lo < hi) {
180
+ const mid = lo + hi >> 1;
181
+ if (keys[mid] < normalized) lo = mid + 1;
182
+ else hi = mid;
183
+ }
184
+ for (let i = lo; i < keys.length && keys[i].startsWith(normalized); i++) {
185
+ const indices = postings[i];
186
+ for (let j = 0; j < indices.length; j++) matches.push(index.records[indices[j]]);
187
+ }
188
+ } else {
189
+ for (const [z, indices] of index.zipIndex) {
190
+ if (z.startsWith(normalized)) {
191
+ for (const idx of indices) matches.push(index.records[idx]);
192
+ }
193
+ }
194
+ sortZipMatches(matches, normalized);
195
+ }
196
+ return matches.slice(0, zipLimit).map((record) => ({ ...record }));
197
+ }
198
+ function rankAgainstName(name, query) {
199
+ if (name === void 0 || name.length === 0) return 0;
200
+ if (name === query) return 3;
201
+ if (name.startsWith(query)) return 2;
202
+ if (name.includes(query)) return 1;
203
+ return 0;
204
+ }
205
+ function computeMatchRank(index, idx, query) {
206
+ const th = rankAgainstName(index.normTambon?.[idx], query);
207
+ if (th === 3) return 3;
208
+ const en = rankAgainstName(index.normTambonEn?.[idx], query);
209
+ return en > th ? en : th;
210
+ }
211
+ var scratchByIndex = /* @__PURE__ */ new WeakMap();
212
+ function getScratch(index) {
213
+ const n = index.records.length;
214
+ let s = scratchByIndex.get(index);
215
+ if (s === void 0 || s.counts.length < n) {
216
+ s = { counts: new Uint32Array(n), touched: new Int32Array(n) };
217
+ scratchByIndex.set(index, s);
218
+ }
219
+ return s;
220
+ }
35
221
  function searchThaiAddress(index, query, options) {
36
- const limit = options?.limit ?? 10;
222
+ const limit = normalizeLimit(options?.limit ?? 10);
37
223
  const threshold = options?.threshold ?? 0.4;
38
- if (!index || !query) return [];
224
+ if (!index || !index.map || !index.records || typeof query !== "string") return [];
225
+ if (query.length > 1e3) return [];
39
226
  const normalized = normalizeThaiAddressText(query);
40
227
  if (normalized.length === 0) return [];
41
228
  if (normalized.length > 300) return [];
42
229
  if (ZIP_CODE_RE.test(normalized)) {
43
230
  if (normalized.length < 2) return [];
44
- const matches = [];
45
- for (const [zip, indices] of index.zipIndex) {
46
- if (zip.startsWith(normalized)) {
47
- for (const idx of indices) matches.push(index.records[idx]);
48
- }
49
- }
50
- matches.sort((a, b) => {
51
- if (a.zipCode === normalized && b.zipCode !== normalized) return -1;
52
- if (b.zipCode === normalized && a.zipCode !== normalized) return 1;
53
- return a.zipCode.localeCompare(b.zipCode, void 0, { numeric: true });
231
+ return lookupByZipCode(index, normalized, {
232
+ zipLimit: options?.zipLimit ?? Infinity
54
233
  });
55
- return matches.slice(0, limit);
56
234
  }
57
- if (normalized.length < 3 && query.trim().length < 3) return [];
58
- const queryTrigrams = extractTrigramsNormalized(normalized);
59
- const hits = /* @__PURE__ */ new Map();
235
+ if (normalized.length < 3) return [];
236
+ const searchText = options?.romanizationAliases !== false && HAS_LATIN_RE.test(normalized) ? applyRomanizationAliases(normalized) : normalized;
237
+ const queryTrigrams = extractTrigramsNormalized(searchText);
238
+ const { counts, touched } = getScratch(index);
239
+ let touchedLen = 0;
60
240
  for (const trigram of queryTrigrams) {
61
241
  const candidates = index.map.get(trigram);
62
242
  if (!candidates) continue;
63
243
  for (const idx of candidates) {
64
- hits.set(idx, (hits.get(idx) ?? 0) + 1);
244
+ if (counts[idx] === 0) touched[touchedLen++] = idx;
245
+ counts[idx]++;
65
246
  }
66
247
  }
67
248
  const scored = [];
68
- for (const [idx, count] of hits) {
69
- const score = count / queryTrigrams.size;
249
+ const totalTrigrams = queryTrigrams.size;
250
+ for (let i = 0; i < touchedLen; i++) {
251
+ const idx = touched[i];
252
+ const score = counts[idx] / totalTrigrams;
253
+ counts[idx] = 0;
70
254
  if (score >= threshold) {
71
- scored.push({ idx, score });
255
+ scored.push({ idx, score, matchRank: computeMatchRank(index, idx, searchText) });
72
256
  }
73
257
  }
74
258
  scored.sort((a, b) => {
75
259
  if (b.score !== a.score) return b.score - a.score;
260
+ return b.matchRank - a.matchRank;
261
+ });
262
+ const windowSize = Math.max(limit * 4, 50);
263
+ const window = scored.slice(0, windowSize);
264
+ window.sort((a, b) => {
265
+ if (b.score !== a.score) return b.score - a.score;
266
+ if (b.matchRank !== a.matchRank) return b.matchRank - a.matchRank;
76
267
  const ra = index.records[a.idx];
77
268
  const rb = index.records[b.idx];
78
- return ra.provinceNameTh.localeCompare(rb.provinceNameTh, "th") || ra.amphureNameTh.localeCompare(rb.amphureNameTh, "th") || ra.tambonNameTh.localeCompare(rb.tambonNameTh, "th");
269
+ return TH_COLLATOR.compare(ra.provinceNameTh, rb.provinceNameTh) || TH_COLLATOR.compare(ra.amphureNameTh, rb.amphureNameTh) || TH_COLLATOR.compare(ra.tambonNameTh, rb.tambonNameTh);
79
270
  });
80
- return scored.slice(0, limit).map(({ idx }) => index.records[idx]);
271
+ return window.slice(0, limit).map(({ idx }) => ({ ...index.records[idx] }));
81
272
  }
82
273
 
83
274
  // src/core/formatter.ts
84
- function formatThaiAddressSuggestion(record) {
275
+ function formatThaiAddressSuggestion(record, options) {
276
+ const labelTh = `${record.tambonNameTh} > ${record.amphureNameTh} > ${record.provinceNameTh} ${record.zipCode}`;
277
+ const labelEn = `${record.tambonNameEn} > ${record.amphureNameEn} > ${record.provinceNameEn} ${record.zipCode}`;
85
278
  return {
86
279
  id: String(record.tambonId),
87
- label: `${record.tambonNameTh} > ${record.amphureNameTh} > ${record.provinceNameTh} ${record.zipCode}`,
280
+ label: options?.locale === "en" ? labelEn : labelTh,
281
+ labelTh,
282
+ labelEn,
88
283
  tambon: record.tambonNameTh,
89
284
  tambonEn: record.tambonNameEn,
90
285
  amphure: record.amphureNameTh,
@@ -115,13 +310,36 @@ function resolveThaiAddress(record) {
115
310
 
116
311
  // src/react/useThaiAddressAutocomplete.ts
117
312
  function useThaiAddressAutocomplete(options) {
118
- const { index, limit = 10, debounce: debounceMs = 200, threshold = 0.4 } = options;
313
+ const {
314
+ index,
315
+ limit = 10,
316
+ debounce: debounceMs = 200,
317
+ threshold = 0.4,
318
+ zipLimit,
319
+ initialQuery = "",
320
+ locale,
321
+ onSelect
322
+ } = options;
119
323
  const indexRef = react.useRef(index);
120
324
  indexRef.current = index;
121
- const [query, setQuery] = react.useState("");
325
+ const [query, setQuery] = react.useState(initialQuery);
122
326
  const [suggestions, setSuggestions] = react.useState([]);
123
327
  const timerRef = react.useRef(null);
124
328
  const matchedRecordsMapRef = react.useRef(/* @__PURE__ */ new Map());
329
+ const suppressedQueryRef = react.useRef(initialQuery);
330
+ const runSearch = react.useCallback(
331
+ (q, idx) => {
332
+ const records = searchThaiAddress(idx, q, { limit, threshold, zipLimit });
333
+ const formatted = records.map((record) => formatThaiAddressSuggestion(record, { locale }));
334
+ const recordMap = /* @__PURE__ */ new Map();
335
+ for (let i = 0; i < records.length; i++) {
336
+ recordMap.set(formatted[i].id, records[i]);
337
+ }
338
+ matchedRecordsMapRef.current = recordMap;
339
+ setSuggestions(formatted);
340
+ },
341
+ [limit, threshold, zipLimit, locale]
342
+ );
125
343
  react.useEffect(() => {
126
344
  if (timerRef.current) clearTimeout(timerRef.current);
127
345
  if (query.length === 0) {
@@ -129,53 +347,52 @@ function useThaiAddressAutocomplete(options) {
129
347
  matchedRecordsMapRef.current = /* @__PURE__ */ new Map();
130
348
  return;
131
349
  }
350
+ if (query === suppressedQueryRef.current) return;
351
+ suppressedQueryRef.current = "";
132
352
  timerRef.current = setTimeout(() => {
133
- const records = searchThaiAddress(indexRef.current, query, { limit, threshold });
134
- const formatted = records.map(formatThaiAddressSuggestion);
135
- const recordMap = /* @__PURE__ */ new Map();
136
- for (let i = 0; i < records.length; i++) {
137
- recordMap.set(formatted[i].id, records[i]);
138
- }
139
- matchedRecordsMapRef.current = recordMap;
140
- setSuggestions(formatted);
353
+ runSearch(query, indexRef.current);
141
354
  }, debounceMs);
142
355
  return () => {
143
356
  if (timerRef.current) clearTimeout(timerRef.current);
144
357
  };
145
- }, [query, limit, threshold, debounceMs]);
358
+ }, [query, limit, threshold, debounceMs, zipLimit, locale, runSearch]);
146
359
  react.useEffect(() => {
147
360
  if (query.length === 0) return;
361
+ if (query === suppressedQueryRef.current) return;
148
362
  if (timerRef.current) clearTimeout(timerRef.current);
149
- const records = searchThaiAddress(index, query, { limit, threshold });
150
- const formatted = records.map(formatThaiAddressSuggestion);
151
- const recordMap = /* @__PURE__ */ new Map();
152
- for (let i = 0; i < records.length; i++) {
153
- recordMap.set(formatted[i].id, records[i]);
154
- }
155
- matchedRecordsMapRef.current = recordMap;
156
- setSuggestions(formatted);
363
+ runSearch(query, index);
157
364
  }, [index]);
158
365
  const selectSuggestion = react.useCallback(
159
366
  (item) => {
160
367
  if (timerRef.current) clearTimeout(timerRef.current);
161
368
  const record = matchedRecordsMapRef.current.get(item.id);
162
369
  if (!record) {
163
- throw new Error(`[thaizip] No record found for suggestion id "${item.id}". Make sure to use suggestions returned by this hook.`);
370
+ return null;
164
371
  }
165
372
  setSuggestions([]);
166
373
  matchedRecordsMapRef.current = /* @__PURE__ */ new Map();
167
- return resolveThaiAddress(record);
374
+ const resolved = resolveThaiAddress(record);
375
+ onSelect?.(resolved);
376
+ return resolved;
168
377
  },
169
- []
378
+ [onSelect]
170
379
  );
171
380
  const clear = react.useCallback(() => {
172
381
  setQuery("");
173
382
  setSuggestions([]);
174
383
  matchedRecordsMapRef.current = /* @__PURE__ */ new Map();
175
384
  }, []);
385
+ const setQuerySilent = react.useCallback((value) => {
386
+ if (timerRef.current) clearTimeout(timerRef.current);
387
+ suppressedQueryRef.current = value;
388
+ setSuggestions([]);
389
+ matchedRecordsMapRef.current = /* @__PURE__ */ new Map();
390
+ setQuery(value);
391
+ }, []);
176
392
  return {
177
393
  query,
178
394
  setQuery,
395
+ setQuerySilent,
179
396
  suggestions,
180
397
  isOpen: query.length > 0 && suggestions.length > 0,
181
398
  selectSuggestion,
package/dist/react.d.cts CHANGED
@@ -16,10 +16,42 @@ type TrigramIndex = {
16
16
  map: Map<string, Set<number>>;
17
17
  records: ThaiAddressRecord[];
18
18
  zipIndex: Map<string, number[]>;
19
+ /**
20
+ * Zip codes of `zipIndex` in ascending lexical order, with postings
21
+ * parallel in `sortedZipPostings`. Used by `lookupByZipCode` for
22
+ * O(log n + matches) prefix lookup instead of scanning every zip code.
23
+ * Optional: older-shaped or hand-built indexes without these fields fall
24
+ * back to the O(n_zips) scan.
25
+ */
26
+ sortedZipKeys?: string[];
27
+ sortedZipPostings?: number[][];
28
+ /**
29
+ * Normalized Thai tambon names, parallel to `records` (same index).
30
+ * Used by the search ranker for exact/prefix match detection without
31
+ * re-normalizing on every query.
32
+ */
33
+ normTambon: string[];
34
+ /**
35
+ * Normalized English (RTGS) tambon names, parallel to `records`. Needed so
36
+ * romanized queries get the same own-name-beats-parent-name ranking that
37
+ * Thai queries get.
38
+ */
39
+ normTambonEn: string[];
40
+ /** Record indices grouped by `provinceId`, in insertion order. */
41
+ byProvince: Map<number, number[]>;
42
+ /** Record indices grouped by `amphureId`, in insertion order. */
43
+ byAmphure: Map<number, number[]>;
19
44
  };
45
+ /** Which language a formatted label should be rendered in. */
46
+ type AddressLocale = 'th' | 'en';
20
47
  type ThaiAddressSuggestion = {
21
48
  id: string;
49
+ /** Human-readable label in the requested locale (Thai by default). */
22
50
  label: string;
51
+ /** Human-readable label in Thai, regardless of the requested locale. */
52
+ labelTh: string;
53
+ /** Human-readable label in English, regardless of the requested locale. */
54
+ labelEn: string;
23
55
  tambon: string;
24
56
  tambonEn: string;
25
57
  amphure: string;
@@ -47,6 +79,13 @@ type UseThaiAddressAutocompleteOptions = {
47
79
  limit?: number;
48
80
  debounce?: number;
49
81
  threshold?: number;
82
+ zipLimit?: number;
83
+ /** Seed value for `query` on first render. Defaults to `''`. */
84
+ initialQuery?: string;
85
+ /** Locale for the `label` field of returned suggestions. Defaults to `'th'`. */
86
+ locale?: AddressLocale;
87
+ /** Called after a suggestion is successfully resolved via `selectSuggestion`. */
88
+ onSelect?: (address: ResolvedThaiAddress) => void;
50
89
  };
51
90
 
52
91
  /**
@@ -59,13 +98,22 @@ type UseThaiAddressAutocompleteOptions = {
59
98
  * @param options.limit - Maximum number of suggestions (default 10).
60
99
  * @param options.debounce - Debounce delay in ms (default 200).
61
100
  * @param options.threshold - Minimum trigram score 0–1 (default 0.4).
101
+ * @param options.zipLimit - Maximum results for all-digit (zip code) queries, passed
102
+ * through to `searchThaiAddress`.
103
+ * @param options.initialQuery - Seed value for `query` on first render. Does NOT
104
+ * trigger a search — useful for pre-filling the input with an already-chosen
105
+ * address (e.g. react-hook-form `defaultValues`) without popping the dropdown open.
106
+ * @param options.locale - Locale for the `label` field of returned suggestions.
107
+ * @param options.onSelect - Called with the resolved address after a successful
108
+ * `selectSuggestion`.
62
109
  */
63
110
  declare function useThaiAddressAutocomplete(options: UseThaiAddressAutocompleteOptions): {
64
111
  query: string;
65
112
  setQuery: react.Dispatch<react.SetStateAction<string>>;
113
+ setQuerySilent: (value: string) => void;
66
114
  suggestions: ThaiAddressSuggestion[];
67
115
  isOpen: boolean;
68
- selectSuggestion: (item: ThaiAddressSuggestion) => ResolvedThaiAddress;
116
+ selectSuggestion: (item: ThaiAddressSuggestion) => ResolvedThaiAddress | null;
69
117
  clear: () => void;
70
118
  };
71
119
 
package/dist/react.d.ts CHANGED
@@ -16,10 +16,42 @@ type TrigramIndex = {
16
16
  map: Map<string, Set<number>>;
17
17
  records: ThaiAddressRecord[];
18
18
  zipIndex: Map<string, number[]>;
19
+ /**
20
+ * Zip codes of `zipIndex` in ascending lexical order, with postings
21
+ * parallel in `sortedZipPostings`. Used by `lookupByZipCode` for
22
+ * O(log n + matches) prefix lookup instead of scanning every zip code.
23
+ * Optional: older-shaped or hand-built indexes without these fields fall
24
+ * back to the O(n_zips) scan.
25
+ */
26
+ sortedZipKeys?: string[];
27
+ sortedZipPostings?: number[][];
28
+ /**
29
+ * Normalized Thai tambon names, parallel to `records` (same index).
30
+ * Used by the search ranker for exact/prefix match detection without
31
+ * re-normalizing on every query.
32
+ */
33
+ normTambon: string[];
34
+ /**
35
+ * Normalized English (RTGS) tambon names, parallel to `records`. Needed so
36
+ * romanized queries get the same own-name-beats-parent-name ranking that
37
+ * Thai queries get.
38
+ */
39
+ normTambonEn: string[];
40
+ /** Record indices grouped by `provinceId`, in insertion order. */
41
+ byProvince: Map<number, number[]>;
42
+ /** Record indices grouped by `amphureId`, in insertion order. */
43
+ byAmphure: Map<number, number[]>;
19
44
  };
45
+ /** Which language a formatted label should be rendered in. */
46
+ type AddressLocale = 'th' | 'en';
20
47
  type ThaiAddressSuggestion = {
21
48
  id: string;
49
+ /** Human-readable label in the requested locale (Thai by default). */
22
50
  label: string;
51
+ /** Human-readable label in Thai, regardless of the requested locale. */
52
+ labelTh: string;
53
+ /** Human-readable label in English, regardless of the requested locale. */
54
+ labelEn: string;
23
55
  tambon: string;
24
56
  tambonEn: string;
25
57
  amphure: string;
@@ -47,6 +79,13 @@ type UseThaiAddressAutocompleteOptions = {
47
79
  limit?: number;
48
80
  debounce?: number;
49
81
  threshold?: number;
82
+ zipLimit?: number;
83
+ /** Seed value for `query` on first render. Defaults to `''`. */
84
+ initialQuery?: string;
85
+ /** Locale for the `label` field of returned suggestions. Defaults to `'th'`. */
86
+ locale?: AddressLocale;
87
+ /** Called after a suggestion is successfully resolved via `selectSuggestion`. */
88
+ onSelect?: (address: ResolvedThaiAddress) => void;
50
89
  };
51
90
 
52
91
  /**
@@ -59,13 +98,22 @@ type UseThaiAddressAutocompleteOptions = {
59
98
  * @param options.limit - Maximum number of suggestions (default 10).
60
99
  * @param options.debounce - Debounce delay in ms (default 200).
61
100
  * @param options.threshold - Minimum trigram score 0–1 (default 0.4).
101
+ * @param options.zipLimit - Maximum results for all-digit (zip code) queries, passed
102
+ * through to `searchThaiAddress`.
103
+ * @param options.initialQuery - Seed value for `query` on first render. Does NOT
104
+ * trigger a search — useful for pre-filling the input with an already-chosen
105
+ * address (e.g. react-hook-form `defaultValues`) without popping the dropdown open.
106
+ * @param options.locale - Locale for the `label` field of returned suggestions.
107
+ * @param options.onSelect - Called with the resolved address after a successful
108
+ * `selectSuggestion`.
62
109
  */
63
110
  declare function useThaiAddressAutocomplete(options: UseThaiAddressAutocompleteOptions): {
64
111
  query: string;
65
112
  setQuery: react.Dispatch<react.SetStateAction<string>>;
113
+ setQuerySilent: (value: string) => void;
66
114
  suggestions: ThaiAddressSuggestion[];
67
115
  isOpen: boolean;
68
- selectSuggestion: (item: ThaiAddressSuggestion) => ResolvedThaiAddress;
116
+ selectSuggestion: (item: ThaiAddressSuggestion) => ResolvedThaiAddress | null;
69
117
  clear: () => void;
70
118
  };
71
119