@celestia-island/hikari 0.40.3 → 0.40.6
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/package.json +1 -1
- package/src/components/HkAffixPicker.test.tsx +40 -5
- package/src/components/HkAffixPicker.tsx +28 -2
- package/src/components/HkInput.field.test.tsx +112 -0
- package/src/components/HkInput.tsx +27 -2
- package/src/components/HkPhoneInput.test.tsx +4 -4
- package/src/components/HkPhoneInput.tsx +3 -4
- package/src/data/dialCodes.test.ts +0 -1
- package/src/data/dialCodes.ts +1 -7
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import HkAffixPicker, { type HkAffixOption } from "./HkAffixPicker";
|
|
|
5
5
|
|
|
6
6
|
const OPTIONS: HkAffixOption[] = [
|
|
7
7
|
{ key: "cn", label: "China", meta: "+86", flag: "🇨🇳", keywords: "zhongguo 中国 0086" },
|
|
8
|
+
{ key: "cn-main", label: "中华人民共和国", meta: "+86", flag: "🇨🇳" },
|
|
8
9
|
{ key: "jp", label: "Japan", meta: "+81", flag: "🇯🇵" },
|
|
9
10
|
{ key: "us", label: "United States", meta: "+1", flag: "🇺🇸" },
|
|
10
11
|
];
|
|
@@ -154,9 +155,9 @@ describe("HkAffixPicker", () => {
|
|
|
154
155
|
it("single mode lists every option, marks the active one, closes on pick", async () => {
|
|
155
156
|
const { events, container } = mountPicker({ selected: "cn" });
|
|
156
157
|
await openPopup(container);
|
|
157
|
-
expect(rows()).toHaveLength(
|
|
158
|
+
expect(rows()).toHaveLength(4);
|
|
158
159
|
expect(rows()[0].hasAttribute("data-active")).toBe(true);
|
|
159
|
-
rows()[
|
|
160
|
+
rows()[2].click();
|
|
160
161
|
await nextTick();
|
|
161
162
|
expect(events.select.at(-1)).toBe("jp");
|
|
162
163
|
// Single mode closes after the pick (leave transition may linger).
|
|
@@ -176,6 +177,30 @@ describe("HkAffixPicker", () => {
|
|
|
176
177
|
expect(document.querySelector(".hk-affix-empty")?.textContent).toContain("No matches");
|
|
177
178
|
});
|
|
178
179
|
|
|
180
|
+
it("fuzzy fallback finds a renamed CJK label by in-order subsequence", async () => {
|
|
181
|
+
const { container } = mountPicker();
|
|
182
|
+
await openPopup(container);
|
|
183
|
+
// 中国 is NOT a substring of 中华人民共和国 — only the in-order
|
|
184
|
+
// subsequence fallback (中 at 0, 国 at 6) can surface the cn-main
|
|
185
|
+
// row the substring pass misses.
|
|
186
|
+
await typeQuery("中国");
|
|
187
|
+
const labels = rows().map((r) => r.textContent);
|
|
188
|
+
// Both the China row (keyword substring match) and the renamed
|
|
189
|
+
// cn-main row (fuzzy subsequence) surface.
|
|
190
|
+
expect(labels).toContain("🇨🇳China+86");
|
|
191
|
+
expect(labels.some((l) => l.includes("中华人民共和国"))).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("order-violating query does not fuzzy-match the CJK label", async () => {
|
|
195
|
+
const { container } = mountPicker();
|
|
196
|
+
await openPopup(container);
|
|
197
|
+
// 国民 is out of order in 中华人民共和国 (国 comes after 民) — the
|
|
198
|
+
// subsequence pass must NOT surface the row.
|
|
199
|
+
await typeQuery("国民");
|
|
200
|
+
expect(rows()).toHaveLength(0);
|
|
201
|
+
expect(document.querySelector(".hk-affix-empty")?.textContent).toContain("No matches");
|
|
202
|
+
});
|
|
203
|
+
|
|
179
204
|
it("re-attaches the row list when the empty-state query is cleared", async () => {
|
|
180
205
|
const { container } = mountPicker();
|
|
181
206
|
await openPopup(container);
|
|
@@ -190,6 +215,7 @@ describe("HkAffixPicker", () => {
|
|
|
190
215
|
expect(document.querySelector(".hk-affix-empty")).toBeNull();
|
|
191
216
|
expect(rows().map((r) => r.textContent)).toEqual([
|
|
192
217
|
expect.stringContaining("China"),
|
|
218
|
+
expect.stringContaining("中华人民共和国"),
|
|
193
219
|
expect.stringContaining("Japan"),
|
|
194
220
|
expect.stringContaining("United States"),
|
|
195
221
|
]);
|
|
@@ -197,7 +223,12 @@ describe("HkAffixPicker", () => {
|
|
|
197
223
|
expect(document.querySelector(".hk-affix-list")).toBeTruthy();
|
|
198
224
|
// Re-filtering after the remount keeps working on the live list.
|
|
199
225
|
await typeQuery("+86");
|
|
200
|
-
|
|
226
|
+
// Both +86 rows match by substring (the cn-main row via its meta) —
|
|
227
|
+
// the original fixtured "China" row is still surfaced.
|
|
228
|
+
expect(rows().map((r) => r.textContent)).toEqual([
|
|
229
|
+
expect.stringContaining("China"),
|
|
230
|
+
expect.stringContaining("中华人民共和国"),
|
|
231
|
+
]);
|
|
201
232
|
});
|
|
202
233
|
|
|
203
234
|
it("multi mode renders selected tags and offers only the remaining rows", async () => {
|
|
@@ -211,13 +242,17 @@ describe("HkAffixPicker", () => {
|
|
|
211
242
|
expect(tags()[0].querySelector(".hk-affix-tag-dot")).toBeTruthy();
|
|
212
243
|
expect(tags()[1].querySelector(".hk-affix-tag-dot")).toBeNull();
|
|
213
244
|
// Rows exclude the already-selected keys.
|
|
214
|
-
expect(rows().map((r) => r.textContent)).toEqual([
|
|
245
|
+
expect(rows().map((r) => r.textContent)).toEqual([
|
|
246
|
+
expect.stringContaining("中华人民共和国"),
|
|
247
|
+
expect.stringContaining("United States"),
|
|
248
|
+
]);
|
|
215
249
|
});
|
|
216
250
|
|
|
217
251
|
it("multi mode keeps the popup open when a row is picked", async () => {
|
|
218
252
|
const { events, container } = mountPicker({ mode: "multi", selected: ["cn"] });
|
|
219
253
|
await openPopup(container);
|
|
220
|
-
rows
|
|
254
|
+
// With cn selected, the remaining rows are [cn-main, jp, us]; pick jp.
|
|
255
|
+
rows()[1].click();
|
|
221
256
|
await nextTick();
|
|
222
257
|
expect(events.select.at(-1)).toBe("jp");
|
|
223
258
|
expect(rows().length).toBeGreaterThan(0);
|
|
@@ -38,6 +38,18 @@ export interface HkAffixOption {
|
|
|
38
38
|
disabled?: boolean;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** True when every character of `query` appears in `text` in the same
|
|
42
|
+
* order — gaps allowed ("中国" finds 中华人民共和国). Callers lowercase
|
|
43
|
+
* both sides first, matching the substring pass. */
|
|
44
|
+
function isSubsequence(query: string, text: string): boolean {
|
|
45
|
+
let i = 0;
|
|
46
|
+
for (const ch of text) {
|
|
47
|
+
if (ch === query[i]) i++;
|
|
48
|
+
if (i === query.length) return true;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
/**
|
|
42
54
|
* HkAffixPicker — the standardized "affix chip + searchable popup list"
|
|
43
55
|
* control. A chip rides the prefix (left) or suffix (right) slot of a
|
|
@@ -190,7 +202,16 @@ export const HkAffixPicker = defineComponent({
|
|
|
190
202
|
);
|
|
191
203
|
|
|
192
204
|
/** Rows of the pick list. Multi hides already-selected keys (they
|
|
193
|
-
* live in the tag list instead); single always shows everything.
|
|
205
|
+
* live in the tag list instead); single always shows everything.
|
|
206
|
+
*
|
|
207
|
+
* Filtering is TWO-PASS over a single field at a time (never across
|
|
208
|
+
* concatenated fields, which produces noise):
|
|
209
|
+
* 1. an exact substring match for precision (label / meta / key /
|
|
210
|
+
* keywords, case-folded);
|
|
211
|
+
* 2. an in-order character subsequence fallback (gaps allowed) —
|
|
212
|
+
* so a renamed or CJK-composed label like "中华人民共和国" is
|
|
213
|
+
* still found by its short form "中国" (中 at 0, 国 at 6).
|
|
214
|
+
*/
|
|
194
215
|
const filteredRows = computed<readonly HkAffixOption[]>(() => {
|
|
195
216
|
const base =
|
|
196
217
|
props.mode === "multi"
|
|
@@ -202,7 +223,12 @@ export const HkAffixPicker = defineComponent({
|
|
|
202
223
|
if (o.label.toLowerCase().includes(q)) return true;
|
|
203
224
|
if (o.meta && o.meta.toLowerCase().includes(q)) return true;
|
|
204
225
|
if (o.key.toLowerCase().includes(q)) return true;
|
|
205
|
-
|
|
226
|
+
if (o.keywords && o.keywords.toLowerCase().includes(q)) return true;
|
|
227
|
+
// Fuzzy fallback — per field, in-order subsequence, gaps allowed.
|
|
228
|
+
if (isSubsequence(q, o.label.toLowerCase())) return true;
|
|
229
|
+
if (o.meta && isSubsequence(q, o.meta.toLowerCase())) return true;
|
|
230
|
+
if (isSubsequence(q, o.key.toLowerCase())) return true;
|
|
231
|
+
return !!o.keywords && isSubsequence(q, o.keywords.toLowerCase());
|
|
206
232
|
});
|
|
207
233
|
});
|
|
208
234
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkInput from "./HkInput";
|
|
5
|
+
|
|
6
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
7
|
+
const containers: HTMLElement[] = [];
|
|
8
|
+
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
11
|
+
for (const el of containers.splice(0)) el.remove();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
interface FieldHandle {
|
|
15
|
+
wrapper: HTMLElement;
|
|
16
|
+
label: HTMLLabelElement | null;
|
|
17
|
+
field: HTMLInputElement | HTMLTextAreaElement;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Mount HkInput with the given props and grab label + field element. */
|
|
21
|
+
async function mountField(
|
|
22
|
+
props: Record<string, unknown>,
|
|
23
|
+
textarea = false,
|
|
24
|
+
): Promise<FieldHandle> {
|
|
25
|
+
const container = document.createElement("div");
|
|
26
|
+
document.body.appendChild(container);
|
|
27
|
+
containers.push(container);
|
|
28
|
+
|
|
29
|
+
const model = ref("");
|
|
30
|
+
const Wrapper = defineComponent({
|
|
31
|
+
setup() {
|
|
32
|
+
return () =>
|
|
33
|
+
h(HkInput, {
|
|
34
|
+
modelValue: model.value,
|
|
35
|
+
"onUpdate:modelValue": (v: string) => { model.value = v; },
|
|
36
|
+
...props,
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const app = createApp(Wrapper);
|
|
41
|
+
mounts.push(app);
|
|
42
|
+
app.mount(container);
|
|
43
|
+
await nextTick();
|
|
44
|
+
|
|
45
|
+
const wrapper = container.querySelector<HTMLElement>(".hk-input-wrapper")!;
|
|
46
|
+
const field = (textarea
|
|
47
|
+
? wrapper.querySelector("textarea")
|
|
48
|
+
: wrapper.querySelector("input")) as
|
|
49
|
+
HTMLInputElement | HTMLTextAreaElement;
|
|
50
|
+
return { wrapper, label: wrapper.querySelector("label"), field };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("HkInput label association", () => {
|
|
54
|
+
it("binds the rendered label to the field without caller effort", async () => {
|
|
55
|
+
const { label, field } = await mountField({ label: "Workspace name" });
|
|
56
|
+
expect(label).toBeTruthy();
|
|
57
|
+
expect(label!.getAttribute("for")).toBe(field.id);
|
|
58
|
+
expect(field.id).not.toBe("");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("generates unique ids to sibling fields in the same app", async () => {
|
|
62
|
+
const container = document.createElement("div");
|
|
63
|
+
document.body.appendChild(container);
|
|
64
|
+
containers.push(container);
|
|
65
|
+
|
|
66
|
+
const Wrapper = defineComponent({
|
|
67
|
+
setup() {
|
|
68
|
+
return () => [
|
|
69
|
+
h(HkInput, { modelValue: "", label: "One" }),
|
|
70
|
+
h(HkInput, { modelValue: "", label: "Two" }),
|
|
71
|
+
];
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const app = createApp(Wrapper);
|
|
75
|
+
mounts.push(app);
|
|
76
|
+
app.mount(container);
|
|
77
|
+
await nextTick();
|
|
78
|
+
|
|
79
|
+
const fields = container.querySelectorAll<HTMLInputElement>(".hk-input-element");
|
|
80
|
+
expect(fields).toHaveLength(2);
|
|
81
|
+
expect(fields[0]!.id).not.toBe("");
|
|
82
|
+
expect(fields[0]!.id).not.toBe(fields[1]!.id);
|
|
83
|
+
const labels = container.querySelectorAll<HTMLLabelElement>(".hk-input-label");
|
|
84
|
+
expect(labels[0]!.getAttribute("for")).toBe(fields[0]!.id);
|
|
85
|
+
expect(labels[1]!.getAttribute("for")).toBe(fields[1]!.id);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("honors an explicit id prop over the generated one", async () => {
|
|
89
|
+
const { label, field } = await mountField({ label: "Name", id: "given-id" });
|
|
90
|
+
expect(field.id).toBe("given-id");
|
|
91
|
+
expect(label!.getAttribute("for")).toBe("given-id");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("associates textarea fields the same way", async () => {
|
|
95
|
+
const { label, field } = await mountField(
|
|
96
|
+
{ label: "Comment", type: "textarea" },
|
|
97
|
+
true,
|
|
98
|
+
);
|
|
99
|
+
expect(field.tagName).toBe("TEXTAREA");
|
|
100
|
+
expect(label!.getAttribute("for")).toBe(field.id);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("omits spellcheck entirely when the prop is undefined", async () => {
|
|
104
|
+
const { field } = await mountField({ label: "Default" });
|
|
105
|
+
expect(field.hasAttribute("spellcheck")).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("renders spellcheck={false} as an explicit attribute", async () => {
|
|
109
|
+
const { field } = await mountField({ label: "Literal", spellcheck: false });
|
|
110
|
+
expect(field.getAttribute("spellcheck")).toBe("false");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Eye, EyeOff } from "lucide-vue-next";
|
|
2
|
-
import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, watch } from "vue";
|
|
2
|
+
import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch } from "vue";
|
|
3
3
|
|
|
4
4
|
import { useI18n } from "../i18n/context";
|
|
5
5
|
|
|
@@ -20,6 +20,20 @@ export default defineComponent({
|
|
|
20
20
|
readonly: { type: Boolean, default: false },
|
|
21
21
|
required: { type: Boolean, default: false },
|
|
22
22
|
name: { type: String, default: undefined },
|
|
23
|
+
/**
|
|
24
|
+
* `id` for the field element; the rendered label's `for` points here.
|
|
25
|
+
* Generated via Vue's useId when omitted, so a bare `label` prop is
|
|
26
|
+
* fully associated for screen readers and label clicks with zero
|
|
27
|
+
* caller effort.
|
|
28
|
+
*/
|
|
29
|
+
id: { type: String, default: undefined },
|
|
30
|
+
/**
|
|
31
|
+
* Native spellcheck toggle. Undefined keeps the browser default;
|
|
32
|
+
* `false` is the right call for exact-literal entry fields (type-
|
|
33
|
+
* to-confirm gates, addresses, serial paths) where the red squiggle
|
|
34
|
+
* under correct input reads as an error.
|
|
35
|
+
*/
|
|
36
|
+
spellcheck: { type: Boolean, default: undefined },
|
|
23
37
|
/** Submit intent on Enter (no modifiers) — see HkPasswordInput. */
|
|
24
38
|
submitOnEnter: { type: Function, default: undefined },
|
|
25
39
|
autocomplete: { type: String, default: "off" },
|
|
@@ -117,6 +131,13 @@ export default defineComponent({
|
|
|
117
131
|
return rest;
|
|
118
132
|
});
|
|
119
133
|
|
|
134
|
+
// Field identity: the rendered label points at this id, so a bare
|
|
135
|
+
// `label` prop gives a fully associated field (screen readers, label
|
|
136
|
+
// clicks) without caller effort. An explicit `id` prop wins.
|
|
137
|
+
// useId() must run synchronously in setup; it is SSR-safe.
|
|
138
|
+
const generatedId = useId();
|
|
139
|
+
const fieldId = computed(() => props.id ?? generatedId);
|
|
140
|
+
|
|
120
141
|
// ── affix width reservation ──────────────────────────────────────
|
|
121
142
|
// The input element overlays the WHOLE box (absolute inset:0) while
|
|
122
143
|
// the prefix/suffix affixes flow above it — so centered text, the
|
|
@@ -250,7 +271,7 @@ export default defineComponent({
|
|
|
250
271
|
return () => (
|
|
251
272
|
<div class="hk-input-wrapper">
|
|
252
273
|
{props.label && (
|
|
253
|
-
<label class="hk-input-label">
|
|
274
|
+
<label class="hk-input-label" for={fieldId.value}>
|
|
254
275
|
{props.label}
|
|
255
276
|
{props.required && <span class="hk-input-required">*</span>}
|
|
256
277
|
</label>
|
|
@@ -279,11 +300,13 @@ export default defineComponent({
|
|
|
279
300
|
{isText ? (
|
|
280
301
|
<input
|
|
281
302
|
ref={inputRef}
|
|
303
|
+
id={fieldId.value}
|
|
282
304
|
type={resolvedType.value}
|
|
283
305
|
value={props.modelValue}
|
|
284
306
|
placeholder={nativePlaceholder.value}
|
|
285
307
|
disabled={props.disabled}
|
|
286
308
|
readonly={props.readonly}
|
|
309
|
+
spellcheck={props.spellcheck}
|
|
287
310
|
name={props.name}
|
|
288
311
|
autocomplete={props.autocomplete}
|
|
289
312
|
data-1p-ignore
|
|
@@ -313,10 +336,12 @@ export default defineComponent({
|
|
|
313
336
|
) : (
|
|
314
337
|
<textarea
|
|
315
338
|
ref={inputRef}
|
|
339
|
+
id={fieldId.value}
|
|
316
340
|
value={props.modelValue}
|
|
317
341
|
placeholder={nativePlaceholder.value}
|
|
318
342
|
disabled={props.disabled}
|
|
319
343
|
readonly={props.readonly}
|
|
344
|
+
spellcheck={props.spellcheck}
|
|
320
345
|
rows={props.rows}
|
|
321
346
|
name={props.name}
|
|
322
347
|
autocomplete={props.autocomplete}
|
|
@@ -117,12 +117,12 @@ describe("HkPhoneInput", () => {
|
|
|
117
117
|
}
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
-
it("still finds the PRC row when searching the short
|
|
120
|
+
it("still finds the PRC row when searching the short form 中国", async () => {
|
|
121
121
|
const { container } = mountPhone();
|
|
122
122
|
await openPicker(container);
|
|
123
|
-
// The row renders the formal zh name (中华人民共和国), which
|
|
124
|
-
//
|
|
125
|
-
//
|
|
123
|
+
// The row renders the formal zh name (中华人民共和国), which contains
|
|
124
|
+
// 中国 only as an in-order subsequence (中 at 0, 国 at 6) — the affix
|
|
125
|
+
// picker's fuzzy fallback surfaces the +86 row, no alias needed.
|
|
126
126
|
const search = document.querySelector<HTMLInputElement>(
|
|
127
127
|
".hk-affix-search .hk-input-element",
|
|
128
128
|
);
|
|
@@ -102,15 +102,14 @@ export const HkPhoneInput = defineComponent({
|
|
|
102
102
|
);
|
|
103
103
|
|
|
104
104
|
/** Picker rows come from the shared affix catalog shape; the
|
|
105
|
-
* keywords haystack keeps the old filter reach: en/zh names
|
|
106
|
-
*
|
|
107
|
-
* dial digits ("86"/"0086"). */
|
|
105
|
+
* keywords haystack keeps the old filter reach: en/zh names, ISO
|
|
106
|
+
* code, bare and zero-prefixed dial digits ("86"/"0086"). */
|
|
108
107
|
const dialOptions = computed<readonly HkAffixOption[]>(() =>
|
|
109
108
|
props.countries.map((c) => ({
|
|
110
109
|
key: c.iso,
|
|
111
110
|
label: dialCodeName(c, locale),
|
|
112
111
|
meta: `+${c.dial}`,
|
|
113
|
-
keywords: `${c.en} ${c.zh} ${c.
|
|
112
|
+
keywords: `${c.en} ${c.zh} ${c.iso} +${c.dial} 00${c.dial}`,
|
|
114
113
|
})),
|
|
115
114
|
);
|
|
116
115
|
|
package/src/data/dialCodes.ts
CHANGED
|
@@ -19,12 +19,6 @@ export interface DialCodeEntry {
|
|
|
19
19
|
en: string;
|
|
20
20
|
/** Simplified Chinese country name. */
|
|
21
21
|
zh: string;
|
|
22
|
-
/** Search-only Chinese alias — never rendered as the display name.
|
|
23
|
-
* HkPhoneInput folds it into the popup's keyword haystack so the old
|
|
24
|
-
* short form still finds the entry after a formal rename (e.g. the
|
|
25
|
-
* PRC row renamed to 中华人民共和国 no longer contains 中国 as a
|
|
26
|
-
* substring). */
|
|
27
|
-
zhAlias?: string;
|
|
28
22
|
}
|
|
29
23
|
|
|
30
24
|
/** Flag glyph for an ISO 3166-1 alpha-2 code (regional indicators). */
|
|
@@ -49,7 +43,7 @@ export function dialCodeName(entry: DialCodeEntry, locale: string): string {
|
|
|
49
43
|
* prefixes like +1 / +7).
|
|
50
44
|
*/
|
|
51
45
|
export const DIAL_CODES: readonly DialCodeEntry[] = [
|
|
52
|
-
{ iso: "cn", dial: "86", en: "China", zh: "中华人民共和国"
|
|
46
|
+
{ iso: "cn", dial: "86", en: "China", zh: "中华人民共和国" },
|
|
53
47
|
{ iso: "hk", dial: "852", en: "Hong Kong (China)", zh: "中国香港" },
|
|
54
48
|
{ iso: "mo", dial: "853", en: "Macau (China)", zh: "中国澳门" },
|
|
55
49
|
{ iso: "tw", dial: "886", en: "Taiwan (China)", zh: "中国台湾" },
|