@celestia-island/hikari 0.42.1 → 0.43.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/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/HkMinimap.scss +10 -0
- package/src/components/HkMinimap.toolbarOnly.test.tsx +54 -0
- package/src/components/HkMinimap.tsx +44 -35
- package/src/components/HkThemeToggle.contract.test.ts +28 -0
- package/src/components/HkThemeToggle.scss +11 -1
- package/src/i18n/locales/ar/platform.json +1 -0
- package/src/i18n/locales/ar/time.json +9 -0
- package/src/i18n/locales/de/platform.json +1 -0
- package/src/i18n/locales/de/time.json +9 -0
- package/src/i18n/locales/en/platform.json +1 -0
- package/src/i18n/locales/en/time.json +9 -0
- package/src/i18n/locales/es/platform.json +1 -0
- package/src/i18n/locales/es/time.json +9 -0
- package/src/i18n/locales/fr/platform.json +1 -0
- package/src/i18n/locales/fr/time.json +9 -0
- package/src/i18n/locales/ja/platform.json +1 -0
- package/src/i18n/locales/ja/time.json +9 -0
- package/src/i18n/locales/ko/platform.json +1 -0
- package/src/i18n/locales/ko/time.json +9 -0
- package/src/i18n/locales/pt/platform.json +1 -0
- package/src/i18n/locales/pt/time.json +9 -0
- package/src/i18n/locales/ru/platform.json +1 -0
- package/src/i18n/locales/ru/time.json +9 -0
- package/src/i18n/locales/zh-Hans/platform.json +1 -0
- package/src/i18n/locales/zh-Hans/time.json +9 -0
- package/src/i18n/locales/zh-Hant/platform.json +1 -0
- package/src/i18n/locales/zh-Hant/time.json +9 -0
- package/src/i18n/timeKeys.test.ts +79 -0
- package/src/utils/format.test.ts +116 -0
- package/src/utils/format.ts +16 -3
package/package.json
CHANGED
|
@@ -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}
|
|
@@ -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;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkMinimap from "./HkMinimap";
|
|
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
|
+
function mountMinimap(props: Record<string, unknown>): HTMLElement {
|
|
15
|
+
const container = document.createElement("div");
|
|
16
|
+
document.body.appendChild(container);
|
|
17
|
+
containers.push(container);
|
|
18
|
+
|
|
19
|
+
const Host = defineComponent({
|
|
20
|
+
setup() {
|
|
21
|
+
return () => h("div", [h(HkMinimap, props)]);
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
const app = createApp(Host);
|
|
25
|
+
mounts.push(app);
|
|
26
|
+
app.mount(container);
|
|
27
|
+
return container;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("HkMinimap toolbar-only form", () => {
|
|
31
|
+
it("renders nothing without boxes, an image, or the toolbar-only flag", () => {
|
|
32
|
+
const c = mountMinimap({ zoomPercent: 100 });
|
|
33
|
+
expect(c.querySelector(".hk-minimap")).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("renders only the zoom bar under toolbarOnly (no overview map)", () => {
|
|
37
|
+
const c = mountMinimap({ zoomPercent: 100, toolbarOnly: true });
|
|
38
|
+
const root = c.querySelector<HTMLElement>(".hk-minimap");
|
|
39
|
+
expect(root).not.toBeNull();
|
|
40
|
+
expect(root!.hasAttribute("data-toolbar-only")).toBe(true);
|
|
41
|
+
expect(root!.querySelector(".hk-minimap-svg")).toBeNull();
|
|
42
|
+
expect(root!.querySelector(".hk-mm-zoom-bar")).not.toBeNull();
|
|
43
|
+
expect(root!.querySelectorAll(".hk-mm-zoom-btn").length).toBe(2);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("keeps the overview map when toolbarOnly is not set and an image is given", () => {
|
|
47
|
+
const c = mountMinimap({ imageSrc: "data:image/gif;base64,", toolbarOnly: false });
|
|
48
|
+
const root = c.querySelector<HTMLElement>(".hk-minimap");
|
|
49
|
+
expect(root).not.toBeNull();
|
|
50
|
+
expect(root!.hasAttribute("data-toolbar-only")).toBe(false);
|
|
51
|
+
expect(root!.querySelector(".hk-minimap-svg")).not.toBeNull();
|
|
52
|
+
expect(root!.querySelector(".hk-mm-zoom-bar")).not.toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -65,6 +65,12 @@ export default defineComponent({
|
|
|
65
65
|
/** Show the reset/fit button in the zoom bar (chest only rendered it
|
|
66
66
|
* when a reset handler was wired up). */
|
|
67
67
|
showReset: { type: Boolean, default: false },
|
|
68
|
+
/** Toolbar-only form: render just the zoom bar, no overview map. For
|
|
69
|
+
* hosts whose viewport rect is uninformative at rest (e.g. the image
|
|
70
|
+
* lightbox at fit zoom) but that still want the zoom controls
|
|
71
|
+
* visible; the map is expected to reappear once it has something to
|
|
72
|
+
* say (the viewer switches this off when zoomed past fit). */
|
|
73
|
+
toolbarOnly: { type: Boolean, default: false },
|
|
68
74
|
/** Optional prop-callback surface (alternative to the emits). */
|
|
69
75
|
onZoomTo: { type: Function as PropType<(percent: number) => void>, default: undefined },
|
|
70
76
|
onReset: { type: Function as PropType<() => void>, default: undefined },
|
|
@@ -180,7 +186,7 @@ export default defineComponent({
|
|
|
180
186
|
});
|
|
181
187
|
|
|
182
188
|
return () => {
|
|
183
|
-
if (props.boxes.length === 0 && !props.imageSrc) return null;
|
|
189
|
+
if (props.boxes.length === 0 && !props.imageSrc && !props.toolbarOnly) return null;
|
|
184
190
|
|
|
185
191
|
const s = scale.value;
|
|
186
192
|
const vr = viewportRect.value;
|
|
@@ -213,42 +219,45 @@ export default defineComponent({
|
|
|
213
219
|
ref={rootRef}
|
|
214
220
|
class="hk-minimap"
|
|
215
221
|
data-dragging={dragging.value ? "" : undefined}
|
|
222
|
+
data-toolbar-only={props.toolbarOnly ? "" : undefined}
|
|
216
223
|
>
|
|
217
|
-
|
|
218
|
-
{
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
224
|
+
{!props.toolbarOnly && (
|
|
225
|
+
<svg viewBox={`0 0 ${svgW} ${svgH}`} width={svgW} height={svgH} class="hk-minimap-svg">
|
|
226
|
+
{imgPos && (
|
|
227
|
+
<image
|
|
228
|
+
href={props.imageSrc}
|
|
229
|
+
x={imgPos[0]}
|
|
230
|
+
y={imgPos[1]}
|
|
231
|
+
width={ib.w * s}
|
|
232
|
+
height={ib.h * s}
|
|
233
|
+
preserveAspectRatio="none"
|
|
234
|
+
class="hk-mm-image"
|
|
235
|
+
/>
|
|
236
|
+
)}
|
|
237
|
+
{boxRects}
|
|
238
|
+
{hubP && (
|
|
239
|
+
<circle
|
|
240
|
+
cx={hubP[0]}
|
|
241
|
+
cy={hubP[1]}
|
|
242
|
+
r={3}
|
|
243
|
+
fill="rgb(var(--color-primary))"
|
|
244
|
+
filter="drop-shadow(0 0 2px rgb(var(--color-primary) / 0.6))"
|
|
245
|
+
/>
|
|
246
|
+
)}
|
|
247
|
+
<rect
|
|
248
|
+
x={vr.x}
|
|
249
|
+
y={vr.y}
|
|
250
|
+
width={Math.max(1, vr.w)}
|
|
251
|
+
height={Math.max(1, vr.h)}
|
|
252
|
+
fill="none"
|
|
253
|
+
stroke="rgb(var(--color-primary))"
|
|
254
|
+
stroke-width="1"
|
|
255
|
+
stroke-dasharray="3 2"
|
|
256
|
+
rx="2"
|
|
257
|
+
opacity="0.85"
|
|
227
258
|
/>
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
{hubP && (
|
|
231
|
-
<circle
|
|
232
|
-
cx={hubP[0]}
|
|
233
|
-
cy={hubP[1]}
|
|
234
|
-
r={3}
|
|
235
|
-
fill="rgb(var(--color-primary))"
|
|
236
|
-
filter="drop-shadow(0 0 2px rgb(var(--color-primary) / 0.6))"
|
|
237
|
-
/>
|
|
238
|
-
)}
|
|
239
|
-
<rect
|
|
240
|
-
x={vr.x}
|
|
241
|
-
y={vr.y}
|
|
242
|
-
width={Math.max(1, vr.w)}
|
|
243
|
-
height={Math.max(1, vr.h)}
|
|
244
|
-
fill="none"
|
|
245
|
-
stroke="rgb(var(--color-primary))"
|
|
246
|
-
stroke-width="1"
|
|
247
|
-
stroke-dasharray="3 2"
|
|
248
|
-
rx="2"
|
|
249
|
-
opacity="0.85"
|
|
250
|
-
/>
|
|
251
|
-
</svg>
|
|
259
|
+
</svg>
|
|
260
|
+
)}
|
|
252
261
|
<div class="hk-mm-zoom-bar">
|
|
253
262
|
<button
|
|
254
263
|
class="hk-mm-zoom-btn"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the theme-row leading cell sizing (2026-09-11
|
|
3
|
+
* field report: chest's 28px three-dot palette swatch overflowed its
|
|
4
|
+
* 16px generic icon cell and bled into the row gap — at fractional zoom
|
|
5
|
+
* rounding the dots visually overlapped the row name). happy-dom has no
|
|
6
|
+
* layout engine, so the geometry contract is pinned as an scss-text
|
|
7
|
+
* assertion: the theme-row lead cell must carry an explicit size that
|
|
8
|
+
* fits the widest host lead mark, not just the mixin's generic box.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const read = (f: string): string => readFileSync(join(here, f), "utf-8");
|
|
17
|
+
|
|
18
|
+
describe("HkThemeToggle row lead-cell contract", () => {
|
|
19
|
+
it("sizes the theme-row lead cell to fit a 28px swatch mark", () => {
|
|
20
|
+
const css = read("HkThemeToggle.scss");
|
|
21
|
+
const block = css.match(
|
|
22
|
+
/\.s-theme-item-btn \.hk-menu-item-icon\s*\{[^}]*\}/,
|
|
23
|
+
);
|
|
24
|
+
expect(block).not.toBeNull();
|
|
25
|
+
expect(block![0]).toContain("width: 28px");
|
|
26
|
+
expect(block![0]).toContain("height: 28px");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -188,9 +188,19 @@
|
|
|
188
188
|
@include mi.label;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
/* Leading check / customize glyphs share the spec's fixed icon cell
|
|
191
|
+
/* Leading check / customize glyphs share the spec's fixed icon cell —
|
|
192
|
+
* sized for the WIDEST lead mark hosts render there, not the generic
|
|
193
|
+
* 16px box: chest's three-dot palette swatch is 28px (3×8px dots + 2px
|
|
194
|
+
* gaps), and a mark wider than its cell bleeds symmetrically out of the
|
|
195
|
+
* cell into the row gap — at fractional zoom rounding the dots visually
|
|
196
|
+
* overlap the name (2026-09-11 field report). A uniform 28px cell keeps
|
|
197
|
+
* every row's name starting at the same x (swatch rows and icon rows
|
|
198
|
+
* stay aligned) and leaves the bleed zero room at any zoom. */
|
|
192
199
|
.s-theme-item-btn .hk-menu-item-icon {
|
|
193
200
|
@include mi.icon;
|
|
201
|
+
|
|
202
|
+
width: 28px;
|
|
203
|
+
height: 28px;
|
|
194
204
|
}
|
|
195
205
|
|
|
196
206
|
/* Custom rows keep the name clear of the overlaid delete button. */
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"hikari::theme.tokens.onSolidIcon": "أيقونات على لون العلامة",
|
|
42
42
|
"hikari::theme.groupCount": "{count} ألوان",
|
|
43
43
|
"hikari::theme.extendedColors": "ألوان موسعة",
|
|
44
|
+
"hikari::theme.onSolidGroupTitle": "المحتوى على لون العلامة",
|
|
44
45
|
"hikari::secret.title": "سر",
|
|
45
46
|
"hikari::secret.notice": "يُعرض هذا السر مرة واحدة فقط. انسخه واحفظه قبل الإغلاق.",
|
|
46
47
|
"hikari::secret.copy": "نسخ",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"hikari::theme.tokens.onSolidIcon": "Symbole auf Markenfarbe",
|
|
42
42
|
"hikari::theme.groupCount": "{count} Farben",
|
|
43
43
|
"hikari::theme.extendedColors": "Erweiterte Farben",
|
|
44
|
+
"hikari::theme.onSolidGroupTitle": "Inhalte auf Brandfarben",
|
|
44
45
|
"hikari::secret.title": "Geheimnis",
|
|
45
46
|
"hikari::secret.notice": "Dieses Geheimnis wird nur einmal angezeigt. Kopieren und speichern Sie es vor dem Schließen.",
|
|
46
47
|
"hikari::secret.copy": "Kopieren",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"hikari::theme.tokens.onSolidIcon": "Icons on brand fill",
|
|
42
42
|
"hikari::theme.groupCount": "{count} colors",
|
|
43
43
|
"hikari::theme.extendedColors": "Extended colors",
|
|
44
|
+
"hikari::theme.onSolidGroupTitle": "On-brand content",
|
|
44
45
|
"hikari::secret.title": "Secret",
|
|
45
46
|
"hikari::secret.notice": "This secret is shown only once. Copy and store it before closing.",
|
|
46
47
|
"hikari::secret.copy": "Copy",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"hikari::theme.tokens.onSolidIcon": "Iconos sobre color de marca",
|
|
42
42
|
"hikari::theme.groupCount": "{count} colores",
|
|
43
43
|
"hikari::theme.extendedColors": "Colores extendidos",
|
|
44
|
+
"hikari::theme.onSolidGroupTitle": "Contenido sobre el color de marca",
|
|
44
45
|
"hikari::secret.title": "Secreto",
|
|
45
46
|
"hikari::secret.notice": "Este secreto se muestra solo una vez. Cópielo y guárdelo antes de cerrar.",
|
|
46
47
|
"hikari::secret.copy": "Copiar",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"hikari::theme.tokens.onSolidIcon": "Icônes sur la couleur de marque",
|
|
42
42
|
"hikari::theme.groupCount": "{count} couleurs",
|
|
43
43
|
"hikari::theme.extendedColors": "Couleurs étendues",
|
|
44
|
+
"hikari::theme.onSolidGroupTitle": "Contenu sur la couleur de marque",
|
|
44
45
|
"hikari::secret.title": "Secret",
|
|
45
46
|
"hikari::secret.notice": "Ce secret n'est affiché qu'une seule fois. Copiez-le et conservez-le avant de fermer.",
|
|
46
47
|
"hikari::secret.copy": "Copier",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"hikari::theme.tokens.onSolidIcon": "ブランド色上のアイコン",
|
|
42
42
|
"hikari::theme.groupCount": "{count} 色",
|
|
43
43
|
"hikari::theme.extendedColors": "拡張カラー",
|
|
44
|
+
"hikari::theme.onSolidGroupTitle": "ブランドカラー上のコンテンツ",
|
|
44
45
|
"hikari::secret.title": "シークレット",
|
|
45
46
|
"hikari::secret.notice": "このシークレットは一度だけ表示されます。閉じる前にコピーして保存してください。",
|
|
46
47
|
"hikari::secret.copy": "コピー",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"hikari::theme.tokens.onSolidIcon": "브랜드 색 위의 아이콘",
|
|
42
42
|
"hikari::theme.groupCount": "색상 {count}개",
|
|
43
43
|
"hikari::theme.extendedColors": "확장 색상",
|
|
44
|
+
"hikari::theme.onSolidGroupTitle": "브랜드 색상 콘텐츠",
|
|
44
45
|
"hikari::secret.title": "비밀",
|
|
45
46
|
"hikari::secret.notice": "이 비밀은 한 번만 표시됩니다. 닫기 전에 복사하여 저장하세요.",
|
|
46
47
|
"hikari::secret.copy": "복사",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"hikari::theme.tokens.onSolidIcon": "Ícones sobre a cor da marca",
|
|
42
42
|
"hikari::theme.groupCount": "{count} cores",
|
|
43
43
|
"hikari::theme.extendedColors": "Cores estendidas",
|
|
44
|
+
"hikari::theme.onSolidGroupTitle": "Conteúdo sobre a cor da marca",
|
|
44
45
|
"hikari::secret.title": "Segredo",
|
|
45
46
|
"hikari::secret.notice": "Este segredo é mostrado apenas uma vez. Copie e guarde antes de fechar.",
|
|
46
47
|
"hikari::secret.copy": "Copiar",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"hikari::theme.tokens.onSolidIcon": "Значки на фирменном цвете",
|
|
42
42
|
"hikari::theme.groupCount": "Цветов: {count}",
|
|
43
43
|
"hikari::theme.extendedColors": "Расширенные цвета",
|
|
44
|
+
"hikari::theme.onSolidGroupTitle": "Контент на фирменном цвете",
|
|
44
45
|
"hikari::secret.title": "Секрет",
|
|
45
46
|
"hikari::secret.notice": "Этот секрет показывается только один раз. Скопируйте и сохраните его до закрытия.",
|
|
46
47
|
"hikari::secret.copy": "Копировать",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"hikari::theme.tokens.onSolidIcon": "品牌色上的图标",
|
|
42
42
|
"hikari::theme.groupCount": "{count} 种颜色",
|
|
43
43
|
"hikari::theme.extendedColors": "扩展配色",
|
|
44
|
+
"hikari::theme.onSolidGroupTitle": "品牌色内容",
|
|
44
45
|
"hikari::secret.title": "密钥",
|
|
45
46
|
"hikari::secret.notice": "此密钥仅显示一次,请先复制并妥善保存。",
|
|
46
47
|
"hikari::secret.copy": "复制",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"hikari::theme.tokens.onSolidIcon": "品牌色上的圖示",
|
|
42
42
|
"hikari::theme.groupCount": "{count} 種顏色",
|
|
43
43
|
"hikari::theme.extendedColors": "擴充配色",
|
|
44
|
+
"hikari::theme.onSolidGroupTitle": "品牌色內容",
|
|
44
45
|
"hikari::secret.title": "金鑰",
|
|
45
46
|
"hikari::secret.notice": "此金鑰僅顯示一次,請先複製並妥善保存。",
|
|
46
47
|
"hikari::secret.copy": "複製",
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { setLocale, useI18n } from "./context";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Locale key pack for the formatRelativeTime facility
|
|
7
|
+
* (common.time.justNow/minutesAgo/hoursAgo/daysAgo/weeksAgo, served from
|
|
8
|
+
* the new locales/<lang>/time.json bundles). The JSON keys are the literal
|
|
9
|
+
* unprefixed full names because t() resolves by exact full-name match
|
|
10
|
+
* (context.ts merges string leaves under their literal key) and the
|
|
11
|
+
* facility + its host-t consumers call t("common.time.*") unprefixed —
|
|
12
|
+
* the "hikari::" prefix convention is reserved for
|
|
13
|
+
* hikari-component-owned keys. A key missing from any locale silently
|
|
14
|
+
* falls back to English there, so every locale must define all five keys
|
|
15
|
+
* with matching {n} placeholder presence (hikari review lessons
|
|
16
|
+
* #373/#374, same shape as contextKeys.test.ts).
|
|
17
|
+
*/
|
|
18
|
+
const modules = import.meta.glob<{ default: Record<string, Record<string, string>> }>(
|
|
19
|
+
"./locales/*/time.json",
|
|
20
|
+
{ eager: true },
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const EXPECTED_LOCALES = [
|
|
24
|
+
"ar", "de", "en", "es", "fr", "ja", "ko", "pt", "ru", "zh-Hans", "zh-Hant",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const REQUIRED_KEYS = [
|
|
28
|
+
"common.time.justNow",
|
|
29
|
+
"common.time.minutesAgo",
|
|
30
|
+
"common.time.hoursAgo",
|
|
31
|
+
"common.time.daysAgo",
|
|
32
|
+
"common.time.weeksAgo",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
describe("common.time locale keys", () => {
|
|
36
|
+
const bundles = Object.entries(modules).map(([path, mod]) => ({
|
|
37
|
+
locale: path.match(/locales\/([^/]+)\/time\.json/)?.[1] ?? "",
|
|
38
|
+
flat: mod.default.time ?? {},
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
it("covers all 11 time.json locale files", () => {
|
|
42
|
+
expect(bundles.map((b) => b.locale).sort()).toEqual(EXPECTED_LOCALES);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("defines all 5 common.time.* keys in every locale, non-empty", () => {
|
|
46
|
+
for (const { locale, flat } of bundles) {
|
|
47
|
+
for (const key of REQUIRED_KEYS) {
|
|
48
|
+
expect(typeof flat[key], `${locale} must define ${key}`).toBe("string");
|
|
49
|
+
expect((flat[key] ?? "").length, `${locale} ${key} must not be empty`).toBeGreaterThan(0);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("matches the {n} placeholder pattern of en in every locale", () => {
|
|
55
|
+
const en = bundles.find((b) => b.locale === "en");
|
|
56
|
+
expect(en, "en bundle renders").toBeTruthy();
|
|
57
|
+
for (const { locale, flat } of bundles) {
|
|
58
|
+
for (const key of REQUIRED_KEYS) {
|
|
59
|
+
const wantsN = en!.flat[key].includes("{n}");
|
|
60
|
+
expect(flat[key].includes("{n}"), `${locale} ${key} {n} parity`).toBe(wantsN);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("resolves through useI18n at runtime, per locale", async () => {
|
|
66
|
+
await setLocale("zh-Hans");
|
|
67
|
+
const zh = useI18n();
|
|
68
|
+
expect(zh.t("common.time.justNow", "Just now")).toBe("刚刚");
|
|
69
|
+
expect(zh.t("common.time.hoursAgo", "{n} h ago").replace("{n}", "3")).toBe("3 小时前");
|
|
70
|
+
|
|
71
|
+
await setLocale("de");
|
|
72
|
+
const de = useI18n();
|
|
73
|
+
expect(de.t("common.time.daysAgo", "{n} d ago").replace("{n}", "2")).toBe("vor 2 Tg.");
|
|
74
|
+
|
|
75
|
+
await setLocale("en");
|
|
76
|
+
const en = useI18n();
|
|
77
|
+
expect(en.t("common.time.weeksAgo", "{n} w ago")).toBe("{n} w ago");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { formatRelativeTime, type RelativeTimeT } from "./format";
|
|
4
|
+
|
|
5
|
+
const MIN = 60_000;
|
|
6
|
+
const HOUR = 3_600_000;
|
|
7
|
+
const DAY = 86_400_000;
|
|
8
|
+
|
|
9
|
+
function ago(ms: number): Date {
|
|
10
|
+
return new Date(Date.now() - ms);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CapturedCall {
|
|
14
|
+
key: string;
|
|
15
|
+
fallback: string;
|
|
16
|
+
named?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Fake translator: records every lookup and renders the {n} slot so
|
|
20
|
+
* injection can be asserted end-to-end. */
|
|
21
|
+
function makeCaptureT(): { t: RelativeTimeT; calls: CapturedCall[] } {
|
|
22
|
+
const calls: CapturedCall[] = [];
|
|
23
|
+
const t: RelativeTimeT = (key, fallback, named) => {
|
|
24
|
+
calls.push({ key, fallback, named });
|
|
25
|
+
return fallback.replace("{n}", String(named?.n ?? ""));
|
|
26
|
+
};
|
|
27
|
+
return { t, calls };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("formatRelativeTime", () => {
|
|
31
|
+
it("returns an empty string for missing input", () => {
|
|
32
|
+
expect(formatRelativeTime("")).toBe("");
|
|
33
|
+
expect(formatRelativeTime(0)).toBe("");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns an empty string for unparseable input", () => {
|
|
37
|
+
expect(formatRelativeTime("not-a-date")).toBe("");
|
|
38
|
+
expect(formatRelativeTime(Number.NaN)).toBe("");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("renders the compact English fallback per tier", () => {
|
|
42
|
+
expect(formatRelativeTime(ago(59_000))).toBe("Just now");
|
|
43
|
+
expect(formatRelativeTime(ago(MIN))).toBe("1m ago");
|
|
44
|
+
expect(formatRelativeTime(ago(59 * MIN))).toBe("59m ago");
|
|
45
|
+
expect(formatRelativeTime(ago(HOUR))).toBe("1h ago");
|
|
46
|
+
expect(formatRelativeTime(ago(23 * HOUR))).toBe("23h ago");
|
|
47
|
+
expect(formatRelativeTime(ago(DAY))).toBe("1d ago");
|
|
48
|
+
expect(formatRelativeTime(ago(6 * DAY))).toBe("6d ago");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("adds the weeks tier from 7d up to (excluding) 30d", () => {
|
|
52
|
+
expect(formatRelativeTime(ago(7 * DAY))).toBe("1w ago");
|
|
53
|
+
expect(formatRelativeTime(ago(13 * DAY))).toBe("1w ago");
|
|
54
|
+
expect(formatRelativeTime(ago(14 * DAY))).toBe("2w ago");
|
|
55
|
+
expect(formatRelativeTime(ago(29 * DAY))).toBe("4w ago");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("falls to the absolute-date path at >= 30d", () => {
|
|
59
|
+
const d = ago(30 * DAY);
|
|
60
|
+
// Do not pin a locale-specific rendering; assert the value comes
|
|
61
|
+
// from the same Date's toLocaleDateString().
|
|
62
|
+
expect(formatRelativeTime(d)).toBe(d.toLocaleDateString());
|
|
63
|
+
expect(formatRelativeTime(d).length).toBeGreaterThan(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("clamps future timestamps into the justNow tier", () => {
|
|
67
|
+
expect(formatRelativeTime(ago(-5 * MIN))).toBe("Just now");
|
|
68
|
+
expect(formatRelativeTime(ago(-2 * DAY))).toBe("Just now");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("passes the documented keys with injected {n} to the translator", () => {
|
|
72
|
+
const { t, calls } = makeCaptureT();
|
|
73
|
+
|
|
74
|
+
expect(formatRelativeTime(ago(30_000), t)).toBe("Just now");
|
|
75
|
+
expect(calls[0]).toEqual({
|
|
76
|
+
key: "common.time.justNow",
|
|
77
|
+
fallback: "Just now",
|
|
78
|
+
named: undefined,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(formatRelativeTime(ago(5 * MIN), t)).toBe("5 min ago");
|
|
82
|
+
expect(calls[1]).toEqual({
|
|
83
|
+
key: "common.time.minutesAgo",
|
|
84
|
+
fallback: "{n} min ago",
|
|
85
|
+
named: { n: 5 },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(formatRelativeTime(ago(3 * HOUR), t)).toBe("3 h ago");
|
|
89
|
+
expect(calls[2]).toEqual({
|
|
90
|
+
key: "common.time.hoursAgo",
|
|
91
|
+
fallback: "{n} h ago",
|
|
92
|
+
named: { n: 3 },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(formatRelativeTime(ago(2 * DAY), t)).toBe("2 d ago");
|
|
96
|
+
expect(calls[3]).toEqual({
|
|
97
|
+
key: "common.time.daysAgo",
|
|
98
|
+
fallback: "{n} d ago",
|
|
99
|
+
named: { n: 2 },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(formatRelativeTime(ago(8 * DAY), t)).toBe("1 w ago");
|
|
103
|
+
expect(calls[4]).toEqual({
|
|
104
|
+
key: "common.time.weeksAgo",
|
|
105
|
+
fallback: "{n} w ago",
|
|
106
|
+
named: { n: 1 },
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("keeps absolute dates away from the translator", () => {
|
|
111
|
+
const { t, calls } = makeCaptureT();
|
|
112
|
+
const d = ago(45 * DAY);
|
|
113
|
+
expect(formatRelativeTime(d, t)).toBe(d.toLocaleDateString());
|
|
114
|
+
expect(calls).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
});
|
package/src/utils/format.ts
CHANGED
|
@@ -37,7 +37,8 @@ export function formatPriceUsd(n: number, currency = "$"): string {
|
|
|
37
37
|
return `${currency}${n.toFixed(2)}`;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** Timestamp -> "Just now" / "5m ago" / "3h ago" / "2d ago" /
|
|
40
|
+
/** Timestamp -> "Just now" / "5m ago" / "3h ago" / "2d ago" / "2w ago" /
|
|
41
|
+
* locale date. */
|
|
41
42
|
export type RelativeTimeT = (
|
|
42
43
|
key: string,
|
|
43
44
|
fallback: string,
|
|
@@ -46,7 +47,12 @@ export type RelativeTimeT = (
|
|
|
46
47
|
|
|
47
48
|
/** Relative-time formatting ("just now / {n} min ago / …").
|
|
48
49
|
* Pass an optional translator for localized variants; defaults to
|
|
49
|
-
* compact English text.
|
|
50
|
+
* compact English text. Tiers: <1min justNow, <60min minutes,
|
|
51
|
+
* <24h hours, <7d days, <30d weeks, otherwise an absolute
|
|
52
|
+
* locale-rendered date (browser locale, like formatDateTime).
|
|
53
|
+
* The translator owns {n} interpolation — hikari's own useI18n().t
|
|
54
|
+
* does NOT interpolate named params, so wrap it (the canonical key
|
|
55
|
+
* set lives in the per-locale i18n time bundles). */
|
|
50
56
|
export function formatRelativeTime(
|
|
51
57
|
input: string | number | Date,
|
|
52
58
|
t?: RelativeTimeT,
|
|
@@ -54,7 +60,8 @@ export function formatRelativeTime(
|
|
|
54
60
|
if (!input) return "";
|
|
55
61
|
const d = input instanceof Date ? input : new Date(input);
|
|
56
62
|
if (isNaN(d.getTime())) return "";
|
|
57
|
-
|
|
63
|
+
// Clamp future timestamps to "just now" — no negative weeks/days.
|
|
64
|
+
const diff = Math.max(0, Date.now() - d.getTime());
|
|
58
65
|
const mins = Math.floor(diff / 60000);
|
|
59
66
|
const hours = Math.floor(diff / 3600000);
|
|
60
67
|
const days = Math.floor(diff / 86400000);
|
|
@@ -62,6 +69,12 @@ export function formatRelativeTime(
|
|
|
62
69
|
if (mins < 60) return t?.("common.time.minutesAgo", "{n} min ago", { n: mins }) ?? `${mins}m ago`;
|
|
63
70
|
if (hours < 24) return t?.("common.time.hoursAgo", "{n} h ago", { n: hours }) ?? `${hours}h ago`;
|
|
64
71
|
if (days < 7) return t?.("common.time.daysAgo", "{n} d ago", { n: days }) ?? `${days}d ago`;
|
|
72
|
+
if (days < 30) {
|
|
73
|
+
const weeks = Math.floor(days / 7);
|
|
74
|
+
return (
|
|
75
|
+
t?.("common.time.weeksAgo", "{n} w ago", { n: weeks }) ?? `${weeks}w ago`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
65
78
|
return d.toLocaleDateString();
|
|
66
79
|
}
|
|
67
80
|
|