@celestia-island/hikari 0.40.34 → 0.40.35

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.34",
3
+ "version": "0.40.35",
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,159 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h } from "vue";
3
+
4
+ import HkButton from "./HkButton";
5
+
6
+ /**
7
+ * HkButton contract tests:
8
+ * - variant / size / block / loading class mapping on the root <button>
9
+ * - the native disabled attribute is the click guard (disabled OR loading)
10
+ * - loading renders the spinner INSTEAD of the icon and flags aria-busy
11
+ * - default-slot text, aria-label (icon-only usage), suffix, shortcut kbd
12
+ * - attr fallthrough: inheritAttrs is false, the component MUST spread
13
+ * $attrs itself (type / data-* / class merging)
14
+ *
15
+ * House style: raw createApp mounts on a shared container list torn down
16
+ * after each case; DOM assertions via document queries.
17
+ */
18
+
19
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
20
+
21
+ function mount(node: ReturnType<typeof h>) {
22
+ const container = document.createElement("div");
23
+ document.body.appendChild(container);
24
+ const app = createApp({ render: () => node });
25
+ app.mount(container);
26
+ mounts.push({ app, container });
27
+ return container;
28
+ }
29
+
30
+ function button(c: HTMLElement): HTMLButtonElement {
31
+ return c.querySelector("button")!;
32
+ }
33
+
34
+ afterEach(() => {
35
+ for (const { app, container } of mounts.splice(0)) {
36
+ app.unmount();
37
+ container.remove();
38
+ }
39
+ });
40
+
41
+ describe("HkButton class mapping", () => {
42
+ it("renders the default primary/md classes with default-slot text and type=button", () => {
43
+ const c = mount(h(HkButton, () => "Deploy"));
44
+ const btn = button(c);
45
+ expect(btn).not.toBeNull();
46
+ expect(btn.className).toBe("hk-btn hk-btn-primary hk-btn-md");
47
+ expect(btn.getAttribute("type")).toBe("button");
48
+ expect(btn.textContent).toContain("Deploy");
49
+ });
50
+
51
+ it("maps variant and size props to modifier classes", () => {
52
+ const c = mount(h(HkButton, { variant: "danger", size: "sm" }, () => "Delete"));
53
+ const cls = button(c).className;
54
+ expect(cls).toContain("hk-btn-danger");
55
+ expect(cls).toContain("hk-btn-sm");
56
+ expect(cls).not.toContain("hk-btn-primary");
57
+ expect(cls).not.toContain("hk-btn-md");
58
+ });
59
+
60
+ it("adds the block modifier only when block is set", () => {
61
+ const blocked = mount(h(HkButton, { block: true }, () => "wide"));
62
+ expect(button(blocked).className).toContain("hk-btn-block");
63
+ const plain = mount(h(HkButton, () => "narrow"));
64
+ expect(button(plain).className).not.toContain("hk-btn-block");
65
+ });
66
+ });
67
+
68
+ describe("HkButton click guard", () => {
69
+ it("emits the declared click event on activation", async () => {
70
+ let clicks = 0;
71
+ const c = mount(h(HkButton, { onClick: () => { clicks += 1; } }, () => "go"));
72
+ button(c).click();
73
+ await Promise.resolve();
74
+ expect(clicks).toBe(1);
75
+ });
76
+
77
+ it("suppresses the click emit while disabled", async () => {
78
+ let clicks = 0;
79
+ const c = mount(h(HkButton, { disabled: true, onClick: () => { clicks += 1; } }, () => "no"));
80
+ // The guard is the NATIVE disabled attribute: the platform (and
81
+ // happy-dom) fires no click listeners on a disabled button.
82
+ expect(button(c).disabled).toBe(true);
83
+ button(c).click();
84
+ await Promise.resolve();
85
+ expect(clicks).toBe(0);
86
+ });
87
+
88
+ it("disables the button and suppresses clicks while loading", async () => {
89
+ let clicks = 0;
90
+ const c = mount(h(HkButton, { loading: true, onClick: () => { clicks += 1; } }, () => "busy"));
91
+ const btn = button(c);
92
+ expect(btn.disabled).toBe(true); // loading implies disabled
93
+ expect(btn.className).toContain("hk-btn-loading");
94
+ expect(btn.getAttribute("aria-busy")).toBe("true");
95
+ btn.click();
96
+ await Promise.resolve();
97
+ expect(clicks).toBe(0);
98
+ });
99
+
100
+ it("clears the loading state back to a clickable button (aria-busy drops off)", () => {
101
+ const busy = mount(h(HkButton, { loading: true }, () => "x"));
102
+ expect(busy.querySelector(".hk-spinner")).not.toBeNull();
103
+ const idle = mount(h(HkButton, () => "x"));
104
+ const btn = button(idle);
105
+ expect(idle.querySelector(".hk-spinner")).toBeNull();
106
+ expect(btn.disabled).toBe(false);
107
+ expect(btn.getAttribute("aria-busy")).toBeNull();
108
+ expect(btn.className).not.toContain("hk-btn-loading");
109
+ });
110
+ });
111
+
112
+ describe("HkButton content slots", () => {
113
+ it("renders the leading icon only when NOT loading", () => {
114
+ const idle = mount(h(HkButton, { icon: "close" }, () => "x"));
115
+ expect(idle.querySelector(".hk-btn-icon")).not.toBeNull();
116
+ expect(idle.querySelector(".hk-spinner")).toBeNull();
117
+
118
+ // Loading swaps the icon out for the spinner — never both.
119
+ const busy = mount(h(HkButton, { icon: "close", loading: true }, () => "x"));
120
+ expect(busy.querySelector(".hk-btn-icon")).toBeNull();
121
+ expect(busy.querySelector(".hk-spinner")).not.toBeNull();
122
+ });
123
+
124
+ it("renders the suffix icon slot", () => {
125
+ const c = mount(h(HkButton, { suffix: "close" }, () => "Open"));
126
+ expect(c.querySelector(".hk-btn-suffix")).not.toBeNull();
127
+ });
128
+
129
+ it("renders the shortcut kbd and its marker class", () => {
130
+ const c = mount(h(HkButton, { shortcut: "Ctrl+Enter" }, () => "Send"));
131
+ const btn = button(c);
132
+ expect(btn.className).toContain("hk-btn-has-shortcut");
133
+ const kbd = c.querySelector("kbd.hk-kbd")!;
134
+ expect(kbd).not.toBeNull();
135
+ expect(kbd.textContent).toContain("Ctrl");
136
+ expect(kbd.textContent).toContain("Enter");
137
+ });
138
+
139
+ it("exposes aria-label for icon-only usage", () => {
140
+ const c = mount(h(HkButton, { icon: "close", ariaLabel: "Close inspector" }));
141
+ expect(button(c).getAttribute("aria-label")).toBe("Close inspector");
142
+ });
143
+ });
144
+
145
+ describe("HkButton attr fallthrough (inheritAttrs: false)", () => {
146
+ it("spreads extra attrs and honors an explicit type", () => {
147
+ const c = mount(h(HkButton, { type: "submit", "data-test": "probe" }, () => "Save"));
148
+ const btn = button(c);
149
+ expect(btn.getAttribute("type")).toBe("submit");
150
+ expect(btn.getAttribute("data-test")).toBe("probe");
151
+ });
152
+
153
+ it("merges a fallthrough class onto its own class list", () => {
154
+ const c = mount(h(HkButton, { class: "extra-class" }, () => "x"));
155
+ const cls = button(c).className;
156
+ expect(cls).toContain("hk-btn");
157
+ expect(cls).toContain("extra-class");
158
+ });
159
+ });
@@ -0,0 +1,202 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkCheckbox from "./HkCheckbox";
5
+
6
+ /**
7
+ * HkCheckbox contract tests:
8
+ * - tri-state model wiring: false → unlit box, true → check icon (or dot
9
+ * in radio mode), null → indeterminate dash (checkbox mode only)
10
+ * - the label shell is a real <label> wrapping the native input, so
11
+ * clicking the TEXT forwards to the input and toggles (label association)
12
+ * - change events carry the input's NEW checked value; disabled drops them
13
+ * - the data-animating pulse is set on change and cleared by the 300ms
14
+ * cron one-shot
15
+ *
16
+ * House style: raw createApp mounts on a shared container list torn down
17
+ * after each case; DOM assertions via document queries.
18
+ */
19
+
20
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
21
+
22
+ function mount(node: ReturnType<typeof h>) {
23
+ const container = document.createElement("div");
24
+ document.body.appendChild(container);
25
+ const app = createApp({ render: () => node });
26
+ app.mount(container);
27
+ mounts.push({ app, container });
28
+ return container;
29
+ }
30
+
31
+ /** v-model harness: mirrors consumer usage, records every emitted value. */
32
+ function mountVModel(initial: boolean | null, props: Record<string, unknown> = {}) {
33
+ const model = ref<boolean | null>(initial);
34
+ const emitted: Array<boolean> = [];
35
+ const container = document.createElement("div");
36
+ document.body.appendChild(container);
37
+ const app = createApp(defineComponent({
38
+ setup: () => () =>
39
+ h(HkCheckbox, {
40
+ ...props,
41
+ modelValue: model.value as boolean,
42
+ "onUpdate:modelValue": (v: boolean) => {
43
+ model.value = v;
44
+ emitted.push(v);
45
+ },
46
+ }),
47
+ }));
48
+ app.mount(container);
49
+ mounts.push({ app, container });
50
+ const input = () => container.querySelector<HTMLInputElement>("input")!;
51
+ const box = () => container.querySelector<HTMLElement>(".hk-checkbox-box")!;
52
+ const root = () => container.querySelector<HTMLElement>("label")!;
53
+ return { container, model, emitted, input, box, root };
54
+ }
55
+
56
+ afterEach(() => {
57
+ vi.useRealTimers();
58
+ for (const { app, container } of mounts.splice(0)) {
59
+ app.unmount();
60
+ container.remove();
61
+ }
62
+ });
63
+
64
+ describe("HkCheckbox model states", () => {
65
+ it("renders an unlit box and an unchecked native checkbox by default", () => {
66
+ const c = mount(h(HkCheckbox));
67
+ const root = c.querySelector<HTMLElement>("label")!;
68
+ expect(root.className).toBe("hk-checkbox hk-checkbox-md");
69
+ expect(root.getAttribute("data-type")).toBe("checkbox");
70
+ const input = c.querySelector<HTMLInputElement>("input")!;
71
+ expect(input.type).toBe("checkbox");
72
+ expect(input.checked).toBe(false);
73
+ const box = c.querySelector<HTMLElement>(".hk-checkbox-box")!;
74
+ expect(box.hasAttribute("data-checked")).toBe(false);
75
+ expect(box.hasAttribute("data-indeterminate")).toBe(false);
76
+ expect(c.querySelector(".hk-checkbox-icon")).toBeNull();
77
+ expect(c.querySelector(".hk-checkbox-indeterminate")).toBeNull();
78
+ });
79
+
80
+ it("maps modelValue=true to the checked box with the check icon", () => {
81
+ const c = mount(h(HkCheckbox, { modelValue: true }));
82
+ const box = c.querySelector<HTMLElement>(".hk-checkbox-box")!;
83
+ expect(box.getAttribute("data-checked")).toBe("");
84
+ expect(c.querySelector<HTMLInputElement>("input")!.checked).toBe(true);
85
+ // The glyph is the lucide Check svg itself (lucide puts the class on
86
+ // its root <svg>), not the radio dot.
87
+ expect(c.querySelector("svg.hk-checkbox-icon")).not.toBeNull();
88
+ expect(c.querySelector(".hk-checkbox-dot")).toBeNull();
89
+ });
90
+
91
+ it("maps modelValue=null to the indeterminate dash without checking the input", () => {
92
+ const c = mount(h(HkCheckbox, { modelValue: null as unknown as boolean }));
93
+ const box = c.querySelector<HTMLElement>(".hk-checkbox-box")!;
94
+ expect(box.getAttribute("data-indeterminate")).toBe("");
95
+ expect(box.hasAttribute("data-checked")).toBe(false);
96
+ // null is NOT true: the native input stays unchecked.
97
+ expect(c.querySelector<HTMLInputElement>("input")!.checked).toBe(false);
98
+ expect(c.querySelector(".hk-checkbox-indeterminate")).not.toBeNull();
99
+ expect(c.querySelector(".hk-checkbox-icon")).toBeNull();
100
+ });
101
+
102
+ it("radio mode swaps the check icon for the dot and drops the indeterminate dash", () => {
103
+ const checked = mount(h(HkCheckbox, { modelValue: true, type: "radio" }));
104
+ expect(checked.querySelector("label")!.getAttribute("data-type")).toBe("radio");
105
+ expect(checked.querySelector<HTMLInputElement>("input")!.type).toBe("radio");
106
+ expect(checked.querySelector(".hk-checkbox-dot")).not.toBeNull();
107
+ expect(checked.querySelector(".hk-checkbox-icon")).toBeNull();
108
+
109
+ // null in radio mode: no dash — indeterminate is checkbox-only.
110
+ const unset = mount(h(HkCheckbox, { modelValue: null as unknown as boolean, type: "radio" }));
111
+ expect(unset.querySelector(".hk-checkbox-indeterminate")).toBeNull();
112
+ expect(unset.querySelector(".hk-checkbox-dot")).toBeNull();
113
+ });
114
+
115
+ it("maps the size prop to the shell modifier", () => {
116
+ const c = mount(h(HkCheckbox, { size: "sm" }));
117
+ expect(c.querySelector("label")!.className).toContain("hk-checkbox-sm");
118
+ });
119
+ });
120
+
121
+ describe("HkCheckbox label", () => {
122
+ it("renders the label prop through the HkLabel shell", () => {
123
+ const c = mount(h(HkCheckbox, { label: "Remember login" }));
124
+ const label = c.querySelector<HTMLElement>(".hk-label")!;
125
+ expect(label).not.toBeNull();
126
+ expect(label.textContent).toBe("Remember login");
127
+ });
128
+
129
+ it("prefers default-slot content over the label prop", () => {
130
+ const c = mount(
131
+ h(HkCheckbox, { label: "plain" }, () => ["rich ", h("b", "text")]),
132
+ );
133
+ expect(c.querySelector(".hk-label")!.textContent).toBe("rich text");
134
+ });
135
+
136
+ it("renders no label shell without label prop or slot", () => {
137
+ const c = mount(h(HkCheckbox));
138
+ expect(c.querySelector(".hk-label")).toBeNull();
139
+ });
140
+ });
141
+
142
+ describe("HkCheckbox change wiring", () => {
143
+ it("round-trips v-model through native input activation", async () => {
144
+ const t = mountVModel(false);
145
+ t.input().click();
146
+ await nextTick();
147
+ expect(t.emitted).toEqual([true]);
148
+ expect(t.model.value).toBe(true);
149
+ expect(t.input().checked).toBe(true);
150
+ expect(t.box().getAttribute("data-checked")).toBe("");
151
+
152
+ t.input().click();
153
+ await nextTick();
154
+ expect(t.emitted).toEqual([true, false]);
155
+ expect(t.box().hasAttribute("data-checked")).toBe(false);
156
+ });
157
+
158
+ it("toggles through the label text — the shell is a real <label>", async () => {
159
+ const t = mountVModel(false, { label: "Remember login" });
160
+ // Clicking the TEXT (not the input): the platform forwards the click
161
+ // to the first labelable descendant — the checkbox input — which
162
+ // toggles and fires the change the component listens to.
163
+ (t.container.querySelector(".hk-label") as HTMLElement).click();
164
+ await nextTick();
165
+ expect(t.emitted).toEqual([true]);
166
+ expect(t.input().checked).toBe(true);
167
+ });
168
+
169
+ it("emits the post-click indeterminate resolution as a plain boolean", async () => {
170
+ // null (indeterminate) + click → the input lands checked → emits true.
171
+ const t = mountVModel(null);
172
+ t.input().click();
173
+ await nextTick();
174
+ expect(t.emitted).toEqual([true]);
175
+ expect(t.box().getAttribute("data-checked")).toBe("");
176
+ expect(t.box().hasAttribute("data-indeterminate")).toBe(false);
177
+ });
178
+
179
+ it("pulses data-animating on change and clears it after the 300ms cron", async () => {
180
+ vi.useFakeTimers();
181
+ const t = mountVModel(false);
182
+ t.input().click();
183
+ await nextTick();
184
+ expect(t.root().getAttribute("data-animating")).toBe("");
185
+
186
+ vi.advanceTimersByTime(300);
187
+ await nextTick();
188
+ expect(t.root().hasAttribute("data-animating")).toBe(false);
189
+ });
190
+
191
+ it("drops change events entirely while disabled", async () => {
192
+ const t = mountVModel(false, { disabled: true });
193
+ expect(t.input().disabled).toBe(true);
194
+ expect(t.root().getAttribute("data-disabled")).toBe("");
195
+ // The platform fires no activation on a disabled input.
196
+ t.input().click();
197
+ t.container.querySelector<HTMLElement>(".hk-checkbox-box")!.click();
198
+ await nextTick();
199
+ expect(t.emitted).toEqual([]);
200
+ expect(t.input().checked).toBe(false);
201
+ });
202
+ });
@@ -0,0 +1,206 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkRadio, { type HkRadioOption } from "./HkRadio";
5
+
6
+ /**
7
+ * HkRadio contract tests:
8
+ * - option selection semantics: the model value matches option VALUES
9
+ * (strict, type-preserving — a numeric option emits a number), and the
10
+ * checked markers (box data-checked, dot, native radio checked) follow
11
+ * the matching option only
12
+ * - direction / size group modifiers
13
+ * - each option is a real <label> wrapping its native radio input, so
14
+ * clicking an option's TEXT selects it; re-activating the already
15
+ * selected option fires no platform change (and thus no emit)
16
+ * - disabled: whole group or per option; disabled inputs swallow clicks
17
+ *
18
+ * The component wires no custom keyboard handlers (no roving tabindex /
19
+ * arrow-key code exists) — keyboard behavior is whatever the native
20
+ * inputs provide, so no keyboard case is asserted here.
21
+ *
22
+ * House style: raw createApp mounts on a shared container list torn
23
+ * down after each case; DOM assertions via document queries.
24
+ */
25
+
26
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
27
+
28
+ function mount(node: ReturnType<typeof h>) {
29
+ const container = document.createElement("div");
30
+ document.body.appendChild(container);
31
+ const app = createApp({ render: () => node });
32
+ app.mount(container);
33
+ mounts.push({ app, container });
34
+ return container;
35
+ }
36
+
37
+ const OPTS = [
38
+ { value: "a", label: "Alpha" },
39
+ { value: "b", label: "Beta" },
40
+ ];
41
+
42
+ const NUM_OPTS = [
43
+ { value: 1, label: "One" },
44
+ { value: 2, label: "Two" },
45
+ ];
46
+
47
+ /** v-model harness: mirrors consumer usage, records every emitted value. */
48
+ function mountVModel(initial: string | number | null, props: Record<string, unknown> = {}) {
49
+ const model = ref<string | number | null>(initial);
50
+ const emitted: Array<string | number> = [];
51
+ const container = document.createElement("div");
52
+ document.body.appendChild(container);
53
+ const app = createApp(defineComponent({
54
+ setup: () => () =>
55
+ h(HkRadio, {
56
+ ...props,
57
+ modelValue: model.value as string | number,
58
+ "onUpdate:modelValue": (v: string | number) => {
59
+ model.value = v;
60
+ emitted.push(v);
61
+ },
62
+ } as never),
63
+ }));
64
+ app.mount(container);
65
+ mounts.push({ app, container });
66
+ const group = () => container.querySelector<HTMLElement>(".hk-radio-group")!;
67
+ const items = () => Array.from(container.querySelectorAll<HTMLElement>("label.hk-radio"));
68
+ const inputs = () => Array.from(container.querySelectorAll<HTMLInputElement>("input"));
69
+ return { container, model, emitted, group, items, inputs };
70
+ }
71
+
72
+ afterEach(() => {
73
+ for (const { app, container } of mounts.splice(0)) {
74
+ app.unmount();
75
+ container.remove();
76
+ }
77
+ });
78
+
79
+ describe("HkRadio group shell", () => {
80
+ it("renders the horizontal/md group by default with one option per label", () => {
81
+ const c = mount(h(HkRadio, { options: OPTS }));
82
+ const group = c.querySelector<HTMLElement>(".hk-radio-group")!;
83
+ expect(group).not.toBeNull();
84
+ expect(group.className).toBe("hk-radio-group hk-radio-group-horizontal hk-radio-group-md");
85
+ const items = c.querySelectorAll("label.hk-radio");
86
+ expect(items.length).toBe(2);
87
+ // Option labels render through the HkLabel shell.
88
+ const labels = Array.from(c.querySelectorAll<HTMLElement>(".hk-label"));
89
+ expect(labels.map((l) => l.textContent)).toEqual(["Alpha", "Beta"]);
90
+ const inputs = c.querySelectorAll("input");
91
+ expect(inputs.length).toBe(2);
92
+ expect(Array.from(inputs).every((i) => i.type === "radio")).toBe(true);
93
+ });
94
+
95
+ it("maps direction and size props to group modifiers", () => {
96
+ const c = mount(h(HkRadio, { options: OPTS, direction: "vertical", size: "lg" }));
97
+ const cls = c.querySelector(".hk-radio-group")!.className;
98
+ expect(cls).toContain("hk-radio-group-vertical");
99
+ expect(cls).toContain("hk-radio-group-lg");
100
+ expect(cls).not.toContain("hk-radio-group-horizontal");
101
+ expect(c.querySelector("label.hk-radio")!.getAttribute("data-size")).toBe("lg");
102
+ });
103
+
104
+ it("lets the label slot override the option text", () => {
105
+ const c = mount(h(HkRadio, {
106
+ options: OPTS,
107
+ }, {
108
+ label: ({ option }: { option: HkRadioOption }) => h("span", { class: "custom-opt" }, `#${option.label}`),
109
+ }));
110
+ const customs = Array.from(c.querySelectorAll<HTMLElement>(".custom-opt"));
111
+ expect(customs.map((el) => el.textContent)).toEqual(["#Alpha", "#Beta"]);
112
+ });
113
+ });
114
+
115
+ describe("HkRadio selection semantics", () => {
116
+ it("marks only the option whose value matches the model", () => {
117
+ const c = mount(h(HkRadio, { options: OPTS, modelValue: "b" }));
118
+ const boxes = Array.from(c.querySelectorAll<HTMLElement>(".hk-radio-box"));
119
+ expect(boxes[0]!.hasAttribute("data-checked")).toBe(false);
120
+ expect(boxes[1]!.getAttribute("data-checked")).toBe("");
121
+ // The dot renders in the selected option only.
122
+ expect(c.querySelectorAll(".hk-radio-dot").length).toBe(1);
123
+ expect(boxes[1]!.querySelector(".hk-radio-dot")).not.toBeNull();
124
+ const inputs = Array.from(c.querySelectorAll<HTMLInputElement>("input"));
125
+ expect(inputs[0]!.checked).toBe(false);
126
+ expect(inputs[1]!.checked).toBe(true);
127
+ });
128
+
129
+ it("emits the clicked option's value and moves the selection", async () => {
130
+ const t = mountVModel("a", { options: OPTS });
131
+ t.inputs()[1]!.click();
132
+ await nextTick();
133
+ expect(t.emitted).toEqual(["b"]);
134
+ expect(t.model.value).toBe("b");
135
+ // Selection moved: dot and data-checked follow the new option.
136
+ const boxes = Array.from(t.container.querySelectorAll<HTMLElement>(".hk-radio-box"));
137
+ expect(boxes[0]!.hasAttribute("data-checked")).toBe(false);
138
+ expect(boxes[1]!.getAttribute("data-checked")).toBe("");
139
+ });
140
+
141
+ it("emits the option value type-preserving (number stays number)", async () => {
142
+ const t = mountVModel(1, { options: NUM_OPTS });
143
+ t.inputs()[1]!.click();
144
+ await nextTick();
145
+ expect(t.emitted).toEqual([2]);
146
+ expect(t.emitted[0]).toBe(2);
147
+ expect(typeof t.emitted[0]).toBe("number");
148
+ // Strict matching against the numeric model still selects.
149
+ expect(
150
+ Array.from(t.container.querySelectorAll<HTMLInputElement>("input"))[1]!.checked,
151
+ ).toBe(true);
152
+ });
153
+
154
+ it("selects through the option label text (native label→input forwarding)", async () => {
155
+ const t = mountVModel("a", { options: OPTS });
156
+ (t.container.querySelectorAll(".hk-label")[1] as HTMLElement).click();
157
+ await nextTick();
158
+ expect(t.emitted).toEqual(["b"]);
159
+ expect(t.inputs()[1]!.checked).toBe(true);
160
+ });
161
+
162
+ it("fires no emit when re-activating the already selected option", async () => {
163
+ const t = mountVModel("a", { options: OPTS });
164
+ t.inputs()[0]!.click(); // already checked: the platform fires no change
165
+ await nextTick();
166
+ expect(t.emitted).toEqual([]);
167
+ expect(t.model.value).toBe("a");
168
+ });
169
+
170
+ it("leaves every option unselected with a null model", () => {
171
+ const c = mount(h(HkRadio, { options: OPTS }));
172
+ expect(c.querySelector(".hk-radio-dot")).toBeNull();
173
+ expect(Array.from(c.querySelectorAll(".hk-radio-box")).every((b) => !b.hasAttribute("data-checked"))).toBe(true);
174
+ });
175
+ });
176
+
177
+ describe("HkRadio disabled", () => {
178
+ it("disables every option from the group prop", () => {
179
+ const c = mount(h(HkRadio, { options: OPTS, disabled: true }));
180
+ const items = Array.from(c.querySelectorAll<HTMLElement>("label.hk-radio"));
181
+ expect(items.every((el) => el.getAttribute("data-disabled") === "")).toBe(true);
182
+ expect(Array.from(c.querySelectorAll<HTMLInputElement>("input")).every((i) => i.disabled)).toBe(true);
183
+ });
184
+
185
+ it("disables a single option without touching its siblings", () => {
186
+ const c = mount(h(HkRadio, {
187
+ options: [{ value: "a", label: "Alpha" }, { value: "b", label: "Beta", disabled: true }],
188
+ }));
189
+ const items = Array.from(c.querySelectorAll<HTMLElement>("label.hk-radio"));
190
+ expect(items[0]!.hasAttribute("data-disabled")).toBe(false);
191
+ expect(items[1]!.getAttribute("data-disabled")).toBe("");
192
+ const inputs = Array.from(c.querySelectorAll<HTMLInputElement>("input"));
193
+ expect(inputs[0]!.disabled).toBe(false);
194
+ expect(inputs[1]!.disabled).toBe(true);
195
+ });
196
+
197
+ it("swallows clicks on a disabled option", async () => {
198
+ const t = mountVModel("a", {
199
+ options: [{ value: "a", label: "Alpha" }, { value: "b", label: "Beta", disabled: true }],
200
+ });
201
+ t.inputs()[1]!.click();
202
+ await nextTick();
203
+ expect(t.emitted).toEqual([]);
204
+ expect(t.model.value).toBe("a");
205
+ });
206
+ });
@@ -0,0 +1,174 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkSwitch from "./HkSwitch";
5
+
6
+ /**
7
+ * HkSwitch contract tests:
8
+ * - role: the interactive control is a NATIVE <input type="checkbox">
9
+ * inside a real <label> — keyboard interaction (Space) rides the
10
+ * platform checkbox for free, so the tests exercise the input's
11
+ * activation, which is exactly what a Space keypress produces
12
+ * (happy-dom does not synthesize Space → click, so dispatching a raw
13
+ * KeyboardEvent would assert nothing real)
14
+ * - model flip on activation emits !modelValue exactly once — the track
15
+ * handler calls preventDefault() so the label's native click-forwarding
16
+ * cannot produce a second toggle; that dedup is pinned here
17
+ * - data-checked / data-disabled state markers, size/color modifiers,
18
+ * checked/unchecked track contents, label shell
19
+ *
20
+ * House style: raw createApp mounts on a shared container list torn down
21
+ * after each case; DOM assertions via document queries.
22
+ */
23
+
24
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
25
+
26
+ function mount(node: ReturnType<typeof h>) {
27
+ const container = document.createElement("div");
28
+ document.body.appendChild(container);
29
+ const app = createApp({ render: () => node });
30
+ app.mount(container);
31
+ mounts.push({ app, container });
32
+ return container;
33
+ }
34
+
35
+ /** v-model harness: mirrors consumer usage, records every emitted value. */
36
+ function mountVModel(initial: boolean, props: Record<string, unknown> = {}) {
37
+ const model = ref<boolean>(initial);
38
+ const emitted: Array<boolean> = [];
39
+ const container = document.createElement("div");
40
+ document.body.appendChild(container);
41
+ const app = createApp(defineComponent({
42
+ setup: () => () =>
43
+ h(HkSwitch, {
44
+ ...props,
45
+ modelValue: model.value,
46
+ "onUpdate:modelValue": (v: boolean) => {
47
+ model.value = v;
48
+ emitted.push(v);
49
+ },
50
+ }),
51
+ }));
52
+ app.mount(container);
53
+ mounts.push({ app, container });
54
+ const input = () => container.querySelector<HTMLInputElement>("input")!;
55
+ const root = () => container.querySelector<HTMLElement>("label")!;
56
+ const track = () => container.querySelector<HTMLElement>(".hk-switch-track")!;
57
+ return { container, model, emitted, input, root, track };
58
+ }
59
+
60
+ afterEach(() => {
61
+ for (const { app, container } of mounts.splice(0)) {
62
+ app.unmount();
63
+ container.remove();
64
+ }
65
+ });
66
+
67
+ describe("HkSwitch rendering", () => {
68
+ it("exposes a native checkbox inside the label with md/primary defaults", () => {
69
+ const c = mount(h(HkSwitch));
70
+ const root = c.querySelector<HTMLElement>("label")!;
71
+ expect(root).not.toBeNull();
72
+ expect(root.className).toBe("hk-switch hk-switch-md hk-switch-primary");
73
+ expect(root.hasAttribute("data-checked")).toBe(false);
74
+ const input = c.querySelector<HTMLInputElement>("input")!;
75
+ expect(input.type).toBe("checkbox");
76
+ expect(input.checked).toBe(false);
77
+ expect(c.querySelector(".hk-switch-track")).not.toBeNull();
78
+ expect(c.querySelector(".hk-switch-thumb")).not.toBeNull();
79
+ });
80
+
81
+ it("maps modelValue=true to the checked markers on root and input", () => {
82
+ const c = mount(h(HkSwitch, { modelValue: true }));
83
+ expect(c.querySelector<HTMLElement>("label")!.getAttribute("data-checked")).toBe("");
84
+ expect(c.querySelector<HTMLInputElement>("input")!.checked).toBe(true);
85
+ });
86
+
87
+ it("maps size and color props to modifiers", () => {
88
+ const c = mount(h(HkSwitch, { size: "sm", color: "danger" }));
89
+ const cls = c.querySelector("label")!.className;
90
+ expect(cls).toContain("hk-switch-sm");
91
+ expect(cls).toContain("hk-switch-danger");
92
+ expect(cls).not.toContain("hk-switch-md");
93
+ expect(cls).not.toContain("hk-switch-primary");
94
+ });
95
+
96
+ it("swaps the track contents with the checked state", () => {
97
+ const off = mount(h(HkSwitch, {
98
+ checkedContent: "on-text",
99
+ uncheckedContent: "off-text",
100
+ }));
101
+ const offContent = off.querySelector<HTMLElement>(".hk-switch-content")!;
102
+ expect(offContent.className).toContain("hk-switch-content-off");
103
+ expect(offContent.textContent).toBe("off-text");
104
+ expect(off.querySelector(".hk-switch-content-on")).toBeNull();
105
+
106
+ const on = mount(h(HkSwitch, {
107
+ modelValue: true,
108
+ checkedContent: "on-text",
109
+ uncheckedContent: "off-text",
110
+ }));
111
+ const onContent = on.querySelector<HTMLElement>(".hk-switch-content")!;
112
+ expect(onContent.className).toContain("hk-switch-content-on");
113
+ expect(onContent.textContent).toBe("on-text");
114
+ expect(on.querySelector(".hk-switch-content-off")).toBeNull();
115
+ });
116
+
117
+ it("renders the label prop through the HkLabel shell", () => {
118
+ const c = mount(h(HkSwitch, { label: "Dark mode" }));
119
+ expect(c.querySelector(".hk-label")!.textContent).toBe("Dark mode");
120
+ });
121
+ });
122
+
123
+ describe("HkSwitch toggling", () => {
124
+ it("flips the model exactly once per track click (preventDefault dedup)", async () => {
125
+ const t = mountVModel(false);
126
+ t.track().click();
127
+ await nextTick();
128
+ // The track handler toggles AND calls preventDefault so the wrapping
129
+ // label cannot forward a second click to the native input — without
130
+ // that guard one tap would emit twice and net-zero the flip.
131
+ expect(t.emitted).toEqual([true]);
132
+ expect(t.model.value).toBe(true);
133
+ expect(t.root().getAttribute("data-checked")).toBe("");
134
+
135
+ t.track().click();
136
+ await nextTick();
137
+ expect(t.emitted).toEqual([true, false]);
138
+ });
139
+
140
+ it("toggles through the label text via native label→input forwarding", async () => {
141
+ const t = mountVModel(false, { label: "Dark mode" });
142
+ (t.container.querySelector(".hk-label") as HTMLElement).click();
143
+ await nextTick();
144
+ expect(t.emitted).toEqual([true]);
145
+ // The forwarded click natively flipped the input's checkedness.
146
+ expect(t.input().checked).toBe(true);
147
+ });
148
+
149
+ it("flips via the native checkbox activation (the Space-key path)", async () => {
150
+ const t = mountVModel(true);
151
+ // A real Space keypress on a focused checkbox produces exactly this
152
+ // input activation (checked flip + change event); the component owns
153
+ // no custom key handlers, so the platform control IS the keyboard
154
+ // story. Enter is not a checkbox activation key — nothing to assert.
155
+ t.input().click();
156
+ await nextTick();
157
+ expect(t.emitted).toEqual([false]);
158
+ expect(t.input().checked).toBe(false);
159
+ });
160
+
161
+ it("drops every toggle path while disabled", async () => {
162
+ const t = mountVModel(false, { disabled: true });
163
+ expect(t.root().getAttribute("data-disabled")).toBe("");
164
+ expect(t.input().disabled).toBe(true);
165
+ t.track().click();
166
+ t.input().click();
167
+ // Also the label-forwarding path: the platform drops clicks on a
168
+ // disabled input, so the forwarded activation dies too.
169
+ (t.container.querySelector(".hk-switch-thumb") as HTMLElement).click();
170
+ await nextTick();
171
+ expect(t.emitted).toEqual([]);
172
+ expect(t.model.value).toBe(false);
173
+ });
174
+ });
@@ -0,0 +1,226 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, h, nextTick } from "vue";
3
+
4
+ import HkTooltip from "./HkTooltip";
5
+ import { POPUP_Z_BANDS, POPUP_Z_STEP, usePopupManager } from "../runtime/usePopupManager";
6
+
7
+ /**
8
+ * HkTooltip contract tests:
9
+ * - popup-manager registration: every mounted tooltip registers ONE
10
+ * entry of kind "tooltip" in the real singleton registry (read back
11
+ * through usePopupManager), stacked in the tooltip z band (above the
12
+ * window/dropdown bands, below toasts), never scroll-locking; the
13
+ * entry leaves the registry on unmount
14
+ * - the band z lands INLINE on the teleported popup element
15
+ * - show/hide: anchor mouseenter shows after the delay (default 300ms),
16
+ * mouseleave hides and cancels a pending show; focusin/focusout ride
17
+ * the same path
18
+ * - anchoring: placement drives the popup class, the wrapper
19
+ * data-position and the measured offset style (happy-dom reports a
20
+ * zero rect, so the numbers pin the 8px gap and per-placement
21
+ * transform)
22
+ *
23
+ * House style: raw createApp mounts on a shared container list torn
24
+ * down after each case; the popup-manager singleton is REAL state, so
25
+ * afterEach sweeps it the way usePopupManager.test.ts does.
26
+ */
27
+
28
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
29
+
30
+ function mount(props: Record<string, unknown> = {}) {
31
+ const container = document.createElement("div");
32
+ document.body.appendChild(container);
33
+ const app = createApp({
34
+ render: () =>
35
+ h(HkTooltip, props as never, () => h("span", { class: "anchor-probe" }, "anchor")),
36
+ });
37
+ app.mount(container);
38
+ mounts.push({ app, container });
39
+ return { app, container };
40
+ }
41
+
42
+ const manager = usePopupManager();
43
+
44
+ function tooltipEntries() {
45
+ return [...manager.registry.value.values()].filter((e) => e.kind === "tooltip");
46
+ }
47
+
48
+ function popup(): HTMLElement {
49
+ return document.querySelector<HTMLElement>(".hk-tooltip-popup")!;
50
+ }
51
+
52
+ function wrapper(c: HTMLElement): HTMLElement {
53
+ return c.querySelector<HTMLElement>(".hk-tooltip-wrapper")!;
54
+ }
55
+
56
+ /** real-timer settle: covers the show setTimeout(0) + the rAF position pass */
57
+ async function settle() {
58
+ await new Promise((r) => setTimeout(r, 20));
59
+ await nextTick();
60
+ }
61
+
62
+ function enter(c: HTMLElement) {
63
+ wrapper(c).dispatchEvent(new MouseEvent("mouseenter"));
64
+ }
65
+
66
+ function leave(c: HTMLElement) {
67
+ wrapper(c).dispatchEvent(new MouseEvent("mouseleave"));
68
+ }
69
+
70
+ afterEach(() => {
71
+ vi.useRealTimers();
72
+ for (const { app, container } of mounts.splice(0)) {
73
+ app.unmount();
74
+ container.remove();
75
+ }
76
+ // The registry is a module singleton — sweep leftovers so tests do not
77
+ // leak z state (same discipline as usePopupManager.test.ts).
78
+ for (const entry of [...manager.registry.value.values()]) {
79
+ manager.unregister(entry.id);
80
+ }
81
+ document.querySelectorAll(".hk-tooltip-popup").forEach((el) => el.remove());
82
+ });
83
+
84
+ describe("HkTooltip popup-manager registration", () => {
85
+ it("registers one tooltip-kind entry at the tooltip band and stamps its z inline", async () => {
86
+ mount({ text: "hello" });
87
+ const entries = tooltipEntries();
88
+ expect(entries.length).toBe(1);
89
+ expect(entries[0]!.kind).toBe("tooltip");
90
+ expect(entries[0]!.zIndex).toBe(POPUP_Z_BANDS.tooltip);
91
+ // Tooltips are transient annotations — they never lock scroll.
92
+ expect(entries[0]!.locksScroll).toBe(false);
93
+ expect(document.body.style.overflow).not.toBe("hidden");
94
+ // The band z lands on the teleported popup element (set from
95
+ // onMounted, so the patch flushes on the next tick), overriding the
96
+ // --hi-z-tooltip SCSS fallback.
97
+ await nextTick();
98
+ expect(popup().style.zIndex).toBe(String(POPUP_Z_BANDS.tooltip));
99
+ });
100
+
101
+ it("stacks a second tooltip one step above within the band", () => {
102
+ mount({ text: "one" });
103
+ mount({ text: "two" });
104
+ const entries = tooltipEntries();
105
+ expect(entries.length).toBe(2);
106
+ const zs = entries.map((e) => e.zIndex).sort((a, b) => a - b);
107
+ expect(zs[1]).toBe(zs[0] + POPUP_Z_STEP);
108
+ expect(zs[0]).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
109
+ });
110
+
111
+ it("removes its registry entry on unmount", () => {
112
+ const { app } = mount({ text: "bye" });
113
+ expect(tooltipEntries().length).toBe(1);
114
+ app.unmount();
115
+ expect(tooltipEntries().length).toBe(0);
116
+ });
117
+
118
+ it("keeps a single registration across show/hide cycles", async () => {
119
+ const { container } = mount({ text: "stable", delay: 0 });
120
+ enter(container);
121
+ await settle();
122
+ leave(container);
123
+ await nextTick();
124
+ enter(container);
125
+ await settle();
126
+ expect(tooltipEntries().length).toBe(1);
127
+ });
128
+ });
129
+
130
+ describe("HkTooltip show/hide", () => {
131
+ it("teleports the popup to body, hidden, with the text content", () => {
132
+ const { container } = mount({ text: "the tip" });
133
+ const el = popup();
134
+ expect(document.body.contains(el)).toBe(true);
135
+ expect(el.parentElement).toBe(document.body);
136
+ expect(el.className).not.toContain("hk-tooltip-visible");
137
+ expect(el.querySelector(".hk-tooltip-content")!.textContent).toBe("the tip");
138
+ // The anchor slot renders inside the wrapper's trigger span.
139
+ expect(wrapper(container).querySelector(".hk-tooltip-trigger .anchor-probe")).not.toBeNull();
140
+ });
141
+
142
+ it("waits out the default 300ms delay before showing", async () => {
143
+ vi.useFakeTimers();
144
+ const { container } = mount({ text: "delayed" });
145
+ enter(container);
146
+ await vi.advanceTimersByTimeAsync(299);
147
+ await nextTick();
148
+ expect(popup().className).not.toContain("hk-tooltip-visible");
149
+
150
+ await vi.advanceTimersByTimeAsync(1);
151
+ await nextTick();
152
+ expect(popup().className).toContain("hk-tooltip-visible");
153
+ });
154
+
155
+ it("shows on anchor enter and hides on leave (delay 0)", async () => {
156
+ const { container } = mount({ text: "quick", delay: 0 });
157
+ enter(container);
158
+ await settle();
159
+ expect(popup().className).toContain("hk-tooltip-visible");
160
+
161
+ leave(container);
162
+ await nextTick();
163
+ expect(popup().className).not.toContain("hk-tooltip-visible");
164
+ });
165
+
166
+ it("cancels a pending show when the anchor is left before the delay elapses", async () => {
167
+ vi.useFakeTimers();
168
+ const { container } = mount({ text: "cancelled", delay: 200 });
169
+ enter(container);
170
+ await vi.advanceTimersByTimeAsync(100);
171
+ leave(container);
172
+ await vi.advanceTimersByTimeAsync(1000);
173
+ await nextTick();
174
+ expect(popup().className).not.toContain("hk-tooltip-visible");
175
+ });
176
+
177
+ it("shows on focusin and hides on focusout like hover", async () => {
178
+ const { container } = mount({ text: "focused", delay: 0 });
179
+ container
180
+ .querySelector(".hk-tooltip-trigger")!
181
+ .dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
182
+ await settle();
183
+ expect(popup().className).toContain("hk-tooltip-visible");
184
+
185
+ container
186
+ .querySelector(".hk-tooltip-trigger")!
187
+ .dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
188
+ await nextTick();
189
+ expect(popup().className).not.toContain("hk-tooltip-visible");
190
+ });
191
+ });
192
+
193
+ describe("HkTooltip anchoring", () => {
194
+ it("places the popup above the anchor with the top placement (gap + transform)", async () => {
195
+ const { container } = mount({ text: "up", delay: 0, placement: "top" });
196
+ enter(container);
197
+ await settle();
198
+ const el = popup();
199
+ expect(el.className).toContain("hk-tooltip-top");
200
+ expect(wrapper(container).getAttribute("data-position")).toBe("top");
201
+ // happy-dom reports a zero anchor rect: top = 0 - 8 gap, left = the
202
+ // anchor center, transform flips the popup above that point.
203
+ expect(el.style.top).toBe("-8px");
204
+ expect(el.style.left).toBe("0px");
205
+ expect(el.style.transform).toBe("translate(-50%, -100%)");
206
+ });
207
+
208
+ it("places the popup beside the anchor with the right placement", async () => {
209
+ const { container } = mount({ text: "side", delay: 0, placement: "right" });
210
+ enter(container);
211
+ await settle();
212
+ const el = popup();
213
+ expect(el.className).toContain("hk-tooltip-right");
214
+ expect(wrapper(container).getAttribute("data-position")).toBe("right");
215
+ expect(el.style.top).toBe("0px");
216
+ expect(el.style.left).toBe("8px");
217
+ expect(el.style.transform).toBe("translate(0, -50%)");
218
+ });
219
+
220
+ it("carries the maxWidth prop onto the popup style", async () => {
221
+ const { container } = mount({ text: "wide", delay: 0, maxWidth: "220px" });
222
+ enter(container);
223
+ await settle();
224
+ expect(popup().style.maxWidth).toBe("220px");
225
+ });
226
+ });