@celestia-island/hikari 0.55.116 → 0.55.117

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.55.116",
3
+ "version": "0.55.117",
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",
@@ -60,13 +60,18 @@ describe("HkAuthMethodList", () => {
60
60
  expect(c.querySelector(".s-auth-methods [role='radiogroup']")).toBeNull();
61
61
  });
62
62
 
63
- it("wraps every tile in a tooltip carrying the provider label", () => {
63
+ it("wraps every tile in a tooltip carrying the provider label", async () => {
64
64
  const c = mount(
65
65
  h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })),
66
66
  );
67
67
  const wrappers = c.querySelectorAll(".hk-tooltip-wrapper");
68
68
  expect(wrappers.length).toBe(2);
69
- // The teleported popups exist with the label text (hidden until hover).
69
+ // Popups mount lazily on first engage — a touch tap mounts each one
70
+ // immediately — and then carry the label text.
71
+ for (const w of wrappers) {
72
+ w.dispatchEvent(new PointerEvent("pointerdown", { pointerType: "touch", bubbles: true }));
73
+ }
74
+ await Promise.resolve();
70
75
  const popups = [...document.querySelectorAll(".hk-tooltip-popup .hk-tooltip-content")]
71
76
  .map((el) => el.textContent);
72
77
  expect(popups).toContain("GitHub");
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { createApp, h, defineComponent } from "vue";
2
+ import { createApp, h, defineComponent, nextTick } from "vue";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -8,6 +8,7 @@ import HkCopyBadge from "./HkCopyBadge";
8
8
  import { useToast } from "../runtime/useToast";
9
9
 
10
10
  const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
11
+ const appUnmounts: Array<{ app: ReturnType<typeof createApp> }> = [];
11
12
 
12
13
  function mount(renderNode: () => ReturnType<typeof h>) {
13
14
  const container = document.createElement("div");
@@ -15,6 +16,7 @@ function mount(renderNode: () => ReturnType<typeof h>) {
15
16
  const app = createApp({ render: renderNode });
16
17
  app.mount(container);
17
18
  mounts.push({ app, container });
19
+ appUnmounts.push({ app });
18
20
  return container;
19
21
  }
20
22
 
@@ -126,18 +128,64 @@ describe("HkCopyBadge", () => {
126
128
  expect(writeText).not.toHaveBeenCalled();
127
129
  });
128
130
 
129
- it("renders the custom tooltip text", () => {
131
+ /** Engage the lazy tooltip without hovering: a touch tap mounts the
132
+ * popup immediately (showNow), no fake timers needed. */
133
+ function engageTooltip(c: HTMLElement) {
134
+ c.querySelector(".hk-tooltip-wrapper")!.dispatchEvent(
135
+ new PointerEvent("pointerdown", { pointerType: "touch", bubbles: true }),
136
+ );
137
+ }
138
+
139
+ it("renders the custom tooltip text", async () => {
130
140
  const c = mount(() => h(HkCopyBadge, { tooltip: "Open details" }, () => "#d.132"));
131
- const content = c.parentElement!.querySelector(".hk-tooltip-popup .hk-tooltip-content");
141
+ // The popup mounts lazily on first engage (dense-list contract).
142
+ expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
143
+ engageTooltip(c);
144
+ await nextTick();
145
+ const content = document.body.querySelector(".hk-tooltip-popup .hk-tooltip-content");
132
146
  expect(content?.textContent).toBe("Open details");
133
147
  });
134
148
 
135
- it("shows the localized click-to-copy tooltip by default", () => {
149
+ it("shows the localized click-to-copy tooltip by default", async () => {
136
150
  const c = mount(() => h(HkCopyBadge, null, () => "#d.132"));
137
- const content = c.parentElement!.querySelector(".hk-tooltip-popup .hk-tooltip-content");
151
+ engageTooltip(c);
152
+ await nextTick();
153
+ const content = document.body.querySelector(".hk-tooltip-popup .hk-tooltip-content");
138
154
  expect(content?.textContent).toBe("Click to copy");
139
155
  });
140
156
 
157
+ it("links the badge to the popup via aria-describedby across the whole lifecycle", async () => {
158
+ const c = mount(() => h(HkCopyBadge, { tooltip: "Open details" }, () => "#d.132"));
159
+ const badge = c.querySelector(".hk-badge")!;
160
+
161
+ // Dormant: no popup, no describedby.
162
+ expect(badge.getAttribute("aria-describedby")).toBeNull();
163
+ expect(document.body.querySelector(".hk-tooltip-popup")).toBeNull();
164
+
165
+ // Engage: describedby == the popup id.
166
+ engageTooltip(c);
167
+ await nextTick();
168
+ const popup = document.body.querySelector(".hk-tooltip-popup")!;
169
+ expect(badge.getAttribute("aria-describedby")).toBe(popup.getAttribute("id"));
170
+ expect(popup.querySelector(".hk-tooltip-content")!.textContent).toBe("Open details");
171
+
172
+ // Leave: the attribute flips BACK (slot re-invocation must not stick).
173
+ c.querySelector(".hk-tooltip-wrapper")!.dispatchEvent(new MouseEvent("mouseleave"));
174
+ await nextTick();
175
+ expect(badge.getAttribute("aria-describedby")).toBeNull();
176
+
177
+ // Re-engage: returns, reusing the same popup element.
178
+ engageTooltip(c);
179
+ await nextTick();
180
+ expect(badge.getAttribute("aria-describedby")).toBe(
181
+ document.body.querySelector(".hk-tooltip-popup")!.getAttribute("id"),
182
+ );
183
+
184
+ // Unmount while visible: the teleported popup goes away.
185
+ appUnmounts.splice(0).forEach(({ app }) => app.unmount());
186
+ expect(document.body.querySelector(".hk-tooltip-popup")).toBeNull();
187
+ });
188
+
141
189
  it("renders the inert badge face when disabled", () => {
142
190
  const c = mount(() => h(HkCopyBadge, { disabled: true }, () => "#d.132"));
143
191
  const badge = c.querySelector(".hk-badge")!;
@@ -27,9 +27,12 @@ import "./HkCopyBadge.scss";
27
27
  *
28
28
  * A11y: the tag is a real button (role, tab order, Enter/Space), and the
29
29
  * click is stopped from reaching clickable ancestors — a tag inside a
30
- * selectable row copies without selecting the row. Consumer attrs
31
- * (class/style/data-*) land on the badge element itself, so a call site
32
- * skinning the pill keeps working; the tooltip wrapper stays clean.
30
+ * selectable row copies without selecting the row. The tooltip popup is
31
+ * linked to the badge via aria-describedby (HkTooltip hands the popup id
32
+ * down as a slot prop, so the describedby lands on the actual focus
33
+ * target). Consumer attrs (class/style/data-*) land on the badge element
34
+ * itself, so a call site skinning the pill keeps working; the tooltip
35
+ * wrapper stays clean.
33
36
  */
34
37
  export default defineComponent({
35
38
  name: "HkCopyBadge",
@@ -95,7 +98,7 @@ export default defineComponent({
95
98
  // itself; only the interactive contract below is ours to add.
96
99
  const { class: attrClass, style: attrStyle, ...restAttrs } = attrs;
97
100
 
98
- const badge = (
101
+ const buildBadge = (describedBy?: string) => (
99
102
  <HBadge
100
103
  ref={badgeRef}
101
104
  {...restAttrs}
@@ -110,6 +113,7 @@ export default defineComponent({
110
113
  color={props.color}
111
114
  bgColor={props.bgColor}
112
115
  borderColor={props.borderColor}
116
+ aria-describedby={describedBy}
113
117
  {...(props.disabled
114
118
  ? {}
115
119
  : {
@@ -133,8 +137,13 @@ export default defineComponent({
133
137
  </HBadge>
134
138
  );
135
139
 
136
- if (props.disabled) return badge;
137
- return <HTooltip text={tooltipText.value}>{badge}</HTooltip>;
140
+ if (props.disabled) return buildBadge();
141
+ return (
142
+ <HTooltip text={tooltipText.value}>
143
+ {(tip: { popupId: string; visible: boolean }) =>
144
+ buildBadge(tip?.visible ? tip.popupId : undefined)}
145
+ </HTooltip>
146
+ );
138
147
  };
139
148
  },
140
149
  });
@@ -235,3 +235,31 @@ describe("HkToast", () => {
235
235
  expect(items().length).toBe(0);
236
236
  });
237
237
  });
238
+
239
+ describe("HkToast live-region semantics", () => {
240
+ it("announces errors as alert/assertive and everything else as status/polite", async () => {
241
+ mountHost();
242
+ const toast = useToast();
243
+ toast.error("boom");
244
+ toast.success("nice");
245
+ toast.warning("hmm");
246
+ toast.info("fyi");
247
+ toast.loading("working");
248
+ await settle();
249
+
250
+ // The toast surface teleports to body — the host container never
251
+ // contains the items.
252
+ const items = [...document.body.querySelectorAll(".hk-toast-item")];
253
+ expect(items.length).toBe(5);
254
+ const byType = (type: string) =>
255
+ items.find((el) => el.classList.contains(`hk-toast-${type}`))!;
256
+ const err = byType("error");
257
+ expect(err.getAttribute("role")).toBe("alert");
258
+ expect(err.getAttribute("aria-live")).toBe("assertive");
259
+ for (const type of ["success", "warning", "info", "loading"]) {
260
+ const el = byType(type);
261
+ expect(el.getAttribute("role"), type).toBe("status");
262
+ expect(el.getAttribute("aria-live"), type).toBe("polite");
263
+ }
264
+ });
265
+ });
@@ -105,8 +105,19 @@ const HkToastItem = defineComponent({
105
105
  const long = isLong(text);
106
106
  const count = props.toast.messages.length;
107
107
 
108
+ // Live-region semantics: errors interrupt (role=alert implies
109
+ // assertive), everything else announces politely (role=status).
110
+ const a11y: { role: string; "aria-live": "assertive" | "polite" } =
111
+ props.toast.type === "error"
112
+ ? { role: "alert", "aria-live": "assertive" }
113
+ : { role: "status", "aria-live": "polite" };
114
+
108
115
  return (
109
- <div class={["hk-toast-item", `hk-toast-${props.toast.type}`]}>
116
+ <div
117
+ class={["hk-toast-item", `hk-toast-${props.toast.type}`]}
118
+ role={a11y.role}
119
+ aria-live={a11y["aria-live"]}
120
+ >
110
121
  <span class="hk-toast-icon">{renderIcon(props.toast.type)}</span>
111
122
  <div class="hk-toast-body">
112
123
  <Transition
@@ -82,8 +82,18 @@ afterEach(() => {
82
82
  });
83
83
 
84
84
  describe("HkTooltip popup-manager registration", () => {
85
+ it("defers the band entry until the first engage (lazy contract)", () => {
86
+ mount({ text: "dormant" });
87
+ // A never-engaged tooltip — the dense-list majority — costs no
88
+ // teleported popup and no registry entry at all.
89
+ expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
90
+ expect(tooltipEntries().length).toBe(0);
91
+ });
92
+
85
93
  it("registers one tooltip-kind entry at the tooltip band and stamps its z inline", async () => {
86
- mount({ text: "hello" });
94
+ const { container } = mount({ text: "hello" });
95
+ enter(container);
96
+ await settle();
87
97
  const entries = tooltipEntries();
88
98
  expect(entries.length).toBe(1);
89
99
  expect(entries[0]!.kind).toBe("tooltip");
@@ -91,25 +101,28 @@ describe("HkTooltip popup-manager registration", () => {
91
101
  // Tooltips are transient annotations — they never lock scroll.
92
102
  expect(entries[0]!.locksScroll).toBe(false);
93
103
  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
104
+ // The band z lands on the teleported popup element, overriding the
96
105
  // --hi-z-tooltip SCSS fallback.
97
- await nextTick();
98
106
  expect(popup().style.zIndex).toBe(String(POPUP_Z_BANDS.tooltip));
99
107
  });
100
108
 
101
- it("stacks a second tooltip one step above within the band", () => {
102
- mount({ text: "one" });
103
- mount({ text: "two" });
109
+ it("stacks a second tooltip one step above within the band", async () => {
110
+ const a = mount({ text: "one" });
111
+ const b = mount({ text: "two" });
112
+ enter(a.container);
113
+ enter(b.container);
114
+ await settle();
104
115
  const entries = tooltipEntries();
105
116
  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);
117
+ const zs = entries.map((e) => e.zIndex).sort((x, y) => x - y);
118
+ expect(zs[1]).toBe(zs[0]! + POPUP_Z_STEP);
108
119
  expect(zs[0]).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
109
120
  });
110
121
 
111
- it("removes its registry entry on unmount", () => {
112
- const { app } = mount({ text: "bye" });
122
+ it("removes its registry entry on unmount", async () => {
123
+ const { app, container } = mount({ text: "bye" });
124
+ enter(container);
125
+ await settle();
113
126
  expect(tooltipEntries().length).toBe(1);
114
127
  app.unmount();
115
128
  expect(tooltipEntries().length).toBe(0);
@@ -128,8 +141,15 @@ describe("HkTooltip popup-manager registration", () => {
128
141
  });
129
142
 
130
143
  describe("HkTooltip show/hide", () => {
131
- it("teleports the popup to body, hidden, with the text content", () => {
144
+ it("mounts the popup on first engage and keeps it hidden after hide, with the text content", async () => {
132
145
  const { container } = mount({ text: "the tip" });
146
+ // Nothing before the first engage…
147
+ expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
148
+ // …then engage and hide: the popup persists, hidden, for reuse.
149
+ enter(container);
150
+ await settle();
151
+ leave(container);
152
+ await nextTick();
133
153
  const el = popup();
134
154
  expect(document.body.contains(el)).toBe(true);
135
155
  expect(el.parentElement).toBe(document.body);
@@ -139,6 +159,46 @@ describe("HkTooltip show/hide", () => {
139
159
  expect(wrapper(container).querySelector(".hk-tooltip-trigger .anchor-probe")).not.toBeNull();
140
160
  });
141
161
 
162
+ it("wires aria: role=tooltip + id on the popup, describedby on the wrapper while visible", async () => {
163
+ const { container } = mount({ text: "a11y tip", delay: 0 });
164
+ enter(container);
165
+ await settle();
166
+ const el = popup();
167
+ expect(el.getAttribute("role")).toBe("tooltip");
168
+ const id = el.getAttribute("id");
169
+ expect(id).toBeTruthy();
170
+ expect(wrapper(container).getAttribute("aria-describedby")).toBe(id);
171
+ leave(container);
172
+ await nextTick();
173
+ expect(wrapper(container).getAttribute("aria-describedby")).toBeNull();
174
+ });
175
+
176
+ it("hands { popupId, visible } to the default slot for focus-target wiring", async () => {
177
+ const container = document.createElement("div");
178
+ document.body.appendChild(container);
179
+ let seen: { popupId?: string; visible?: boolean } = {};
180
+ const app = createApp({
181
+ render: () =>
182
+ h(
183
+ HkTooltip,
184
+ { text: "slot props", delay: 0 } as never,
185
+ // The scoped-slot child needs a cast: HkTooltip does not declare
186
+ // slot prop types, so h() types the default slot as no-arg.
187
+ ((slotProps: { popupId: string; visible: boolean }) => {
188
+ seen = slotProps;
189
+ return h("span", { class: "anchor-probe" }, "anchor");
190
+ }) as never,
191
+ ),
192
+ });
193
+ app.mount(container);
194
+ mounts.push({ app, container });
195
+ await nextTick();
196
+ wrapper(container).dispatchEvent(new MouseEvent("mouseenter"));
197
+ await settle();
198
+ expect(seen.visible).toBe(true);
199
+ expect(seen.popupId).toBe(popup().getAttribute("id"));
200
+ });
201
+
142
202
  it("waits out the default 300ms delay before showing", async () => {
143
203
  vi.useFakeTimers();
144
204
  const { container } = mount({ text: "delayed" });
@@ -236,7 +296,9 @@ describe("HkTooltip touch taps", () => {
236
296
  new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
237
297
  );
238
298
  await nextTick();
239
- expect(popup().className).not.toContain("hk-tooltip-visible");
299
+ // A mouse press never engages the lazy tooltip at all — no popup
300
+ // node, no visible class.
301
+ expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
240
302
  });
241
303
 
242
304
  it("does not re-open from the synthetic mouseenter right after a closing tap", async () => {
@@ -1,8 +1,10 @@
1
- import { computed, defineComponent, onBeforeUnmount, onMounted, ref, Teleport, type CSSProperties, type PropType } from "vue";
1
+ import { computed, defineComponent, onBeforeUnmount, ref, Teleport, type CSSProperties, type PropType } from "vue";
2
2
  import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
3
3
  import { applyTooltipPosition, type TooltipPlacement } from "../runtime/tooltipPosition";
4
4
  import "./HkTooltip.scss";
5
5
 
6
+ let popupSeq = 0;
7
+
6
8
  export default defineComponent({
7
9
  name: "HkTooltip",
8
10
  props: {
@@ -13,22 +15,35 @@ export default defineComponent({
13
15
  },
14
16
  setup(props, { slots }) {
15
17
  const visible = ref(false);
18
+ // Stable id for the aria-describedby link from the trigger wrapper to
19
+ // the popup (set while the popup is visible, mirroring the tooltip
20
+ // bridge's contract).
21
+ const popupId = `hk-tooltip-${++popupSeq}`;
16
22
  const wrapperRef = ref<HTMLElement | null>(null);
17
23
  const popupRef = ref<HTMLElement | null>(null);
18
24
  let showTimer: ReturnType<typeof setTimeout> | null = null;
19
25
 
20
- // Registers with the popup manager (kind "tooltip") so tooltips hold
21
- // the tooltip band (above modal/drawer overlays, below toasts); the
22
- // zIndex lands on the popup element and overrides the --hi-z-tooltip
23
- // fallback in the SCSS.
26
+ // The band handle and the popup DOM are both LAZY (dense-list contract,
27
+ // 2026-10-05): a tooltip that is never engaged — the overwhelming
28
+ // majority in a list of tags — costs no teleported node, no band
29
+ // registration and no pointer wiring beyond the wrapper's own handlers.
30
+ // The first engage registers the popup-manager handle (kind "tooltip",
31
+ // above modal/drawer overlays, below toasts) and mounts the popup;
32
+ // both are held until unmount so re-shows never churn.
24
33
  const manager = usePopupManager();
25
34
  let popupHandle: PopupHandle | null = null;
26
35
  const zIndex = ref<number | null>(null);
36
+ const everShown = ref(false);
27
37
 
28
- onMounted(() => {
29
- popupHandle = manager.register("tooltip", false);
30
- zIndex.value = popupHandle.zIndex;
31
- });
38
+ function ensureEngaged() {
39
+ if (!everShown.value) {
40
+ everShown.value = true;
41
+ if (!popupHandle) {
42
+ popupHandle = manager.register("tooltip", false);
43
+ }
44
+ zIndex.value = popupHandle.zIndex;
45
+ }
46
+ }
32
47
 
33
48
  function updatePosition() {
34
49
  if (!wrapperRef.value || !popupRef.value) return;
@@ -45,6 +60,7 @@ export default defineComponent({
45
60
 
46
61
  function show() {
47
62
  clearShowTimer();
63
+ ensureEngaged();
48
64
  showTimer = setTimeout(() => {
49
65
  visible.value = true;
50
66
  requestAnimationFrame(updatePosition);
@@ -53,6 +69,7 @@ export default defineComponent({
53
69
 
54
70
  function showNow() {
55
71
  clearShowTimer();
72
+ ensureEngaged();
56
73
  visible.value = true;
57
74
  requestAnimationFrame(updatePosition);
58
75
  }
@@ -136,6 +153,7 @@ export default defineComponent({
136
153
  ref={wrapperRef}
137
154
  class="hk-tooltip-wrapper"
138
155
  data-position={props.placement}
156
+ aria-describedby={visible.value ? popupId : undefined}
139
157
  onPointerdown={onPointerdown}
140
158
  onMouseenter={() => {
141
159
  // Synthetic mouseenter right after a touch tap must not
@@ -148,16 +166,20 @@ export default defineComponent({
148
166
  onFocusout={hide}
149
167
  >
150
168
  <span class="hk-tooltip-trigger">
151
- {slots.default?.()}
169
+ {slots.default?.({ popupId, visible: visible.value })}
152
170
  </span>
153
171
  <Teleport to="body">
154
- <div
155
- ref={popupRef}
156
- class={tooltipCls.value}
157
- style={popupStyle.value}
158
- >
159
- <div class="hk-tooltip-content">{props.text}</div>
160
- </div>
172
+ {everShown.value && (
173
+ <div
174
+ ref={popupRef}
175
+ id={popupId}
176
+ role="tooltip"
177
+ class={tooltipCls.value}
178
+ style={popupStyle.value}
179
+ >
180
+ <div class="hk-tooltip-content">{props.text}</div>
181
+ </div>
182
+ )}
161
183
  </Teleport>
162
184
  </span>
163
185
  );