@celestia-island/hikari 0.30.0 → 0.31.0

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 +47 -0
  6. package/src/components/HkAuthMethodList.test.tsx +65 -0
  7. package/src/components/HkAuthMethodList.tsx +72 -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 +109 -6
  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 +37 -4
  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.0",
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,47 @@
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
+ /* Fixed icon + label columns: the two spans keep the same width on every
27
+ row, so icons and labels align across buttons while each button stays a
28
+ centered hk-btn-block. HkButton has a fragment root, so inbound classes
29
+ don't fall through — scope everything off the `.s-auth-methods`
30
+ container instead of a button-level class. Apps with longer labels
31
+ widen the label column via `--auth-methods-label-width`. */
32
+ .s-auth-methods > .hk-btn .s-auth-methods-icon {
33
+ width: 20px;
34
+ flex: none;
35
+ display: inline-flex;
36
+ align-items: center;
37
+ justify-content: center;
38
+ }
39
+
40
+ .s-auth-methods > .hk-btn .s-auth-methods-label {
41
+ width: var(--auth-methods-label-width, 8em);
42
+ flex: none;
43
+ text-align: start;
44
+ white-space: nowrap;
45
+ overflow: hidden;
46
+ text-overflow: ellipsis;
47
+ }
@@ -0,0 +1,65 @@
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 component itself is a fragment).
33
+ const c = mount(
34
+ h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { divider: "其他方式登录", methods })),
35
+ );
36
+ const divider = c.querySelector(".s-auth-methods-divider");
37
+ expect(divider?.textContent).toContain("其他方式登录");
38
+ const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods > .hk-btn");
39
+ expect(buttons.length).toBe(2);
40
+ const labels = [...buttons].map(
41
+ (b) => b.querySelector<HTMLElement>(".s-auth-methods-label")?.textContent,
42
+ );
43
+ expect(labels).toEqual(["GitHub", "LinuxDo"]);
44
+ for (const b of buttons) {
45
+ expect(b.querySelector(".s-auth-methods-icon")).toBeTruthy();
46
+ expect(b.classList.contains("hk-btn-block")).toBe(true);
47
+ }
48
+ });
49
+
50
+ it("emits select with the method key on click", async () => {
51
+ let picked = "";
52
+ const c = mount(
53
+ h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, {
54
+ methods,
55
+ onSelect: (key: string) => {
56
+ picked = key;
57
+ },
58
+ })),
59
+ );
60
+ const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods > .hk-btn");
61
+ buttons[1]!.click();
62
+ await nextTick();
63
+ expect(picked).toBe("linuxdo");
64
+ });
65
+ });
@@ -0,0 +1,72 @@
1
+ import { defineComponent, type PropType } 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 (CSS var `--auth-methods-label-width`, default 8em), so the
11
+ * icons and the label text start at the same x on every row regardless of
12
+ * label length; because every content block is then the same width, the
13
+ * row can stay centered like any HkButton without the columns drifting.
14
+ *
15
+ * Render it through HkAuthCard's `methods` slot: the card owns the
16
+ * full-width block layout and the vertical rhythm, and the footer's
17
+ * checkbox rows keep their centered-group behavior instead of being
18
+ * dragged full width with the buttons.
19
+ */
20
+ export default defineComponent({
21
+ name: "HkAuthMethodList",
22
+ props: {
23
+ /** Optional divider text rendered above the buttons, e.g. "其他方式登录".
24
+ * Consumer-localized on purpose: hikari ships no dictionary dependency
25
+ * onto hosts here. */
26
+ divider: { type: String, default: "" },
27
+ /** Provider entries, in render order. */
28
+ methods: {
29
+ type: Array as unknown as () => Array<{
30
+ key: string;
31
+ label: string;
32
+ /** Prebuilt icon vnode (brand SVG, <img>, …) — `null` renders an
33
+ * empty fixed-width column so the labels still align. Typed loose
34
+ * on purpose (same as HkAltSignIn entries): hosts materialize
35
+ * hikari against their own vue store, and a hard VNode type
36
+ * breaks typecheck whenever the host's vue minor differs. The
37
+ * value renders as-is inside the fixed-size column. */
38
+ icon?: unknown;
39
+ disabled?: boolean;
40
+ }>,
41
+ required: true,
42
+ },
43
+ },
44
+ emits: {
45
+ /** A method button was clicked. */
46
+ select: (_key: string) => true,
47
+ },
48
+ setup(props, { emit }) {
49
+ return () => (
50
+ <>
51
+ {props.divider && (
52
+ <div class="s-auth-methods-divider">
53
+ <span>{props.divider}</span>
54
+ </div>
55
+ )}
56
+ {props.methods.map((method) => (
57
+ <HkButton
58
+ key={method.key}
59
+ variant="outline"
60
+ size="md"
61
+ block
62
+ disabled={method.disabled === true}
63
+ onClick={() => emit("select", method.key)}
64
+ >
65
+ <span class="s-auth-methods-icon">{method.icon}</span>
66
+ <span class="s-auth-methods-label">{method.label}</span>
67
+ </HkButton>
68
+ ))}
69
+ </>
70
+ );
71
+ },
72
+ });
@@ -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
+ });
@@ -0,0 +1,87 @@
1
+ import { defineComponent, onBeforeUnmount, watch } from "vue";
2
+
3
+ import { useI18n } from "../i18n/context";
4
+ import "./HkImageLightbox.scss";
5
+ import HIconButton from "./HkIconButton";
6
+ import HImageViewer from "./HkImageViewer";
7
+ import HModal from "./HkModal";
8
+
9
+ /**
10
+ * Immersive fullscreen viewer for a single image — the "special modal"
11
+ * behind HImagePreview's click-to-enlarge. Built entirely from standard
12
+ * components: HModal (with the `contentClass` escape hatch for the
13
+ * immersive layout) hosting an HImageViewer plus a floating close button.
14
+ *
15
+ * HModal is opened with `closable={false}`, so its own ESC / overlay-click
16
+ * guards never close it; the lightbox instead owns a window-level Escape
17
+ * listener for as long as it is open (removed on close/unmount) and emits
18
+ * `update:modelValue(false)` from there and from the close button.
19
+ */
20
+ export default defineComponent({
21
+ name: "HkImageLightbox",
22
+ props: {
23
+ src: { type: String, required: true },
24
+ alt: { type: String, default: "" },
25
+ modelValue: { type: Boolean, required: true },
26
+ },
27
+ emits: {
28
+ "update:modelValue": (_value: boolean) => true,
29
+ },
30
+ setup(props, { emit }) {
31
+ const { t } = useI18n();
32
+
33
+ function close() {
34
+ emit("update:modelValue", false);
35
+ }
36
+
37
+ function onWindowKeydown(e: KeyboardEvent) {
38
+ if (e.key === "Escape") close();
39
+ }
40
+
41
+ watch(
42
+ () => props.modelValue,
43
+ (open) => {
44
+ if (open) window.addEventListener("keydown", onWindowKeydown);
45
+ else window.removeEventListener("keydown", onWindowKeydown);
46
+ },
47
+ { immediate: true },
48
+ );
49
+
50
+ onBeforeUnmount(() => {
51
+ window.removeEventListener("keydown", onWindowKeydown);
52
+ });
53
+
54
+ return () => (
55
+ <HModal
56
+ modelValue={props.modelValue}
57
+ onUpdate:modelValue={(v: boolean) => emit("update:modelValue", v)}
58
+ contentClass="hk-image-lightbox"
59
+ // Match the CSS width so the frame's inline max-width does not
60
+ // clamp it to HModal's 32rem default.
61
+ width="min(96vw, 80rem)"
62
+ closable={false}
63
+ // Header-less chrome-only surface: name the dialog layer (popup
64
+ // breadcrumb + aria-label) without rendering a header.
65
+ surfaceTitle={t("hikari::imageLightbox.title", "Image viewer")}
66
+ >
67
+ <div class="hk-image-lightbox-stage">
68
+ <HImageViewer src={props.src} alt={props.alt} />
69
+ <HIconButton
70
+ class="hk-image-lightbox-close"
71
+ size={32}
72
+ variant="ghost"
73
+ aria-label={t("hikari::imageLightbox.close", "Close")}
74
+ onClick={close}
75
+ >
76
+ {{ icon: () => (
77
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
78
+ <line x1="18" y1="6" x2="6" y2="18" />
79
+ <line x1="6" y1="6" x2="18" y2="18" />
80
+ </svg>
81
+ ) }}
82
+ </HIconButton>
83
+ </div>
84
+ </HModal>
85
+ );
86
+ },
87
+ });