@celestia-island/hikari 0.55.131 → 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.131",
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
+ });