@celestia-island/hikari 0.41.9 → 0.42.2
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 +35 -0
- package/src/components/HkColorSchemeDialog.test.ts +14 -8
- package/src/components/HkColorSchemeEditor.test.ts +35 -11
- package/src/components/HkColorSchemeEditor.tsx +54 -31
- package/src/components/HkImageLightbox.contract.test.ts +44 -0
- package/src/components/HkImageLightbox.scss +34 -1
- package/src/components/HkImageViewer.tsx +8 -1
- package/src/components/HkLoadMore.scss +53 -0
- package/src/components/HkLoadMore.test.tsx +65 -0
- package/src/components/HkLoadMore.tsx +72 -0
- package/src/components/HkMinimap.scss +10 -0
- package/src/components/HkMinimap.toolbarOnly.test.tsx +54 -0
- package/src/components/HkMinimap.tsx +44 -35
- package/src/components/HkScrollContainer.test.ts +44 -1
- package/src/components/HkScrollContainer.tsx +63 -2
- package/src/components/HkTag.test.tsx +76 -0
- package/src/components/HkTag.tsx +8 -0
- package/src/components/HkTagInput.scss +307 -0
- package/src/components/HkTagInput.singleScroll.contract.test.ts +73 -0
- package/src/components/HkTagInput.test.tsx +1122 -0
- package/src/components/HkTagInput.tsx +761 -0
- package/src/components/HkThemeToggle.contract.test.ts +28 -0
- package/src/components/HkThemeToggle.scss +11 -1
- package/src/composables/useApproachEnd.test.ts +261 -0
- package/src/composables/useApproachEnd.ts +169 -0
- package/src/i18n/locales/ar/components.json +9 -1
- package/src/i18n/locales/ar/platform.json +1 -0
- package/src/i18n/locales/de/components.json +9 -1
- package/src/i18n/locales/de/platform.json +1 -0
- package/src/i18n/locales/en/components.json +9 -1
- package/src/i18n/locales/en/platform.json +1 -0
- package/src/i18n/locales/es/components.json +9 -1
- package/src/i18n/locales/es/platform.json +1 -0
- package/src/i18n/locales/fr/components.json +9 -1
- package/src/i18n/locales/fr/platform.json +1 -0
- package/src/i18n/locales/ja/components.json +9 -1
- package/src/i18n/locales/ja/platform.json +1 -0
- package/src/i18n/locales/ko/components.json +9 -1
- package/src/i18n/locales/ko/platform.json +1 -0
- package/src/i18n/locales/pt/components.json +9 -1
- package/src/i18n/locales/pt/platform.json +1 -0
- package/src/i18n/locales/ru/components.json +9 -1
- package/src/i18n/locales/ru/platform.json +1 -0
- package/src/i18n/locales/zh-Hans/components.json +9 -1
- package/src/i18n/locales/zh-Hans/platform.json +1 -0
- package/src/i18n/locales/zh-Hant/components.json +9 -1
- package/src/i18n/locales/zh-Hant/platform.json +1 -0
- package/src/index.ts +4 -0
package/package.json
CHANGED
package/src/DemoApp.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
HSkeleton, HSkeletonList, HAvatar, HKbd, HDivider,
|
|
17
17
|
HAlert, HEmptyState, HExpansionPanel,
|
|
18
18
|
HTabs, HCard, HTable, HTimeline,
|
|
19
|
+
HScrollContainer, HLoadMore,
|
|
19
20
|
HMediaSlider, HMediaPlayer, HImageViewer,
|
|
20
21
|
HImagePreview,
|
|
21
22
|
HZoomToolbar, HMinimap, HTrendChart,
|
|
@@ -197,6 +198,24 @@ export default defineComponent({
|
|
|
197
198
|
syncZoom();
|
|
198
199
|
}
|
|
199
200
|
|
|
201
|
+
// HLoadMore + HScrollContainer approachEnd — the canonical dynamic
|
|
202
|
+
// loading pair: the container emits `approachEnd` when the content
|
|
203
|
+
// end nears (including the initial under-filled pass), the demo
|
|
204
|
+
// "fetches" a page, and the indicator morphs button → spinner → end.
|
|
205
|
+
const pagedRows = ref(Array.from({ length: 10 }, (_, i) => `Row ${i + 1}`));
|
|
206
|
+
const pagedState = ref<"idle" | "loading" | "end">("idle");
|
|
207
|
+
function loadPagedRows() {
|
|
208
|
+
if (pagedState.value !== "idle") return;
|
|
209
|
+
pagedState.value = "loading";
|
|
210
|
+
setTimeout(() => {
|
|
211
|
+
pagedRows.value = [
|
|
212
|
+
...pagedRows.value,
|
|
213
|
+
...Array.from({ length: 10 }, (_, i) => `Row ${pagedRows.value.length + i + 1}`),
|
|
214
|
+
];
|
|
215
|
+
pagedState.value = pagedRows.value.length >= 60 ? "end" : "idle";
|
|
216
|
+
}, 500);
|
|
217
|
+
}
|
|
218
|
+
|
|
200
219
|
return () => (
|
|
201
220
|
<div class="demo">
|
|
202
221
|
<header class="demo-header">
|
|
@@ -225,6 +244,22 @@ export default defineComponent({
|
|
|
225
244
|
</div>
|
|
226
245
|
</section>
|
|
227
246
|
|
|
247
|
+
<section>
|
|
248
|
+
<h2>HScrollContainer + HLoadMore</h2>
|
|
249
|
+
<HScrollContainer
|
|
250
|
+
style={{ height: "320px" }}
|
|
251
|
+
approachEnd
|
|
252
|
+
onApproachEnd={loadPagedRows}
|
|
253
|
+
>
|
|
254
|
+
<div style={{ display: "flex", flexDirection: "column" }}>
|
|
255
|
+
{pagedRows.value.map((r) => (
|
|
256
|
+
<div key={r} style={{ padding: "6px 12px", borderBottom: "1px solid rgb(var(--color-muted, 108 108 108) / 18%)" }}>{r}</div>
|
|
257
|
+
))}
|
|
258
|
+
<HLoadMore state={pagedState.value} shown={pagedRows.value.length} total={60} onLoadMore={loadPagedRows} />
|
|
259
|
+
</div>
|
|
260
|
+
</HScrollContainer>
|
|
261
|
+
</section>
|
|
262
|
+
|
|
228
263
|
<section>
|
|
229
264
|
<h2>HIconButton</h2>
|
|
230
265
|
<div class="row">
|
|
@@ -84,22 +84,28 @@ describe("HkColorSchemeDialog extension groups", () => {
|
|
|
84
84
|
it("renders the extension section reactively when a group is registered after mount", async () => {
|
|
85
85
|
mountDialog();
|
|
86
86
|
await nextTick();
|
|
87
|
-
//
|
|
88
|
-
|
|
87
|
+
// The section always renders now (the on-brand content group lives
|
|
88
|
+
// here); before any registration it holds exactly that one panel.
|
|
89
|
+
const before = document.body.querySelector(".s-scheme-groups");
|
|
90
|
+
expect(before).toBeTruthy();
|
|
91
|
+
expect(before!.querySelectorAll(".hk-expansion-panel")).toHaveLength(1);
|
|
89
92
|
|
|
90
93
|
// Late registration (after the dialog is already rendered): the
|
|
91
94
|
// registry's reactive version invalidates the computed and the
|
|
92
|
-
//
|
|
95
|
+
// group's panel appears without a remount.
|
|
93
96
|
registerTokenGroup(DIALOG_GROUP);
|
|
94
97
|
await nextTick();
|
|
95
98
|
|
|
96
99
|
const section = document.body.querySelector(".s-scheme-groups");
|
|
97
100
|
expect(section).toBeTruthy();
|
|
98
|
-
//
|
|
99
|
-
expect(section!.querySelectorAll(".hk-expansion-panel")).toHaveLength(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
// The late group's panel lands after the on-brand content panel.
|
|
102
|
+
expect(section!.querySelectorAll(".hk-expansion-panel")).toHaveLength(2);
|
|
103
|
+
// The late group's own grid: scoped by its panel title, 2 slots.
|
|
104
|
+
const groupPanel = [...section!.querySelectorAll<HTMLElement>(".hk-expansion-panel")].find(
|
|
105
|
+
(p) => p.querySelector(".hk-expansion-panel-title")?.textContent === "Dialog wires",
|
|
106
|
+
);
|
|
107
|
+
expect(groupPanel).toBeTruthy();
|
|
108
|
+
expect(groupPanel!.querySelectorAll(".hk-color-picker")).toHaveLength(2);
|
|
103
109
|
});
|
|
104
110
|
|
|
105
111
|
it("renders one expansion panel per section of a sectioned group", async () => {
|
|
@@ -143,6 +143,23 @@ async function setSwatchHex(swatchIndex: number, hex: string): Promise<void> {
|
|
|
143
143
|
await nextTick();
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/** Set an on-brand content slot (0 = onSolidText, 1 = onSolidIcon) of the
|
|
147
|
+
* active mode to a hex color — the pair edits inside the extended-colors
|
|
148
|
+
* section's first group panel, not the accent row. */
|
|
149
|
+
async function setOnSolidHex(slotIndex: number, hex: string): Promise<void> {
|
|
150
|
+
const swatches = document.body.querySelectorAll(
|
|
151
|
+
".s-scheme-groups .s-scheme-group-grid .hk-color-picker-swatch-btn",
|
|
152
|
+
);
|
|
153
|
+
(swatches[slotIndex] as HTMLButtonElement).click();
|
|
154
|
+
await settle();
|
|
155
|
+
const input = document.body.querySelector(
|
|
156
|
+
".hk-color-picker-hex-row input.hk-input-element",
|
|
157
|
+
) as HTMLInputElement | null;
|
|
158
|
+
input!.value = hex;
|
|
159
|
+
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
160
|
+
await nextTick();
|
|
161
|
+
}
|
|
162
|
+
|
|
146
163
|
beforeEach(() => {
|
|
147
164
|
// Pin the effective mode so edits deterministically target the dark side.
|
|
148
165
|
useTheme().setMode("dark");
|
|
@@ -156,14 +173,21 @@ afterEach(() => {
|
|
|
156
173
|
});
|
|
157
174
|
|
|
158
175
|
describe("HkColorSchemeEditor", () => {
|
|
159
|
-
it("renders
|
|
176
|
+
it("renders seven accent pickers and moves the on-solid pair into the extended section", async () => {
|
|
160
177
|
const { container } = mountEditor();
|
|
161
178
|
await nextTick();
|
|
162
179
|
|
|
163
180
|
const pickers = container.querySelectorAll(".s-scheme-colors .hk-color-picker");
|
|
164
|
-
expect(pickers).toHaveLength(
|
|
165
|
-
//
|
|
166
|
-
|
|
181
|
+
expect(pickers).toHaveLength(7); // accents only — the on-solid pair moved out
|
|
182
|
+
// The extended section is always rendered: the on-brand content group
|
|
183
|
+
// lives there even with no extension token group registered.
|
|
184
|
+
const groups = container.querySelector(".s-scheme-groups");
|
|
185
|
+
expect(groups).not.toBeNull();
|
|
186
|
+
expect(groups!.querySelector(".s-scheme-groups-title")).not.toBeNull();
|
|
187
|
+
const onSolidPickers = groups!.querySelectorAll(
|
|
188
|
+
".s-scheme-group-grid .hk-color-picker",
|
|
189
|
+
);
|
|
190
|
+
expect(onSolidPickers).toHaveLength(2); // onSolidText + onSolidIcon
|
|
167
191
|
});
|
|
168
192
|
|
|
169
193
|
it("editing a token updates getDraft() with the edited RGB", async () => {
|
|
@@ -175,15 +199,15 @@ describe("HkColorSchemeEditor", () => {
|
|
|
175
199
|
expect(draft.dark.primary).toEqual({ r: 255, g: 0, b: 0 });
|
|
176
200
|
});
|
|
177
201
|
|
|
178
|
-
it("editing an on-
|
|
202
|
+
it("editing an on-brand content color updates getDraft()", async () => {
|
|
179
203
|
const { ref } = mountEditor();
|
|
180
204
|
await nextTick();
|
|
181
205
|
|
|
182
|
-
//
|
|
206
|
+
// 2nd picker in the on-brand group grid = onSolidIcon (text first).
|
|
183
207
|
const swatches = document.body.querySelectorAll(
|
|
184
|
-
".s-scheme-
|
|
208
|
+
".s-scheme-groups .s-scheme-group-grid .hk-color-picker-swatch-btn",
|
|
185
209
|
);
|
|
186
|
-
(swatches[
|
|
210
|
+
(swatches[1] as HTMLButtonElement).click();
|
|
187
211
|
await settle();
|
|
188
212
|
const input = document.body.querySelector(
|
|
189
213
|
".hk-color-picker-hex-row input.hk-input-element",
|
|
@@ -237,8 +261,8 @@ describe("HkColorSchemeEditor", () => {
|
|
|
237
261
|
const { ref } = mountEditor({ initialDark: legacyDark });
|
|
238
262
|
await nextTick();
|
|
239
263
|
|
|
240
|
-
//
|
|
241
|
-
await
|
|
264
|
+
// 1st picker in the on-brand group grid = onSolidText.
|
|
265
|
+
await setOnSolidHex(0, "00ff00");
|
|
242
266
|
expect(ref.value!.getDraft().dark.onSolidText).toEqual({ r: 0, g: 255, b: 0 });
|
|
243
267
|
|
|
244
268
|
ref.value!.reset();
|
|
@@ -259,7 +283,7 @@ describe("HkColorSchemeEditor", () => {
|
|
|
259
283
|
});
|
|
260
284
|
await nextTick();
|
|
261
285
|
|
|
262
|
-
await
|
|
286
|
+
await setOnSolidHex(0, "00ff00");
|
|
263
287
|
expect(ref.value!.getDraft().dark.onSolidText).toEqual({ r: 0, g: 255, b: 0 });
|
|
264
288
|
|
|
265
289
|
ref.value!.reset();
|
|
@@ -123,14 +123,16 @@ export interface HCustomTheme {
|
|
|
123
123
|
* their own surface (tabs, drawers, panels) instead of the built-in modal.
|
|
124
124
|
*
|
|
125
125
|
* Edits the seven accent tokens (primary/secondary/accent/success/error/
|
|
126
|
-
* warning/info)
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
126
|
+
* warning/info) per mode (dark/light); the remaining surface tokens are
|
|
127
|
+
* derived. The on-solid content colors (onSolidText — text on brand
|
|
128
|
+
* fills, onSolidIcon — icons/shapes such as the switch thumb) are
|
|
129
|
+
* user-choosable too and edit as the FIRST group of the "Extended
|
|
130
|
+
* colors" section (2026-09-11 user direction: moved out of the accent
|
|
131
|
+
* row). When extension token groups are registered
|
|
132
|
+
* (`registerTokenGroup` / `registerTokenGroupConfig`), the section
|
|
133
|
+
* renders one Material expansion panel per group — sub-sectioned groups
|
|
134
|
+
* get one panel per section — with the slots laid out on a 2–3 column
|
|
135
|
+
* grid of hue-clamped pickers showing full localized color names.
|
|
134
136
|
*
|
|
135
137
|
* Exposes `reset()` (re-seed from props + current effective mode) and
|
|
136
138
|
* `getDraft()` (snapshot the current edits as a `HCustomTheme`) for the
|
|
@@ -365,6 +367,40 @@ export const HkColorSchemeEditor = defineComponent({
|
|
|
365
367
|
return panels;
|
|
366
368
|
}
|
|
367
369
|
|
|
370
|
+
// ── On-brand content colors as their own extended group ──────────
|
|
371
|
+
// The two content tokens (text / icons on solid brand fills) USED to
|
|
372
|
+
// sit in the top accent row (2026-09-11 user direction: they are
|
|
373
|
+
// niche, rarely-tuned colors — they belong with the other extension
|
|
374
|
+
// palettes, as the section's FIRST group). Editing them here writes
|
|
375
|
+
// the exact same draft slots as before; only the location moved.
|
|
376
|
+
function renderOnSolidGroup() {
|
|
377
|
+
return (
|
|
378
|
+
<HExpansionPanel
|
|
379
|
+
key="on-solid-content"
|
|
380
|
+
title={t("hikari::theme.onSolidGroupTitle", "On-brand content")}
|
|
381
|
+
subtitle={countLabel(contentTokens.length)}
|
|
382
|
+
>
|
|
383
|
+
<div class="s-scheme-group-grid">
|
|
384
|
+
{contentTokens.map((key) => {
|
|
385
|
+
// Optional slots: an older prefilled custom theme may omit them.
|
|
386
|
+
const rgb = currentTokens.value[key] ?? WHITE;
|
|
387
|
+
return (
|
|
388
|
+
<HColorPicker
|
|
389
|
+
key={key}
|
|
390
|
+
r={rgb.r}
|
|
391
|
+
g={rgb.g}
|
|
392
|
+
b={rgb.b}
|
|
393
|
+
label={t(`hikari::theme.tokens.${key}`)}
|
|
394
|
+
layout="row"
|
|
395
|
+
onChange={(next: { r: number; g: number; b: number }) => updateToken(key, next)}
|
|
396
|
+
/>
|
|
397
|
+
);
|
|
398
|
+
})}
|
|
399
|
+
</div>
|
|
400
|
+
</HExpansionPanel>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
368
404
|
return () => (
|
|
369
405
|
<div class="s-scheme-dialog">
|
|
370
406
|
{props.showName && (
|
|
@@ -393,31 +429,18 @@ export const HkColorSchemeEditor = defineComponent({
|
|
|
393
429
|
onChange={(rgb: { r: number; g: number; b: number }) => updateToken(key, rgb)}
|
|
394
430
|
/>
|
|
395
431
|
))}
|
|
396
|
-
{contentTokens.map((key) => {
|
|
397
|
-
// Optional slots: an older prefilled custom theme may omit them.
|
|
398
|
-
const rgb = currentTokens.value[key] ?? WHITE;
|
|
399
|
-
return (
|
|
400
|
-
<HColorPicker
|
|
401
|
-
key={key}
|
|
402
|
-
r={rgb.r}
|
|
403
|
-
g={rgb.g}
|
|
404
|
-
b={rgb.b}
|
|
405
|
-
label={t(`hikari::theme.tokens.${key}`)}
|
|
406
|
-
onChange={(next: { r: number; g: number; b: number }) => updateToken(key, next)}
|
|
407
|
-
/>
|
|
408
|
-
);
|
|
409
|
-
})}
|
|
410
432
|
</div>
|
|
411
|
-
{
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
<div class="s-scheme-group-panels">
|
|
417
|
-
{registeredGroups.value.map((group) => renderGroup(group))}
|
|
418
|
-
</div>
|
|
433
|
+
{/* Always rendered now: the on-brand content group lives here even
|
|
434
|
+
when no extension token group is registered. */}
|
|
435
|
+
<div class="s-scheme-groups">
|
|
436
|
+
<div class="s-scheme-groups-title">
|
|
437
|
+
{t("hikari::theme.extendedColors", "Extended colors")}
|
|
419
438
|
</div>
|
|
420
|
-
|
|
439
|
+
<div class="s-scheme-group-panels">
|
|
440
|
+
{renderOnSolidGroup()}
|
|
441
|
+
{registeredGroups.value.map((group) => renderGroup(group))}
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
421
444
|
</div>
|
|
422
445
|
);
|
|
423
446
|
},
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the immersive lightbox layout (2026-09-11 field
|
|
3
|
+
* report wave). Pinned as scss-text assertions because the failure class
|
|
4
|
+
* is invisible to DOM tests: happy-dom has no layout engine, so a
|
|
5
|
+
* collapsed flex chain (the "white lightbox" — viewer 0px tall inside a
|
|
6
|
+
* full-size modal frame) and a cascade-losing hover state (the floating
|
|
7
|
+
* close button melting into the page) both pass every render test.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const read = (f: string): string => readFileSync(join(here, f), "utf-8");
|
|
16
|
+
|
|
17
|
+
describe("HkImageLightbox layout contract", () => {
|
|
18
|
+
it("makes the modal scroll region a flex container so the body chain can fill the frame", () => {
|
|
19
|
+
const css = read("HkImageLightbox.scss");
|
|
20
|
+
// The lightbox override block must carry the display itself: the inner
|
|
21
|
+
// only carries flex: 1, which is inert inside a block parent — without
|
|
22
|
+
// this the stage collapses to its padding and the viewer renders 0px
|
|
23
|
+
// tall (white canvas, dead gestures).
|
|
24
|
+
const block = css.slice(
|
|
25
|
+
css.indexOf(".hk-modal-content.hk-image-lightbox"),
|
|
26
|
+
css.indexOf(".hk-image-lightbox-stage"),
|
|
27
|
+
);
|
|
28
|
+
expect(block).toContain(".hk-modal-body-scroll");
|
|
29
|
+
expect(block).toMatch(/\.hk-modal-body-scroll\s*\{[^}]*display:\s*flex/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("re-asserts the dark chip on hover/focus above the window-close cascade", () => {
|
|
33
|
+
const css = read("HkImageLightbox.scss");
|
|
34
|
+
// window-close.scss's :hover paints the light theme tint and loses to
|
|
35
|
+
// nothing here — the lightbox hover must re-assert the real background
|
|
36
|
+
// (not just the custom properties) at higher specificity, and push the
|
|
37
|
+
// color into the glyph span (which keeps its own white declaration).
|
|
38
|
+
expect(css).toContain("&.hk-window-close:hover");
|
|
39
|
+
expect(css).toContain("&.hk-window-close:focus-visible");
|
|
40
|
+
const hoverBlock = css.slice(css.indexOf("&.hk-window-close:hover"));
|
|
41
|
+
expect(hoverBlock.indexOf("background:")).toBeGreaterThan(-1);
|
|
42
|
+
expect(hoverBlock).toContain(".hk-icon-button-icon");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
// The frame is a fixed-size stage; the body chain (body → scroll →
|
|
9
9
|
// inner) is flattened to pure flex so the HImageViewer fills it, and the
|
|
10
10
|
// floating close button rides on top of the stage.
|
|
11
|
+
//
|
|
12
|
+
// The scroll link MUST become a flex container itself: the inner below it
|
|
13
|
+
// only carries `flex: 1`, which does nothing inside a block parent —
|
|
14
|
+
// without `display: flex` here the chain collapses to the stage's own
|
|
15
|
+
// padding and the viewer renders 0px tall (the 2026-09-11 "white
|
|
16
|
+
// lightbox" field report: full-size modal frame, invisible canvas, and
|
|
17
|
+
// every wheel/pointer gesture dead because their surface had no area).
|
|
11
18
|
// ------
|
|
12
19
|
|
|
13
20
|
.hk-modal-content.hk-image-lightbox {
|
|
@@ -28,6 +35,8 @@
|
|
|
28
35
|
flex: 1;
|
|
29
36
|
min-height: 0;
|
|
30
37
|
overflow: hidden;
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: column;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
.hk-modal-body-inner {
|
|
@@ -61,6 +70,17 @@
|
|
|
61
70
|
// Floating close button — legible on any image: translucent dark chip
|
|
62
71
|
// with a blur, light glyph. Colors ride HIconButton's variable layer
|
|
63
72
|
// (--hi-icon-button-*) so the icon span picks them up too.
|
|
73
|
+
//
|
|
74
|
+
// The hover/focus state must RE-ASSERT the real `background`/`color`
|
|
75
|
+
// properties, not just the custom properties: the shared window-close
|
|
76
|
+
// grammar (imported after this sheet, equal specificity on :hover) wins
|
|
77
|
+
// the cascade tie and paints the near-transparent light-theme tint over
|
|
78
|
+
// the dark chip, while the glyph span keeps its own
|
|
79
|
+
// `color: var(--hi-icon-button-icon-color)` (white) — white glyph on the
|
|
80
|
+
// white modal, the button visibly vanished (2026-09-11 field report).
|
|
81
|
+
// The selectors carry the extra `.hk-window-close` class the lightbox
|
|
82
|
+
// button always wears, so the pair here outranks window-close's
|
|
83
|
+
// `&:hover` regardless of bundle order.
|
|
64
84
|
// ------
|
|
65
85
|
|
|
66
86
|
.hk-image-lightbox-close.hk-icon-button {
|
|
@@ -77,11 +97,24 @@
|
|
|
77
97
|
color: var(--hi-icon-button-icon-color);
|
|
78
98
|
backdrop-filter: blur(6px);
|
|
79
99
|
|
|
80
|
-
&:hover
|
|
100
|
+
&:hover,
|
|
101
|
+
&:focus-visible {
|
|
81
102
|
--hi-icon-button-bg: rgb(0 0 0 / 65%);
|
|
82
103
|
--hi-icon-button-icon-color: #fff;
|
|
83
104
|
}
|
|
84
105
|
|
|
106
|
+
// (0,4,0) with the pseudo-class counted — beats
|
|
107
|
+
// `.hk-icon-button.hk-window-close:hover` at (0,3,0).
|
|
108
|
+
&.hk-window-close:hover,
|
|
109
|
+
&.hk-window-close:focus-visible {
|
|
110
|
+
background: var(--hi-icon-button-bg);
|
|
111
|
+
color: var(--hi-icon-button-icon-color);
|
|
112
|
+
|
|
113
|
+
.hk-icon-button-icon {
|
|
114
|
+
color: var(--hi-icon-button-icon-color);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
85
118
|
&:focus-visible {
|
|
86
119
|
outline: 2px solid rgb(255 255 255 / 80%);
|
|
87
120
|
outline-offset: 2px;
|
|
@@ -327,7 +327,13 @@ export default defineComponent({
|
|
|
327
327
|
</div>
|
|
328
328
|
)}
|
|
329
329
|
|
|
330
|
-
{
|
|
330
|
+
{/* The controls are visible for the WHOLE session, not only once
|
|
331
|
+
zoomed past fit (2026-09-11 user report: at fit the viewer
|
|
332
|
+
offered no affordance at all — no buttons, no percent, no hint
|
|
333
|
+
that wheel/pinch/drag exist). At fit the viewport rect says
|
|
334
|
+
nothing worth drawing, so the minimap runs toolbar-only and
|
|
335
|
+
grows the overview map back the moment zooming starts. */}
|
|
336
|
+
{loaded.value && (
|
|
331
337
|
<HMinimap
|
|
332
338
|
imageSrc={props.src}
|
|
333
339
|
imageBounds={contentBounds.value}
|
|
@@ -344,6 +350,7 @@ export default defineComponent({
|
|
|
344
350
|
minZoomPercent={Math.max(1, Math.round(fit.value * 100))}
|
|
345
351
|
maxZoomPercent={Math.round(MAX_ZOOM * 100)}
|
|
346
352
|
showReset
|
|
353
|
+
toolbarOnly={!isZoomed.value}
|
|
347
354
|
onZoomTo={(percent: number) => setZoom(percent / 100)}
|
|
348
355
|
onReset={resetView}
|
|
349
356
|
onPanDelta={onPanDelta}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* HkLoadMore — the unified bottom indicator for dynamically loaded
|
|
3
|
+
* lists. Hairline rules flank the control so the indicator reads as
|
|
4
|
+
* "the list pauses here" rather than a stray button; everything is
|
|
5
|
+
* token-driven (no hardcoded colors) so themes restyle it for free.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
.hk-load-more {
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
gap: var(--space-12);
|
|
12
|
+
padding: var(--space-12) 0;
|
|
13
|
+
color: rgb(var(--color-text-tertiary));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* Hairline rules: transparent at the outer edge, muted towards the
|
|
17
|
+
* control; the end rule mirrors the gradient. */
|
|
18
|
+
.hk-load-more-rule {
|
|
19
|
+
flex: 1;
|
|
20
|
+
height: 1px;
|
|
21
|
+
background: linear-gradient(to right, transparent, rgb(var(--color-muted) / 35%));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.hk-load-more-rule-end {
|
|
25
|
+
background: linear-gradient(to left, transparent, rgb(var(--color-muted) / 35%));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.hk-load-more-core {
|
|
29
|
+
flex: none;
|
|
30
|
+
display: inline-flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
gap: var(--space-10);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Delivery progress ("3 / 19"): mono, tabular so the digits don't
|
|
36
|
+
* shimmer while counting up. */
|
|
37
|
+
.hk-load-more-count {
|
|
38
|
+
font-family: var(--font-mono);
|
|
39
|
+
font-size: var(--text-2xs);
|
|
40
|
+
font-variant-numeric: tabular-nums;
|
|
41
|
+
letter-spacing: 0.04em;
|
|
42
|
+
color: rgb(var(--color-text-tertiary));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.hk-load-more[data-state="loading"] .hk-load-more-count {
|
|
46
|
+
opacity: 0.6;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.hk-load-more-end {
|
|
50
|
+
font-size: var(--text-2xs);
|
|
51
|
+
letter-spacing: 0.08em;
|
|
52
|
+
color: rgb(var(--color-text-tertiary));
|
|
53
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkLoadMore from "./HkLoadMore";
|
|
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
|
+
/** The en bundle loads eagerly, so the two-arg t() fallbacks double as
|
|
25
|
+
* the assertions' expected strings — no i18n test setup needed. */
|
|
26
|
+
describe("HkLoadMore", () => {
|
|
27
|
+
it("idle renders the load-more button and the shown/total counter", () => {
|
|
28
|
+
const c = mount(h(HkLoadMore, { shown: 3, total: 19 }));
|
|
29
|
+
const button = c.querySelector("button.hk-btn");
|
|
30
|
+
expect(button).not.toBeNull();
|
|
31
|
+
expect(button!.textContent).toContain("Load more");
|
|
32
|
+
expect(c.querySelector(".hk-load-more-count")!.textContent).toBe("3 / 19");
|
|
33
|
+
expect(c.querySelector(".hk-load-more")!.getAttribute("data-state")).toBe("idle");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("clicking the button emits load-more", () => {
|
|
37
|
+
const fired: string[] = [];
|
|
38
|
+
const c = mount(h(HkLoadMore, { onLoadMore: () => fired.push("more") }));
|
|
39
|
+
(c.querySelector("button.hk-btn") as HTMLButtonElement).click();
|
|
40
|
+
expect(fired).toEqual(["more"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("loading state disables the button with aria-busy and the loading label", () => {
|
|
44
|
+
const c = mount(h(HkLoadMore, { state: "loading", shown: 3, total: 19 }));
|
|
45
|
+
const button = c.querySelector("button.hk-btn") as HTMLButtonElement;
|
|
46
|
+
expect(button.disabled).toBe(true);
|
|
47
|
+
expect(button.getAttribute("aria-busy")).toBe("true");
|
|
48
|
+
expect(button.textContent).toContain("Loading…");
|
|
49
|
+
expect(c.querySelector(".hk-spinner")).not.toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("end state swaps the button for the reached-the-end status note", () => {
|
|
53
|
+
const c = mount(h(HkLoadMore, { state: "end", shown: 19, total: 19 }));
|
|
54
|
+
expect(c.querySelector("button.hk-btn")).toBeNull();
|
|
55
|
+
const note = c.querySelector('[role="status"]');
|
|
56
|
+
expect(note).not.toBeNull();
|
|
57
|
+
expect(note!.textContent).toContain("You've reached the end");
|
|
58
|
+
expect(c.querySelector(".hk-load-more-count")!.textContent).toBe("19 / 19");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("omits the counter entirely when shown/total are absent", () => {
|
|
62
|
+
const c = mount(h(HkLoadMore));
|
|
63
|
+
expect(c.querySelector(".hk-load-more-count")).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { defineComponent, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import "./HkLoadMore.scss";
|
|
4
|
+
import { useI18n } from "../i18n/context";
|
|
5
|
+
import HButton from "./HkButton";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The unified bottom indicator for dynamically loaded lists — one look
|
|
9
|
+
* for every consumer (cruise panels, tables, feeds), three states:
|
|
10
|
+
*
|
|
11
|
+
* - `idle`: a ghost load-more button; clicking emits `loadMore`.
|
|
12
|
+
* - `loading`: the SAME button with its built-in spinner (HkButton
|
|
13
|
+
* swaps in an HSpinner while loading) — no second spinner here, the
|
|
14
|
+
* affordance morphs in place at a stable height.
|
|
15
|
+
* - `end`: a muted "reached the end" note replacing the button.
|
|
16
|
+
*
|
|
17
|
+
* Optional `shown`/`total` render a tabular mono counter next to the
|
|
18
|
+
* control in every state ("3 / 19"), giving users the delivery
|
|
19
|
+
* progress without each app inventing its own wording.
|
|
20
|
+
*/
|
|
21
|
+
export default defineComponent({
|
|
22
|
+
name: "HkLoadMore",
|
|
23
|
+
props: {
|
|
24
|
+
state: { type: String as PropType<"idle" | "loading" | "end">, default: "idle" },
|
|
25
|
+
/** Delivered item count (with `total`, renders the progress counter). */
|
|
26
|
+
shown: { type: Number, default: undefined },
|
|
27
|
+
/** Full item count behind the windowed list. */
|
|
28
|
+
total: { type: Number, default: undefined },
|
|
29
|
+
},
|
|
30
|
+
emits: ["loadMore"],
|
|
31
|
+
setup(props, { emit }) {
|
|
32
|
+
const { t } = useI18n();
|
|
33
|
+
return () => {
|
|
34
|
+
const hasCount =
|
|
35
|
+
typeof props.shown === "number" && typeof props.total === "number";
|
|
36
|
+
const loading = props.state === "loading";
|
|
37
|
+
return (
|
|
38
|
+
<div class="hk-load-more" data-state={props.state}>
|
|
39
|
+
<span class="hk-load-more-rule" aria-hidden="true" />
|
|
40
|
+
<span class="hk-load-more-core">
|
|
41
|
+
{props.state === "end" ? (
|
|
42
|
+
<span class="hk-load-more-end" role="status">
|
|
43
|
+
{t("hikari::loadMore.end", "You've reached the end")}
|
|
44
|
+
</span>
|
|
45
|
+
) : (
|
|
46
|
+
<HButton
|
|
47
|
+
variant="ghost"
|
|
48
|
+
size="sm"
|
|
49
|
+
icon="ChevronDown"
|
|
50
|
+
loading={loading}
|
|
51
|
+
aria-label={
|
|
52
|
+
loading
|
|
53
|
+
? t("hikari::loadMore.loading", "Loading…")
|
|
54
|
+
: t("hikari::loadMore.more", "Load more")
|
|
55
|
+
}
|
|
56
|
+
onClick={() => emit("loadMore")}
|
|
57
|
+
>
|
|
58
|
+
{loading
|
|
59
|
+
? t("hikari::loadMore.loading", "Loading…")
|
|
60
|
+
: t("hikari::loadMore.more", "Load more")}
|
|
61
|
+
</HButton>
|
|
62
|
+
)}
|
|
63
|
+
{hasCount ? (
|
|
64
|
+
<span class="hk-load-more-count">{props.shown} / {props.total}</span>
|
|
65
|
+
) : null}
|
|
66
|
+
</span>
|
|
67
|
+
<span class="hk-load-more-rule hk-load-more-rule-end" aria-hidden="true" />
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
});
|
|
@@ -41,6 +41,16 @@
|
|
|
41
41
|
pointer-events: auto;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// Toolbar-only form (no overview map): shrink-wrap the card around the
|
|
45
|
+
// zoom bar and drop the map's separator — the bar IS the whole surface.
|
|
46
|
+
.hk-minimap[data-toolbar-only] {
|
|
47
|
+
width: auto;
|
|
48
|
+
|
|
49
|
+
.hk-mm-zoom-bar {
|
|
50
|
+
border-top: none;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
.hk-mm-zoom-btn {
|
|
45
55
|
display: flex;
|
|
46
56
|
align-items: center;
|