@celestia-island/hikari 0.55.233 → 0.55.235
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/HkAdminShell.tsx +4 -2
- package/src/components/HkCalendarHeatmap.barrel.test.ts +48 -0
- package/src/components/HkCalendarHeatmap.test.tsx +168 -25
- package/src/components/HkCalendarHeatmap.tsx +57 -40
- package/src/components/HkFaceShell.rail.test.tsx +208 -0
- package/src/components/HkFaceShell.tsx +70 -14
- package/src/components/HkMenu.rail.test.tsx +341 -28
- package/src/components/HkMenu.scss +47 -13
- package/src/components/HkMenu.tsx +109 -32
- package/src/components/HkSidebar.rail.test.tsx +228 -0
- package/src/components/HkSidebar.tsx +92 -28
- package/src/components/HkSidebarRail.scss +122 -23
- package/src/components/HkSidebarRail.styles.test.ts +113 -21
- package/src/components/HkSidebarRailToggle.test.tsx +51 -1
- package/src/components/HkSidebarRailToggle.tsx +47 -2
- package/src/components/calendarHeatmap.test.ts +161 -7
- package/src/components/calendarHeatmap.ts +81 -26
- package/src/composables/useSidebarRail.test.ts +239 -2
- package/src/composables/useSidebarRail.ts +178 -22
- package/src/index.ts +14 -2
package/package.json
CHANGED
|
@@ -39,8 +39,10 @@ export const HkAdminShell = defineComponent({
|
|
|
39
39
|
sidebarWidth: { type: String, default: "224px" },
|
|
40
40
|
/** Desktop icon-rail capability, forwarded to HkFaceShell (see its
|
|
41
41
|
* prop doc): opt in here and the admin sidebar gains the circular
|
|
42
|
-
* hover-reveal toggle
|
|
43
|
-
*
|
|
42
|
+
* hover-reveal toggle STRADDLING the sidebar's content-facing border,
|
|
43
|
+
* the one-icon rail and the overlay peek drawer (with the peek
|
|
44
|
+
* suppressed while the pointer rests on the toggle, and a pinned
|
|
45
|
+
* panel on expand). Declared on the wrapper on purpose — the same
|
|
44
46
|
* honest-forwarding rule as contentMode (a stray attr must not
|
|
45
47
|
* reach the inner shell's declared prop through fallthrough). */
|
|
46
48
|
sidebarCollapsible: { type: Boolean, default: false },
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import * as barrel from "../index";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The public barrel guard for the calendar heatmap's exports (R3 on the
|
|
6
|
+
* S3-fix wave: deleting any of these lines from index.ts kept the ENTIRE
|
|
7
|
+
* suite green, because every other test reaches the module through the
|
|
8
|
+
* direct "./calendarHeatmap" path — only an external consumer would have
|
|
9
|
+
* noticed). Same shape as HkFaceShell.barrel.test.ts.
|
|
10
|
+
*
|
|
11
|
+
* This file deliberately does NOT mock "@celestia-island/hikari": the
|
|
12
|
+
* package's self-reference resolves to the very same src/index.ts module
|
|
13
|
+
* as "../index", so a vi.mock here would replace the barrel under test
|
|
14
|
+
* with the mock's own namespace.
|
|
15
|
+
*/
|
|
16
|
+
describe("the public barrel", () => {
|
|
17
|
+
it("exports the heatmap component under both family names", () => {
|
|
18
|
+
const hk = barrel.HkCalendarHeatmap as unknown as { name?: string };
|
|
19
|
+
expect(hk, "HkCalendarHeatmap must be re-exported").toBeTruthy();
|
|
20
|
+
expect(hk.name).toBe("HkCalendarHeatmap");
|
|
21
|
+
// The H- alias rides the HCard/HkCard family convention.
|
|
22
|
+
expect(barrel.HCalendarHeatmap).toBe(barrel.HkCalendarHeatmap);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("exports the calendar grid math and the fill ladder", () => {
|
|
26
|
+
for (const name of [
|
|
27
|
+
"bucketLevel",
|
|
28
|
+
"calendarGrid",
|
|
29
|
+
"heatmapCellFill",
|
|
30
|
+
"heatmapCellOpacity",
|
|
31
|
+
"monthMarkers",
|
|
32
|
+
"todayIso",
|
|
33
|
+
"weekdayIndex",
|
|
34
|
+
"shiftIso",
|
|
35
|
+
] as const) {
|
|
36
|
+
expect(typeof barrel[name], `${name} must be re-exported`).toBe("function");
|
|
37
|
+
}
|
|
38
|
+
expect(Array.isArray(barrel.HEATMAP_LEVEL_OPACITY)).toBe(true);
|
|
39
|
+
expect(barrel.HEATMAP_LEVEL_OPACITY).toHaveLength(4);
|
|
40
|
+
expect(typeof barrel.HEATMAP_EMPTY_FILL).toBe("string");
|
|
41
|
+
// The fill ladder the legend and the cells share: level 0 is the
|
|
42
|
+
// empty wash, never the primary token.
|
|
43
|
+
expect(barrel.heatmapCellFill(0)).toBe(barrel.HEATMAP_EMPTY_FILL);
|
|
44
|
+
expect(barrel.heatmapCellFill(0)).not.toContain("primary");
|
|
45
|
+
expect(barrel.heatmapCellOpacity(0)).toBeUndefined();
|
|
46
|
+
expect(barrel.heatmapCellOpacity(4)).toBe(barrel.HEATMAP_LEVEL_OPACITY[3]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
|
3
3
|
|
|
4
4
|
import HkCalendarHeatmap from "./HkCalendarHeatmap";
|
|
5
|
-
import { HEATMAP_LEVEL_OPACITY,
|
|
5
|
+
import { HEATMAP_LEVEL_OPACITY, shiftIso } from "./calendarHeatmap";
|
|
6
6
|
|
|
7
7
|
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
8
8
|
|
|
@@ -16,6 +16,9 @@ function mount(node: ReturnType<typeof h>) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
afterEach(() => {
|
|
19
|
+
// A failed assertion inside a fake-clock test must not leak the frozen
|
|
20
|
+
// clock into the rest of the file.
|
|
21
|
+
vi.useRealTimers();
|
|
19
22
|
for (const { app, container } of mounts.splice(0)) {
|
|
20
23
|
app.unmount();
|
|
21
24
|
container.remove();
|
|
@@ -35,7 +38,7 @@ const daysFrom = (start: string, count: number, value = 0) =>
|
|
|
35
38
|
});
|
|
36
39
|
|
|
37
40
|
describe("HkCalendarHeatmap", () => {
|
|
38
|
-
it("renders the legend (less → more,
|
|
41
|
+
it("renders the legend (less → more, the empty swatch + one per level) and the grid anatomy", () => {
|
|
39
42
|
const c = mount(
|
|
40
43
|
h(HkCalendarHeatmap, {
|
|
41
44
|
days: daysFrom(CLEAN_HEAD_MON, 7),
|
|
@@ -51,18 +54,35 @@ describe("HkCalendarHeatmap", () => {
|
|
|
51
54
|
const legend = c.querySelector('[data-testid="hk-calendar-heatmap-legend"]');
|
|
52
55
|
expect(legend?.textContent).toContain("Less");
|
|
53
56
|
expect(legend?.textContent).toContain("More");
|
|
57
|
+
// The empty swatch plus one per CONSUMING level.
|
|
54
58
|
expect(c.querySelectorAll("[data-legend-level]")).toHaveLength(
|
|
55
|
-
HEATMAP_LEVEL_OPACITY.length,
|
|
59
|
+
1 + HEATMAP_LEVEL_OPACITY.length,
|
|
56
60
|
);
|
|
61
|
+
const swatches = c.querySelectorAll("[data-legend-level]") as NodeListOf<HTMLElement>;
|
|
62
|
+
// The empty swatch carries NO opacity step (its wash lives in the
|
|
63
|
+
// fill, HEATMAP_EMPTY_FILL — happy-dom drops color-mix() values, so
|
|
64
|
+
// the ladder is asserted through the opacity channel it does keep).
|
|
65
|
+
expect(swatches[0].style.opacity).toBe("");
|
|
66
|
+
// The swatch levels run 0..4 in order, empty first.
|
|
67
|
+
expect(
|
|
68
|
+
Array.from(swatches, (s) => s.getAttribute("data-legend-level")),
|
|
69
|
+
).toEqual(["0", "1", "2", "3", "4"]);
|
|
70
|
+
// Consuming levels index the table by level - 1: 1 -> [0] … 4 -> [3].
|
|
71
|
+
expect(swatches[1].style.opacity).toBe(HEATMAP_LEVEL_OPACITY[0]);
|
|
72
|
+
expect(swatches[2].style.opacity).toBe(HEATMAP_LEVEL_OPACITY[1]);
|
|
73
|
+
expect(swatches[3].style.opacity).toBe(HEATMAP_LEVEL_OPACITY[2]);
|
|
74
|
+
expect(swatches[4].style.opacity).toBe(HEATMAP_LEVEL_OPACITY[3]);
|
|
57
75
|
// One week of days, all level 0 (no consumption): plain muted cells.
|
|
58
76
|
expect(grid.querySelectorAll('[data-level="0"]')).toHaveLength(7);
|
|
59
77
|
});
|
|
60
78
|
|
|
61
|
-
it("
|
|
79
|
+
it("labels the next month on the column carrying its 1st, never the partial head month", async () => {
|
|
62
80
|
const c = mount(
|
|
63
81
|
h(HkCalendarHeatmap, {
|
|
64
|
-
// Trailing-October tail + November
|
|
65
|
-
//
|
|
82
|
+
// Trailing-October tail + November. October's 1st is not on the
|
|
83
|
+
// axis, so no slot may carry October; November's 1st (Nov 1, a
|
|
84
|
+
// Saturday) sits in the HEAD column, which is where its marker
|
|
85
|
+
// goes.
|
|
66
86
|
days: daysFrom(MID_MONTH_MON, 10),
|
|
67
87
|
first: null,
|
|
68
88
|
}),
|
|
@@ -72,8 +92,63 @@ describe("HkCalendarHeatmap", () => {
|
|
|
72
92
|
".hk-calendar-heatmap-markers > span",
|
|
73
93
|
) as NodeListOf<HTMLElement>;
|
|
74
94
|
expect(markers.length).toBeGreaterThanOrEqual(2);
|
|
75
|
-
|
|
76
|
-
|
|
95
|
+
const texts = Array.from(markers, (m) => m.textContent);
|
|
96
|
+
// The head month (its 1st is off-axis) is never labeled.
|
|
97
|
+
expect(texts.some((t) => t?.startsWith("Oct"))).toBe(false);
|
|
98
|
+
expect(texts[0]).not.toBe(""); // November's 1st is in this column
|
|
99
|
+
expect(texts[1]).toBe("");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("labels a month on the column carrying its 1st, not one column late", async () => {
|
|
103
|
+
// Grid opens Mon Sep 29: Oct 1 (a Wednesday) is in the head column.
|
|
104
|
+
const c = mount(
|
|
105
|
+
h(HkCalendarHeatmap, {
|
|
106
|
+
days: Array.from({ length: 14 }, (_, i) => ({
|
|
107
|
+
date: shiftIso("2025-09-29", i),
|
|
108
|
+
value: 0,
|
|
109
|
+
})),
|
|
110
|
+
first: null,
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
await nextTick();
|
|
114
|
+
const markers = c.querySelectorAll(
|
|
115
|
+
".hk-calendar-heatmap-markers > span",
|
|
116
|
+
) as NodeListOf<HTMLElement>;
|
|
117
|
+
expect(markers[0].textContent).not.toBe(""); // October, mid-column
|
|
118
|
+
expect(markers[1].textContent).toBe("");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("reserves trailing room exactly when the last column carries a marker", async () => {
|
|
122
|
+
const padOf = async (days: { date: string; value: number }[]) => {
|
|
123
|
+
const c = mount(h(HkCalendarHeatmap, { days, first: null }));
|
|
124
|
+
await nextTick();
|
|
125
|
+
const markersEl = c.querySelector(
|
|
126
|
+
".hk-calendar-heatmap-markers",
|
|
127
|
+
) as HTMLElement;
|
|
128
|
+
const texts = Array.from(
|
|
129
|
+
markersEl.querySelectorAll(":scope > span"),
|
|
130
|
+
(m) => m.textContent,
|
|
131
|
+
);
|
|
132
|
+
return {
|
|
133
|
+
// Logical property: hikari ships RTL hosts (HkMenu reads
|
|
134
|
+
// documentElement.dir), so the reserved room rides inline-end
|
|
135
|
+
// rather than a physical side.
|
|
136
|
+
pad: markersEl.style.paddingInlineEnd,
|
|
137
|
+
lastSet: texts.length > 0 && texts[texts.length - 1] !== "",
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Positive branch: a single week opening on Dec 1 leaves the marker
|
|
142
|
+
// on the LAST column — its ink needs the reserved room.
|
|
143
|
+
const marked = await padOf(daysFrom(CLEAN_HEAD_MON, 7));
|
|
144
|
+
expect(marked.lastSet).toBe(true);
|
|
145
|
+
expect(marked.pad).not.toBe("");
|
|
146
|
+
|
|
147
|
+
// Negative branch: ending on Nov 7 (a Friday) pads the axis to
|
|
148
|
+
// Nov 3–9, so the November marker sits one column in, not last.
|
|
149
|
+
const unmarked = await padOf(daysFrom("2025-10-27", 12));
|
|
150
|
+
expect(unmarked.lastSet).toBe(false);
|
|
151
|
+
expect(unmarked.pad).toBe("");
|
|
77
152
|
});
|
|
78
153
|
|
|
79
154
|
it("shows the leading month marker when the grid's first column opens its month", async () => {
|
|
@@ -90,29 +165,97 @@ describe("HkCalendarHeatmap", () => {
|
|
|
90
165
|
expect(markers[0].textContent).not.toBe(""); // Dec 1 — labeled
|
|
91
166
|
});
|
|
92
167
|
|
|
93
|
-
it("
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
168
|
+
it("fills the cells through the shared ladder: consuming levels primary, level 0 not", () => {
|
|
169
|
+
// happy-dom drops color-mix() but keeps var(), so the
|
|
170
|
+
// empty-vs-primary distinction IS observable here — a hardcoded
|
|
171
|
+
// primary for level 0 (or a hardcoded fill that ignores the
|
|
172
|
+
// helper) turns this red (R3: the call sites had no pin).
|
|
98
173
|
const c = mount(
|
|
99
174
|
h(HkCalendarHeatmap, {
|
|
100
|
-
days: [
|
|
101
|
-
|
|
175
|
+
days: [
|
|
176
|
+
{ date: CLEAN_HEAD_MON, value: 10 },
|
|
177
|
+
{ date: "2025-12-02", value: 0 },
|
|
178
|
+
],
|
|
179
|
+
first: null,
|
|
102
180
|
}),
|
|
103
181
|
);
|
|
104
182
|
const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
183
|
+
const styleOf = (level: string) =>
|
|
184
|
+
(grid.querySelector(`[data-level="${level}"]`) as HTMLElement).getAttribute(
|
|
185
|
+
"style",
|
|
186
|
+
) ?? "";
|
|
187
|
+
expect(styleOf("4")).toContain("var(--hi-color-primary)");
|
|
188
|
+
expect(styleOf("0")).not.toContain("--hi-color-primary");
|
|
189
|
+
// The legend rides the same decision: its empty swatch is not
|
|
190
|
+
// primary, every consuming swatch is.
|
|
191
|
+
const swatches = c.querySelectorAll(
|
|
192
|
+
"[data-legend-level]",
|
|
193
|
+
) as NodeListOf<HTMLElement>;
|
|
194
|
+
expect(swatches[0].getAttribute("style") ?? "").not.toContain(
|
|
195
|
+
"--hi-color-primary",
|
|
196
|
+
);
|
|
197
|
+
for (const level of [1, 2, 3, 4]) {
|
|
198
|
+
expect(swatches[level].getAttribute("style") ?? "").toContain(
|
|
199
|
+
"var(--hi-color-primary)",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("applies the opacity ladder by level - 1, leaving level 0 without an opacity step", () => {
|
|
205
|
+
// The empty cell's wash lives in its FILL, never in the opacity
|
|
206
|
+
// table (the old table's index 0 was dead weight) — level 0 must
|
|
207
|
+
// carry no opacity while every consuming level maps to [level - 1].
|
|
208
|
+
const c = mount(
|
|
209
|
+
h(HkCalendarHeatmap, {
|
|
210
|
+
days: [
|
|
211
|
+
{ date: CLEAN_HEAD_MON, value: 10 },
|
|
212
|
+
{ date: "2025-12-02", value: 5 },
|
|
213
|
+
{ date: "2025-12-03", value: 0 },
|
|
214
|
+
],
|
|
215
|
+
first: null,
|
|
216
|
+
}),
|
|
109
217
|
);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
218
|
+
const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
|
|
219
|
+
const cellAt = (level: string) =>
|
|
220
|
+
grid.querySelector(`[data-level="${level}"]`) as HTMLElement;
|
|
221
|
+
expect(cellAt("4").style.opacity).toBe(HEATMAP_LEVEL_OPACITY[3]);
|
|
222
|
+
expect(cellAt("2").style.opacity).toBe(HEATMAP_LEVEL_OPACITY[1]);
|
|
223
|
+
expect(cellAt("0").style.opacity).toBe("");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("renders gap cells hatched and inert (no tooltip, no level claim)", () => {
|
|
227
|
+
// A one-day series AT today, anchored at first = today: the head
|
|
228
|
+
// padding hatches as "before", the tail as "after". The clock is
|
|
229
|
+
// PINNED to a Wednesday: with the real clock this test carried
|
|
230
|
+
// both-phase expectations into a Monday (no head padding) or a
|
|
231
|
+
// Sunday (no tail), so it went red two days out of seven
|
|
232
|
+
// (R3 2026-10-30, proven with a mocked clock).
|
|
233
|
+
vi.useFakeTimers();
|
|
234
|
+
try {
|
|
235
|
+
vi.setSystemTime(new Date("2025-12-03T10:00:00Z")); // a Wednesday
|
|
236
|
+
const c = mount(
|
|
237
|
+
h(HkCalendarHeatmap, {
|
|
238
|
+
days: [{ date: "2025-12-03", value: 5 }],
|
|
239
|
+
first: "2025-12-03",
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
const grid = c.querySelector(
|
|
243
|
+
'[data-testid="hk-calendar-heatmap"]',
|
|
244
|
+
) as HTMLElement;
|
|
245
|
+
const gaps = grid.querySelectorAll("[data-gap]") as NodeListOf<HTMLElement>;
|
|
246
|
+
expect(gaps.length).toBeGreaterThan(0);
|
|
247
|
+
const phases = new Set(
|
|
248
|
+
Array.from(gaps, (gap) => gap.getAttribute("data-gap")),
|
|
249
|
+
);
|
|
250
|
+
expect(phases.has("before")).toBe(true);
|
|
251
|
+
expect(phases.has("after")).toBe(true);
|
|
252
|
+
for (const gap of gaps) {
|
|
253
|
+
expect(gap.getAttribute("aria-hidden")).toBe("true");
|
|
254
|
+
expect(gap.getAttribute("title")).toBeNull();
|
|
255
|
+
expect(gap.style.pointerEvents).toBe("none");
|
|
256
|
+
}
|
|
257
|
+
} finally {
|
|
258
|
+
vi.useRealTimers();
|
|
116
259
|
}
|
|
117
260
|
});
|
|
118
261
|
|
|
@@ -2,9 +2,9 @@ import { computed, defineComponent, type PropType } from "vue";
|
|
|
2
2
|
|
|
3
3
|
import { useI18n } from "../i18n/context";
|
|
4
4
|
import {
|
|
5
|
-
bucketLevel,
|
|
6
5
|
calendarGrid,
|
|
7
|
-
|
|
6
|
+
heatmapCellFill,
|
|
7
|
+
heatmapCellOpacity,
|
|
8
8
|
monthMarkers,
|
|
9
9
|
todayIso,
|
|
10
10
|
type CalendarCell,
|
|
@@ -12,6 +12,12 @@ import {
|
|
|
12
12
|
} from "./calendarHeatmap";
|
|
13
13
|
import "./HkCalendarHeatmap.scss";
|
|
14
14
|
|
|
15
|
+
/** The legend's five levels, empty first — the same ladder the cells
|
|
16
|
+
* ride, so the swatches come from the one fill/opacity decision
|
|
17
|
+
* (`heatmapCellFill` / `heatmapCellOpacity`) rather than a second,
|
|
18
|
+
* hand-written copy of the colors. */
|
|
19
|
+
const LEGEND_LEVELS = [0, 1, 2, 3, 4] as const;
|
|
20
|
+
|
|
15
21
|
/** The no-data cell (before the account's first activity / after
|
|
16
22
|
* today): muted diagonal hatch over the surface — the wowsp-style
|
|
17
23
|
* "not yet" cell. Rendered inert: no tooltip, no pointer events. */
|
|
@@ -36,11 +42,10 @@ function fill(template: string, vars: Record<string, string | number>): string {
|
|
|
36
42
|
* rail, month markers, and hatched no-data gap cells that never claim
|
|
37
43
|
* a fake zero.
|
|
38
44
|
*
|
|
39
|
-
* The leading month is
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* month start that is not visible.
|
|
45
|
+
* The leading month is never mislabeled: a month is labeled on the
|
|
46
|
+
* column that carries its 1st, so a window starting mid-month has no
|
|
47
|
+
* label over its partial head (that month's 1st is not on the axis),
|
|
48
|
+
* and no month lands one column late.
|
|
44
49
|
*
|
|
45
50
|
* Dumb by design: the caller owns the fetch lifecycle and passes the
|
|
46
51
|
* settled dense series in (`days`, oldest → newest, zero-filled,
|
|
@@ -90,33 +95,35 @@ export default defineComponent({
|
|
|
90
95
|
() => `repeat(${weeks.value}, ${props.cellSize}px)`,
|
|
91
96
|
);
|
|
92
97
|
|
|
93
|
-
// Locale-reactive (the HkDateTimePicker convention):
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
98
|
+
// Locale-reactive formatter cache (the HkDateTimePicker convention):
|
|
99
|
+
// ONE Intl.DateTimeFormat per shape per locale, held in a computed
|
|
100
|
+
// over the context's locale — not one per rendered cell, which a
|
|
101
|
+
// full-year grid would pay ~370 times over. All formatters format
|
|
102
|
+
// the UTC midnight instant WITH an explicit UTC zone (R2: on a
|
|
103
|
+
// negative-offset host the weekday rail shifted a full row and
|
|
104
|
+
// tooltips landed a day off — the series' day boundary is UTC).
|
|
99
105
|
const locale = computed(() => useI18n().locale);
|
|
106
|
+
const formatters = computed(() => {
|
|
107
|
+
const loc = locale.value;
|
|
108
|
+
return {
|
|
109
|
+
weekday: new Intl.DateTimeFormat(loc, { weekday: "short", timeZone: "UTC" }),
|
|
110
|
+
month: new Intl.DateTimeFormat(loc, { month: "short", timeZone: "UTC" }),
|
|
111
|
+
date: new Intl.DateTimeFormat(loc, { dateStyle: "medium", timeZone: "UTC" }),
|
|
112
|
+
};
|
|
113
|
+
});
|
|
100
114
|
const weekdayLabel = (idx: number) =>
|
|
101
|
-
|
|
102
|
-
weekday: "short",
|
|
103
|
-
timeZone: "UTC",
|
|
104
|
-
}).format(
|
|
115
|
+
formatters.value.weekday.format(
|
|
105
116
|
// Any Monday-first week works — take 2024-01-01 (a Monday).
|
|
106
117
|
new Date(Date.UTC(2024, 0, 1 + idx)),
|
|
107
118
|
);
|
|
108
119
|
const monthLabel = (m: string) => {
|
|
109
120
|
if (!m) return "";
|
|
110
|
-
return
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}).format(new Date(Date.UTC(2024, Number(m) - 1, 1)));
|
|
121
|
+
return formatters.value.month.format(
|
|
122
|
+
new Date(Date.UTC(2024, Number(m) - 1, 1)),
|
|
123
|
+
);
|
|
114
124
|
};
|
|
115
125
|
const dateLabel = (iso: string) =>
|
|
116
|
-
new
|
|
117
|
-
dateStyle: "medium",
|
|
118
|
-
timeZone: "UTC",
|
|
119
|
-
}).format(new Date(`${iso}T00:00:00Z`));
|
|
126
|
+
formatters.value.date.format(new Date(`${iso}T00:00:00Z`));
|
|
120
127
|
|
|
121
128
|
const cellTitle = (d: Extract<CalendarCell, { kind: "day" }>) =>
|
|
122
129
|
fill(
|
|
@@ -135,13 +142,24 @@ export default defineComponent({
|
|
|
135
142
|
}
|
|
136
143
|
: {
|
|
137
144
|
borderRadius: "3px",
|
|
138
|
-
background:
|
|
139
|
-
|
|
140
|
-
? "color-mix(in srgb, var(--hi-color-muted) 40%, transparent)"
|
|
141
|
-
: "var(--hi-color-primary)",
|
|
142
|
-
opacity: cell.level === 0 ? undefined : HEATMAP_LEVEL_OPACITY[cell.level],
|
|
145
|
+
background: heatmapCellFill(cell.level),
|
|
146
|
+
opacity: heatmapCellOpacity(cell.level),
|
|
143
147
|
};
|
|
144
148
|
|
|
149
|
+
// A marker may overflow its 14px column into the empty columns
|
|
150
|
+
// ahead (the GitHub convention — a month label is always followed
|
|
151
|
+
// by unmarked columns). A marker on the LAST column has nothing
|
|
152
|
+
// ahead: its ink would be clipped by the scroll container, so that
|
|
153
|
+
// one case reserves inline-end room for it. The logical property
|
|
154
|
+
// matters: hikari ships RTL hosts (HkMenu reads
|
|
155
|
+
// documentElement.dir), and there the label runs the other way.
|
|
156
|
+
const markersTrailingPad = computed(() => {
|
|
157
|
+
const m = markers.value;
|
|
158
|
+
return m.length > 0 && m[m.length - 1] !== ""
|
|
159
|
+
? `${props.cellSize * 2 + props.cellGap}px`
|
|
160
|
+
: undefined;
|
|
161
|
+
});
|
|
162
|
+
|
|
145
163
|
return () => (
|
|
146
164
|
<div class="hk-calendar-heatmap">
|
|
147
165
|
{/* The legend row: the scale in the caller's one unit on the
|
|
@@ -154,19 +172,16 @@ export default defineComponent({
|
|
|
154
172
|
data-testid="hk-calendar-heatmap-legend"
|
|
155
173
|
>
|
|
156
174
|
{t("hikari::calendarHeatmap.less", "Less")}
|
|
157
|
-
{
|
|
175
|
+
{LEGEND_LEVELS.map((level) => (
|
|
158
176
|
<span
|
|
159
|
-
key={
|
|
160
|
-
data-legend-level={
|
|
177
|
+
key={level}
|
|
178
|
+
data-legend-level={level}
|
|
161
179
|
style={{
|
|
162
180
|
width: "10px",
|
|
163
181
|
height: "10px",
|
|
164
182
|
borderRadius: "2px",
|
|
165
|
-
background:
|
|
166
|
-
|
|
167
|
-
? "color-mix(in srgb, var(--hi-color-muted) 40%, transparent)"
|
|
168
|
-
: "var(--hi-color-primary)",
|
|
169
|
-
opacity: i === 0 ? undefined : o,
|
|
183
|
+
background: heatmapCellFill(level),
|
|
184
|
+
opacity: heatmapCellOpacity(level),
|
|
170
185
|
display: "inline-block",
|
|
171
186
|
}}
|
|
172
187
|
/>
|
|
@@ -199,14 +214,16 @@ export default defineComponent({
|
|
|
199
214
|
</div>
|
|
200
215
|
|
|
201
216
|
<div class="hk-calendar-heatmap-cells">
|
|
202
|
-
{/* Month markers, one slot per week column. A
|
|
203
|
-
*
|
|
217
|
+
{/* Month markers, one slot per week column. A month is
|
|
218
|
+
* labeled on the column carrying its 1st; a partial head
|
|
219
|
+
* month therefore renders an empty slot (see
|
|
204
220
|
* monthMarkers). */}
|
|
205
221
|
<div
|
|
206
222
|
class="hk-calendar-heatmap-markers"
|
|
207
223
|
style={{
|
|
208
224
|
gridTemplateColumns: columns.value,
|
|
209
225
|
columnGap: `${props.cellGap}px`,
|
|
226
|
+
paddingInlineEnd: markersTrailingPad.value,
|
|
210
227
|
}}
|
|
211
228
|
>
|
|
212
229
|
{markers.value.map((m, i) => (
|