@celestia-island/hikari 0.55.132 → 0.55.133

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.132",
3
+ "version": "0.55.133",
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",
@@ -119,6 +119,12 @@ registerCssAnimation("hk-placeholder-marquee-scroll", { infinite: true });
119
119
  registerCssAnimation("s-admin-header-emergency-pulse", { infinite: true });
120
120
  registerCssAnimation("hk-loading-veil-in");
121
121
  registerCssAnimation("hk-loading-veil-spin", { infinite: true });
122
+ // components/HkPersistentToast.scss — chrome-bar chip spinner (loops
123
+ // forever while the loading tone shows).
124
+ registerCssAnimation("hk-persistent-toast-spin", { infinite: true });
125
+ // components/HkPersistentToast.scss — hover detail card entrance (plays
126
+ // once; hides instantly on close, so no leave keyframe).
127
+ registerCssAnimation("hk-persistent-toast-card-in");
122
128
  // The styles/theme/mixins.scss ambient/glow/entrance keyframes were
123
129
  // retired with the legacy Gen-1 sheet vendoring (hikari #476) — their
124
130
  // registrar entries went with them.
@@ -0,0 +1,143 @@
1
+ // Chrome-bar persistent status chip — proportions match a title-bar
2
+ // loading pill (HkStatusPill's compactness at toast-family semantics).
3
+ // All colors ride the RGB-channel theme tokens so light/dark and the
4
+ // glass/opacity pipelines apply without component-side branching.
5
+
6
+ .hk-persistent-toast-wrap {
7
+ display: inline-flex;
8
+ align-items: center;
9
+ gap: 2px;
10
+ min-width: 0;
11
+ // The wrap owns the live-region semantics; the chip inside may be a
12
+ // button (chrome no-drag regions only exempt buttons from window
13
+ // dragging — see the wowsp title bar's interactive() gate).
14
+ font-size: var(--text-2xs, 0.6875rem);
15
+ line-height: 1.4;
16
+ }
17
+
18
+ .hk-persistent-toast {
19
+ display: inline-flex;
20
+ align-items: center;
21
+ gap: var(--space-6, 0.375rem);
22
+ padding: 2px var(--space-8, 0.5rem);
23
+ border-radius: var(--radius-full, 999px);
24
+ border: 1px solid var(--border-faint, rgb(var(--color-border) / 10%));
25
+ background: rgb(var(--color-surface) / 60%);
26
+ color: rgb(var(--color-text) / 72%);
27
+ font: inherit;
28
+ max-width: 220px;
29
+ min-width: 0;
30
+ }
31
+
32
+ // Interactive variant (detail slot provided): a real button — reset the
33
+ // browser chrome, tighten the start padding so the icon optically
34
+ // balances, and give the hover a faint lift like HkTag's interactive
35
+ // cousins.
36
+ button.hk-persistent-toast {
37
+ cursor: pointer;
38
+ appearance: none;
39
+ padding-inline-start: var(--space-6, 0.375rem);
40
+
41
+ &:hover {
42
+ background: rgb(var(--color-surface) / 85%);
43
+ color: rgb(var(--color-text) / 90%);
44
+ }
45
+
46
+ &:focus-visible {
47
+ outline: 1px solid rgb(var(--color-primary) / 70%);
48
+ outline-offset: 1px;
49
+ }
50
+ }
51
+
52
+ .hk-persistent-toast__icon {
53
+ flex-shrink: 0;
54
+ }
55
+
56
+ .hk-persistent-toast--warning .hk-persistent-toast__icon {
57
+ color: rgb(var(--color-warning));
58
+ }
59
+
60
+ .hk-persistent-toast--error .hk-persistent-toast__icon {
61
+ color: rgb(var(--color-error));
62
+ }
63
+
64
+ .hk-persistent-toast--info .hk-persistent-toast__icon {
65
+ color: rgb(var(--color-muted));
66
+ }
67
+
68
+ .hk-persistent-toast__label {
69
+ overflow: hidden;
70
+ text-overflow: ellipsis;
71
+ white-space: nowrap;
72
+ }
73
+
74
+ // Same grammar as HkToast's spinner, sized for the chip and themed via
75
+ // currentColor so the tone classes stay in one place.
76
+ .hk-persistent-toast__spinner {
77
+ width: 11px;
78
+ height: 11px;
79
+ flex-shrink: 0;
80
+ border: 2px solid rgb(var(--color-text) / 25%);
81
+ border-top-color: rgb(var(--color-text) / 80%);
82
+ border-radius: 50%;
83
+ box-sizing: border-box;
84
+ animation: hk-persistent-toast-spin 0.7s linear infinite;
85
+ }
86
+
87
+ @keyframes hk-persistent-toast-spin {
88
+ to {
89
+ transform: rotate(360deg);
90
+ }
91
+ }
92
+
93
+ .hk-persistent-toast__dismiss {
94
+ appearance: none;
95
+ border: none;
96
+ background: transparent;
97
+ color: rgb(var(--color-text) / 45%);
98
+ cursor: pointer;
99
+ display: inline-flex;
100
+ align-items: center;
101
+ padding: 2px;
102
+ border-radius: var(--radius-full, 999px);
103
+
104
+ &:hover {
105
+ color: rgb(var(--color-text));
106
+ background: rgb(var(--color-text) / 8%);
107
+ }
108
+
109
+ &:focus-visible {
110
+ outline: 1px solid rgb(var(--color-primary) / 70%);
111
+ }
112
+ }
113
+
114
+ // The detail card: a fixed-position rich surface teleported to body,
115
+ // positioned by the component (below the chip, end-aligned, viewport-
116
+ // clamped). Glass surface rides the shared hii-dropdown-content class;
117
+ // only geometry and motion live here.
118
+ .hk-persistent-toast-card {
119
+ position: fixed;
120
+ min-width: 200px;
121
+ max-width: 340px;
122
+ padding: var(--space-8, 0.5rem) var(--space-10, 0.75rem);
123
+ border-radius: var(--radius-lg, 0.5rem);
124
+ font-size: var(--text-2xs, 0.6875rem);
125
+ line-height: 1.5;
126
+ color: rgb(var(--color-text));
127
+ font-family: inherit;
128
+ animation: hk-persistent-toast-card-in 120ms ease-out;
129
+ }
130
+
131
+ @keyframes hk-persistent-toast-card-in {
132
+ from {
133
+ opacity: 0;
134
+ transform: translateY(-4px);
135
+ }
136
+ }
137
+
138
+ @media (prefers-reduced-motion: reduce) {
139
+ .hk-persistent-toast__spinner,
140
+ .hk-persistent-toast-card {
141
+ animation: none;
142
+ }
143
+ }
@@ -0,0 +1,263 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick } from "vue";
3
+
4
+ import HkPersistentToast from "./HkPersistentToast";
5
+ import { POPUP_Z_BANDS, usePopupManager } from "../runtime/usePopupManager";
6
+
7
+ /**
8
+ * HkPersistentToast contract tests:
9
+ * - inert default: a chip without a detail slot renders a plain span,
10
+ * engages no popup-manager entry and exposes no aria-expanded
11
+ * - hover open/close: detail slot + chip mouseenter opens the rich card
12
+ * after the open delay (one tooltip-kind band entry, z stamped
13
+ * inline); chip mouseleave closes it after the grace; moving onto the
14
+ * card cancels the close (hover continuity is the whole point of the
15
+ * rich card — the host list must survive the trip across the gap)
16
+ * - click pinning: a click pins the card against the grace timer until
17
+ * an outside pointerdown / Escape / another click closes it
18
+ * - dismiss: the ✕ sibling button emits `dismiss` (never nested inside
19
+ * the chip button)
20
+ * - the wrap carries the live-region semantics (role=status +
21
+ * aria-live=polite) so label changes announce
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,
25
+ * swept afterEach the way HkTooltip.test.tsx does.
26
+ */
27
+
28
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
29
+
30
+ interface MountOptions {
31
+ props?: Record<string, unknown>;
32
+ detail?: string;
33
+ onDismiss?: () => void;
34
+ }
35
+
36
+ function mount({ props = {}, detail, onDismiss }: MountOptions = {}) {
37
+ const container = document.createElement("div");
38
+ document.body.appendChild(container);
39
+ const host = defineComponent({
40
+ setup() {
41
+ return () =>
42
+ h(
43
+ HkPersistentToast,
44
+ { ...props, onDismiss } as never,
45
+ detail ? { detail: () => h("p", { class: "card-probe" }, detail) } : undefined,
46
+ );
47
+ },
48
+ });
49
+ const app = createApp(host);
50
+ app.mount(container);
51
+ mounts.push({ app, container });
52
+ return { app, container };
53
+ }
54
+
55
+ const manager = usePopupManager();
56
+
57
+ function tooltipEntries() {
58
+ return [...manager.registry.value.values()].filter((e) => e.kind === "tooltip");
59
+ }
60
+
61
+ function chip(c: HTMLElement): HTMLElement {
62
+ return c.querySelector<HTMLElement>(".hk-persistent-toast")!;
63
+ }
64
+
65
+ function wrap(c: HTMLElement): HTMLElement {
66
+ return c.querySelector<HTMLElement>(".hk-persistent-toast-wrap")!;
67
+ }
68
+
69
+ function card(): HTMLElement {
70
+ return document.querySelector<HTMLElement>(".hk-persistent-toast-card")!;
71
+ }
72
+
73
+ /** real-timer settle: covers the open/close setTimeout + the rAF position pass */
74
+ async function settle() {
75
+ await new Promise((r) => setTimeout(r, 20));
76
+ await nextTick();
77
+ }
78
+
79
+ function fire(el: Element, type: string, Ctor: typeof MouseEvent = MouseEvent) {
80
+ el.dispatchEvent(new Ctor(type, { bubbles: true }));
81
+ }
82
+
83
+ afterEach(() => {
84
+ vi.useRealTimers();
85
+ for (const { app, container } of mounts.splice(0)) {
86
+ app.unmount();
87
+ container.remove();
88
+ }
89
+ for (const entry of [...manager.registry.value.values()]) {
90
+ manager.unregister(entry.id);
91
+ }
92
+ document.querySelectorAll(".hk-persistent-toast-card").forEach((el) => el.remove());
93
+ });
94
+
95
+ describe("HkPersistentToast inert default", () => {
96
+ it("renders label, tone class and live-region semantics as a plain span", () => {
97
+ const { container } = mount({ props: { tone: "error", label: "Upstream fault" } });
98
+ const c = chip(container);
99
+ expect(c.tagName).toBe("SPAN");
100
+ expect(c.classList.contains("hk-persistent-toast--error")).toBe(true);
101
+ expect(c.textContent).toContain("Upstream fault");
102
+ expect(wrap(container).getAttribute("role")).toBe("status");
103
+ expect(wrap(container).getAttribute("aria-live")).toBe("polite");
104
+ expect(c.getAttribute("aria-expanded")).toBeNull();
105
+ expect(tooltipEntries().length).toBe(0);
106
+ expect(document.querySelector(".hk-persistent-toast-card")).toBeNull();
107
+ });
108
+
109
+ it("renders the spinner for the loading tone", () => {
110
+ const { container } = mount({ props: { tone: "loading", label: "Loading" } });
111
+ expect(container.querySelector(".hk-persistent-toast__spinner")).not.toBeNull();
112
+ });
113
+
114
+ it("renders the ✕ sibling button only when dismissible", () => {
115
+ const onDismiss = vi.fn();
116
+ const { container } = mount({ props: { label: "x", dismissible: true }, onDismiss });
117
+ const dismiss = container.querySelector<HTMLElement>(".hk-persistent-toast__dismiss")!;
118
+ expect(dismiss).not.toBeNull();
119
+ // The ✕ must never nest inside the chip (a button in a button is
120
+ // invalid markup).
121
+ expect(chip(container).contains(dismiss)).toBe(false);
122
+ dismiss.click();
123
+ expect(onDismiss).toHaveBeenCalledTimes(1);
124
+ });
125
+
126
+ it("renders no ✕ when not dismissible", () => {
127
+ const { container } = mount({ props: { label: "x" } });
128
+ expect(container.querySelector(".hk-persistent-toast__dismiss")).toBeNull();
129
+ });
130
+ });
131
+
132
+ describe("HkPersistentToast detail card", () => {
133
+ it("opens on hover after the delay and closes after the grace", async () => {
134
+ const { container } = mount({
135
+ props: { label: "fault", openDelay: 0, closeGrace: 0, detailLabel: "Failing hosts" },
136
+ detail: "vortex.wowsgame.cn",
137
+ });
138
+ fire(chip(container), "mouseenter");
139
+ await settle();
140
+ const c = card();
141
+ expect(c).not.toBeNull();
142
+ expect(c.textContent).toContain("vortex.wowsgame.cn");
143
+ expect(chip(container).getAttribute("aria-expanded")).toBe("true");
144
+ const entries = tooltipEntries();
145
+ expect(entries.length).toBe(1);
146
+ expect(entries[0]!.kind).toBe("tooltip");
147
+ expect(c.style.zIndex).toBe(String(POPUP_Z_BANDS.tooltip));
148
+
149
+ fire(chip(container), "mouseleave");
150
+ await settle();
151
+ expect(card()).toBeNull();
152
+ expect(tooltipEntries().length).toBe(0);
153
+ });
154
+
155
+ it("delays the hover open by openDelay", async () => {
156
+ const { container } = mount({
157
+ props: { label: "fault", openDelay: 80, closeGrace: 0 },
158
+ detail: "hosts",
159
+ });
160
+ fire(chip(container), "mouseenter");
161
+ await nextTick();
162
+ expect(card()).toBeNull();
163
+ await new Promise((r) => setTimeout(r, 120));
164
+ await nextTick();
165
+ expect(card()).not.toBeNull();
166
+ });
167
+
168
+ it("keeps the card open while the pointer moves onto it", async () => {
169
+ const { container } = mount({
170
+ props: { label: "fault", openDelay: 0, closeGrace: 0 },
171
+ detail: "hosts",
172
+ });
173
+ fire(chip(container), "mouseenter");
174
+ await settle();
175
+ // Chip → card crossing: the close grace must be cancelled by the
176
+ // card's own mouseenter, or the host list dies mid-read.
177
+ fire(chip(container), "mouseleave");
178
+ fire(card(), "mouseenter");
179
+ await settle();
180
+ expect(card()).not.toBeNull();
181
+ // Leaving the card closes (nothing pinned).
182
+ fire(card(), "mouseleave");
183
+ await settle();
184
+ expect(card()).toBeNull();
185
+ });
186
+
187
+ it("pins on click: grace timer and chip leave cannot close it", async () => {
188
+ const { container } = mount({
189
+ props: { label: "fault", openDelay: 0, closeGrace: 0 },
190
+ detail: "hosts",
191
+ });
192
+ fire(chip(container), "mouseenter");
193
+ await settle();
194
+ chip(container).click();
195
+ fire(chip(container), "mouseleave");
196
+ await settle();
197
+ expect(card()).not.toBeNull();
198
+ expect(chip(container).getAttribute("aria-expanded")).toBe("true");
199
+ });
200
+
201
+ it("an outside pointerdown closes a pinned card", async () => {
202
+ const { container } = mount({
203
+ props: { label: "fault", openDelay: 0, closeGrace: 0 },
204
+ detail: "hosts",
205
+ });
206
+ chip(container).click();
207
+ await settle();
208
+ expect(card()).not.toBeNull();
209
+ document.body.dispatchEvent(
210
+ new MouseEvent("pointerdown", { bubbles: true }),
211
+ );
212
+ await settle();
213
+ expect(card()).toBeNull();
214
+ });
215
+
216
+ it("a click inside the card does not close a pinned card", async () => {
217
+ const { container } = mount({
218
+ props: { label: "fault", openDelay: 0, closeGrace: 0 },
219
+ detail: "hosts",
220
+ });
221
+ chip(container).click();
222
+ await settle();
223
+ card().dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
224
+ await settle();
225
+ expect(card()).not.toBeNull();
226
+ });
227
+
228
+ it("Escape closes a pinned card", async () => {
229
+ const { container } = mount({
230
+ props: { label: "fault", openDelay: 0, closeGrace: 0 },
231
+ detail: "hosts",
232
+ });
233
+ chip(container).click();
234
+ await settle();
235
+ chip(container).dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
236
+ await settle();
237
+ expect(card()).toBeNull();
238
+ });
239
+
240
+ it("a second click toggles a pinned card closed", async () => {
241
+ const { container } = mount({
242
+ props: { label: "fault", openDelay: 0, closeGrace: 0 },
243
+ detail: "hosts",
244
+ });
245
+ chip(container).click();
246
+ await settle();
247
+ chip(container).click();
248
+ await settle();
249
+ expect(card()).toBeNull();
250
+ });
251
+
252
+ it("releases the band entry on unmount while open", async () => {
253
+ const { app, container } = mount({
254
+ props: { label: "fault", openDelay: 0, closeGrace: 0 },
255
+ detail: "hosts",
256
+ });
257
+ fire(chip(container), "mouseenter");
258
+ await settle();
259
+ expect(tooltipEntries().length).toBe(1);
260
+ app.unmount();
261
+ expect(tooltipEntries().length).toBe(0);
262
+ });
263
+ });
@@ -0,0 +1,290 @@
1
+ import { AlertTriangle, Info, X } from "lucide-vue-next";
2
+ import {
3
+ computed,
4
+ defineComponent,
5
+ nextTick,
6
+ onBeforeUnmount,
7
+ ref,
8
+ Teleport,
9
+ type PropType,
10
+ type SlotsType,
11
+ } from "vue";
12
+
13
+ import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
14
+ import { useI18n } from "../i18n/context";
15
+ import "./HkPersistentToast.scss";
16
+
17
+ export type PersistentToastTone = "loading" | "info" | "warning" | "error";
18
+
19
+ /** Viewport gutter the detail card clamps into (mirror of
20
+ * --viewport-gutter's desktop value; the card is chrome-adjacent and
21
+ * small, so the mobile/desktop split is not worth a runtime read). */
22
+ const GUTTER_PX = 8;
23
+ /** Vertical gap between the chip's bottom edge and the detail card. */
24
+ const CARD_GAP_PX = 6;
25
+
26
+ let popupSeq = 0;
27
+
28
+ /**
29
+ * HkPersistentToast — a persistent status chip for chrome bars (title
30
+ * bars, status strips): the third member of the toast family after
31
+ * HkToast (transient corner stack) and HkBlockingToast (window-modal).
32
+ * It never auto-dismisses; the host renders it while a condition holds
33
+ * (a load in flight, an upstream outage) and unmounts it when the
34
+ * condition clears.
35
+ *
36
+ * ```tsx
37
+ * <HPersistentToast tone="loading" label="Loading stats…" />
38
+ * <HPersistentToast tone="error" label="Upstream fault" onDismiss={silence}>
39
+ * <template #detail>…rich card…</template>
40
+ * </HPersistentToast>
41
+ * ```
42
+ *
43
+ * Behavior contract:
44
+ * - `tone="loading"` renders a spinner, the other tones a static icon;
45
+ * the label truncates, the chip never wraps.
46
+ * - With a `detail` slot the chip becomes a button: hover (after
47
+ * `openDelay`) or focus opens the rich card anchored below the chip;
48
+ * moving the pointer onto the card keeps it open (`closeGrace`), and
49
+ * a click PINNS it — a pinned card only closes on its own Escape /
50
+ * outside click / another chip click, so touch users get parity.
51
+ * - `onDismiss` (a dismiss emit) renders the ✕ affordance beside the
52
+ * chip; the chip itself stays a single interactive surface (the ✕ is
53
+ * a sibling button, never nested — a button inside a button is dead
54
+ * markup).
55
+ * - The wrap carries role="status" + aria-live="polite", so label
56
+ * changes announce without stealing focus — the property the wowsp
57
+ * title-bar loading chip relies on.
58
+ */
59
+ export default defineComponent({
60
+ name: "HkPersistentToast",
61
+ props: {
62
+ tone: { type: String as PropType<PersistentToastTone>, default: "info" },
63
+ /** Text beside the icon; truncates rather than wraps. */
64
+ label: { type: String, default: "" },
65
+ /** Hover/focus card content; providing the slot makes the chip interactive. */
66
+ detailLabel: { type: String, default: "" },
67
+ /** Renders the ✕ affordance beside the chip; clicking it emits `dismiss`.
68
+ * A sibling button, never a child of the chip — a button inside a
69
+ * button is invalid markup and would break the chip's click toggle. */
70
+ dismissible: { type: Boolean, default: false },
71
+ openDelay: { type: Number, default: 150 },
72
+ closeGrace: { type: Number, default: 250 },
73
+ },
74
+ emits: {
75
+ dismiss: () => true,
76
+ },
77
+ slots: Object as SlotsType<{
78
+ detail?: () => unknown;
79
+ }>,
80
+ setup(props, { slots, emit }) {
81
+ const { t } = useI18n();
82
+ const manager = usePopupManager();
83
+ const wrapRef = ref<HTMLElement | null>(null);
84
+ const cardRef = ref<HTMLElement | null>(null);
85
+ const open = ref(false);
86
+ const pinned = ref(false);
87
+ const zIndex = ref<number | null>(null);
88
+ const popupId = `hk-persistent-toast-${++popupSeq}`;
89
+ let handle: PopupHandle | null = null;
90
+ let openTimer: ReturnType<typeof setTimeout> | null = null;
91
+ let closeTimer: ReturnType<typeof setTimeout> | null = null;
92
+
93
+ const interactive = computed(() => !!slots.detail);
94
+
95
+ // The card is LAZY (tooltip-bridge contract): a chip nobody hovers
96
+ // costs no teleported node and no band registration. The handle is
97
+ // held for the whole open window — hover-out only closes, unmount
98
+ // releases.
99
+ function ensureHandle() {
100
+ if (!handle) {
101
+ handle = manager.register("tooltip", false);
102
+ zIndex.value = handle.zIndex;
103
+ }
104
+ }
105
+
106
+ function releaseHandle() {
107
+ if (handle) {
108
+ manager.unregister(handle.id);
109
+ handle = null;
110
+ }
111
+ zIndex.value = null;
112
+ }
113
+
114
+ function clearTimers() {
115
+ if (openTimer !== null) {
116
+ clearTimeout(openTimer);
117
+ openTimer = null;
118
+ }
119
+ if (closeTimer !== null) {
120
+ clearTimeout(closeTimer);
121
+ closeTimer = null;
122
+ }
123
+ }
124
+
125
+ /** Anchor the card below the chip, right-aligned to it (chrome bars
126
+ * hug the window's trailing corner, so an end-aligned card grows
127
+ * leftward into the roomier side), clamped into the viewport. */
128
+ function positionCard() {
129
+ const chip = wrapRef.value;
130
+ const card = cardRef.value;
131
+ if (!chip || !card) return;
132
+ const rect = chip.getBoundingClientRect();
133
+ const vw = window.innerWidth;
134
+ const vh = window.innerHeight;
135
+ let left = rect.right - card.offsetWidth;
136
+ left = Math.max(GUTTER_PX, Math.min(left, vw - GUTTER_PX - card.offsetWidth));
137
+ let top = rect.bottom + CARD_GAP_PX;
138
+ top = Math.max(GUTTER_PX, Math.min(top, vh - GUTTER_PX - card.offsetHeight));
139
+ card.style.left = `${left}px`;
140
+ card.style.top = `${top}px`;
141
+ }
142
+
143
+ function openCard(pin: boolean) {
144
+ clearTimers();
145
+ ensureHandle();
146
+ if (pin) pinned.value = true;
147
+ open.value = true;
148
+ nextTick(() => requestAnimationFrame(positionCard));
149
+ }
150
+
151
+ function closeCard(force = false) {
152
+ if (pinned.value && !force) return;
153
+ pinned.value = false;
154
+ open.value = false;
155
+ releaseHandle();
156
+ }
157
+
158
+ function scheduleOpen() {
159
+ if (openTimer !== null) clearTimeout(openTimer);
160
+ openTimer = setTimeout(() => {
161
+ openTimer = null;
162
+ openCard(false);
163
+ }, props.openDelay);
164
+ }
165
+
166
+ function scheduleClose() {
167
+ if (closeTimer !== null) clearTimeout(closeTimer);
168
+ closeTimer = setTimeout(() => {
169
+ closeTimer = null;
170
+ closeCard(false);
171
+ }, props.closeGrace);
172
+ }
173
+
174
+ function cancelClose() {
175
+ if (closeTimer !== null) {
176
+ clearTimeout(closeTimer);
177
+ closeTimer = null;
178
+ }
179
+ }
180
+
181
+ function onChipClick() {
182
+ if (pinned.value) {
183
+ closeCard(true);
184
+ } else {
185
+ // A click during the hover-open window promotes to pinned in
186
+ // place — the card must not flicker closed and reopen.
187
+ openCard(true);
188
+ }
189
+ }
190
+
191
+ function onChipKeydown(e: KeyboardEvent) {
192
+ if (e.key === "Escape" && open.value) {
193
+ e.preventDefault();
194
+ e.stopPropagation();
195
+ closeCard(true);
196
+ }
197
+ }
198
+
199
+ // Outside clicks close a PINNED card (a hover-open card closes on
200
+ // its own grace timer; listening always would fight the chip's own
201
+ // click toggle — hence the contains check).
202
+ function onDocumentPointerdown(e: PointerEvent) {
203
+ if (!open.value || !pinned.value) return;
204
+ const target = e.target as Node;
205
+ if (wrapRef.value?.contains(target)) return;
206
+ if (cardRef.value?.contains(target)) return;
207
+ closeCard(true);
208
+ }
209
+
210
+ function onWindowResize() {
211
+ if (open.value) positionCard();
212
+ }
213
+
214
+ document.addEventListener("pointerdown", onDocumentPointerdown, true);
215
+ window.addEventListener("resize", onWindowResize);
216
+
217
+ onBeforeUnmount(() => {
218
+ clearTimers();
219
+ releaseHandle();
220
+ document.removeEventListener("pointerdown", onDocumentPointerdown, true);
221
+ window.removeEventListener("resize", onWindowResize);
222
+ });
223
+
224
+ const cardStyle = computed(() => (zIndex.value != null ? { zIndex: zIndex.value } : {}));
225
+
226
+ return () => {
227
+ const Tag = (interactive.value ? "button" : "span") as "button";
228
+ return (
229
+ <span ref={wrapRef} class="hk-persistent-toast-wrap" role="status" aria-live="polite">
230
+ <Tag
231
+ type={interactive.value ? "button" : undefined}
232
+ class={["hk-persistent-toast", `hk-persistent-toast--${props.tone}`]}
233
+ aria-expanded={interactive.value ? open.value : undefined}
234
+ aria-haspopup={interactive.value ? "true" : undefined}
235
+ aria-controls={open.value ? popupId : undefined}
236
+ onMouseenter={interactive.value ? scheduleOpen : undefined}
237
+ onMouseleave={interactive.value ? scheduleClose : undefined}
238
+ onFocusin={interactive.value ? () => openCard(false) : undefined}
239
+ onFocusout={
240
+ interactive.value
241
+ ? () => {
242
+ if (!pinned.value) closeCard(false);
243
+ }
244
+ : undefined
245
+ }
246
+ onClick={interactive.value ? onChipClick : undefined}
247
+ onKeydown={interactive.value ? onChipKeydown : undefined}
248
+ >
249
+ {props.tone === "loading" ? (
250
+ <span class="hk-persistent-toast__spinner" aria-hidden="true" />
251
+ ) : props.tone === "error" || props.tone === "warning" ? (
252
+ <AlertTriangle size={12} class="hk-persistent-toast__icon" aria-hidden="true" />
253
+ ) : (
254
+ <Info size={12} class="hk-persistent-toast__icon" aria-hidden="true" />
255
+ )}
256
+ <span class="hk-persistent-toast__label">{props.label}</span>
257
+ </Tag>
258
+ {props.dismissible && (
259
+ <button
260
+ type="button"
261
+ class="hk-persistent-toast__dismiss"
262
+ aria-label={t("hikari::persistentToast.dismiss", "Dismiss")}
263
+ onClick={() => emit("dismiss")}
264
+ >
265
+ <X size={12} aria-hidden="true" />
266
+ </button>
267
+ )}
268
+ {slots.detail && open.value && (
269
+ <Teleport to="body">
270
+ <div
271
+ ref={cardRef}
272
+ id={popupId}
273
+ class="hk-persistent-toast-card hii-dropdown-content"
274
+ role="group"
275
+ aria-label={props.detailLabel || undefined}
276
+ style={cardStyle.value}
277
+ onMouseenter={cancelClose}
278
+ onMouseleave={() => {
279
+ if (!pinned.value) scheduleClose();
280
+ }}
281
+ >
282
+ {slots.detail()}
283
+ </div>
284
+ </Teleport>
285
+ )}
286
+ </span>
287
+ );
288
+ };
289
+ },
290
+ });
@@ -0,0 +1,22 @@
1
+ // Chrome-bar chip row: chips + the "+N" overflow counter, one compact
2
+ // line. Token palette mirrors HkPersistentToast.scss so the pair reads
3
+ // as one family inside a title bar.
4
+ .hk-persistent-toast-group {
5
+ display: inline-flex;
6
+ align-items: center;
7
+ gap: 4px;
8
+ min-width: 0;
9
+ }
10
+
11
+ .hk-persistent-toast-group__extra {
12
+ flex-shrink: 0;
13
+ display: inline-flex;
14
+ align-items: center;
15
+ padding: 1px 6px;
16
+ border-radius: var(--radius-full, 999px);
17
+ background: rgb(var(--color-text) / 8%);
18
+ color: rgb(var(--color-text) / 72%);
19
+ font-size: var(--text-2xs, 0.6875rem);
20
+ line-height: 1.4;
21
+ font-variant-numeric: tabular-nums;
22
+ }
@@ -0,0 +1,82 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h } from "vue";
3
+
4
+ import HkPersistentToast from "./HkPersistentToast";
5
+ import HkPersistentToastGroup from "./HkPersistentToastGroup";
6
+
7
+ /**
8
+ * HkPersistentToastGroup contract tests:
9
+ * - renders the first maxVisible children and collapses the rest into
10
+ * the "+N" counter
11
+ * - comment placeholders (v-if=false children) do not count
12
+ * - hidden children are unmounted, so their own DOM never reaches the
13
+ * document
14
+ *
15
+ * House style: raw createApp mounts torn down afterEach.
16
+ */
17
+
18
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
19
+
20
+ function mountRow(count: number, maxVisible: number) {
21
+ const container = document.createElement("div");
22
+ document.body.appendChild(container);
23
+ const host = defineComponent({
24
+ setup() {
25
+ return () =>
26
+ h(
27
+ HkPersistentToastGroup,
28
+ { maxVisible } as never,
29
+ {
30
+ default: () =>
31
+ Array.from({ length: count }, (_, i) =>
32
+ h(HkPersistentToast, { key: i, tone: "loading", label: `task ${i}` }),
33
+ ),
34
+ },
35
+ );
36
+ },
37
+ });
38
+ const app = createApp(host);
39
+ app.mount(container);
40
+ mounts.push({ app, container });
41
+ return { app, container };
42
+ }
43
+
44
+ afterEach(() => {
45
+ for (const { app, container } of mounts.splice(0)) {
46
+ app.unmount();
47
+ container.remove();
48
+ }
49
+ });
50
+
51
+ function chips(c: HTMLElement) {
52
+ return c.querySelectorAll(".hk-persistent-toast");
53
+ }
54
+
55
+ describe("HkPersistentToastGroup", () => {
56
+ it("renders up to maxVisible chips and collapses the rest into +N", () => {
57
+ const { container } = mountRow(4, 1);
58
+ expect(chips(container).length).toBe(1);
59
+ expect(chips(container)[0]!.textContent).toContain("task 0");
60
+ const extra = container.querySelector<HTMLElement>(".hk-persistent-toast-group__extra")!;
61
+ expect(extra.textContent).toBe("+3");
62
+ });
63
+
64
+ it("renders everything when count fits and skips the counter", () => {
65
+ const { container } = mountRow(2, 3);
66
+ expect(chips(container).length).toBe(2);
67
+ expect(container.querySelector(".hk-persistent-toast-group__extra")).toBeNull();
68
+ });
69
+
70
+ it("unmounts hidden chips entirely (no stray DOM)", () => {
71
+ const { container } = mountRow(3, 1);
72
+ expect(document.body.textContent).not.toContain("task 1");
73
+ expect(document.body.textContent).not.toContain("task 2");
74
+ expect(chips(container).length).toBe(1);
75
+ });
76
+
77
+ it("renders an empty group without noise", () => {
78
+ const { container } = mountRow(0, 1);
79
+ expect(chips(container).length).toBe(0);
80
+ expect(container.querySelector(".hk-persistent-toast-group__extra")).toBeNull();
81
+ });
82
+ });
@@ -0,0 +1,45 @@
1
+ import { Comment, defineComponent, type PropType, type SlotsType, type VNode } from "vue";
2
+
3
+ import "./HkPersistentToastGroup.scss";
4
+
5
+ /**
6
+ * HkPersistentToastGroup — a chrome-bar row of HkPersistentToast chips
7
+ * with a "+N" overflow: renders the first `maxVisible` children and
8
+ * collapses the rest into a passive counter. The wowsp title bar's
9
+ * loading chip ("newest label + +N") is the reference consumer.
10
+ *
11
+ * ```tsx
12
+ * <HPersistentToastGroup maxVisible={1}>
13
+ * {tasks.map((t) => (
14
+ * <HPersistentToast key={t.id} tone="loading" label={t.label} />
15
+ * ))}
16
+ * </HPersistentToastGroup>
17
+ * ```
18
+ *
19
+ * Hidden children are UNMOUNTED, not merely hidden — their hover cards
20
+ * and timers are their own state and die with the vnode; a chip that
21
+ * re-enters the visible window starts fresh (a persistent toast has no
22
+ * meaningful state to preserve across an overflow eviction).
23
+ */
24
+ export default defineComponent({
25
+ name: "HkPersistentToastGroup",
26
+ props: {
27
+ maxVisible: { type: Number as PropType<number>, default: 3 },
28
+ },
29
+ slots: Object as SlotsType<{
30
+ default?: () => unknown;
31
+ }>,
32
+ setup(props, { slots }) {
33
+ return () => {
34
+ const children = ((slots.default?.() ?? []) as VNode[]).filter((v) => v.type !== Comment);
35
+ const visible = children.slice(0, Math.max(0, props.maxVisible));
36
+ const extra = children.length - visible.length;
37
+ return (
38
+ <span class="hk-persistent-toast-group">
39
+ {visible}
40
+ {extra > 0 && <span class="hk-persistent-toast-group__extra">+{extra}</span>}
41
+ </span>
42
+ );
43
+ };
44
+ },
45
+ });
@@ -128,6 +128,7 @@
128
128
  "hikari::passwordInput.strengthLabel": "قوة كلمة المرور",
129
129
  "hikari::passwordInput.strengthStrong": "قوية",
130
130
  "hikari::passwordInput.strengthWeak": "ضعيفة",
131
+ "hikari::persistentToast.dismiss": "تجاهل",
131
132
  "hikari::phoneInput.chooseCountry": "اختر رمز الدولة",
132
133
  "hikari::phoneInput.dialCode": "الرمز",
133
134
  "hikari::phoneInput.noMatches": "لا توجد دولة مطابقة",
@@ -128,6 +128,7 @@
128
128
  "hikari::passwordInput.strengthLabel": "Passwortstärke",
129
129
  "hikari::passwordInput.strengthStrong": "Stark",
130
130
  "hikari::passwordInput.strengthWeak": "Schwach",
131
+ "hikari::persistentToast.dismiss": "Ausblenden",
131
132
  "hikari::phoneInput.chooseCountry": "Ländercode wählen",
132
133
  "hikari::phoneInput.dialCode": "Code",
133
134
  "hikari::phoneInput.noMatches": "Kein passendes Land",
@@ -128,6 +128,7 @@
128
128
  "hikari::passwordInput.strengthLabel": "Password strength",
129
129
  "hikari::passwordInput.strengthStrong": "Strong",
130
130
  "hikari::passwordInput.strengthWeak": "Weak",
131
+ "hikari::persistentToast.dismiss": "Dismiss",
131
132
  "hikari::phoneInput.chooseCountry": "Choose country code",
132
133
  "hikari::phoneInput.dialCode": "Code",
133
134
  "hikari::phoneInput.noMatches": "No matching country",
@@ -128,6 +128,7 @@
128
128
  "hikari::passwordInput.strengthLabel": "Fuerza de la contraseña",
129
129
  "hikari::passwordInput.strengthStrong": "Fuerte",
130
130
  "hikari::passwordInput.strengthWeak": "Débil",
131
+ "hikari::persistentToast.dismiss": "Descartar",
131
132
  "hikari::phoneInput.chooseCountry": "Elegir prefijo país",
132
133
  "hikari::phoneInput.dialCode": "Código",
133
134
  "hikari::phoneInput.noMatches": "Sin coincidencias de país",
@@ -128,6 +128,7 @@
128
128
  "hikari::passwordInput.strengthLabel": "Robustesse du mot de passe",
129
129
  "hikari::passwordInput.strengthStrong": "Fort",
130
130
  "hikari::passwordInput.strengthWeak": "Faible",
131
+ "hikari::persistentToast.dismiss": "Ignorer",
131
132
  "hikari::phoneInput.chooseCountry": "Choisir l'indicatif pays",
132
133
  "hikari::phoneInput.dialCode": "Code",
133
134
  "hikari::phoneInput.noMatches": "Aucun pays correspondant",
@@ -128,6 +128,7 @@
128
128
  "hikari::passwordInput.strengthLabel": "パスワード強度",
129
129
  "hikari::passwordInput.strengthStrong": "強い",
130
130
  "hikari::passwordInput.strengthWeak": "弱い",
131
+ "hikari::persistentToast.dismiss": "閉じる",
131
132
  "hikari::phoneInput.chooseCountry": "国コードを選択",
132
133
  "hikari::phoneInput.dialCode": "コード",
133
134
  "hikari::phoneInput.noMatches": "一致する国がありません",
@@ -128,6 +128,7 @@
128
128
  "hikari::passwordInput.strengthLabel": "비밀번호 강도",
129
129
  "hikari::passwordInput.strengthStrong": "강함",
130
130
  "hikari::passwordInput.strengthWeak": "약함",
131
+ "hikari::persistentToast.dismiss": "닫기",
131
132
  "hikari::phoneInput.chooseCountry": "국가 코드 선택",
132
133
  "hikari::phoneInput.dialCode": "코드",
133
134
  "hikari::phoneInput.noMatches": "일치하는 국가가 없습니다",
@@ -128,6 +128,7 @@
128
128
  "hikari::passwordInput.strengthLabel": "Força da senha",
129
129
  "hikari::passwordInput.strengthStrong": "Forte",
130
130
  "hikari::passwordInput.strengthWeak": "Fraca",
131
+ "hikari::persistentToast.dismiss": "Dispensar",
131
132
  "hikari::phoneInput.chooseCountry": "Escolher código do país",
132
133
  "hikari::phoneInput.dialCode": "Código",
133
134
  "hikari::phoneInput.noMatches": "Nenhum país correspondente",
@@ -128,6 +128,7 @@
128
128
  "hikari::passwordInput.strengthLabel": "Надёжность пароля",
129
129
  "hikari::passwordInput.strengthStrong": "Сильный",
130
130
  "hikari::passwordInput.strengthWeak": "Слабый",
131
+ "hikari::persistentToast.dismiss": "Скрыть",
131
132
  "hikari::phoneInput.chooseCountry": "Выберите код страны",
132
133
  "hikari::phoneInput.dialCode": "Код",
133
134
  "hikari::phoneInput.noMatches": "Подходящей страны не найдено",
@@ -128,6 +128,7 @@
128
128
  "hikari::passwordInput.strengthLabel": "密码强度",
129
129
  "hikari::passwordInput.strengthStrong": "强",
130
130
  "hikari::passwordInput.strengthWeak": "弱",
131
+ "hikari::persistentToast.dismiss": "关闭",
131
132
  "hikari::phoneInput.chooseCountry": "选择国家区号",
132
133
  "hikari::phoneInput.dialCode": "区号",
133
134
  "hikari::phoneInput.noMatches": "没有匹配的国家",
@@ -128,6 +128,7 @@
128
128
  "hikari::passwordInput.strengthLabel": "密碼強度",
129
129
  "hikari::passwordInput.strengthStrong": "強",
130
130
  "hikari::passwordInput.strengthWeak": "弱",
131
+ "hikari::persistentToast.dismiss": "關閉",
131
132
  "hikari::phoneInput.chooseCountry": "選擇國家區碼",
132
133
  "hikari::phoneInput.dialCode": "區碼",
133
134
  "hikari::phoneInput.noMatches": "沒有符合的國家",
package/src/index.ts CHANGED
@@ -114,6 +114,8 @@ export {
114
114
  acceptExtensions,
115
115
  } from "./components/filePicker";
116
116
  export { default as HToast } from "./components/HkToast";
117
+ export { default as HPersistentToast } from "./components/HkPersistentToast";
118
+ export { default as HPersistentToastGroup } from "./components/HkPersistentToastGroup";
117
119
  export { default as HTooltip } from "./components/HkTooltip";
118
120
  export { default as HTree } from "./components/HkTree";
119
121
  export { default as HWindowedItem } from "./components/HkWindowedItem";
@@ -754,6 +756,8 @@ export { default as HkFileField } from "./components/HkFileField";
754
756
  export { default as HkFileBrowserDialog } from "./components/HkFileBrowserDialog";
755
757
  export { default as HkFilePickerField } from "./components/HkFilePickerField";
756
758
  export { default as HkToast } from "./components/HkToast";
759
+ export { default as HkPersistentToast, type PersistentToastTone } from "./components/HkPersistentToast";
760
+ export { default as HkPersistentToastGroup } from "./components/HkPersistentToastGroup";
757
761
  export { default as HkTooltip } from "./components/HkTooltip";
758
762
  export { type HkTooltipSlotProps } from "./components/HkTooltip";
759
763
  export { default as HkTree } from "./components/HkTree";