@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.
- package/package.json +1 -1
- package/src/DemoApp.tsx +11 -1
- package/src/animation/registerAnimations.ts +2 -0
- package/src/components/HkAuthCard.tsx +11 -0
- package/src/components/HkAuthMethodList.scss +47 -0
- package/src/components/HkAuthMethodList.test.tsx +65 -0
- package/src/components/HkAuthMethodList.tsx +72 -0
- package/src/components/HkImageLightbox.scss +124 -0
- package/src/components/HkImageLightbox.test.tsx +155 -0
- package/src/components/HkImageLightbox.tsx +87 -0
- package/src/components/HkImagePreview.scss +110 -0
- package/src/components/HkImagePreview.test.tsx +184 -0
- package/src/components/HkImagePreview.tsx +156 -0
- package/src/components/HkImageViewer.tsx +109 -6
- package/src/components/HkImageViewerTouch.test.tsx +261 -0
- package/src/components/HkLocalizedInput.scss +36 -22
- package/src/components/HkLocalizedInput.test.tsx +39 -4
- package/src/components/HkLocalizedInput.tsx +66 -48
- package/src/components/HkMinimap.tsx +37 -4
- package/src/components/HkModal.tsx +22 -4
- package/src/components/HkSignInCard.test.ts +15 -0
- package/src/components/HkSignInCard.tsx +10 -0
- package/src/i18n/locales/ar/components.json +7 -0
- package/src/i18n/locales/ar/components.toml +5 -0
- package/src/i18n/locales/de/components.json +7 -0
- package/src/i18n/locales/de/components.toml +5 -0
- package/src/i18n/locales/en/components.json +7 -0
- package/src/i18n/locales/en/components.toml +5 -0
- package/src/i18n/locales/es/components.json +7 -0
- package/src/i18n/locales/es/components.toml +5 -0
- package/src/i18n/locales/fr/components.json +7 -0
- package/src/i18n/locales/fr/components.toml +5 -0
- package/src/i18n/locales/ja/components.json +7 -0
- package/src/i18n/locales/ja/components.toml +5 -0
- package/src/i18n/locales/ko/components.json +7 -0
- package/src/i18n/locales/ko/components.toml +5 -0
- package/src/i18n/locales/pt/components.json +7 -0
- package/src/i18n/locales/pt/components.toml +5 -0
- package/src/i18n/locales/ru/components.json +7 -0
- package/src/i18n/locales/ru/components.toml +5 -0
- package/src/i18n/locales/zh-Hans/components.json +7 -0
- package/src/i18n/locales/zh-Hans/components.toml +5 -0
- package/src/i18n/locales/zh-Hant/components.json +7 -0
- package/src/i18n/locales/zh-Hant/components.toml +5 -0
- package/src/index.ts +3 -0
- package/src/styles/admin-tokens.scss +21 -0
- package/src/styles/theme/base.scss +19 -5
package/package.json
CHANGED
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 =
|
|
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
|
+
});
|