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/LICENSE +21 -0
- package/README.md +92 -263
- package/dist/data.cjs +98 -9
- package/dist/data.d.cts +25 -0
- package/dist/data.d.ts +25 -0
- package/dist/data.js +98 -9
- package/dist/index.cjs +380 -29
- package/dist/index.d.cts +187 -2
- package/dist/index.d.ts +187 -2
- package/dist/index.js +375 -30
- package/dist/react.cjs +267 -50
- package/dist/react.d.cts +49 -1
- package/dist/react.d.ts +49 -1
- package/dist/react.js +268 -51
- package/package.json +8 -5
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/react.cjs.map +0 -1
- package/dist/react.js.map +0 -1
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 (
|
|
12
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
58
|
-
const
|
|
59
|
-
const
|
|
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
|
-
|
|
244
|
+
if (counts[idx] === 0) touched[touchedLen++] = idx;
|
|
245
|
+
counts[idx]++;
|
|
65
246
|
}
|
|
66
247
|
}
|
|
67
248
|
const scored = [];
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
+
return null;
|
|
164
371
|
}
|
|
165
372
|
setSuggestions([]);
|
|
166
373
|
matchedRecordsMapRef.current = /* @__PURE__ */ new Map();
|
|
167
|
-
|
|
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
|
|