@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.
- 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 +57 -0
- package/src/components/HkAuthMethodList.test.tsx +98 -0
- package/src/components/HkAuthMethodList.tsx +113 -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 +113 -8
- 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 +35 -8
- 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,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
|
+
});
|