@celestia-island/hikari 0.32.2 → 0.32.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/package.json +1 -2
- package/src/components/HkPhoneInput.scss +140 -0
- package/src/components/HkPhoneInput.test.tsx +133 -0
- package/src/components/HkPhoneInput.tsx +310 -0
- package/src/components/HkQrCode.scss +23 -0
- package/src/components/HkQrCode.test.tsx +66 -0
- package/src/components/HkQrCode.tsx +121 -0
- package/src/data/dialCodes.test.ts +93 -0
- package/src/data/dialCodes.ts +233 -0
- package/src/i18n/locales/en/components.json +6 -1
- package/src/i18n/locales/zh-Hans/components.json +6 -1
- package/src/i18n/locales/zh-Hant/components.json +6 -1
- package/src/index.ts +14 -0
- package/src/utils/qr/qrEncoder.test.ts +53 -0
- package/src/utils/qr/qrEncoder.ts +38 -0
- package/src/utils/qr/qrcode-generator.d.ts +19 -0
- package/src/utils/qr/qrcode-generator.js +2289 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celestia-island/hikari",
|
|
3
|
-
"version": "0.32.
|
|
3
|
+
"version": "0.32.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
|
|
@@ -32,7 +32,6 @@
|
|
|
32
32
|
"import": "./src/styles/index.scss"
|
|
33
33
|
},
|
|
34
34
|
"./styles/*": "./src/styles/*",
|
|
35
|
-
"./theme/*": "./src/theme/*",
|
|
36
35
|
"./components/*": "./src/components/*",
|
|
37
36
|
"./plugins": {
|
|
38
37
|
"import": "./src/plugins/index.ts",
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
.hk-phone-input {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
width: 100%;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/* The dial-code chip riding the field's LEFT edge — flag glyph + "+86"
|
|
8
|
+
* + caret, mirroring the language-tag chip pattern of HkLocalizedInput
|
|
9
|
+
* (which rides the right edge). A button so it is focusable and
|
|
10
|
+
* AT-reachable; mousedown is suppressed in TSX so clicking it never
|
|
11
|
+
* steals focus from the number field before the menu opens. */
|
|
12
|
+
.hk-phone-chip {
|
|
13
|
+
display: inline-flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
gap: 0.3rem;
|
|
16
|
+
max-width: 11rem;
|
|
17
|
+
padding: 2px 6px 2px 4px;
|
|
18
|
+
border: none;
|
|
19
|
+
border-radius: var(--radius-sm, 6px);
|
|
20
|
+
background: transparent;
|
|
21
|
+
color: rgb(var(--color-text));
|
|
22
|
+
cursor: pointer;
|
|
23
|
+
transition: background 0.12s ease, color 0.12s ease;
|
|
24
|
+
|
|
25
|
+
&:hover:not(:disabled),
|
|
26
|
+
&:focus-visible:not(:disabled) {
|
|
27
|
+
background: rgb(var(--color-primary) / 14%);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
&:disabled {
|
|
31
|
+
cursor: default;
|
|
32
|
+
opacity: 0.6;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.hk-phone-chip-flag {
|
|
37
|
+
flex: none;
|
|
38
|
+
font-size: calc(var(--text-sm, 14px) * 1.05);
|
|
39
|
+
line-height: 1;
|
|
40
|
+
/* Windows has no color flag font — the regional-indicator letters it
|
|
41
|
+
* substitutes are tiny; nudge them to the visible size. */
|
|
42
|
+
font-family: "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji",
|
|
43
|
+
sans-serif;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.hk-phone-chip-dial {
|
|
47
|
+
font-weight: 600;
|
|
48
|
+
font-size: var(--text-sm, 14px);
|
|
49
|
+
white-space: nowrap;
|
|
50
|
+
font-variant-numeric: tabular-nums;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.hk-phone-chip-caret {
|
|
54
|
+
flex: none;
|
|
55
|
+
color: rgb(var(--color-muted));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ── country list (menu) ────────────────────────────────────────────
|
|
59
|
+
* The search box caps the menu header; rows below it scroll inside the
|
|
60
|
+
* popup/sheet list surface (HkSelectPanel caps and scrolls the popout,
|
|
61
|
+
* and the mobile sheet body scrolls natively). */
|
|
62
|
+
.hk-phone-dial-search {
|
|
63
|
+
padding: 8px 10px 6px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.hk-phone-dial-list {
|
|
67
|
+
display: flex;
|
|
68
|
+
flex-direction: column;
|
|
69
|
+
gap: 1px;
|
|
70
|
+
padding: 4px 6px 8px;
|
|
71
|
+
/* Roomier than the 2px HkMenu gutter so rows read as a picker. */
|
|
72
|
+
min-width: 13rem;
|
|
73
|
+
max-width: min(19rem, calc(100vw - 2rem));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.hk-phone-dial-row {
|
|
77
|
+
display: flex;
|
|
78
|
+
align-items: center;
|
|
79
|
+
gap: 8px;
|
|
80
|
+
width: 100%;
|
|
81
|
+
min-height: 2.25rem;
|
|
82
|
+
padding: 4px 8px;
|
|
83
|
+
border: none;
|
|
84
|
+
border-radius: var(--radius-sm, 6px);
|
|
85
|
+
background: transparent;
|
|
86
|
+
color: rgb(var(--color-text));
|
|
87
|
+
font-size: var(--text-sm, 14px);
|
|
88
|
+
text-align: left;
|
|
89
|
+
cursor: pointer;
|
|
90
|
+
appearance: none;
|
|
91
|
+
transition: background 0.1s ease;
|
|
92
|
+
|
|
93
|
+
&:hover,
|
|
94
|
+
&:focus-visible {
|
|
95
|
+
background: rgb(var(--color-primary) / 10%);
|
|
96
|
+
outline: none;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
&[data-active] {
|
|
100
|
+
background: rgb(var(--color-primary) / 16%);
|
|
101
|
+
font-weight: 600;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.hk-phone-dial-flag {
|
|
106
|
+
flex: none;
|
|
107
|
+
font-size: calc(var(--text-base, 15px) * 1.05);
|
|
108
|
+
line-height: 1;
|
|
109
|
+
font-family: "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji",
|
|
110
|
+
sans-serif;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.hk-phone-dial-name {
|
|
114
|
+
flex: 1;
|
|
115
|
+
min-width: 0;
|
|
116
|
+
overflow: hidden;
|
|
117
|
+
text-overflow: ellipsis;
|
|
118
|
+
white-space: nowrap;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.hk-phone-dial-code {
|
|
122
|
+
flex: none;
|
|
123
|
+
color: rgb(var(--color-muted));
|
|
124
|
+
font-size: calc(var(--text-sm, 14px) * 0.9);
|
|
125
|
+
font-variant-numeric: tabular-nums;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.hk-phone-dial-empty {
|
|
129
|
+
padding: 14px 12px;
|
|
130
|
+
color: rgb(var(--color-muted));
|
|
131
|
+
font-size: var(--text-sm, 14px);
|
|
132
|
+
text-align: center;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@media (prefers-reduced-motion: reduce) {
|
|
136
|
+
.hk-phone-chip,
|
|
137
|
+
.hk-phone-dial-row {
|
|
138
|
+
transition: none;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkPhoneInput } from "./HkPhoneInput";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
interface MountOptions {
|
|
9
|
+
modelValue?: string;
|
|
10
|
+
dialCode?: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function mountPhone(opts: MountOptions = {}) {
|
|
15
|
+
const events = {
|
|
16
|
+
modelValue: [] as string[],
|
|
17
|
+
dialCode: [] as string[],
|
|
18
|
+
dialchange: [] as string[],
|
|
19
|
+
change: [] as string[],
|
|
20
|
+
};
|
|
21
|
+
const container = document.createElement("div");
|
|
22
|
+
document.body.appendChild(container);
|
|
23
|
+
const app = createApp({
|
|
24
|
+
render: () =>
|
|
25
|
+
h(HkPhoneInput, {
|
|
26
|
+
modelValue: opts.modelValue ?? "",
|
|
27
|
+
dialCode: opts.dialCode ?? "+86",
|
|
28
|
+
disabled: opts.disabled ?? false,
|
|
29
|
+
"onUpdate:modelValue": (v: string) => {
|
|
30
|
+
events.modelValue.push(v);
|
|
31
|
+
},
|
|
32
|
+
"onUpdate:dialCode": (v: string) => {
|
|
33
|
+
events.dialCode.push(v);
|
|
34
|
+
},
|
|
35
|
+
onDialchange: (v: string) => {
|
|
36
|
+
events.dialchange.push(v);
|
|
37
|
+
},
|
|
38
|
+
onChange: (v: string) => {
|
|
39
|
+
events.change.push(v);
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
app.mount(container);
|
|
44
|
+
mounts.push({ app, container });
|
|
45
|
+
return { events, container };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
while (mounts.length > 0) {
|
|
50
|
+
const { app, container } = mounts.pop()!;
|
|
51
|
+
app.unmount();
|
|
52
|
+
container.remove();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function queryChip(container: HTMLElement): HTMLButtonElement {
|
|
57
|
+
const chip = container.querySelector<HTMLButtonElement>(".hk-phone-chip");
|
|
58
|
+
expect(chip, "dial-code chip renders inside the field").toBeTruthy();
|
|
59
|
+
return chip!;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function queryField(container: HTMLElement): HTMLInputElement {
|
|
63
|
+
const field = container.querySelector<HTMLInputElement>(".hk-input-element");
|
|
64
|
+
expect(field).toBeTruthy();
|
|
65
|
+
return field!;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function openPicker(container: HTMLElement) {
|
|
69
|
+
queryChip(container).click();
|
|
70
|
+
await nextTick();
|
|
71
|
+
await nextTick();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pickerRows(): HTMLElement[] {
|
|
75
|
+
return [...document.querySelectorAll<HTMLButtonElement>(".hk-phone-dial-row")];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("HkPhoneInput", () => {
|
|
79
|
+
it("renders the dial chip with the canonical code", () => {
|
|
80
|
+
const { container } = mountPhone({ dialCode: "86" });
|
|
81
|
+
expect(queryChip(container).querySelector(".hk-phone-chip-dial")?.textContent).toBe("+86");
|
|
82
|
+
expect(queryField(container).getAttribute("inputmode")).toBe("tel");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("shows a bare code when the dial matches no catalog entry", () => {
|
|
86
|
+
const { container } = mountPhone({ dialCode: "+999" });
|
|
87
|
+
expect(queryChip(container).querySelector(".hk-phone-chip-dial")?.textContent).toBe("+999");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("sanitizes typed numbers to digits, spaces and dashes", () => {
|
|
91
|
+
const { container, events } = mountPhone();
|
|
92
|
+
const field = queryField(container);
|
|
93
|
+
field.value = "138 1234-5678x+86";
|
|
94
|
+
field.dispatchEvent(new Event("input"));
|
|
95
|
+
expect(events.modelValue.at(-1)).toBe("138 1234-567886");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("emits the composed E.164 on blur", () => {
|
|
99
|
+
const { container, events } = mountPhone({ modelValue: "13812345678" });
|
|
100
|
+
queryField(container).dispatchEvent(new Event("blur"));
|
|
101
|
+
expect(events.change.at(-1)).toBe("+8613812345678");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("lists catalog rows with flags and codes when opened", async () => {
|
|
105
|
+
const { container } = mountPhone();
|
|
106
|
+
await openPicker(container);
|
|
107
|
+
const rows = pickerRows();
|
|
108
|
+
expect(rows.length).toBeGreaterThan(50);
|
|
109
|
+
const first = rows[0];
|
|
110
|
+
expect(first.textContent).toContain("China");
|
|
111
|
+
expect(first.textContent).toContain("+86");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("picks a country from the list and refocuses the number field", async () => {
|
|
115
|
+
const { container, events } = mountPhone();
|
|
116
|
+
await openPicker(container);
|
|
117
|
+
const rows = pickerRows();
|
|
118
|
+
const japan = rows.find((r) => r.textContent?.includes("Japan"))!;
|
|
119
|
+
expect(japan).toBeTruthy();
|
|
120
|
+
japan.click();
|
|
121
|
+
await nextTick();
|
|
122
|
+
expect(events.dialCode.at(-1)).toBe("+81");
|
|
123
|
+
expect(events.dialchange.at(-1)).toBe("+81");
|
|
124
|
+
// The picker plays its leave transition, then unmounts.
|
|
125
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
126
|
+
expect(pickerRows()).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("disables the chip when the field is disabled", () => {
|
|
130
|
+
const { container } = mountPhone({ disabled: true });
|
|
131
|
+
expect(queryChip(container).disabled).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { computed, defineComponent, nextTick, ref, useAttrs, watch, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import { ChevronDown, Search } from "lucide-vue-next";
|
|
4
|
+
|
|
5
|
+
import { useI18n } from "../i18n/context";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
DIAL_CODES,
|
|
9
|
+
dialCodeName,
|
|
10
|
+
flagEmoji,
|
|
11
|
+
formatE164,
|
|
12
|
+
normalizeDial,
|
|
13
|
+
resolveDial,
|
|
14
|
+
type DialCodeEntry,
|
|
15
|
+
} from "../data/dialCodes";
|
|
16
|
+
|
|
17
|
+
import HkInput from "./HkInput";
|
|
18
|
+
import HkMenu from "./HkMenu";
|
|
19
|
+
import "./HkPhoneInput.scss";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* HkPhoneInput — phone-number field with a country dial-code picker.
|
|
23
|
+
*
|
|
24
|
+
* Mirrors the language-tag chip pattern of HkLocalizedInput but for
|
|
25
|
+
* dial codes: a leading chip inside the field (flag glyph + "+86" +
|
|
26
|
+
* caret) opens a searchable country list. The chip rides the LEFT edge
|
|
27
|
+
* of the field — the natural reading order for "which country, then
|
|
28
|
+
* which number" — while the number itself is typed after it.
|
|
29
|
+
*
|
|
30
|
+
* - `modelValue` is the NATIONAL number only ("13812345678"); the
|
|
31
|
+
* country selection lives in `dialCode` ("+86" shape, normalized on
|
|
32
|
+
* emit). Splitting the two keeps callers free to store whatever
|
|
33
|
+
* they already store (a bare "86" works too — matched by dial).
|
|
34
|
+
* - The chip shows the resolved country's flag plus the dial code in
|
|
35
|
+
* canonical "+…" shape. Unknown dial codes fall back to showing
|
|
36
|
+
* the normalized code alone (no flag).
|
|
37
|
+
* - The picker menu (`HkMenu`, desktop popout + mobile bottom sheet)
|
|
38
|
+
* lists the catalog in its defined order, flag + localized name +
|
|
39
|
+
* dial code per row, with a live filter field on top. Filtering
|
|
40
|
+
* matches the country's English/Chinese name, its ISO code, and
|
|
41
|
+
* the bare dial digits ("86", "0086" or "86" both hit China).
|
|
42
|
+
* - Picking a row emits `update:dialCode` ("+…"), `dialchange`
|
|
43
|
+
* (same value) and refocuses the number field. Blurring the field
|
|
44
|
+
* emits `change` with the composed E.164 — use it to validate or
|
|
45
|
+
* submit without recomputing `formatE164` yourself.
|
|
46
|
+
*
|
|
47
|
+
* The number input sanitizes itself: only digits, spaces and dashes
|
|
48
|
+
* survive (pasted "+86 138-1234-5678" becomes "138 1234-5678"), and
|
|
49
|
+
* `inputmode="tel"` raises the dial pad on touch devices. The
|
|
50
|
+
* catalog defaults to the bundled dictionary (China first); pass
|
|
51
|
+
* `countries` to scope the list (e.g. mainland-only deployments).
|
|
52
|
+
*/
|
|
53
|
+
export const HkPhoneInput = defineComponent({
|
|
54
|
+
name: "HkPhoneInput",
|
|
55
|
+
// The root wrapper carries no meaningful semantics; DOM attributes
|
|
56
|
+
// (inputmode, maxlength, aria-*) belong on the inner input.
|
|
57
|
+
inheritAttrs: false,
|
|
58
|
+
props: {
|
|
59
|
+
/** National number only — never includes the dial code. */
|
|
60
|
+
modelValue: { type: String, default: "" },
|
|
61
|
+
/** Dial code in any shape: "+86", "86", "0086". Kept as given
|
|
62
|
+
* (canonicalized only for matching), so v-model round-trips. */
|
|
63
|
+
dialCode: { type: String, default: "+86" },
|
|
64
|
+
/** Country catalog. Defaults to the bundled dictionary. */
|
|
65
|
+
countries: {
|
|
66
|
+
type: Array as PropType<readonly DialCodeEntry[]>,
|
|
67
|
+
default: () => DIAL_CODES,
|
|
68
|
+
},
|
|
69
|
+
placeholder: { type: String, default: "" },
|
|
70
|
+
disabled: { type: Boolean, default: false },
|
|
71
|
+
readonly: { type: Boolean, default: false },
|
|
72
|
+
size: { type: String as PropType<"sm" | "md" | "lg">, default: "md" },
|
|
73
|
+
label: { type: String, default: undefined },
|
|
74
|
+
error: { type: String, default: undefined },
|
|
75
|
+
hint: { type: String, default: undefined },
|
|
76
|
+
required: { type: Boolean, default: false },
|
|
77
|
+
name: { type: String, default: undefined },
|
|
78
|
+
/** Fired when Enter is pressed inside the number field (form
|
|
79
|
+
* submit). Forwarded to the inner HkInput. */
|
|
80
|
+
submitOnEnter: { type: Function, default: undefined },
|
|
81
|
+
},
|
|
82
|
+
emits: {
|
|
83
|
+
"update:modelValue": (_value: string) => true,
|
|
84
|
+
"update:dialCode": (_dial: string) => true,
|
|
85
|
+
/** Country picked from the list — payload "+86" shape. */
|
|
86
|
+
dialchange: (_dial: string) => true,
|
|
87
|
+
/** Field blurred — payload the composed E.164 ("" when empty). */
|
|
88
|
+
change: (_e164: string) => true,
|
|
89
|
+
focus: (_e: FocusEvent) => true,
|
|
90
|
+
blur: (_e: FocusEvent) => true,
|
|
91
|
+
},
|
|
92
|
+
setup(props, { emit }) {
|
|
93
|
+
const { t, locale } = useI18n();
|
|
94
|
+
const attrs = useAttrs();
|
|
95
|
+
|
|
96
|
+
const menuOpen = ref(false);
|
|
97
|
+
const chipRef = ref<HTMLElement | null>(null);
|
|
98
|
+
const rootRef = ref<HTMLElement | null>(null);
|
|
99
|
+
const query = ref("");
|
|
100
|
+
|
|
101
|
+
// A fresh open starts with an empty filter.
|
|
102
|
+
watch(menuOpen, (open) => {
|
|
103
|
+
if (!open) query.value = "";
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/** Canonical "+86" derived from the prop, whatever shape it is in. */
|
|
107
|
+
const canonicalDial = computed(() => {
|
|
108
|
+
const bare = normalizeDial(props.dialCode);
|
|
109
|
+
return bare ? `+${bare}` : "";
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** The country the current dial code resolves to (first catalog
|
|
113
|
+
* match for shared prefixes like +1). */
|
|
114
|
+
const activeEntry = computed(() =>
|
|
115
|
+
resolveDial(props.dialCode, undefined, props.countries),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
/** Searchable rows: filter against en/zh names, ISO code, dial. */
|
|
119
|
+
const filteredRows = computed(() => {
|
|
120
|
+
const q = query.value.trim().toLowerCase();
|
|
121
|
+
const qDial = q.replace(/\D/g, "");
|
|
122
|
+
if (!q) return props.countries;
|
|
123
|
+
return props.countries.filter((c) => {
|
|
124
|
+
if (c.iso.toLowerCase().includes(q)) return true;
|
|
125
|
+
if (qDial && c.dial.includes(qDial)) return true;
|
|
126
|
+
return (
|
|
127
|
+
c.en.toLowerCase().includes(q) ||
|
|
128
|
+
c.zh.toLowerCase().includes(q)
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
function pickCountry(entry: DialCodeEntry) {
|
|
134
|
+
const dial = `+${entry.dial}`;
|
|
135
|
+
menuOpen.value = false;
|
|
136
|
+
emit("update:dialCode", dial);
|
|
137
|
+
emit("dialchange", dial);
|
|
138
|
+
// Refocus the number field so pick-then-type flows without a
|
|
139
|
+
// second tap (the chip's menu is the only focusable sibling).
|
|
140
|
+
nextTick(() => {
|
|
141
|
+
const el = rootRef.value?.querySelector<HTMLElement>("input");
|
|
142
|
+
el?.focus();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Emit the composed E.164 on blur ("" when the number is empty). */
|
|
147
|
+
function onBlur(e: FocusEvent) {
|
|
148
|
+
emit("blur", e);
|
|
149
|
+
emit("change", formatE164(canonicalDial.value, props.modelValue));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function onInput(value: string) {
|
|
153
|
+
// Digits, spaces and dashes only; everything else (plus signs,
|
|
154
|
+
// parentheses, stray letters from a paste) drops out.
|
|
155
|
+
const cleaned = value.replace(/[^\d\s-]/g, "");
|
|
156
|
+
emit("update:modelValue", cleaned);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function chipLabel(): string {
|
|
160
|
+
const bare = normalizeDial(props.dialCode);
|
|
161
|
+
return bare ? `+${bare}` : t("hikari::phoneInput.dialCode", "+…");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return () => {
|
|
165
|
+
const rows = filteredRows.value;
|
|
166
|
+
const active = activeEntry.value;
|
|
167
|
+
const nameFor = (entry: DialCodeEntry) =>
|
|
168
|
+
dialCodeName(entry, locale);
|
|
169
|
+
const inputAttrs = {
|
|
170
|
+
inputmode: "tel" as const,
|
|
171
|
+
maxlength: 18,
|
|
172
|
+
...attrs,
|
|
173
|
+
};
|
|
174
|
+
delete (inputAttrs as Record<string, unknown>).class;
|
|
175
|
+
delete (inputAttrs as Record<string, unknown>).style;
|
|
176
|
+
return (
|
|
177
|
+
<div class="hk-phone-input" ref={rootRef}>
|
|
178
|
+
<HkInput
|
|
179
|
+
modelValue={props.modelValue}
|
|
180
|
+
onUpdate:modelValue={onInput}
|
|
181
|
+
placeholder={props.placeholder}
|
|
182
|
+
disabled={props.disabled}
|
|
183
|
+
readonly={props.readonly}
|
|
184
|
+
size={props.size}
|
|
185
|
+
label={props.label}
|
|
186
|
+
error={props.error}
|
|
187
|
+
hint={props.hint}
|
|
188
|
+
required={props.required}
|
|
189
|
+
name={props.name}
|
|
190
|
+
onBlur={onBlur}
|
|
191
|
+
autocomplete="tel-national"
|
|
192
|
+
submitOnEnter={props.submitOnEnter}
|
|
193
|
+
{...inputAttrs}
|
|
194
|
+
>
|
|
195
|
+
{{
|
|
196
|
+
prefix: () => (
|
|
197
|
+
<button
|
|
198
|
+
ref={(el: unknown) => {
|
|
199
|
+
chipRef.value = (el as HTMLElement) ?? null;
|
|
200
|
+
}}
|
|
201
|
+
type="button"
|
|
202
|
+
class="hk-phone-chip"
|
|
203
|
+
disabled={props.disabled || props.readonly}
|
|
204
|
+
aria-haspopup="menu"
|
|
205
|
+
aria-expanded={menuOpen.value}
|
|
206
|
+
aria-label={t(
|
|
207
|
+
"hikari::phoneInput.chooseCountry",
|
|
208
|
+
"Choose country code",
|
|
209
|
+
)}
|
|
210
|
+
title={active ? `${nameFor(active)} ${chipLabel()}` : chipLabel()}
|
|
211
|
+
onMousedown={(e: MouseEvent) => e.preventDefault()}
|
|
212
|
+
onClick={(e: MouseEvent) => {
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
e.stopPropagation();
|
|
215
|
+
if (!props.disabled && !props.readonly) {
|
|
216
|
+
menuOpen.value = !menuOpen.value;
|
|
217
|
+
}
|
|
218
|
+
}}
|
|
219
|
+
>
|
|
220
|
+
<span class="hk-phone-chip-flag" aria-hidden="true">
|
|
221
|
+
{active ? flagEmoji(active.iso) : ""}
|
|
222
|
+
</span>
|
|
223
|
+
<span class="hk-phone-chip-dial">{chipLabel()}</span>
|
|
224
|
+
<ChevronDown size={12} class="hk-phone-chip-caret" aria-hidden="true" />
|
|
225
|
+
</button>
|
|
226
|
+
),
|
|
227
|
+
}}
|
|
228
|
+
</HkInput>
|
|
229
|
+
<HkMenu
|
|
230
|
+
variant="popup"
|
|
231
|
+
items={[]}
|
|
232
|
+
open={menuOpen.value}
|
|
233
|
+
onUpdate:open={(v: boolean) => {
|
|
234
|
+
menuOpen.value = v;
|
|
235
|
+
}}
|
|
236
|
+
anchorRef={chipRef.value}
|
|
237
|
+
placement="bottom-start"
|
|
238
|
+
matchAnchorWidth={false}
|
|
239
|
+
title={t("hikari::phoneInput.chooseCountry", "Choose country code")}
|
|
240
|
+
>
|
|
241
|
+
{{
|
|
242
|
+
header: () => (
|
|
243
|
+
<div class="hk-phone-dial-search" role="search">
|
|
244
|
+
<HkInput
|
|
245
|
+
modelValue={query.value}
|
|
246
|
+
onUpdate:modelValue={(v: string) => {
|
|
247
|
+
query.value = v;
|
|
248
|
+
}}
|
|
249
|
+
size="sm"
|
|
250
|
+
placeholder={t(
|
|
251
|
+
"hikari::phoneInput.searchCountries",
|
|
252
|
+
"Search country or code",
|
|
253
|
+
)}
|
|
254
|
+
aria-label={t(
|
|
255
|
+
"hikari::phoneInput.searchCountries",
|
|
256
|
+
"Search country or code",
|
|
257
|
+
)}
|
|
258
|
+
autocomplete="off"
|
|
259
|
+
onKeydown={(e: KeyboardEvent) => {
|
|
260
|
+
// Enter picks the first filtered row.
|
|
261
|
+
if (e.key === "Enter" && !e.isComposing && rows.length > 0) {
|
|
262
|
+
e.preventDefault();
|
|
263
|
+
pickCountry(rows[0]);
|
|
264
|
+
}
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
{{
|
|
268
|
+
prefixIcon: () => (
|
|
269
|
+
<Search size={13} aria-hidden="true" />
|
|
270
|
+
),
|
|
271
|
+
}}
|
|
272
|
+
</HkInput>
|
|
273
|
+
</div>
|
|
274
|
+
),
|
|
275
|
+
default: () =>
|
|
276
|
+
rows.length > 0 ? (
|
|
277
|
+
<div class="hk-phone-dial-list">
|
|
278
|
+
{rows.map((entry) => {
|
|
279
|
+
const selected = entry.iso === active?.iso;
|
|
280
|
+
return (
|
|
281
|
+
<button
|
|
282
|
+
key={entry.iso}
|
|
283
|
+
type="button"
|
|
284
|
+
class="hk-phone-dial-row"
|
|
285
|
+
data-active={selected || undefined}
|
|
286
|
+
onClick={() => pickCountry(entry)}
|
|
287
|
+
>
|
|
288
|
+
<span class="hk-phone-dial-flag" aria-hidden="true">
|
|
289
|
+
{flagEmoji(entry.iso)}
|
|
290
|
+
</span>
|
|
291
|
+
<span class="hk-phone-dial-name">{nameFor(entry)}</span>
|
|
292
|
+
<span class="hk-phone-dial-code">+{entry.dial}</span>
|
|
293
|
+
</button>
|
|
294
|
+
);
|
|
295
|
+
})}
|
|
296
|
+
</div>
|
|
297
|
+
) : (
|
|
298
|
+
<div class="hk-phone-dial-empty">
|
|
299
|
+
{t("hikari::phoneInput.noMatches", "No matching country")}
|
|
300
|
+
</div>
|
|
301
|
+
),
|
|
302
|
+
}}
|
|
303
|
+
</HkMenu>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
};
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
export default HkPhoneInput;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
.hk-qr-card {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
align-items: center;
|
|
5
|
+
gap: var(--space-6, 6px);
|
|
6
|
+
width: 100%;
|
|
7
|
+
box-sizing: border-box;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.hk-qr-canvas {
|
|
11
|
+
display: block;
|
|
12
|
+
border-radius: var(--radius-sm, 4px);
|
|
13
|
+
// Always a white backing so the QR stays scannable regardless of theme.
|
|
14
|
+
background: #ffffff;
|
|
15
|
+
box-shadow: 0 1px 6px rgb(0 0 0 / 0.08);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.hk-qr-label {
|
|
19
|
+
font-size: var(--text-2xs, 0.75rem);
|
|
20
|
+
line-height: 1.4;
|
|
21
|
+
color: rgb(var(--color-muted, 120 113 108));
|
|
22
|
+
text-align: center;
|
|
23
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkQrCode } from "./HkQrCode";
|
|
5
|
+
|
|
6
|
+
const OTPAUTH =
|
|
7
|
+
"otpauth://totp/Example:langyo%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example";
|
|
8
|
+
|
|
9
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
10
|
+
|
|
11
|
+
function mountQr(props: Record<string, unknown> = {}) {
|
|
12
|
+
const container = document.createElement("div");
|
|
13
|
+
document.body.appendChild(container);
|
|
14
|
+
const app = createApp({
|
|
15
|
+
render: () => h(HkQrCode, props),
|
|
16
|
+
});
|
|
17
|
+
app.mount(container);
|
|
18
|
+
mounts.push({ app, container });
|
|
19
|
+
return container;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
while (mounts.length > 0) {
|
|
24
|
+
const { app, container } = mounts.pop()!;
|
|
25
|
+
app.unmount();
|
|
26
|
+
container.remove();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("HkQrCode", () => {
|
|
31
|
+
it("renders a labeled canvas for a value", async () => {
|
|
32
|
+
const container = mountQr({ value: OTPAUTH });
|
|
33
|
+
await nextTick();
|
|
34
|
+
const canvas = container.querySelector("canvas.hk-qr-canvas");
|
|
35
|
+
expect(canvas).toBeTruthy();
|
|
36
|
+
expect(canvas!.getAttribute("role")).toBe("img");
|
|
37
|
+
expect(canvas!.getAttribute("aria-label")).toBe("QR code");
|
|
38
|
+
expect((canvas as HTMLElement).style.width).toBe("168px");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("shows the caption label under the QR", async () => {
|
|
42
|
+
const container = mountQr({ value: OTPAUTH, label: "Scan with your app" });
|
|
43
|
+
await nextTick();
|
|
44
|
+
expect(container.querySelector(".hk-qr-label")?.textContent).toBe(
|
|
45
|
+
"Scan with your app",
|
|
46
|
+
);
|
|
47
|
+
// The aria-label falls back to the caption.
|
|
48
|
+
expect(container.querySelector("canvas")?.getAttribute("aria-label")).toBe(
|
|
49
|
+
"Scan with your app",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders nothing for an empty value", async () => {
|
|
54
|
+
const container = mountQr({ value: "" });
|
|
55
|
+
await nextTick();
|
|
56
|
+
expect(container.querySelector("canvas")).toBeNull();
|
|
57
|
+
expect(container.querySelector(".hk-qr-card")?.getAttribute("data-empty")).toBe("true");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("re-encodes when the value changes", async () => {
|
|
61
|
+
const container = mountQr({ value: OTPAUTH, size: 200 });
|
|
62
|
+
await nextTick();
|
|
63
|
+
const canvas = container.querySelector("canvas") as HTMLCanvasElement;
|
|
64
|
+
expect((canvas.style.width)).toBe("200px");
|
|
65
|
+
});
|
|
66
|
+
});
|