@celestia-island/hikari 0.40.2 → 0.40.4

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.40.2",
3
+ "version": "0.40.4",
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",
@@ -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,6 +117,26 @@ describe("HkPhoneInput", () => {
117
117
  }
118
118
  });
119
119
 
120
+ it("still finds the PRC row when searching the short alias", async () => {
121
+ const { container } = mountPhone();
122
+ await openPicker(container);
123
+ // The row renders the formal zh name (中华人民共和国), which no longer
124
+ // contains 中国 as a substring — the catalog's zhAlias keeps the old
125
+ // short form reachable from the search field.
126
+ const search = document.querySelector<HTMLInputElement>(
127
+ ".hk-affix-search .hk-input-element",
128
+ );
129
+ expect(search, "popup search field renders").toBeTruthy();
130
+ search!.value = "中国";
131
+ search!.dispatchEvent(new Event("input"));
132
+ await nextTick();
133
+ await nextTick();
134
+ const rows = pickerRows();
135
+ expect(rows.length).toBeGreaterThan(0);
136
+ expect(rows[0].textContent).toContain("China");
137
+ expect(rows[0].textContent).toContain("+86");
138
+ });
139
+
120
140
  it("picks a country from the list and refocuses the number field", async () => {
121
141
  const { container, events } = mountPhone();
122
142
  await openPicker(container);
@@ -102,14 +102,15 @@ 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, ISO
106
- * code, bare and zero-prefixed dial digits ("86"/"0086"). */
105
+ * keywords haystack keeps the old filter reach: en/zh names plus
106
+ * the optional zh search alias, ISO code, bare and zero-prefixed
107
+ * dial digits ("86"/"0086"). */
107
108
  const dialOptions = computed<readonly HkAffixOption[]>(() =>
108
109
  props.countries.map((c) => ({
109
110
  key: c.iso,
110
111
  label: dialCodeName(c, locale),
111
112
  meta: `+${c.dial}`,
112
- keywords: `${c.en} ${c.zh} ${c.iso} +${c.dial} 00${c.dial}`,
113
+ keywords: `${c.en} ${c.zh} ${c.zhAlias ?? ""} ${c.iso} +${c.dial} 00${c.dial}`,
113
114
  })),
114
115
  );
115
116
 
@@ -12,7 +12,13 @@ import {
12
12
 
13
13
  describe("dialCodes catalog", () => {
14
14
  it("starts with China and contains the expected shape", () => {
15
- expect(DIAL_CODES[0]).toMatchObject({ iso: "cn", dial: "86", en: "China", zh: "中国" });
15
+ expect(DIAL_CODES[0]).toMatchObject({
16
+ iso: "cn",
17
+ dial: "86",
18
+ en: "China",
19
+ zh: "中华人民共和国",
20
+ zhAlias: "中国",
21
+ });
16
22
  for (const entry of DIAL_CODES) {
17
23
  expect(entry.iso).toMatch(/^[a-z]{2}$/);
18
24
  expect(entry.dial).toMatch(/^\d+$/);
@@ -19,6 +19,12 @@ 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;
22
28
  }
23
29
 
24
30
  /** Flag glyph for an ISO 3166-1 alpha-2 code (regional indicators). */
@@ -43,7 +49,7 @@ export function dialCodeName(entry: DialCodeEntry, locale: string): string {
43
49
  * prefixes like +1 / +7).
44
50
  */
45
51
  export const DIAL_CODES: readonly DialCodeEntry[] = [
46
- { iso: "cn", dial: "86", en: "China", zh: "中国" },
52
+ { iso: "cn", dial: "86", en: "China", zh: "中华人民共和国", zhAlias: "中国" },
47
53
  { iso: "hk", dial: "852", en: "Hong Kong (China)", zh: "中国香港" },
48
54
  { iso: "mo", dial: "853", en: "Macau (China)", zh: "中国澳门" },
49
55
  { iso: "tw", dial: "886", en: "Taiwan (China)", zh: "中国台湾" },