@celestia-island/hikari 0.30.0 → 0.31.1

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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/src/DemoApp.tsx +11 -1
  3. package/src/animation/registerAnimations.ts +2 -0
  4. package/src/components/HkAuthCard.tsx +11 -0
  5. package/src/components/HkAuthMethodList.scss +57 -0
  6. package/src/components/HkAuthMethodList.test.tsx +98 -0
  7. package/src/components/HkAuthMethodList.tsx +113 -0
  8. package/src/components/HkImageLightbox.scss +124 -0
  9. package/src/components/HkImageLightbox.test.tsx +155 -0
  10. package/src/components/HkImageLightbox.tsx +87 -0
  11. package/src/components/HkImagePreview.scss +110 -0
  12. package/src/components/HkImagePreview.test.tsx +184 -0
  13. package/src/components/HkImagePreview.tsx +156 -0
  14. package/src/components/HkImageViewer.tsx +113 -8
  15. package/src/components/HkImageViewerTouch.test.tsx +261 -0
  16. package/src/components/HkLocalizedInput.scss +36 -22
  17. package/src/components/HkLocalizedInput.test.tsx +39 -4
  18. package/src/components/HkLocalizedInput.tsx +66 -48
  19. package/src/components/HkMinimap.tsx +35 -8
  20. package/src/components/HkModal.tsx +22 -4
  21. package/src/components/HkSignInCard.test.ts +15 -0
  22. package/src/components/HkSignInCard.tsx +10 -0
  23. package/src/i18n/locales/ar/components.json +7 -0
  24. package/src/i18n/locales/ar/components.toml +5 -0
  25. package/src/i18n/locales/de/components.json +7 -0
  26. package/src/i18n/locales/de/components.toml +5 -0
  27. package/src/i18n/locales/en/components.json +7 -0
  28. package/src/i18n/locales/en/components.toml +5 -0
  29. package/src/i18n/locales/es/components.json +7 -0
  30. package/src/i18n/locales/es/components.toml +5 -0
  31. package/src/i18n/locales/fr/components.json +7 -0
  32. package/src/i18n/locales/fr/components.toml +5 -0
  33. package/src/i18n/locales/ja/components.json +7 -0
  34. package/src/i18n/locales/ja/components.toml +5 -0
  35. package/src/i18n/locales/ko/components.json +7 -0
  36. package/src/i18n/locales/ko/components.toml +5 -0
  37. package/src/i18n/locales/pt/components.json +7 -0
  38. package/src/i18n/locales/pt/components.toml +5 -0
  39. package/src/i18n/locales/ru/components.json +7 -0
  40. package/src/i18n/locales/ru/components.toml +5 -0
  41. package/src/i18n/locales/zh-Hans/components.json +7 -0
  42. package/src/i18n/locales/zh-Hans/components.toml +5 -0
  43. package/src/i18n/locales/zh-Hant/components.json +7 -0
  44. package/src/i18n/locales/zh-Hant/components.toml +5 -0
  45. package/src/index.ts +3 -0
  46. package/src/styles/admin-tokens.scss +21 -0
  47. package/src/styles/theme/base.scss +19 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
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",
package/src/DemoApp.tsx CHANGED
@@ -13,11 +13,12 @@ import {
13
13
  HAlert, HEmptyState, HExpansionPanel,
14
14
  HTabs, HCard, HTable, HTimeline,
15
15
  HMediaSlider, HMediaPlayer, HImageViewer,
16
+ HImagePreview,
16
17
  HZoomToolbar, HMinimap, HTrendChart,
17
18
  type TrendPen, type MinimapBox,
18
19
  } from "@celestia-island/hikari";
19
20
 
20
- const totalComponents = 64;
21
+ const totalComponents = 66;
21
22
 
22
23
  const icons = ["home", "settings", "user", "search", "bell", "heart", "star", "mail", "download", "upload", "trash", "edit", "plus", "check", "x"];
23
24
 
@@ -444,6 +445,15 @@ export default defineComponent({
444
445
  <HImageViewer src={demoImageSrc} alt="Hikari demo image" />
445
446
  </section>
446
447
 
448
+ <section>
449
+ <h2>HImagePreview</h2>
450
+ <h3>zoomable (click to open the lightbox) + fixed, contain-fit</h3>
451
+ <div class="row" style="max-width:720px">
452
+ <HImagePreview src={demoImageSrc} alt="Zoomable demo image" ratio="16 / 9" style="flex:1" />
453
+ <HImagePreview src={demoImageSrc} alt="Fixed demo image" zoomable={false} objectFit="contain" ratio="4 / 3" style="flex:1" />
454
+ </div>
455
+ </section>
456
+
447
457
  <section>
448
458
  <h2>HZoomToolbar / HMinimap</h2>
449
459
  <div style="position:relative;height:200px;border-radius:8px;overflow:hidden;background:rgba(0,0,0,0.35)">
@@ -78,6 +78,8 @@ registerCssAnimation("hk-modal-breadcrumb-in");
78
78
  registerCssAnimation("hk-toast-spin", { infinite: true });
79
79
  // components/HkSkeleton.scss — skeleton shimmer (loops forever).
80
80
  registerCssAnimation("hk-skeleton-shimmer", { infinite: true });
81
+ // components/HkImagePreview.scss — thumbnail loading shimmer (loops forever).
82
+ registerCssAnimation("hk-image-preview-shimmer", { infinite: true });
81
83
  // components/HkProgressBar.scss — indeterminate bar sweep (loops forever).
82
84
  registerCssAnimation("hk-progress-indeterminate", { infinite: true });
83
85
  // components/HkPasswordInput.scss — caps-lock warning flash (plays once).
@@ -6,6 +6,12 @@ import { defineComponent, type PropType } from "vue";
6
6
  * form body.
7
7
  *
8
8
  * Slot layout contract:
9
+ * - `methods` renders into a full-width block between the form and the
10
+ * footer (typically `HkAuthMethodList`): alternative sign-in buttons
11
+ * that must line up with the form inputs. Keeping them OUT of the
12
+ * footer is what lets the footer's checkbox rows stay a centered,
13
+ * fit-content group instead of being dragged full width with the
14
+ * buttons.
9
15
  * - `footer` renders into `.s-auth-footer`, a flex column sized to its
10
16
  * widest row and centered in the card: every slot child is its own row
11
17
  * (remember-me, protocol consent, a sign-in link…) and the rows share
@@ -39,6 +45,11 @@ export const HkAuthCard = defineComponent({
39
45
  <div class="s-auth-form">
40
46
  {slots.default?.()}
41
47
  </div>
48
+ {slots.methods && (
49
+ <div class="s-auth-methods">
50
+ {slots.methods()}
51
+ </div>
52
+ )}
42
53
  {slots.footer && (
43
54
  <div class="s-auth-footer">
44
55
  {slots.footer()}
@@ -0,0 +1,57 @@
1
+ /* Full-width stacked alternative sign-in buttons (login card). The block
2
+ container `.s-auth-methods` and its spacing are owned by HkAuthCard's
3
+ slot styles (styles/admin-tokens.scss); this file only styles the
4
+ divider and the buttons the component renders into that slot. */
5
+
6
+ /* "Other ways to sign in" divider row between rules. */
7
+ .s-auth-methods-divider {
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+ gap: 10px;
12
+ margin: 0 0 var(--space-4, 0.25rem);
13
+ color: var(--hi-color-muted, #999);
14
+ font-size: var(--text-xs, 0.75rem);
15
+ text-align: center;
16
+ }
17
+
18
+ .s-auth-methods-divider::before,
19
+ .s-auth-methods-divider::after {
20
+ content: "";
21
+ flex: 1;
22
+ height: 1px;
23
+ background: var(--hi-color-border, #ddd);
24
+ }
25
+
26
+ /* The component's own wrapper is `display: contents` (measurement anchor +
27
+ CSS-var inheritance) — layout-wise the buttons remain direct children of
28
+ HkAuthCard's `.s-auth-methods` slot container. */
29
+ .s-auth-methods-list {
30
+ display: contents;
31
+ }
32
+
33
+ /* Fixed icon + label columns: the two spans keep the same width on every
34
+ row, so icons and labels align across buttons while each button stays a
35
+ centered hk-btn-block. HkButton has a fragment root, so inbound classes
36
+ don't fall through — scope everything off the `.s-auth-methods`
37
+ container (descendant match: the component wrapper is display:contents).
38
+ The label column width is MEASURED by the component (widest label text,
39
+ published as `--auth-methods-label-width` on its wrapper, inherited into
40
+ the rows); the 8em fallback only covers the pre-measure frame, and apps
41
+ may still override the var explicitly. */
42
+ .s-auth-methods .hk-btn .s-auth-methods-icon {
43
+ width: 20px;
44
+ flex: none;
45
+ display: inline-flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ }
49
+
50
+ .s-auth-methods .hk-btn .s-auth-methods-label {
51
+ width: var(--auth-methods-label-width, 8em);
52
+ flex: none;
53
+ text-align: start;
54
+ white-space: nowrap;
55
+ overflow: hidden;
56
+ text-overflow: ellipsis;
57
+ }
@@ -0,0 +1,98 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h, nextTick } from "vue";
3
+
4
+ import HkAuthMethodList from "./HkAuthMethodList";
5
+
6
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
+
8
+ function mount(node: ReturnType<typeof h>) {
9
+ const container = document.createElement("div");
10
+ document.body.appendChild(container);
11
+ const app = createApp({ render: () => node });
12
+ app.mount(container);
13
+ mounts.push({ app, container });
14
+ return container;
15
+ }
16
+
17
+ afterEach(() => {
18
+ for (const { app, container } of mounts.splice(0)) {
19
+ app.unmount();
20
+ container.remove();
21
+ }
22
+ });
23
+
24
+ const methods = [
25
+ { key: "github", label: "GitHub" },
26
+ { key: "linuxdo", label: "LinuxDo" },
27
+ ];
28
+
29
+ describe("HkAuthMethodList", () => {
30
+ it("renders one fixed-column button per method and an optional divider", () => {
31
+ // The card's `methods` slot owns the `.s-auth-methods` container —
32
+ // reproduce that context here (the buttons are layout children of it
33
+ // through the component's display:contents wrapper).
34
+ const c = mount(
35
+ h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { divider: "其他方式登录", methods })),
36
+ );
37
+ const divider = c.querySelector(".s-auth-methods-divider");
38
+ expect(divider?.textContent).toContain("其他方式登录");
39
+ const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-btn");
40
+ expect(buttons.length).toBe(2);
41
+ const labels = [...buttons].map(
42
+ (b) => b.querySelector<HTMLElement>(".s-auth-methods-label")?.textContent,
43
+ );
44
+ expect(labels).toEqual(["GitHub", "LinuxDo"]);
45
+ for (const b of buttons) {
46
+ expect(b.querySelector(".s-auth-methods-icon")).toBeTruthy();
47
+ expect(b.classList.contains("hk-btn-block")).toBe(true);
48
+ }
49
+ });
50
+
51
+ it("emits select with the method key on click", async () => {
52
+ let picked = "";
53
+ const c = mount(
54
+ h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, {
55
+ methods,
56
+ onSelect: (key: string) => {
57
+ picked = key;
58
+ },
59
+ })),
60
+ );
61
+ const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-btn");
62
+ buttons[1]!.click();
63
+ await nextTick();
64
+ expect(picked).toBe("linuxdo");
65
+ });
66
+
67
+ it("publishes the widest label text as the label-column custom property", async () => {
68
+ const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
69
+ await nextTick();
70
+ const wrapper = c.querySelector<HTMLElement>(".s-auth-methods-list")!;
71
+ expect(wrapper).toBeTruthy();
72
+ // Layout engines in test DOM report zero text metrics; stub scrollWidth
73
+ // on the label spans to a deterministic widest-wins pair (LinuxDo wider).
74
+ const spans = [...wrapper.querySelectorAll<HTMLElement>(".s-auth-methods-label")];
75
+ expect(spans.length).toBe(2);
76
+ Object.defineProperty(spans[0]!, "scrollWidth", { configurable: true, value: 61 });
77
+ Object.defineProperty(spans[1]!, "scrollWidth", { configurable: true, value: 59 });
78
+ // Trigger the measurement through a fresh label change (watch path).
79
+ await nextTick();
80
+ const { app } = mounts[mounts.length - 1]!;
81
+ // Re-run measurement via a re-mount-less route: the component exposes
82
+ // measure() — reach it through the rendered component instance.
83
+ const instance = (wrapper as unknown as { __vueParentComponent?: { exposed?: Record<string, unknown> } })
84
+ .__vueParentComponent?.exposed;
85
+ expect(typeof instance?.measure).toBe("function");
86
+ (instance!.measure as () => void)();
87
+ expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("61px");
88
+ void app;
89
+ });
90
+
91
+ it("leaves the column fallback intact when no text metrics are available", async () => {
92
+ const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
93
+ await nextTick();
94
+ const wrapper = c.querySelector<HTMLElement>(".s-auth-methods-list")!;
95
+ // Zero scrollWidth (no layout) must not publish a 0px column.
96
+ expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("");
97
+ });
98
+ });
@@ -0,0 +1,113 @@
1
+ import { defineComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
2
+ import HkButton from "./HkButton";
3
+ import "./HkAuthMethodList.scss";
4
+
5
+ /**
6
+ * HkAuthMethodList — full-width stacked third-party sign-in buttons for
7
+ * auth cards (the ERP login's provider-button grammar, componentized).
8
+ *
9
+ * Every button renders [icon | label] with a FIXED icon column and a FIXED
10
+ * label column, so the icons and the label text start at the same x on
11
+ * every row regardless of label length; because every content block is
12
+ * then the same width, the row can stay centered like any HkButton
13
+ * without the columns drifting.
14
+ *
15
+ * The label column is auto-sized: after mount (and whenever the label set
16
+ * or the document fonts change) the component measures the widest label
17
+ * text and publishes it as `--auth-methods-label-width` on its wrapper,
18
+ * so the aligned text region hugs the longest label instead of a blind
19
+ * hardcoded width. Consumers can still override the var explicitly.
20
+ *
21
+ * The wrapper is `display: contents`: the component keeps a DOM node for
22
+ * measurement and CSS-var inheritance while the buttons remain direct
23
+ * layout children of HkAuthCard's `.s-auth-methods` slot container.
24
+ */
25
+ export default defineComponent({
26
+ name: "HkAuthMethodList",
27
+ props: {
28
+ /** Optional divider text rendered above the buttons, e.g. "其他方式登录".
29
+ * Consumer-localized on purpose: hikari ships no dictionary dependency
30
+ * onto hosts here. */
31
+ divider: { type: String, default: "" },
32
+ /** Provider entries, in render order. */
33
+ methods: {
34
+ type: Array as unknown as () => Array<{
35
+ key: string;
36
+ label: string;
37
+ /** Prebuilt icon vnode (brand SVG, <img>, …) — `null` renders an
38
+ * empty fixed-width column so the labels still align. Typed loose
39
+ * on purpose (same as HkAltSignIn entries): hosts materialize
40
+ * hikari against their own vue store, and a hard VNode type
41
+ * breaks typecheck whenever the host's vue minor differs. The
42
+ * value renders as-is inside the fixed-size column. */
43
+ icon?: unknown;
44
+ disabled?: boolean;
45
+ }>,
46
+ required: true,
47
+ },
48
+ },
49
+ emits: {
50
+ /** A method button was clicked. */
51
+ select: (_key: string) => true,
52
+ },
53
+ setup(props, { emit, expose }) {
54
+ const listRef = ref<HTMLDivElement | null>(null);
55
+
56
+ /** Measure the widest label text and publish it as the label-column
57
+ * width custom property. `scrollWidth` reports the text width even
58
+ * while the fixed column clips it, so this is correct both before
59
+ * the variable is first set and after any label change. */
60
+ function measure() {
61
+ const root = listRef.value;
62
+ if (!root) return;
63
+ let widest = 0;
64
+ for (const label of root.querySelectorAll<HTMLElement>(".s-auth-methods-label")) {
65
+ widest = Math.max(widest, label.scrollWidth);
66
+ }
67
+ if (widest > 0) root.style.setProperty("--auth-methods-label-width", `${widest}px`);
68
+ }
69
+
70
+ onMounted(() => {
71
+ void nextTick(measure);
72
+ // Webfonts change text metrics after first paint; re-measure when
73
+ // they finish loading (no-op in environments without document.fonts).
74
+ if (typeof document !== "undefined" && document.fonts?.ready) {
75
+ void document.fonts.ready.then(measure).catch(() => {});
76
+ }
77
+ });
78
+ onBeforeUnmount(() => {
79
+ listRef.value?.style.removeProperty("--auth-methods-label-width");
80
+ });
81
+ watch(
82
+ () => props.methods.map((method) => method.label).join("\u0000"),
83
+ () => {
84
+ void nextTick(measure);
85
+ },
86
+ );
87
+
88
+ expose({ measure });
89
+
90
+ return () => (
91
+ <div class="s-auth-methods-list" ref={listRef}>
92
+ {props.divider && (
93
+ <div class="s-auth-methods-divider">
94
+ <span>{props.divider}</span>
95
+ </div>
96
+ )}
97
+ {props.methods.map((method) => (
98
+ <HkButton
99
+ key={method.key}
100
+ variant="outline"
101
+ size="md"
102
+ block
103
+ disabled={method.disabled === true}
104
+ onClick={() => emit("select", method.key)}
105
+ >
106
+ <span class="s-auth-methods-icon">{method.icon}</span>
107
+ <span class="s-auth-methods-label">{method.label}</span>
108
+ </HkButton>
109
+ ))}
110
+ </div>
111
+ );
112
+ },
113
+ });
@@ -0,0 +1,124 @@
1
+ // ------
2
+ // HkImageLightbox
3
+ // Immersive layout override for HModal (attached via the modal's
4
+ // `contentClass` escape hatch). Selectors are doubled as
5
+ // `.hk-modal-content.hk-image-lightbox` so the overrides win the cascade
6
+ // over HkModal's own rules regardless of stylesheet import order.
7
+ //
8
+ // The frame is a fixed-size stage; the body chain (body → scroll →
9
+ // inner) is flattened to pure flex so the HImageViewer fills it, and the
10
+ // floating close button rides on top of the stage.
11
+ // ------
12
+
13
+ .hk-modal-content.hk-image-lightbox {
14
+ width: min(96vw, 80rem);
15
+ height: min(90vh, 56rem);
16
+
17
+ // The body fills the frame; nothing scrolls here — the viewer pans
18
+ // internally, so the modal's scroll region is inert.
19
+ .hk-modal-body {
20
+ flex: 1;
21
+ min-height: 0;
22
+ max-height: none;
23
+ display: flex;
24
+ flex-direction: column;
25
+ }
26
+
27
+ .hk-modal-body-scroll {
28
+ flex: 1;
29
+ min-height: 0;
30
+ overflow: hidden;
31
+ }
32
+
33
+ .hk-modal-body-inner {
34
+ flex: 1;
35
+ min-height: 0;
36
+ display: flex;
37
+ flex-direction: column;
38
+ padding: 0;
39
+ }
40
+ }
41
+
42
+ .hk-image-lightbox-stage {
43
+ position: relative;
44
+ flex: 1;
45
+ min-height: 0;
46
+ display: flex;
47
+ padding: var(--space-12, 0.75rem);
48
+
49
+ // The viewer fills the stage; its own overflow: hidden + rounded
50
+ // corners clip the panning image into an inset "canvas".
51
+ .hk-image-viewer {
52
+ flex: 1;
53
+ min-width: 0;
54
+ height: auto;
55
+ min-height: 0;
56
+ border-radius: var(--radius-md, 8px);
57
+ }
58
+ }
59
+
60
+ // ------
61
+ // Floating close button — legible on any image: translucent dark chip
62
+ // with a blur, light glyph. Colors ride HIconButton's variable layer
63
+ // (--hi-icon-button-*) so the icon span picks them up too.
64
+ // ------
65
+
66
+ .hk-image-lightbox-close.hk-icon-button {
67
+ --hi-icon-button-bg: rgb(0 0 0 / 45%);
68
+ --hi-icon-button-bg-active: rgb(0 0 0 / 65%);
69
+ --hi-icon-button-icon-color: rgb(255 255 255 / 92%);
70
+ --hi-icon-button-icon-color-active: #fff;
71
+
72
+ position: absolute;
73
+ top: var(--space-12, 0.75rem);
74
+ right: var(--space-12, 0.75rem);
75
+ z-index: 2;
76
+ background: var(--hi-icon-button-bg);
77
+ color: var(--hi-icon-button-icon-color);
78
+ backdrop-filter: blur(6px);
79
+
80
+ &:hover {
81
+ --hi-icon-button-bg: rgb(0 0 0 / 65%);
82
+ --hi-icon-button-icon-color: #fff;
83
+ }
84
+
85
+ &:focus-visible {
86
+ outline: 2px solid rgb(255 255 255 / 80%);
87
+ outline-offset: 2px;
88
+ }
89
+ }
90
+
91
+ // ------
92
+ // Mobile (≤767px, matches HkModal's bottom-sheet breakpoint): the
93
+ // lightbox has no header/footer chrome to dock, so the sheet form
94
+ // (top inset + bottom gap + rounded corners) would only cost viewing
95
+ // area — go true fullscreen instead. HkModal's own `max-width: 100%
96
+ // !important` beats the inline max-width on phones, so plain width/height
97
+ // finish the job without fighting that rule.
98
+ // ------
99
+
100
+ @media (max-width: 767px) {
101
+ .hk-modal-content.hk-image-lightbox {
102
+ top: 0;
103
+ bottom: auto;
104
+ left: 0;
105
+ width: 100vw;
106
+ // Plain-vh fallback first for dvh-less engines (older Android
107
+ // WebView / Tauri), then the dynamic-viewport height.
108
+ height: 100vh;
109
+ height: 100dvh;
110
+ max-height: none;
111
+ border: 0;
112
+ border-radius: 0;
113
+ }
114
+
115
+ .hk-image-lightbox-stage {
116
+ padding: var(--space-8, 0.5rem);
117
+ }
118
+
119
+ .hk-image-lightbox-close.hk-icon-button {
120
+ // Keep the close button clear of notches / home-bar gesture areas.
121
+ top: calc(var(--space-8, 0.5rem) + env(safe-area-inset-top, 0px));
122
+ right: calc(var(--space-8, 0.5rem) + env(safe-area-inset-right, 0px));
123
+ }
124
+ }
@@ -0,0 +1,155 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkImageLightbox from "./HkImageLightbox";
5
+
6
+ const mounts: ReturnType<typeof createApp>[] = [];
7
+ const containers: HTMLElement[] = [];
8
+
9
+ afterEach(() => {
10
+ for (const app of mounts.splice(0)) app.unmount();
11
+ for (const el of containers.splice(0)) el.remove();
12
+ });
13
+
14
+ interface Setup {
15
+ dialog: () => HTMLElement | null;
16
+ closeButton: () => HTMLButtonElement;
17
+ emitted: boolean[];
18
+ }
19
+
20
+ function mountLightbox(initialOpen: boolean): Setup {
21
+ const container = document.createElement("div");
22
+ document.body.appendChild(container);
23
+ containers.push(container);
24
+
25
+ const open = ref(initialOpen);
26
+ const emitted: boolean[] = [];
27
+ const Wrapper = defineComponent({
28
+ setup() {
29
+ return () =>
30
+ h(HkImageLightbox, {
31
+ src: "/test-wallpaper.svg",
32
+ alt: "wallpaper",
33
+ modelValue: open.value,
34
+ "onUpdate:modelValue": (v: boolean) => { emitted.push(v); open.value = v; },
35
+ });
36
+ },
37
+ });
38
+ const app = createApp(Wrapper);
39
+ mounts.push(app);
40
+ app.mount(container);
41
+
42
+ return {
43
+ dialog: () => document.body.querySelector<HTMLElement>(".hk-modal-content.hk-image-lightbox"),
44
+ closeButton: () => {
45
+ const btn = document.body.querySelector<HTMLButtonElement>(".hk-image-lightbox-close");
46
+ if (!btn) throw new Error("lightbox close button not rendered");
47
+ return btn;
48
+ },
49
+ emitted,
50
+ };
51
+ }
52
+
53
+ describe("HkImageLightbox", () => {
54
+ it("renders the immersive dialog (viewer + close button) while open", () => {
55
+ const s = mountLightbox(true);
56
+
57
+ const dialog = s.dialog();
58
+ expect(dialog).not.toBeNull();
59
+ expect(dialog!.querySelector(".hk-image-viewer")).not.toBeNull();
60
+ expect(dialog!.querySelector(".hk-image-viewer-img")).not.toBeNull();
61
+
62
+ const btn = s.closeButton();
63
+ expect(btn.getAttribute("aria-label")).toBe("Close");
64
+ });
65
+
66
+ it("renders nothing while closed", () => {
67
+ const s = mountLightbox(false);
68
+ expect(s.dialog()).toBeNull();
69
+ });
70
+
71
+ it("the close button emits update:modelValue false", async () => {
72
+ const s = mountLightbox(true);
73
+
74
+ s.closeButton().click();
75
+ await nextTick();
76
+
77
+ expect(s.emitted).toEqual([false]);
78
+ });
79
+
80
+ it("Escape closes via the lightbox's own window listener", async () => {
81
+ const s = mountLightbox(true);
82
+
83
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
84
+ await nextTick();
85
+
86
+ expect(s.emitted).toEqual([false]);
87
+ });
88
+
89
+ it("the Escape listener is removed once closed (no stray closes)", async () => {
90
+ const s = mountLightbox(true);
91
+
92
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
93
+ await nextTick();
94
+ expect(s.emitted).toEqual([false]);
95
+
96
+ // The modelValue watch removed the window listener on close — a
97
+ // second Escape must not emit another update.
98
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
99
+ await nextTick();
100
+ expect(s.emitted).toEqual([false]);
101
+ });
102
+
103
+ it("names the dialog via surfaceTitle without rendering a header or a dev warning", () => {
104
+ // The spy must be live before mount: the popup manager warns (DEV
105
+ // builds) the moment an untitled layer registers.
106
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
107
+ try {
108
+ const s = mountLightbox(true);
109
+
110
+ const dialog = s.dialog();
111
+ expect(dialog).not.toBeNull();
112
+ // surfaceTitle names the dialog…
113
+ expect(dialog!.getAttribute("aria-label")).toBe("Image viewer");
114
+ // …without conjuring header chrome.
115
+ expect(dialog!.querySelector(".hk-modal-header")).toBeNull();
116
+ // …and without tripping the untitled-popup dev warning.
117
+ expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("popup registered without a title"));
118
+ } finally {
119
+ warn.mockRestore();
120
+ }
121
+ });
122
+
123
+ it("unmounting while open removes the window Escape listener", async () => {
124
+ const container = document.createElement("div");
125
+ document.body.appendChild(container);
126
+ containers.push(container);
127
+
128
+ const open = ref(true);
129
+ const emitted: boolean[] = [];
130
+ const Wrapper = defineComponent({
131
+ setup() {
132
+ return () =>
133
+ h(HkImageLightbox, {
134
+ src: "/test-wallpaper.svg",
135
+ modelValue: open.value,
136
+ "onUpdate:modelValue": (v: boolean) => { emitted.push(v); open.value = v; },
137
+ });
138
+ },
139
+ });
140
+ const app = createApp(Wrapper);
141
+ mounts.push(app);
142
+ app.mount(container);
143
+ await nextTick();
144
+ expect(document.body.querySelector(".hk-modal-content.hk-image-lightbox")).not.toBeNull();
145
+
146
+ // Tear the whole component down while the dialog is open…
147
+ app.unmount();
148
+ await nextTick();
149
+
150
+ // …a stray Escape must reach nobody.
151
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
152
+ await nextTick();
153
+ expect(emitted).toEqual([]);
154
+ });
155
+ });