@celestia-island/hikari 0.42.2 → 0.43.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.42.2",
3
+ "version": "0.43.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
package/src/DemoApp.tsx CHANGED
@@ -150,6 +150,8 @@ export default defineComponent({
150
150
  { key: "c", label: "Archived" },
151
151
  ],
152
152
  });
153
+ const centerAnchor = ref<HTMLElement | null>(null);
154
+ const centerPanelOpen = ref(false);
153
155
  function togglePanelOpt(key: string, v: boolean) {
154
156
  panel.value.checked = v
155
157
  ? [...panel.value.checked, key]
@@ -417,6 +419,26 @@ export default defineComponent({
417
419
  >{opt.label}</HCheckbox>
418
420
  ))}
419
421
  </HSelectPanel>
422
+ <span style="align-self:center;color:var(--color-muted, gray)">center placement:</span>
423
+ <span ref={centerAnchor} style="display:inline-flex">
424
+ <HButton onClick={() => (centerPanelOpen.value = !centerPanelOpen.value)}>Engine {centerPanelOpen.value ? <ChevronUp size={14} /> : <ChevronDown size={14} />}</HButton>
425
+ </span>
426
+ <HSelectPanel
427
+ open={centerPanelOpen.value}
428
+ onUpdate:open={(v: boolean) => (centerPanelOpen.value = v)}
429
+ anchorRef={centerAnchor.value ?? null}
430
+ title="Engine"
431
+ placement="top-center"
432
+ matchAnchorWidth={false}
433
+ >
434
+ {panel.value.opts.map((opt) => (
435
+ <HCheckbox
436
+ key={opt.key}
437
+ modelValue={panel.value.checked.includes(opt.key)}
438
+ onUpdate:modelValue={(v: boolean) => togglePanelOpt(opt.key, v)}
439
+ >{opt.label}</HCheckbox>
440
+ ))}
441
+ </HSelectPanel>
420
442
  </div>
421
443
  </section>
422
444
 
@@ -106,14 +106,16 @@
106
106
  position: fixed;
107
107
  min-width: 180px;
108
108
 
109
- /* Pop motion family — the popout grows out of the anchor edge it sits
110
- * on (data-side/data-align are set by HkSelectPanel from the RESOLVED
111
- * placement, including after an auto-flip) and retracts back into it.
112
- * Same tokens as HkPopover/HkTooltip so every anchored surface moves
113
- * with one curve. */
109
+ /* Pop motion family — the popout grows out of the anchor edge (or
110
+ * midpoint, for the -center placements) it sits on (data-side/data-align
111
+ * are set by HkSelectPanel from the RESOLVED placement, including after
112
+ * an auto-flip) and retracts back into it. Same tokens as
113
+ * HkPopover/HkTooltip so every anchored surface moves with one curve. */
114
114
  &[data-side="bottom"] { transform-origin: top left; }
115
+ &[data-side="bottom"][data-align="center"] { transform-origin: top center; }
115
116
  &[data-side="bottom"][data-align="end"] { transform-origin: top right; }
116
117
  &[data-side="top"] { transform-origin: bottom left; }
118
+ &[data-side="top"][data-align="center"] { transform-origin: bottom center; }
117
119
  &[data-side="top"][data-align="end"] { transform-origin: bottom right; }
118
120
  }
119
121
 
@@ -301,6 +301,94 @@ describe("HkSelectPanel custom invocation", () => {
301
301
  expect(flipped).toBeGreaterThan(8);
302
302
  });
303
303
 
304
+ it("balances a top-center popout on the anchor's horizontal midpoint", async () => {
305
+ // happy-dom lays nothing out, so the panel measures at the
306
+ // max(anchor, 180) fallback width — anchor spanning 200..360 (midpoint
307
+ // 280, fallback width 180) puts the balanced left edge at
308
+ // 280 - 180/2 = 190.
309
+ const { container, open } = mountPanel({ placement: "top-center" });
310
+ await nextTick();
311
+
312
+ const anchor = container.querySelector<HTMLButtonElement>("button")!;
313
+ anchor.getBoundingClientRect = () =>
314
+ ({ top: 500, bottom: 520, left: 200, right: 360, width: 160, height: 20 }) as DOMRect;
315
+ open.value = true;
316
+ await nextTick();
317
+
318
+ const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
319
+ expect(host.style.left).toBe("190px");
320
+ // Top-side still honored (anchor bottom 520 → panel above it), and the
321
+ // resolved alignment drives the pop motion's transform origin.
322
+ expect(Number.parseInt(host.style.top, 10)).toBeLessThan(520);
323
+ expect(host.dataset.align).toBe("center");
324
+ expect(host.dataset.side).toBe("top");
325
+
326
+ // A centered panel poking past the left edge clamps to the viewport
327
+ // pad instead of mirroring the overflow to both sides: anchor
328
+ // 0..40 → raw left (20 - 180/2) = -70 → clamped to 8.
329
+ anchor.getBoundingClientRect = () =>
330
+ ({ top: 500, bottom: 520, left: 0, right: 40, width: 40, height: 20 }) as DOMRect;
331
+ window.dispatchEvent(new Event("resize"));
332
+ await nextTick();
333
+ expect(host.style.left).toBe("8px");
334
+ });
335
+
336
+ it("reports center alignment through an auto-flip so the pop origin follows", async () => {
337
+ // mountPanel's rig anchor sits at the very top of the test layout, so
338
+ // a top-center panel cannot fit above and flips bottom-side — the
339
+ // flip must carry data-side/data-align with it (transform origin).
340
+ const { container, open } = mountPanel({ placement: "top-center" });
341
+ await nextTick();
342
+ open.value = true;
343
+ await nextTick();
344
+ await new Promise((resolve) => setTimeout(resolve, 0));
345
+ await nextTick();
346
+
347
+ const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
348
+ expect(host.dataset.side).toBe("bottom");
349
+ expect(host.dataset.align).toBe("center");
350
+ });
351
+
352
+ it("opens an unflipped bottom-center popout below the anchor", async () => {
353
+ // The bottom-center twin: same balance math, opposite side, and no
354
+ // flip here (the faked anchor sits low with room below).
355
+ const { container, open } = mountPanel({ placement: "bottom-center" });
356
+ await nextTick();
357
+
358
+ const anchor = container.querySelector<HTMLButtonElement>("button")!;
359
+ anchor.getBoundingClientRect = () =>
360
+ ({ top: 100, bottom: 120, left: 200, right: 360, width: 160, height: 20 }) as DOMRect;
361
+ open.value = true;
362
+ await nextTick();
363
+
364
+ const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
365
+ expect(host.dataset.side).toBe("bottom");
366
+ expect(host.dataset.align).toBe("center");
367
+ // Anchor top 100 + offset 4 → the panel hangs below 104.
368
+ expect(Number.parseInt(host.style.top, 10)).toBeGreaterThan(120);
369
+ expect(host.style.left).toBe("190px");
370
+ });
371
+
372
+ it("clamps a centered popout at the right viewport pad near the right edge", async () => {
373
+ // Anchor hugging the right edge: raw balanced left 1100 + (80 - 180)/2
374
+ // = 1050 would poke past 1200 - 8 - 180 = 1012 → pinned at the pad.
375
+ // The expected left edge is viewport arithmetic, so pin the width here
376
+ // (happy-dom's bare default is 1024 — the afterEach reset only helps
377
+ // full-file runs, not -t filtering; afterEach re-asserts 1200 anyway).
378
+ setViewport(1200);
379
+ const { container, open } = mountPanel({ placement: "bottom-center" });
380
+ await nextTick();
381
+
382
+ const anchor = container.querySelector<HTMLButtonElement>("button")!;
383
+ anchor.getBoundingClientRect = () =>
384
+ ({ top: 100, bottom: 120, left: 1100, right: 1180, width: 80, height: 20 }) as DOMRect;
385
+ open.value = true;
386
+ await nextTick();
387
+
388
+ const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
389
+ expect(host.style.left).toBe("1012px");
390
+ });
391
+
304
392
  it("clamps a tall flipped popout into the viewport instead of going negative", async () => {
305
393
  // The taller viewport-relative CSS cap admits ~576px panels: anchored
306
394
  // mid-viewport (top 380 / bottom 424 on an 800px-tall viewport), a
@@ -56,8 +56,10 @@ import "./HkSelect.scss";
56
56
 
57
57
  export type SelectPanelPlacement =
58
58
  | "bottom-start"
59
+ | "bottom-center"
59
60
  | "bottom-end"
60
61
  | "top-start"
62
+ | "top-center"
61
63
  | "top-end";
62
64
 
63
65
  const VIEWPORT_PAD = 8;
@@ -71,7 +73,10 @@ export default defineComponent({
71
73
  anchorRef: { type: Object as PropType<HTMLElement | null>, default: null },
72
74
  /** Sheet header on mobile / a11y name for the panel. */
73
75
  title: { type: String, default: "" },
74
- /** Base placement of the popout relative to the anchor (auto-flips). */
76
+ /** Base placement of the popout relative to the anchor (auto-flips).
77
+ * The `-center` variants center the popout on the anchor's horizontal
78
+ * midpoint (footer-center triggers whose menus would otherwise read
79
+ * as lopsided off a narrow pill). */
75
80
  placement: {
76
81
  type: String as PropType<SelectPanelPlacement>,
77
82
  default: "bottom-start",
@@ -474,9 +479,10 @@ export default defineComponent({
474
479
  // again after the DOM settles, when the panel's real box is known and
475
480
  // flip/clamp decisions can use actual numbers.
476
481
  /** Resolved popout orientation — drives the pop transition's
477
- * transform-origin (grow from the anchor edge, data-side/align on
478
- * the host). Updated by positionPanel on every reposition. */
479
- const resolved = ref<{ side: "top" | "bottom"; align: "start" | "end" }>({
482
+ * transform-origin (grow from the anchor edge or midpoint,
483
+ * data-side/align on the host). Updated by positionPanel on every
484
+ * reposition. */
485
+ const resolved = ref<{ side: "top" | "bottom"; align: "start" | "center" | "end" }>({
480
486
  side: "bottom",
481
487
  align: "start",
482
488
  });
@@ -501,10 +507,12 @@ export default defineComponent({
501
507
  side = "bottom";
502
508
  top = r.bottom + props.offset;
503
509
  }
504
- resolved.value = {
505
- side,
506
- align: props.placement.endsWith("-end") ? "end" : "start",
507
- };
510
+ const align = props.placement.endsWith("-center")
511
+ ? "center"
512
+ : props.placement.endsWith("-end")
513
+ ? "end"
514
+ : "start";
515
+ resolved.value = { side, align };
508
516
  // One flip never re-checks: taller menu panels (the viewport-relative
509
517
  // CSS cap) made this band reachable — a mid-viewport anchor flips
510
518
  // bottom→top into a negative top that was applied verbatim. Clamp so
@@ -512,7 +520,15 @@ export default defineComponent({
512
520
  // the panel's own internal scroll takes over.
513
521
  const maxTop = Math.max(VIEWPORT_PAD, window.innerHeight - VIEWPORT_PAD - ph);
514
522
  top = Math.min(Math.max(top, VIEWPORT_PAD), maxTop);
515
- let left = props.placement.endsWith("-end") ? r.right - pw : r.left;
523
+ // -center balances the panel on the anchor's horizontal midpoint
524
+ // (still clamped, so a half-off-screen anchor keeps the panel
525
+ // readable instead of mirroring the overflow to both edges).
526
+ let left =
527
+ align === "center"
528
+ ? r.left + (r.width - pw) / 2
529
+ : props.placement.endsWith("-end")
530
+ ? r.right - pw
531
+ : r.left;
516
532
  const maxLeft = Math.max(VIEWPORT_PAD, window.innerWidth - VIEWPORT_PAD - pw);
517
533
  left = Math.min(Math.max(left, VIEWPORT_PAD), maxLeft);
518
534
  coords.value = {
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "الآن",
4
+ "common.time.minutesAgo": "قبل {n} دقيقة",
5
+ "common.time.hoursAgo": "قبل {n} ساعة",
6
+ "common.time.daysAgo": "قبل {n} يوم",
7
+ "common.time.weeksAgo": "قبل {n} أسبوع"
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "Gerade eben",
4
+ "common.time.minutesAgo": "vor {n} Min.",
5
+ "common.time.hoursAgo": "vor {n} Std.",
6
+ "common.time.daysAgo": "vor {n} Tg.",
7
+ "common.time.weeksAgo": "vor {n} Wo."
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "Just now",
4
+ "common.time.minutesAgo": "{n} min ago",
5
+ "common.time.hoursAgo": "{n} h ago",
6
+ "common.time.daysAgo": "{n} d ago",
7
+ "common.time.weeksAgo": "{n} w ago"
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "Ahora mismo",
4
+ "common.time.minutesAgo": "hace {n} min",
5
+ "common.time.hoursAgo": "hace {n} h",
6
+ "common.time.daysAgo": "hace {n} d",
7
+ "common.time.weeksAgo": "hace {n} sem"
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "À l'instant",
4
+ "common.time.minutesAgo": "il y a {n} min",
5
+ "common.time.hoursAgo": "il y a {n} h",
6
+ "common.time.daysAgo": "il y a {n} j",
7
+ "common.time.weeksAgo": "il y a {n} sem."
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "たった今",
4
+ "common.time.minutesAgo": "{n} 分前",
5
+ "common.time.hoursAgo": "{n} 時間前",
6
+ "common.time.daysAgo": "{n} 日前",
7
+ "common.time.weeksAgo": "{n} 週間前"
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "방금 전",
4
+ "common.time.minutesAgo": "{n}분 전",
5
+ "common.time.hoursAgo": "{n}시간 전",
6
+ "common.time.daysAgo": "{n}일 전",
7
+ "common.time.weeksAgo": "{n}주 전"
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "Agora mesmo",
4
+ "common.time.minutesAgo": "há {n} min",
5
+ "common.time.hoursAgo": "há {n} h",
6
+ "common.time.daysAgo": "há {n} d",
7
+ "common.time.weeksAgo": "há {n} sem"
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "Только что",
4
+ "common.time.minutesAgo": "{n} мин. назад",
5
+ "common.time.hoursAgo": "{n} ч. назад",
6
+ "common.time.daysAgo": "{n} дн. назад",
7
+ "common.time.weeksAgo": "{n} нед. назад"
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "刚刚",
4
+ "common.time.minutesAgo": "{n} 分钟前",
5
+ "common.time.hoursAgo": "{n} 小时前",
6
+ "common.time.daysAgo": "{n} 天前",
7
+ "common.time.weeksAgo": "{n} 周前"
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "time": {
3
+ "common.time.justNow": "剛剛",
4
+ "common.time.minutesAgo": "{n} 分鐘前",
5
+ "common.time.hoursAgo": "{n} 小時前",
6
+ "common.time.daysAgo": "{n} 天前",
7
+ "common.time.weeksAgo": "{n} 週前"
8
+ }
9
+ }
@@ -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
+ });
@@ -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" / locale date. */
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
- const diff = Date.now() - d.getTime();
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