@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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
defineComponent,
|
|
4
|
+
onMounted,
|
|
5
|
+
ref,
|
|
6
|
+
watch,
|
|
7
|
+
type PropType,
|
|
8
|
+
} from "vue";
|
|
9
|
+
|
|
10
|
+
import { useI18n } from "../i18n/context";
|
|
11
|
+
import { qrMatrix, type QrErrorCorrectionLevel } from "../utils/qr/qrEncoder";
|
|
12
|
+
import "./HkQrCode.scss";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* HkQrCode — render a scannable QR code from an arbitrary string value
|
|
16
|
+
* (typically an `otpauth://` URI).
|
|
17
|
+
*
|
|
18
|
+
* The QR is always rendered as dark modules on a white card (independent of
|
|
19
|
+
* the app theme) so it stays scannable in dark mode, matching the pattern of
|
|
20
|
+
* authenticator apps. Rendering goes to a `<canvas>` (crisp at any scale and
|
|
21
|
+
* device-pixel-ratio). Re-encodes whenever `value` changes.
|
|
22
|
+
*
|
|
23
|
+
* Contract:
|
|
24
|
+
* - `value` is the payload (e.g. an otpauth URI); empty/whitespace renders
|
|
25
|
+
* nothing.
|
|
26
|
+
* - `size` is the rendered CSS size in px; the canvas backing store is
|
|
27
|
+
* scaled by the device pixel ratio so module edges stay sharp.
|
|
28
|
+
* - `inverse` swaps dark/light (advanced use; the default light card is the
|
|
29
|
+
* high-contrast, always-scannable choice).
|
|
30
|
+
*/
|
|
31
|
+
export const HkQrCode = defineComponent({
|
|
32
|
+
name: "HkQrCode",
|
|
33
|
+
props: {
|
|
34
|
+
/** Payload to encode. Render nothing when empty. */
|
|
35
|
+
value: { type: String, default: "" },
|
|
36
|
+
/** Rendered CSS size in px (square). Default 168. */
|
|
37
|
+
size: { type: Number, default: 168 },
|
|
38
|
+
/** Quiet-zone width in modules. Default 2. */
|
|
39
|
+
margin: { type: Number, default: 2 },
|
|
40
|
+
/** Error-correction level. Default "M". */
|
|
41
|
+
level: { type: String as PropType<QrErrorCorrectionLevel>, default: "M" },
|
|
42
|
+
colorDark: { type: String, default: "#000000" },
|
|
43
|
+
colorLight: { type: String, default: "#ffffff" },
|
|
44
|
+
/** Swap dark/light (use with a dark card only). Default false. */
|
|
45
|
+
inverse: { type: Boolean, default: false },
|
|
46
|
+
/** Optional caption shown under the QR. */
|
|
47
|
+
label: { type: String, default: "" },
|
|
48
|
+
disabled: { type: Boolean, default: false },
|
|
49
|
+
},
|
|
50
|
+
setup(props) {
|
|
51
|
+
const { t } = useI18n();
|
|
52
|
+
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
|
53
|
+
|
|
54
|
+
const dark = computed(() => (props.inverse ? props.colorLight : props.colorDark));
|
|
55
|
+
const light = computed(() => (props.inverse ? props.colorDark : props.colorLight));
|
|
56
|
+
const hasValue = computed(() => props.value.trim().length > 0);
|
|
57
|
+
|
|
58
|
+
function draw() {
|
|
59
|
+
if (!canvasRef.value || !hasValue.value) return;
|
|
60
|
+
if (typeof document === "undefined") return; // SSR guard
|
|
61
|
+
const canvas = canvasRef.value;
|
|
62
|
+
const matrix = qrMatrix(props.value, {
|
|
63
|
+
errorCorrectionLevel: props.level,
|
|
64
|
+
});
|
|
65
|
+
const count = matrix.length;
|
|
66
|
+
const totalModules = count + props.margin * 2;
|
|
67
|
+
const dpr = (typeof window !== "undefined" && window.devicePixelRatio) || 1;
|
|
68
|
+
const cssSize = props.size;
|
|
69
|
+
const backing = Math.round(cssSize * dpr);
|
|
70
|
+
canvas.width = backing;
|
|
71
|
+
canvas.height = backing;
|
|
72
|
+
const ctx = canvas.getContext("2d");
|
|
73
|
+
if (!ctx) return;
|
|
74
|
+
// Fill the quiet-zone / background.
|
|
75
|
+
ctx.fillStyle = light.value;
|
|
76
|
+
ctx.fillRect(0, 0, backing, backing);
|
|
77
|
+
const cell = backing / totalModules;
|
|
78
|
+
// Draw each dark module (quiet zone = margin offset).
|
|
79
|
+
for (let row = 0; row < count; row++) {
|
|
80
|
+
for (let col = 0; col < count; col++) {
|
|
81
|
+
if (matrix[row][col]) {
|
|
82
|
+
ctx.fillStyle = dark.value;
|
|
83
|
+
ctx.fillRect(
|
|
84
|
+
(props.margin + col) * cell,
|
|
85
|
+
(props.margin + row) * cell,
|
|
86
|
+
cell + 0.5, // slight overlap avoids hairline seams on scaled canvases
|
|
87
|
+
cell + 0.5,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
onMounted(draw);
|
|
95
|
+
watch(
|
|
96
|
+
() => [props.value, props.size, props.margin, props.level, props.colorDark, props.colorLight, props.inverse],
|
|
97
|
+
draw,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const ariaLabel = computed(
|
|
101
|
+
() => props.label || t("hikari::qrCode.alt", "QR code"),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return () => (
|
|
105
|
+
<div class="hk-qr-card" data-empty={hasValue.value ? undefined : true}>
|
|
106
|
+
{hasValue.value ? (
|
|
107
|
+
<canvas
|
|
108
|
+
ref={canvasRef}
|
|
109
|
+
class="hk-qr-canvas"
|
|
110
|
+
role="img"
|
|
111
|
+
aria-label={ariaLabel.value}
|
|
112
|
+
style={{ width: `${props.size}px`, height: `${props.size}px` }}
|
|
113
|
+
/>
|
|
114
|
+
) : null}
|
|
115
|
+
{props.label ? <div class="hk-qr-label">{props.label}</div> : null}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
export default HkQrCode;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DIAL_CODES,
|
|
5
|
+
entryByDialCode,
|
|
6
|
+
entryByIso,
|
|
7
|
+
flagEmoji,
|
|
8
|
+
formatE164,
|
|
9
|
+
normalizeDial,
|
|
10
|
+
parseE164,
|
|
11
|
+
} from "./dialCodes";
|
|
12
|
+
|
|
13
|
+
describe("dialCodes catalog", () => {
|
|
14
|
+
it("starts with China and contains the expected shape", () => {
|
|
15
|
+
expect(DIAL_CODES[0]).toMatchObject({ iso: "cn", dial: "86", en: "China", zh: "中国" });
|
|
16
|
+
for (const entry of DIAL_CODES) {
|
|
17
|
+
expect(entry.iso).toMatch(/^[a-z]{2}$/);
|
|
18
|
+
expect(entry.dial).toMatch(/^\d+$/);
|
|
19
|
+
expect(entry.en.length).toBeGreaterThan(0);
|
|
20
|
+
expect(entry.zh.length).toBeGreaterThan(0);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("has no duplicate ISO codes and no dangling dial codes", () => {
|
|
25
|
+
const isos = new Set(DIAL_CODES.map((c) => c.iso));
|
|
26
|
+
expect(isos.size).toBe(DIAL_CODES.length);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("derives flag emoji from ISO codes", () => {
|
|
30
|
+
expect(flagEmoji("cn")).toBe("🇨🇳");
|
|
31
|
+
expect(flagEmoji("us")).toBe("🇺🇸");
|
|
32
|
+
expect(flagEmoji("zz")).toBe("🇿🇿"); // any two letters map
|
|
33
|
+
expect(flagEmoji("")).toBe("");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("formatE164 / parseE164", () => {
|
|
38
|
+
it("composes E.164 from dial code and national number", () => {
|
|
39
|
+
expect(formatE164("+86", "13812345678")).toBe("+8613812345678");
|
|
40
|
+
expect(formatE164("86", "138 1234-5678")).toBe("+8613812345678");
|
|
41
|
+
expect(formatE164("0086", "13812345678")).toBe("+8613812345678");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns empty for missing parts", () => {
|
|
45
|
+
expect(formatE164("", "138")).toBe("");
|
|
46
|
+
expect(formatE164("+86", "")).toBe("");
|
|
47
|
+
expect(formatE164("+86", " ")).toBe("");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("round-trips a parsed E.164", () => {
|
|
51
|
+
const parsed = parseE164("+8613812345678");
|
|
52
|
+
expect(parsed).toEqual({ dial: "86", national: "13812345678", iso: "cn" });
|
|
53
|
+
expect(formatE164(`+${parsed.dial}`, parsed.national)).toBe("+8613812345678");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("resolves shared prefixes to the longest dial match", () => {
|
|
57
|
+
// +1 → United States (first catalog entry for dial 1).
|
|
58
|
+
const us = parseE164("+12125551234");
|
|
59
|
+
expect(us.dial).toBe("1");
|
|
60
|
+
expect(us.iso).toBe("us");
|
|
61
|
+
expect(us.national).toBe("2125551234");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("splits +852 Hong Kong without confusing +85 prefixes", () => {
|
|
65
|
+
const hk = parseE164("+85291234567");
|
|
66
|
+
expect(hk).toEqual({ dial: "852", national: "91234567", iso: "hk" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("handles a bare number without a country match", () => {
|
|
70
|
+
const parsed = parseE164("99999999");
|
|
71
|
+
// "99" matches no catalog dial prefix (no entry starts 99), so the
|
|
72
|
+
// whole string stays national.
|
|
73
|
+
expect(parsed.national).toBe("99999999");
|
|
74
|
+
expect(parsed.dial).toBe("");
|
|
75
|
+
expect(parsed.iso).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("lookups", () => {
|
|
80
|
+
it("finds entries by iso and by dial", () => {
|
|
81
|
+
expect(entryByIso("JP")?.dial).toBe("81");
|
|
82
|
+
expect(entryByDialCode("+81")?.iso).toBe("jp");
|
|
83
|
+
expect(entryByDialCode("81")?.iso).toBe("jp");
|
|
84
|
+
expect(entryByDialCode("jp")?.dial).toBe("81");
|
|
85
|
+
expect(entryByDialCode("+999")).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("normalizes dial shapes", () => {
|
|
89
|
+
expect(normalizeDial("+86")).toBe("86");
|
|
90
|
+
expect(normalizeDial("0086")).toBe("86");
|
|
91
|
+
expect(normalizeDial("+1 234")).toBe("1234");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Country dial-code dictionary for phone inputs (SMS enrollment, contact
|
|
3
|
+
* forms, …). Deliberately NOT part of the locale JSON: it is a data
|
|
4
|
+
* catalog, not copy — locale JSON files stay small and this table stays
|
|
5
|
+
* machine-queryable.
|
|
6
|
+
*
|
|
7
|
+
* Every entry carries an ISO 3166-1 alpha-2 code (lowercase) plus
|
|
8
|
+
* localized country names in English and Simplified Chinese. The flag is
|
|
9
|
+
* derived from the ISO code at render time (`flagEmoji`), so the table
|
|
10
|
+
* never carries hand-typed emoji that can silently rot.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface DialCodeEntry {
|
|
14
|
+
/** ISO 3166-1 alpha-2 country code, lowercase (e.g. "cn"). */
|
|
15
|
+
iso: string;
|
|
16
|
+
/** National dial code WITHOUT the leading "+" (e.g. "86"). */
|
|
17
|
+
dial: string;
|
|
18
|
+
/** English country name (index + fallback). */
|
|
19
|
+
en: string;
|
|
20
|
+
/** Simplified Chinese country name. */
|
|
21
|
+
zh: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Flag glyph for an ISO 3166-1 alpha-2 code (regional indicators). */
|
|
25
|
+
export function flagEmoji(iso: string): string {
|
|
26
|
+
const upper = iso.toUpperCase();
|
|
27
|
+
if (!/^[A-Z]{2}$/.test(upper)) return "";
|
|
28
|
+
return String.fromCodePoint(
|
|
29
|
+
...Array.from(upper, (c) => 0x1f1e6 + c.charCodeAt(0) - 65),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Localized country name: zh locales read the zh field, everything else
|
|
34
|
+
* falls back to English (the dictionary ships en + zh only). */
|
|
35
|
+
export function dialCodeName(entry: DialCodeEntry, locale: string): string {
|
|
36
|
+
return locale.toLowerCase().startsWith("zh") ? entry.zh : entry.en;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Ordered catalog. China first (the primary audience of the workspace),
|
|
41
|
+
* then Asia-Pacific neighbors, then the rest of the world. Order also
|
|
42
|
+
* drives parse ambiguity resolution (first match wins for shared dial
|
|
43
|
+
* prefixes like +1 / +7).
|
|
44
|
+
*/
|
|
45
|
+
export const DIAL_CODES: readonly DialCodeEntry[] = [
|
|
46
|
+
{ iso: "cn", dial: "86", en: "China", zh: "中国" },
|
|
47
|
+
{ iso: "hk", dial: "852", en: "Hong Kong (China)", zh: "中国香港" },
|
|
48
|
+
{ iso: "mo", dial: "853", en: "Macau (China)", zh: "中国澳门" },
|
|
49
|
+
{ iso: "tw", dial: "886", en: "Taiwan (China)", zh: "中国台湾" },
|
|
50
|
+
{ iso: "jp", dial: "81", en: "Japan", zh: "日本" },
|
|
51
|
+
{ iso: "kr", dial: "82", en: "South Korea", zh: "韩国" },
|
|
52
|
+
{ iso: "sg", dial: "65", en: "Singapore", zh: "新加坡" },
|
|
53
|
+
{ iso: "my", dial: "60", en: "Malaysia", zh: "马来西亚" },
|
|
54
|
+
{ iso: "th", dial: "66", en: "Thailand", zh: "泰国" },
|
|
55
|
+
{ iso: "vn", dial: "84", en: "Vietnam", zh: "越南" },
|
|
56
|
+
{ iso: "ph", dial: "63", en: "Philippines", zh: "菲律宾" },
|
|
57
|
+
{ iso: "id", dial: "62", en: "Indonesia", zh: "印度尼西亚" },
|
|
58
|
+
{ iso: "in", dial: "91", en: "India", zh: "印度" },
|
|
59
|
+
{ iso: "pk", dial: "92", en: "Pakistan", zh: "巴基斯坦" },
|
|
60
|
+
{ iso: "bd", dial: "880", en: "Bangladesh", zh: "孟加拉国" },
|
|
61
|
+
{ iso: "lk", dial: "94", en: "Sri Lanka", zh: "斯里兰卡" },
|
|
62
|
+
{ iso: "np", dial: "977", en: "Nepal", zh: "尼泊尔" },
|
|
63
|
+
{ iso: "kh", dial: "855", en: "Cambodia", zh: "柬埔寨" },
|
|
64
|
+
{ iso: "la", dial: "856", en: "Laos", zh: "老挝" },
|
|
65
|
+
{ iso: "mm", dial: "95", en: "Myanmar", zh: "缅甸" },
|
|
66
|
+
{ iso: "bn", dial: "673", en: "Brunei", zh: "文莱" },
|
|
67
|
+
{ iso: "mn", dial: "976", en: "Mongolia", zh: "蒙古" },
|
|
68
|
+
{ iso: "us", dial: "1", en: "United States", zh: "美国" },
|
|
69
|
+
{ iso: "ca", dial: "1", en: "Canada", zh: "加拿大" },
|
|
70
|
+
{ iso: "gb", dial: "44", en: "United Kingdom", zh: "英国" },
|
|
71
|
+
{ iso: "de", dial: "49", en: "Germany", zh: "德国" },
|
|
72
|
+
{ iso: "fr", dial: "33", en: "France", zh: "法国" },
|
|
73
|
+
{ iso: "it", dial: "39", en: "Italy", zh: "意大利" },
|
|
74
|
+
{ iso: "es", dial: "34", en: "Spain", zh: "西班牙" },
|
|
75
|
+
{ iso: "pt", dial: "351", en: "Portugal", zh: "葡萄牙" },
|
|
76
|
+
{ iso: "nl", dial: "31", en: "Netherlands", zh: "荷兰" },
|
|
77
|
+
{ iso: "be", dial: "32", en: "Belgium", zh: "比利时" },
|
|
78
|
+
{ iso: "ch", dial: "41", en: "Switzerland", zh: "瑞士" },
|
|
79
|
+
{ iso: "at", dial: "43", en: "Austria", zh: "奥地利" },
|
|
80
|
+
{ iso: "ie", dial: "353", en: "Ireland", zh: "爱尔兰" },
|
|
81
|
+
{ iso: "se", dial: "46", en: "Sweden", zh: "瑞典" },
|
|
82
|
+
{ iso: "no", dial: "47", en: "Norway", zh: "挪威" },
|
|
83
|
+
{ iso: "dk", dial: "45", en: "Denmark", zh: "丹麦" },
|
|
84
|
+
{ iso: "fi", dial: "358", en: "Finland", zh: "芬兰" },
|
|
85
|
+
{ iso: "is", dial: "354", en: "Iceland", zh: "冰岛" },
|
|
86
|
+
{ iso: "pl", dial: "48", en: "Poland", zh: "波兰" },
|
|
87
|
+
{ iso: "cz", dial: "420", en: "Czechia", zh: "捷克" },
|
|
88
|
+
{ iso: "sk", dial: "421", en: "Slovakia", zh: "斯洛伐克" },
|
|
89
|
+
{ iso: "hu", dial: "36", en: "Hungary", zh: "匈牙利" },
|
|
90
|
+
{ iso: "ro", dial: "40", en: "Romania", zh: "罗马尼亚" },
|
|
91
|
+
{ iso: "bg", dial: "359", en: "Bulgaria", zh: "保加利亚" },
|
|
92
|
+
{ iso: "gr", dial: "30", en: "Greece", zh: "希腊" },
|
|
93
|
+
{ iso: "hr", dial: "385", en: "Croatia", zh: "克罗地亚" },
|
|
94
|
+
{ iso: "rs", dial: "381", en: "Serbia", zh: "塞尔维亚" },
|
|
95
|
+
{ iso: "ua", dial: "380", en: "Ukraine", zh: "乌克兰" },
|
|
96
|
+
{ iso: "ru", dial: "7", en: "Russia", zh: "俄罗斯" },
|
|
97
|
+
{ iso: "kz", dial: "7", en: "Kazakhstan", zh: "哈萨克斯坦" },
|
|
98
|
+
{ iso: "tr", dial: "90", en: "Türkiye", zh: "土耳其" },
|
|
99
|
+
{ iso: "il", dial: "972", en: "Israel", zh: "以色列" },
|
|
100
|
+
{ iso: "ae", dial: "971", en: "United Arab Emirates", zh: "阿联酋" },
|
|
101
|
+
{ iso: "sa", dial: "966", en: "Saudi Arabia", zh: "沙特阿拉伯" },
|
|
102
|
+
{ iso: "qa", dial: "974", en: "Qatar", zh: "卡塔尔" },
|
|
103
|
+
{ iso: "kw", dial: "965", en: "Kuwait", zh: "科威特" },
|
|
104
|
+
{ iso: "bh", dial: "973", en: "Bahrain", zh: "巴林" },
|
|
105
|
+
{ iso: "om", dial: "968", en: "Oman", zh: "阿曼" },
|
|
106
|
+
{ iso: "jo", dial: "962", en: "Jordan", zh: "约旦" },
|
|
107
|
+
{ iso: "lb", dial: "961", en: "Lebanon", zh: "黎巴嫩" },
|
|
108
|
+
{ iso: "iq", dial: "964", en: "Iraq", zh: "伊拉克" },
|
|
109
|
+
{ iso: "ir", dial: "98", en: "Iran", zh: "伊朗" },
|
|
110
|
+
{ iso: "af", dial: "93", en: "Afghanistan", zh: "阿富汗" },
|
|
111
|
+
{ iso: "ge", dial: "995", en: "Georgia", zh: "格鲁吉亚" },
|
|
112
|
+
{ iso: "am", dial: "374", en: "Armenia", zh: "亚美尼亚" },
|
|
113
|
+
{ iso: "az", dial: "994", en: "Azerbaijan", zh: "阿塞拜疆" },
|
|
114
|
+
{ iso: "uz", dial: "998", en: "Uzbekistan", zh: "乌兹别克斯坦" },
|
|
115
|
+
{ iso: "eg", dial: "20", en: "Egypt", zh: "埃及" },
|
|
116
|
+
{ iso: "ma", dial: "212", en: "Morocco", zh: "摩洛哥" },
|
|
117
|
+
{ iso: "dz", dial: "213", en: "Algeria", zh: "阿尔及利亚" },
|
|
118
|
+
{ iso: "tn", dial: "216", en: "Tunisia", zh: "突尼斯" },
|
|
119
|
+
{ iso: "ng", dial: "234", en: "Nigeria", zh: "尼日利亚" },
|
|
120
|
+
{ iso: "ke", dial: "254", en: "Kenya", zh: "肯尼亚" },
|
|
121
|
+
{ iso: "et", dial: "251", en: "Ethiopia", zh: "埃塞俄比亚" },
|
|
122
|
+
{ iso: "tz", dial: "255", en: "Tanzania", zh: "坦桑尼亚" },
|
|
123
|
+
{ iso: "za", dial: "27", en: "South Africa", zh: "南非" },
|
|
124
|
+
{ iso: "gh", dial: "233", en: "Ghana", zh: "加纳" },
|
|
125
|
+
{ iso: "br", dial: "55", en: "Brazil", zh: "巴西" },
|
|
126
|
+
{ iso: "mx", dial: "52", en: "Mexico", zh: "墨西哥" },
|
|
127
|
+
{ iso: "ar", dial: "54", en: "Argentina", zh: "阿根廷" },
|
|
128
|
+
{ iso: "cl", dial: "56", en: "Chile", zh: "智利" },
|
|
129
|
+
{ iso: "co", dial: "57", en: "Colombia", zh: "哥伦比亚" },
|
|
130
|
+
{ iso: "pe", dial: "51", en: "Peru", zh: "秘鲁" },
|
|
131
|
+
{ iso: "ve", dial: "58", en: "Venezuela", zh: "委内瑞拉" },
|
|
132
|
+
{ iso: "uy", dial: "598", en: "Uruguay", zh: "乌拉圭" },
|
|
133
|
+
{ iso: "ec", dial: "593", en: "Ecuador", zh: "厄瓜多尔" },
|
|
134
|
+
{ iso: "au", dial: "61", en: "Australia", zh: "澳大利亚" },
|
|
135
|
+
{ iso: "nz", dial: "64", en: "New Zealand", zh: "新西兰" },
|
|
136
|
+
{ iso: "fj", dial: "679", en: "Fiji", zh: "斐济" },
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
/** Normalize a dial-code input ("86", "+86", "0086") to bare digits. */
|
|
140
|
+
export function normalizeDial(dial: string): string {
|
|
141
|
+
return dial.replace(/^\+/, "").replace(/^00/, "").replace(/\D/g, "");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Strip every non-digit from a national number (keeps leading zeros
|
|
145
|
+
* where a country genuinely needs them, e.g. Italy). */
|
|
146
|
+
export function normalizeNational(national: string): string {
|
|
147
|
+
return national.replace(/\D/g, "");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Find a catalog entry by lowercase ISO code. */
|
|
151
|
+
export function entryByIso(iso: string, list: readonly DialCodeEntry[] = DIAL_CODES) {
|
|
152
|
+
return list.find((c) => c.iso === iso.toLowerCase());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Find the first catalog entry matching a dial code (order = priority
|
|
156
|
+
* for shared prefixes like +1 / +7). */
|
|
157
|
+
export function entryByDial(dial: string, list: readonly DialCodeEntry[] = DIAL_CODES) {
|
|
158
|
+
const bare = normalizeDial(dial);
|
|
159
|
+
return list.find((c) => c.dial === bare);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Resolve an entry from an ISO code OR dial code (dial wins when the
|
|
163
|
+
* caller only knows the number). */
|
|
164
|
+
export function entryByDialCode(
|
|
165
|
+
isoOrDial: string,
|
|
166
|
+
list: readonly DialCodeEntry[] = DIAL_CODES,
|
|
167
|
+
) {
|
|
168
|
+
return entryByIso(isoOrDial, list) ?? entryByDial(isoOrDial, list);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Resolve an entry from a dial-code string in any shape ("+86" → CN). */
|
|
172
|
+
export function resolveDial(
|
|
173
|
+
dial: string,
|
|
174
|
+
isoHint?: string,
|
|
175
|
+
list: readonly DialCodeEntry[] = DIAL_CODES,
|
|
176
|
+
): DialCodeEntry | undefined {
|
|
177
|
+
const bare = normalizeDial(dial);
|
|
178
|
+
if (!bare) return undefined;
|
|
179
|
+
if (isoHint) {
|
|
180
|
+
const byIso = entryByIso(isoHint, list);
|
|
181
|
+
if (byIso && byIso.dial === bare) return byIso;
|
|
182
|
+
}
|
|
183
|
+
return list.find((c) => c.dial === bare);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Compose an E.164 number: national digits plus the selected dial code.
|
|
188
|
+
* Handles a national number that already carries a trunk prefix ("0…")
|
|
189
|
+
* only where the dial code demands it (kept simple: leading "0" is
|
|
190
|
+
* dropped for every country — trunk prefixes are caller territory).
|
|
191
|
+
*
|
|
192
|
+
* @returns full E.164 string ("+8613812345678") or "" when empty.
|
|
193
|
+
*/
|
|
194
|
+
export function formatE164(
|
|
195
|
+
dialCode: string,
|
|
196
|
+
national: string,
|
|
197
|
+
list: readonly DialCodeEntry[] = DIAL_CODES,
|
|
198
|
+
): string {
|
|
199
|
+
const bare = normalizeDial(dialCode);
|
|
200
|
+
if (!bare) return "";
|
|
201
|
+
const digits = normalizeNational(national);
|
|
202
|
+
if (!digits) return "";
|
|
203
|
+
return `+${bare}${digits}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface ParsedE164 {
|
|
207
|
+
/** Bare dial digits (e.g. "86"), or "" when nothing parses. */
|
|
208
|
+
dial: string;
|
|
209
|
+
/** National digits after the dial code (e.g. "13812345678"). */
|
|
210
|
+
national: string;
|
|
211
|
+
/** Best-guess country (first catalog entry matching the dial). */
|
|
212
|
+
iso?: string;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Split an E.164 string ("+8613812345678") into dial code + national
|
|
216
|
+
* digits. Longest dial prefix wins (so "+1" keeps US/CA whole). */
|
|
217
|
+
export function parseE164(
|
|
218
|
+
e164: string,
|
|
219
|
+
list: readonly DialCodeEntry[] = DIAL_CODES,
|
|
220
|
+
): ParsedE164 {
|
|
221
|
+
const digits = e164.replace(/^\+/, "").replace(/\D/g, "");
|
|
222
|
+
if (!digits) return { dial: "", national: "" };
|
|
223
|
+
const byLength = [...list]
|
|
224
|
+
.filter((c) => digits.startsWith(c.dial))
|
|
225
|
+
.sort((a, b) => b.dial.length - a.dial.length);
|
|
226
|
+
const best = byLength[0];
|
|
227
|
+
if (!best) return { dial: "", national: digits };
|
|
228
|
+
return {
|
|
229
|
+
dial: best.dial,
|
|
230
|
+
national: digits.slice(best.dial.length),
|
|
231
|
+
iso: best.iso,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -146,6 +146,11 @@
|
|
|
146
146
|
"hikari::statusBar.backend.ok": "OK",
|
|
147
147
|
"hikari::statusBar.backend.warn": "Degraded",
|
|
148
148
|
"hikari::statusBar.backend.error": "Error",
|
|
149
|
-
"hikari::statusBar.backend.unknown": "Unknown"
|
|
149
|
+
"hikari::statusBar.backend.unknown": "Unknown",
|
|
150
|
+
"hikari::qrCode.alt": "QR code",
|
|
151
|
+
"hikari::phoneInput.dialCode": "Code",
|
|
152
|
+
"hikari::phoneInput.chooseCountry": "Choose country code",
|
|
153
|
+
"hikari::phoneInput.searchCountries": "Search country or code",
|
|
154
|
+
"hikari::phoneInput.noMatches": "No matching country"
|
|
150
155
|
}
|
|
151
156
|
}
|
|
@@ -150,6 +150,11 @@
|
|
|
150
150
|
"hikari::statusBar.region.CN": "中国大陆",
|
|
151
151
|
"hikari::statusBar.region.TW": "中国台湾",
|
|
152
152
|
"hikari::statusBar.region.HK": "中国香港",
|
|
153
|
-
"hikari::statusBar.region.MO": "中国澳门"
|
|
153
|
+
"hikari::statusBar.region.MO": "中国澳门",
|
|
154
|
+
"hikari::qrCode.alt": "二维码",
|
|
155
|
+
"hikari::phoneInput.dialCode": "区号",
|
|
156
|
+
"hikari::phoneInput.chooseCountry": "选择国家区号",
|
|
157
|
+
"hikari::phoneInput.searchCountries": "搜索国家或区号",
|
|
158
|
+
"hikari::phoneInput.noMatches": "没有匹配的国家"
|
|
154
159
|
}
|
|
155
160
|
}
|
|
@@ -150,6 +150,11 @@
|
|
|
150
150
|
"hikari::statusBar.region.CN": "中國大陸",
|
|
151
151
|
"hikari::statusBar.region.TW": "中國台灣",
|
|
152
152
|
"hikari::statusBar.region.HK": "中國香港",
|
|
153
|
-
"hikari::statusBar.region.MO": "中國澳門"
|
|
153
|
+
"hikari::statusBar.region.MO": "中國澳門",
|
|
154
|
+
"hikari::qrCode.alt": "QR 碼",
|
|
155
|
+
"hikari::phoneInput.dialCode": "區碼",
|
|
156
|
+
"hikari::phoneInput.chooseCountry": "選擇國家區碼",
|
|
157
|
+
"hikari::phoneInput.searchCountries": "搜尋國家或區碼",
|
|
158
|
+
"hikari::phoneInput.noMatches": "沒有符合的國家"
|
|
154
159
|
}
|
|
155
160
|
}
|
package/src/index.ts
CHANGED
|
@@ -34,9 +34,11 @@ export { default as HModal } from "./components/HkModal";
|
|
|
34
34
|
export { default as HNavItem } from "./components/HkNavItem";
|
|
35
35
|
export { default as HNumberInput } from "./components/HkNumberInput";
|
|
36
36
|
export { default as HPasswordInput } from "./components/HkPasswordInput";
|
|
37
|
+
export { default as HPhoneInput } from "./components/HkPhoneInput";
|
|
37
38
|
export { default as HPhaseTransition } from "./components/HkPhaseTransition";
|
|
38
39
|
export { default as HGaugeRing } from "./components/HkGaugeRing";
|
|
39
40
|
export { default as HProgressRing } from "./components/HkProgressRing";
|
|
41
|
+
export { default as HQrCode } from "./components/HkQrCode";
|
|
40
42
|
export { default as HRollingNumber } from "./components/HkRollingNumber";
|
|
41
43
|
export { default as HLocalePickerPopup } from "./components/HkLocalePickerPopup";
|
|
42
44
|
export { default as HHoverRevealAction } from "./components/HkHoverRevealAction";
|
|
@@ -357,3 +359,15 @@ export {
|
|
|
357
359
|
formatMs,
|
|
358
360
|
type RelativeTimeT,
|
|
359
361
|
} from "./utils/format";
|
|
362
|
+
|
|
363
|
+
export {
|
|
364
|
+
DIAL_CODES,
|
|
365
|
+
flagEmoji,
|
|
366
|
+
dialCodeName,
|
|
367
|
+
formatE164,
|
|
368
|
+
parseE164,
|
|
369
|
+
normalizeDial,
|
|
370
|
+
normalizeNational,
|
|
371
|
+
type DialCodeEntry,
|
|
372
|
+
type ParsedE164,
|
|
373
|
+
} from "./data/dialCodes";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { qrMatrix } from "./qrEncoder";
|
|
4
|
+
|
|
5
|
+
const OTPAUTH =
|
|
6
|
+
"otpauth://totp/Example:langyo%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example";
|
|
7
|
+
|
|
8
|
+
describe("qrMatrix", () => {
|
|
9
|
+
it("returns a square boolean matrix for a real otpauth URI", () => {
|
|
10
|
+
const m = qrMatrix(OTPAUTH);
|
|
11
|
+
expect(m.length).toBeGreaterThan(0);
|
|
12
|
+
for (const row of m) {
|
|
13
|
+
expect(row).toHaveLength(m.length);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("is deterministic for the same payload", () => {
|
|
18
|
+
expect(qrMatrix(OTPAUTH)).toEqual(qrMatrix(OTPAUTH));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("places three 7x7 finder patterns on the corners", () => {
|
|
22
|
+
const m = qrMatrix("HELLO WORLD");
|
|
23
|
+
const n = m.length;
|
|
24
|
+
// Finder pattern: dark border ring, light 1-module gap ring, dark 3x3
|
|
25
|
+
// core. Check the top-left corner box.
|
|
26
|
+
const expectFinderAt = (r0: number, c0: number) => {
|
|
27
|
+
for (let r = 0; r < 7; r++) {
|
|
28
|
+
for (let c = 0; c < 7; c++) {
|
|
29
|
+
const ring = r === 0 || r === 6 || c === 0 || c === 6;
|
|
30
|
+
const core = r >= 2 && r <= 4 && c >= 2 && c <= 4;
|
|
31
|
+
expect(m[r0 + r][c0 + c]).toBe(ring || core);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
expectFinderAt(0, 0);
|
|
36
|
+
expectFinderAt(0, n - 7);
|
|
37
|
+
expectFinderAt(n - 7, 0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("encodes a balanced module population", () => {
|
|
41
|
+
const m = qrMatrix(OTPAUTH);
|
|
42
|
+
const dark = m.flat().filter(Boolean).length;
|
|
43
|
+
const total = m.length * m.length;
|
|
44
|
+
// QR data is error-corrected and masked; a healthy code lands well
|
|
45
|
+
// between an empty and a full grid.
|
|
46
|
+
expect(dark).toBeGreaterThan(total * 0.2);
|
|
47
|
+
expect(dark).toBeLessThan(total * 0.8);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("throws when the payload overflows the largest version", () => {
|
|
51
|
+
expect(() => qrMatrix("x".repeat(5000))).toThrow();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Thin TS wrapper over the vendored MIT qrcode-generator (Kazuhiko Arase).
|
|
2
|
+
// Exposes a single `qrMatrix` helper used by HkQrCode to render a QR code
|
|
3
|
+
// without any external dependency (the shared pnpm virtual-store layout
|
|
4
|
+
// cannot install new packages in this workspace, so the encoder is vendored
|
|
5
|
+
// as a self-contained ES module).
|
|
6
|
+
|
|
7
|
+
import qrcode from "./qrcode-generator";
|
|
8
|
+
|
|
9
|
+
export type QrErrorCorrectionLevel = "L" | "M" | "Q" | "H";
|
|
10
|
+
|
|
11
|
+
export interface QrMatrixOptions {
|
|
12
|
+
/** Explicit QR version 1..40, or 0 for auto-detection. Default 0. */
|
|
13
|
+
typeNumber?: number;
|
|
14
|
+
/** Error-correction level. Default "M". */
|
|
15
|
+
errorCorrectionLevel?: QrErrorCorrectionLevel;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Encode `text` as a QR module matrix. `true` = dark module.
|
|
20
|
+
*
|
|
21
|
+
* When `typeNumber` is 0 (default), the vendored generator auto-selects the
|
|
22
|
+
* smallest version that fits the payload and the chosen ECC level.
|
|
23
|
+
*/
|
|
24
|
+
export function qrMatrix(text: string, opts: QrMatrixOptions = {}): boolean[][] {
|
|
25
|
+
const level = opts.errorCorrectionLevel ?? "M";
|
|
26
|
+
const typeNumber = opts.typeNumber ?? 0;
|
|
27
|
+
const qr = qrcode(typeNumber, level);
|
|
28
|
+
qr.addData(text);
|
|
29
|
+
qr.make();
|
|
30
|
+
const count = qr.getModuleCount();
|
|
31
|
+
const matrix: boolean[][] = [];
|
|
32
|
+
for (let row = 0; row < count; row++) {
|
|
33
|
+
const line: boolean[] = [];
|
|
34
|
+
for (let col = 0; col < count; col++) line.push(qr.isDark(row, col));
|
|
35
|
+
matrix.push(line);
|
|
36
|
+
}
|
|
37
|
+
return matrix;
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Type declarations for the vendored MIT qrcode-generator (Kazuhiko Arase).
|
|
2
|
+
// The implementation lives in ./qrcode-generator.js (ESM default export).
|
|
3
|
+
export interface QRCodeModule {
|
|
4
|
+
/** Add a data segment (default mode "Byte"). */
|
|
5
|
+
addData(data: string, mode?: string): void;
|
|
6
|
+
/** Encode the added data and render the module matrix. */
|
|
7
|
+
make(): void;
|
|
8
|
+
/** Number of modules per side (the rendered matrix is count x count). */
|
|
9
|
+
getModuleCount(): number;
|
|
10
|
+
/** Whether the module at (row, col) is dark. */
|
|
11
|
+
isDark(row: number, col: number): boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
declare function qrcode(
|
|
15
|
+
typeNumber: number,
|
|
16
|
+
errorCorrectionLevel: "L" | "M" | "Q" | "H",
|
|
17
|
+
): QRCodeModule;
|
|
18
|
+
|
|
19
|
+
export default qrcode;
|