@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.32.2",
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
+ });