@celestia-island/hikari 0.40.33 → 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.33",
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
+ });
@@ -29,6 +29,21 @@
29
29
  }
30
30
  }
31
31
 
32
+ /* ONE SCROLLBAR PER WINDOW (2026-09-08 audit): inside a window surface's
33
+ * own scroller the bounded tree cap lit a second scrollbar under the
34
+ * window's — the window's scrollbar is the only one there and the tree
35
+ * grows with its content. Pages and standalone cards (the error landing,
36
+ * chat tool blocks outside windows) keep the bounded viewport above.
37
+ * The host's deferred overlay pass self-hides its rails once the
38
+ * content fits, so no attach-side change is needed. */
39
+ .hk-modal-body-scroll .s-tool-json-tree,
40
+ .hk-select-sheet-list .s-tool-json-tree,
41
+ .hk-select-popout .s-tool-json-tree,
42
+ .hk-popover-panel .s-tool-json-tree,
43
+ .hk-drawer-body .s-tool-json-tree {
44
+ max-height: none;
45
+ }
46
+
32
47
  .s-jt-row {
33
48
  /* Baseline alignment keeps the small type hints (boolean/number/string)
34
49
  on the value's text baseline instead of floating above it. */
@@ -1,15 +1,7 @@
1
+ /* ONE SCROLLBAR PER WINDOW (2026-09-08 audit): the body grows with its
2
+ * content and the HkModal body scroller is THE scrollbar. The old
3
+ * `bodyHeight`-driven nested scroll region (with its own overlay chrome)
4
+ * was removed as dead API — no consumer in the family ever passed it. */
1
5
  .s-protocol-modal {
2
6
  position: relative;
3
7
  }
4
-
5
-
6
- /* With `bodyHeight` set the body becomes its own scroll region (nested
7
- * inside HkModal's body scroller) — overlay chrome only. */
8
- .s-protocol-modal {
9
- overflow-y: auto;
10
- scrollbar-width: none;
11
-
12
- &::-webkit-scrollbar {
13
- display: none;
14
- }
15
- }
@@ -1,4 +1,4 @@
1
- import { computed, defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
1
+ import { computed, defineComponent, type PropType } from "vue";
2
2
  import {
3
3
  HMarkdownRenderer,
4
4
  HModal,
@@ -9,7 +9,6 @@ import {
9
9
  } from "@celestia-island/hikari";
10
10
 
11
11
  import { useI18n } from "../i18n/context";
12
- import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
13
12
 
14
13
  import "./HkProtocolModal.scss";
15
14
 
@@ -21,6 +20,12 @@ import "./HkProtocolModal.scss";
21
20
  * a Decline/Accept footer. `accept`/`decline` are the caller's commit
22
21
  * actions; closing (overlay click / ESC / X) only emits `update:modelValue`
23
22
  * so the caller can decide whether close === decline.
23
+ *
24
+ * ONE SCROLLBAR PER WINDOW: the body grows with its content and the
25
+ * HkModal body scroller is THE scrollbar. The old `bodyHeight` prop
26
+ * (a height-capped scroll region nested inside the modal's own scroller —
27
+ * the exact double-scroll this library eliminates) was removed as dead
28
+ * API: no consumer in the family ever passed it.
24
29
  */
25
30
  export const HkProtocolModal = defineComponent({
26
31
  name: "HkProtocolModal",
@@ -37,8 +42,6 @@ export const HkProtocolModal = defineComponent({
37
42
  /** Allow dismissing without a decision (overlay/ESC/X). Default true. */
38
43
  closable: { type: Boolean, default: true },
39
44
  width: { type: String as PropType<ModalWidth>, default: "48rem" },
40
- /** Cap the scroll body height (e.g. "60vh"). */
41
- bodyHeight: { type: String, default: undefined },
42
45
  },
43
46
  emits: {
44
47
  "update:modelValue": (_v: boolean) => true,
@@ -69,32 +72,6 @@ export const HkProtocolModal = defineComponent({
69
72
  },
70
73
  ]);
71
74
 
72
- // ── overlay scrollbar (shared chrome) ─────────────────────────
73
- // With `bodyHeight` set the body becomes a height-capped scroll
74
- // region NESTED inside HkModal's overlay-equipped body scroller —
75
- // without the overlay it would be a surviving native bar (the exact
76
- // double-scroll this library eliminates). Attach on mount, re-sync
77
- // when the cap flips, detach on unmount.
78
- const bodyRef = ref<HTMLElement | null>(null);
79
- let bodyScrollbar: OverlayScrollbarHandle | null = null;
80
- function syncBodyScrollbar(): void {
81
- if (props.bodyHeight && bodyRef.value) {
82
- if (!bodyScrollbar) bodyScrollbar = attachOverlayScrollbars(bodyRef.value);
83
- } else {
84
- bodyScrollbar?.detach();
85
- bodyScrollbar = null;
86
- }
87
- }
88
- function bindBodyRef(el: unknown): void {
89
- bodyRef.value = (el as HTMLElement) ?? null;
90
- syncBodyScrollbar();
91
- }
92
- watch(() => props.bodyHeight, () => syncBodyScrollbar());
93
- onBeforeUnmount(() => {
94
- bodyScrollbar?.detach();
95
- bodyScrollbar = null;
96
- });
97
-
98
75
  return () => (
99
76
  <HModal
100
77
  modelValue={props.modelValue}
@@ -104,11 +81,7 @@ export const HkProtocolModal = defineComponent({
104
81
  closable={props.closable}
105
82
  footerActions={footerActions.value}
106
83
  >
107
- <div
108
- ref={bindBodyRef}
109
- class="s-protocol-modal"
110
- style={props.bodyHeight ? { maxHeight: props.bodyHeight } : undefined}
111
- >
84
+ <div class="s-protocol-modal">
112
85
  <HMarkdownRenderer content={props.content} />
113
86
  </div>
114
87
  </HModal>
@@ -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
+ });
@@ -114,6 +114,22 @@
114
114
  }
115
115
  }
116
116
 
117
+ /* ONE SCROLLBAR PER WINDOW (2026-09-08 audit, HkJsonTree tool blocks and
118
+ code panes): inside a window surface's own scroller the bounded pane
119
+ cap lit a SECOND scrollbar under the window's — one per pane in pane
120
+ lists like the chest todo-log modal. There the window's scrollbar is
121
+ the only one and the pane grows with its content. Chat message cards
122
+ and pages are NOT window surfaces: the bounded viewport above stays.
123
+ (The panes' overlay rails self-hide once the content fits, so the
124
+ deferred attach pass needs no changes.) */
125
+ .hk-modal-body-scroll .s-tool-code,
126
+ .hk-select-sheet-list .s-tool-code,
127
+ .hk-select-popout .s-tool-code,
128
+ .hk-popover-panel .s-tool-code,
129
+ .hk-drawer-body .s-tool-code {
130
+ max-height: none;
131
+ }
132
+
117
133
  .s-tool-result {
118
134
  border-left: 2px solid rgb(var(--color-success) / 40%);
119
135
  padding-left: var(--space-8, 0.5rem);
@@ -167,6 +183,15 @@
167
183
  }
168
184
  }
169
185
 
186
+ /* Window-surface twin of the .s-tool-code rule above. */
187
+ .hk-modal-body-scroll .s-tool-code-block,
188
+ .hk-select-sheet-list .s-tool-code-block,
189
+ .hk-select-popout .s-tool-code-block,
190
+ .hk-popover-panel .s-tool-code-block,
191
+ .hk-drawer-body .s-tool-code-block {
192
+ max-height: none;
193
+ }
194
+
170
195
  .s-tool-code-table {
171
196
  border-collapse: collapse;
172
197
  width: 100%;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Source contract: ONE SCROLLBAR PER WINDOW — tool panes (2026-09-08
3
+ * audit follow-up). The tool block's code panes and the shared JSON tree
4
+ * keep a bounded widget viewport in chat cards and on pages (the exempt
5
+ * content-widget class, like markdown code blocks), but INSIDE a window
6
+ * surface's own scroller (modal body, select sheet, popout, popover
7
+ * sheet/panel, drawer body) that cap lit a second scrollbar under the
8
+ * window's — one per pane in pane lists like the chest todo-log modal.
9
+ * There the window's scrollbar is the only one and the pane grows with
10
+ * its content.
11
+ */
12
+ import { describe, expect, it } from "vitest";
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const toolScss = readFileSync(join(here, "HkToolBlock.scss"), "utf-8");
19
+ const treeScss = readFileSync(join(here, "HkJsonTree.scss"), "utf-8");
20
+ const toolTsx = readFileSync(join(here, "HkToolBlock.tsx"), "utf-8");
21
+
22
+ /** Window-surface scrollers — the ONE-scrollbar owners. */
23
+ const SCROLLERS = [
24
+ ".hk-modal-body-scroll",
25
+ ".hk-select-sheet-list",
26
+ ".hk-select-popout",
27
+ ".hk-popover-panel",
28
+ ".hk-drawer-body",
29
+ ] as const;
30
+
31
+ const PANES = [".s-tool-code", ".s-tool-code-block", ".s-tool-json-tree"] as const;
32
+
33
+ /** Extract ONE balanced `{...}` declaration block following `selector`
34
+ * (a naive `[^}]*` would stop at the first nested `}` — see the
35
+ * HkAffixPicker single-scroll contract for the established pattern). */
36
+ function cssBlock(src: string, selector: string): string {
37
+ const at = src.indexOf(selector);
38
+ if (at < 0) return "";
39
+ const open = src.indexOf("{", at);
40
+ if (open < 0) return "";
41
+ let depth = 0;
42
+ for (let i = open; i < src.length; i++) {
43
+ if (src[i] === "{") depth++;
44
+ else if (src[i] === "}") {
45
+ depth--;
46
+ if (depth === 0) return src.slice(open, i + 1);
47
+ }
48
+ }
49
+ return "";
50
+ }
51
+
52
+ describe("tool pane single-scrollbar-per-window contract", () => {
53
+ it("keeps the bounded widget viewport outside windows", () => {
54
+ expect(cssBlock(toolScss, ".s-tool-code ")).toContain("max-height: 240px");
55
+ expect(cssBlock(toolScss, ".s-tool-code-block ")).toContain("max-height: 240px");
56
+ expect(cssBlock(treeScss, ".s-tool-json-tree ")).toContain("max-height: 320px");
57
+ });
58
+
59
+ it("drops the cap inside every window surface scroller", () => {
60
+ for (const pane of PANES) {
61
+ for (const scroller of SCROLLERS) {
62
+ // The uncap rule lives next to the pane's own file: tool panes in
63
+ // HkToolBlock.scss, the tree in HkJsonTree.scss. Search BOTH — a
64
+ // rule moved between the files must not fail the pin, a rule
65
+ // deleted anywhere must fail it.
66
+ const body =
67
+ cssBlock(toolScss, `${scroller} ${pane}`) ||
68
+ cssBlock(treeScss, `${scroller} ${pane}`);
69
+ expect(body, `${scroller} ${pane} uncap rule exists`).toBeTruthy();
70
+ expect(body, `${scroller} ${pane} uncaps via max-height: none`).toContain(
71
+ "max-height: none",
72
+ );
73
+ }
74
+ }
75
+ });
76
+
77
+ it("no window-scroller context ever re-caps a pane with a length", () => {
78
+ for (const scss of [toolScss, treeScss]) {
79
+ for (const scroller of SCROLLERS) {
80
+ // Any scroller-descendant rule for a pane carrying a px/vh/rem
81
+ // max-height would reintroduce the nested scrollbar.
82
+ const re = new RegExp(
83
+ `${scroller.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} [^{]*\\{[^}]*max-height:\\s*(?!none)[\\d.]`,
84
+ );
85
+ expect(scss.match(re), `${scroller} descendant px cap`).toBeNull();
86
+ }
87
+ }
88
+ });
89
+
90
+ it("the deferred overlay pass still targets the three panes (rails self-hide when uncapped)", () => {
91
+ expect(toolTsx).toContain('".s-tool-code, .s-tool-code-block, .s-tool-json-tree"');
92
+ });
93
+ });
@@ -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
+ });