@celestia-island/hikari 0.55.324 → 0.55.327
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/animation/registerAnimations.ts +6 -0
- package/src/components/HkAdminShell.test.tsx +11 -5
- package/src/components/HkAdminTablePage.tsx +67 -41
- package/src/components/HkCalendarHeatmap.scss +43 -6
- package/src/components/HkCalendarHeatmap.test.tsx +136 -0
- package/src/components/HkCalendarHeatmap.tsx +73 -6
- package/src/components/HkCardList.tsx +13 -13
- package/src/components/HkEdgeGlow.barrel.test.ts +27 -0
- package/src/components/HkEdgeGlow.scss +132 -0
- package/src/components/HkEdgeGlow.test.tsx +244 -0
- package/src/components/HkEdgeGlow.tsx +130 -0
- package/src/components/HkFaceShell.test.tsx +53 -2
- package/src/components/HkFaceShell.tsx +37 -13
- package/src/components/HkFilterPanel.scss +13 -0
- package/src/components/HkKanban.scss +8 -0
- package/src/components/HkScrollContainer.fade.contract.test.ts +9 -7
- package/src/components/HkScrollContainer.scss +39 -62
- package/src/components/HkScrollContainer.selectionHint.test.ts +106 -87
- package/src/components/HkScrollContainer.test.ts +337 -1
- package/src/components/HkScrollContainer.tsx +346 -215
- package/src/components/HkScrollProgressDock.scss +101 -0
- package/src/components/HkScrollProgressDock.test.tsx +1175 -0
- package/src/components/HkScrollProgressDock.tsx +532 -0
- package/src/components/HkTable.sections.test.tsx +1 -1
- package/src/components/HkTable.tsx +10 -9
- package/src/components/HkTimeZoneGlobe.scss +9 -0
- package/src/components/nativeScrollbar.contract.test.ts +603 -0
- package/src/i18n/locales/ar/components.json +2 -1
- package/src/i18n/locales/de/components.json +2 -1
- package/src/i18n/locales/en/components.json +2 -1
- package/src/i18n/locales/es/components.json +2 -1
- package/src/i18n/locales/fr/components.json +2 -1
- package/src/i18n/locales/ja/components.json +2 -1
- package/src/i18n/locales/ko/components.json +2 -1
- package/src/i18n/locales/pt/components.json +2 -1
- package/src/i18n/locales/ru/components.json +2 -1
- package/src/i18n/locales/zh-Hans/components.json +2 -1
- package/src/i18n/locales/zh-Hant/components.json +2 -1
- package/src/index.ts +17 -6
- package/src/components/HkScrollProgress.scss +0 -61
- package/src/components/HkScrollProgress.test.tsx +0 -687
- package/src/components/HkScrollProgress.tsx +0 -103
package/package.json
CHANGED
|
@@ -145,3 +145,9 @@ registerCssAnimation("hk-range-ribbon-pulse", { infinite: true });
|
|
|
145
145
|
// prop's dot breathes while its state is in-flight — a settled dot sits
|
|
146
146
|
// static. Reduced motion drops the loop (house pattern, see HkSkeleton).
|
|
147
147
|
registerCssAnimation("hk-badge-dot-pulse", { infinite: true });
|
|
148
|
+
// components/HkEdgeGlow.scss — the shared edge-glow breathe (loops
|
|
149
|
+
// forever): the one painter behind HkScrollContainer's overflow glow and
|
|
150
|
+
// any host-mounted band (the static selection hint keeps animate off).
|
|
151
|
+
// Reduced motion keeps the band and drops the loop (house pattern, see
|
|
152
|
+
// HkSkeleton); the global CSS-animation switch pauses it as usual.
|
|
153
|
+
registerCssAnimation("hk-edge-glow-breathe", { infinite: true });
|
|
@@ -81,7 +81,14 @@ const setWidth = (w: number) => {
|
|
|
81
81
|
function paddingInner(container: HTMLElement): HTMLElement | null | undefined {
|
|
82
82
|
const viewport = container.querySelector(".hk-scroll-container-viewport")
|
|
83
83
|
?? container.querySelector(".hk-scroll-container");
|
|
84
|
-
|
|
84
|
+
// The shell's fallback scroll-progress dock (2026-11-09) wraps the
|
|
85
|
+
// content with a display:contents scope — descend through it, the
|
|
86
|
+
// padding wrapper is still the viewport's only layout box.
|
|
87
|
+
let inner = viewport?.firstElementChild as HTMLElement | null | undefined;
|
|
88
|
+
if (inner?.classList.contains("hk-scroll-progress-dock-scope")) {
|
|
89
|
+
inner = inner.firstElementChild as HTMLElement;
|
|
90
|
+
}
|
|
91
|
+
return inner;
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
function mobileInnerPadding(container: HTMLElement): string {
|
|
@@ -240,10 +247,9 @@ describe("HkAdminShell", () => {
|
|
|
240
247
|
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
241
248
|
));
|
|
242
249
|
// The padded inner wrapper exists INSIDE the scroll viewport (not as
|
|
243
|
-
// padding on the container itself)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const inner = viewport?.firstElementChild as HTMLElement | null | undefined;
|
|
250
|
+
// padding on the container itself) — behind the fallback dock's
|
|
251
|
+
// display:contents scope (see paddingInner).
|
|
252
|
+
const inner = paddingInner(c);
|
|
247
253
|
expect(inner).toBeTruthy();
|
|
248
254
|
expect(inner?.style.padding).toBe("2rem");
|
|
249
255
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineComponent, type Component, type PropType } from "vue";
|
|
2
2
|
import { HAlert, HCard, HEmptyState, HSpinner, HTable, useI18n } from "@celestia-island/hikari";
|
|
3
3
|
import HkCrossfade from "./HkCrossfade";
|
|
4
|
+
import HkScrollProgressDock from "./HkScrollProgressDock";
|
|
4
5
|
import { HkPageHeader } from "./HkPageHeader";
|
|
5
6
|
import type { HkRowManageGate } from "./HkTable";
|
|
6
7
|
|
|
@@ -80,6 +81,24 @@ export const HkAdminTablePage = defineComponent({
|
|
|
80
81
|
* zero so a fresh or empty list shows no "共 0 条" flash. Pages
|
|
81
82
|
* that render their own count elsewhere set this false. */
|
|
82
83
|
countReadout: { type: Boolean, default: true },
|
|
84
|
+
/** The page's own scroll-progress DOCK (user direction 2026-11-09:
|
|
85
|
+
* the list large-component uses the dock itself). DEFAULT ON — the
|
|
86
|
+
* page mounts an HkScrollProgressDock around the body crossfade so
|
|
87
|
+
* the table (or a body-slot roster) self-registers against it and
|
|
88
|
+
* the pill floats over the scrolling viewport; the crossfade's
|
|
89
|
+
* generation swaps happen INSIDE the dock, so the registration
|
|
90
|
+
* survives every exchange. A page under a shell fallback dock has
|
|
91
|
+
* its OWN dock stand down automatically (the nested-dock rule
|
|
92
|
+
* serves the outermost enabled dock — the fallback keeps the
|
|
93
|
+
* pill); set `false` to demote the page — lists then fall through
|
|
94
|
+
* to the nearest enabled dock above, if any. Layering: the shell
|
|
95
|
+
* master switch (HkFaceShell `scrollProgress`) > this prop > the
|
|
96
|
+
* dock's own `disabled`. Like every dock gate, the layering is
|
|
97
|
+
* RESOLVED
|
|
98
|
+
* ONCE at the dock's setup — hosts pass these statically, and a
|
|
99
|
+
* runtime flip needs a remount (the HkScrollContainer prop
|
|
100
|
+
* precedent). */
|
|
101
|
+
progressDock: { type: Boolean, default: true },
|
|
83
102
|
},
|
|
84
103
|
emits: {
|
|
85
104
|
reorder: (_fromIndex: number, _toIndex: number) => true,
|
|
@@ -151,48 +170,55 @@ export const HkAdminTablePage = defineComponent({
|
|
|
151
170
|
every host dissolves between them instead of popping. The
|
|
152
171
|
key collapses loading-vs-empty into one "placeholder"
|
|
153
172
|
generation: spinner → empty is a data arrival, not a slot
|
|
154
|
-
identity change worth animating.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
<HCard padded={false}>
|
|
179
|
-
<HTable
|
|
180
|
-
columns={props.columns}
|
|
181
|
-
rows={props.rows}
|
|
182
|
-
rowKey={props.rowKey}
|
|
183
|
-
total={props.total}
|
|
184
|
-
draggable={props.draggable}
|
|
185
|
-
manageable={props.manageable}
|
|
186
|
-
onReorder={(from: number, to: number) => emit("reorder", from, to)}
|
|
187
|
-
onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
|
|
188
|
-
emit("manage", row, opener)
|
|
189
|
-
}
|
|
173
|
+
identity change worth animating.
|
|
174
|
+
The whole exchange rides INSIDE this page's scroll-progress
|
|
175
|
+
dock (default on; `progressDock:false` demotes to a bare
|
|
176
|
+
passthrough): the dock must wrap the CROSSFADE, not sit in
|
|
177
|
+
a generation — a generation-scoped dock would unmount with
|
|
178
|
+
every swap and drop the registration. */}
|
|
179
|
+
<HkScrollProgressDock disabled={!props.progressDock}>
|
|
180
|
+
<HkCrossfade
|
|
181
|
+
swapKey={
|
|
182
|
+
props.error ? "error"
|
|
183
|
+
: props.loading && !props.rows.length ? "placeholder"
|
|
184
|
+
: !props.rows.length ? "placeholder"
|
|
185
|
+
: "content"
|
|
186
|
+
}
|
|
187
|
+
>
|
|
188
|
+
{props.error ? (
|
|
189
|
+
<HAlert message={props.error} />
|
|
190
|
+
) : props.loading && !props.rows.length ? (
|
|
191
|
+
<HSpinner center />
|
|
192
|
+
) : !props.rows.length ? (
|
|
193
|
+
<HEmptyState
|
|
194
|
+
boxed
|
|
195
|
+
title={emptyTitle}
|
|
196
|
+
description={props.emptyDescription}
|
|
190
197
|
>
|
|
191
|
-
{
|
|
192
|
-
</
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
198
|
+
{emptySlots}
|
|
199
|
+
</HEmptyState>
|
|
200
|
+
) : slots.body ? (
|
|
201
|
+
<div class="hk-admin-table-page-body">{slots.body()}</div>
|
|
202
|
+
) : (
|
|
203
|
+
<HCard padded={false}>
|
|
204
|
+
<HTable
|
|
205
|
+
columns={props.columns}
|
|
206
|
+
rows={props.rows}
|
|
207
|
+
rowKey={props.rowKey}
|
|
208
|
+
total={props.total}
|
|
209
|
+
draggable={props.draggable}
|
|
210
|
+
manageable={props.manageable}
|
|
211
|
+
onReorder={(from: number, to: number) => emit("reorder", from, to)}
|
|
212
|
+
onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
|
|
213
|
+
emit("manage", row, opener)
|
|
214
|
+
}
|
|
215
|
+
>
|
|
216
|
+
{slots}
|
|
217
|
+
</HTable>
|
|
218
|
+
</HCard>
|
|
219
|
+
)}
|
|
220
|
+
</HkCrossfade>
|
|
221
|
+
</HkScrollProgressDock>
|
|
196
222
|
{slots["create-modal"]?.()}
|
|
197
223
|
{slots["edit-modal"]?.()}
|
|
198
224
|
</div>
|
|
@@ -24,20 +24,57 @@
|
|
|
24
24
|
color: var(--hi-color-muted);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
27
|
+
// The strip's scroll host is HkScrollContainer — the root element carries
|
|
28
|
+
// .hk-scroll-container PLUS this class. The native horizontal bar is gone
|
|
29
|
+
// (2026-11-08: the battle heatmap's platform bar was the last one in the
|
|
30
|
+
// chart family and read as an embedded widget); hikari's auto-hiding
|
|
31
|
+
// overlay scrollbar is the only bar, and the container's align="center"
|
|
32
|
+
// centers the calendar while it fits and lets it scroll when it does not
|
|
33
|
+
// (the old auto-margin recipe now lives there — do NOT re-add overflow
|
|
34
|
+
// here: a native bar would come back and the overlay chrome would stack
|
|
35
|
+
// under it).
|
|
36
|
+
//
|
|
37
|
+
// padding-bottom is the OVERLAY TRACK's strip, not breathing room: the
|
|
38
|
+
// shared chrome is positioned against this root (bottom: 2px, height:
|
|
39
|
+
// 6px), so 8px of padding keeps the bar BELOW the Sunday rail instead of
|
|
40
|
+
// painting over it while the strip scrolls.
|
|
31
41
|
&-scroll {
|
|
32
42
|
margin-top: 12px;
|
|
43
|
+
padding-bottom: 8px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Legacy plain-form fallback (Rust/WASM renderer) ──────────────────
|
|
47
|
+
// The Vue form mounted the strip on HkScrollContainer (above), but the
|
|
48
|
+
// Rust/WASM CalendarHeatmap (packages/components/src/data/calendar_heatmap.rs)
|
|
49
|
+
// still renders the pre-migration plain -.hk-calendar-heatmap-scroll div and
|
|
50
|
+
// reads THIS sheet through the generated vue-mirror. The mirror sheet no
|
|
51
|
+
// longer owns the plain form's overflow/centering, so restore the previous
|
|
52
|
+
// horizontal scrolling here for the plain form ONLY: a Vue strip carries
|
|
53
|
+
// .hk-scroll-container on the same element and never matches, so the
|
|
54
|
+
// overlay chrome can never be re-claimed by this rule. The platform bar is
|
|
55
|
+
// suppressed (the chart family's no-native-bar invariant) — until the Rust
|
|
56
|
+
// renderer migrates onto the shared scroll-container script this form
|
|
57
|
+
// scrolls by wheel/touch/keyboard without a visible bar, the same
|
|
58
|
+
// bar-less trade the suppression-only scrollers take.
|
|
59
|
+
&-scroll:not(.hk-scroll-container) {
|
|
33
60
|
overflow-x: auto;
|
|
34
|
-
|
|
61
|
+
scrollbar-width: none;
|
|
62
|
+
|
|
63
|
+
&::-webkit-scrollbar {
|
|
64
|
+
display: none;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
> .hk-calendar-heatmap-body {
|
|
68
|
+
margin-inline: auto;
|
|
69
|
+
}
|
|
35
70
|
}
|
|
36
71
|
|
|
72
|
+
// width: max-content keeps the strip's natural extent (the container's
|
|
73
|
+
// aligner owns the centering, so no auto margins here — they would be
|
|
74
|
+
// resolved inside a box sized exactly to this element's content).
|
|
37
75
|
&-body {
|
|
38
76
|
display: flex;
|
|
39
77
|
width: max-content;
|
|
40
|
-
margin-inline: auto;
|
|
41
78
|
gap: 8px;
|
|
42
79
|
}
|
|
43
80
|
|
|
@@ -1,9 +1,23 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
1
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
6
|
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
|
3
7
|
|
|
4
8
|
import HkCalendarHeatmap from "./HkCalendarHeatmap";
|
|
5
9
|
import { HEATMAP_LEVEL_OPACITY, shiftIso } from "./calendarHeatmap";
|
|
6
10
|
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
/** Resolve after two animation frames: the scroll container coalesces its
|
|
14
|
+
* sensing pass through the shared animationBus, whose rAF is scheduled one
|
|
15
|
+
* frame ahead of the awaited callback. */
|
|
16
|
+
const flushFrames = async () => {
|
|
17
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
18
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
19
|
+
};
|
|
20
|
+
|
|
7
21
|
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
8
22
|
|
|
9
23
|
function mount(node: ReturnType<typeof h>) {
|
|
@@ -381,3 +395,125 @@ describe("HkCalendarHeatmap", () => {
|
|
|
381
395
|
}
|
|
382
396
|
});
|
|
383
397
|
});
|
|
398
|
+
// ── Overlay scroll strip (2026-11-08) ────────────────────────────────────
|
|
399
|
+
// The calendar body rides HkScrollContainer now: hikari's auto-hiding
|
|
400
|
+
// overlay bar is the only bar, the strip opens focused on the NEWEST day
|
|
401
|
+
// (anchor="end"), a host can opt back to the start, and a series
|
|
402
|
+
// replacement re-parks at the end. happy-dom performs no layout, so the
|
|
403
|
+
// strip's extent is stubbed and a dispatched scroll event runs the
|
|
404
|
+
// container's coalesced sensing pass.
|
|
405
|
+
describe("HkCalendarHeatmap overlay scroll strip", () => {
|
|
406
|
+
const stripOf = (c: HTMLElement): HTMLElement =>
|
|
407
|
+
c.querySelector<HTMLElement>(".hk-calendar-heatmap-scroll")!;
|
|
408
|
+
const viewportOf = (c: HTMLElement): HTMLElement =>
|
|
409
|
+
c.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
|
|
410
|
+
|
|
411
|
+
/** Mount a live render function (reactive props re-render — the range
|
|
412
|
+
* switch test replaces the series). */
|
|
413
|
+
function mountLive(render: () => ReturnType<typeof h>): HTMLElement {
|
|
414
|
+
const container = document.createElement("div");
|
|
415
|
+
document.body.appendChild(container);
|
|
416
|
+
const app = createApp({ render });
|
|
417
|
+
app.mount(container);
|
|
418
|
+
mounts.push({ app, container });
|
|
419
|
+
return container;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Give the viewport a scrollable extent. Only the SIZES are stubbed:
|
|
423
|
+
* the position stays happy-dom's settable scrollLeft, so the park's
|
|
424
|
+
* write is observable exactly as a reader's scroll would be. */
|
|
425
|
+
function stubStrip(el: HTMLElement, scrollWidth: number, clientWidth: number) {
|
|
426
|
+
Object.defineProperties(el, {
|
|
427
|
+
scrollWidth: { configurable: true, get: () => scrollWidth },
|
|
428
|
+
clientWidth: { configurable: true, get: () => clientWidth },
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
it("mounts the overlay chrome over the strip, with the body still inside", () => {
|
|
433
|
+
const c = mount(
|
|
434
|
+
h(HkCalendarHeatmap, { days: daysFrom(CLEAN_HEAD_MON, 7), first: null }),
|
|
435
|
+
);
|
|
436
|
+
const strip = stripOf(c);
|
|
437
|
+
// The class contract survives (consumers style .hk-calendar-heatmap-scroll)
|
|
438
|
+
// and the element IS the scroll container root.
|
|
439
|
+
expect(strip.classList.contains("hk-scroll-container")).toBe(true);
|
|
440
|
+
// hikari's auto-hiding overlay track is the only bar…
|
|
441
|
+
expect(
|
|
442
|
+
c.querySelector('.hk-scrollbar-track[data-axis="horizontal"]'),
|
|
443
|
+
).not.toBeNull();
|
|
444
|
+
// …the overflow glow is opted in (the strip hides days at its edges)…
|
|
445
|
+
expect(strip.getAttribute("data-overflow-glow")).toBe("true");
|
|
446
|
+
// …and the body still rides inside the scrolling viewport.
|
|
447
|
+
expect(viewportOf(c).querySelector(".hk-calendar-heatmap-body")).not.toBeNull();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("keeps native overflow out of the Vue form (source contract)", () => {
|
|
451
|
+
const sheet = readFileSync(join(here, "HkCalendarHeatmap.scss"), "utf8");
|
|
452
|
+
// The -rail/-cells contract is pinned by calendarHeatmapRail.contract;
|
|
453
|
+
// this pin owns the STRIP rule the migration rewrote.
|
|
454
|
+
const scrollRule = sheet.match(/&-scroll \{[\s\S]*?\n \}/)?.[0] ?? "";
|
|
455
|
+
expect(scrollRule, "the &-scroll rule still owns the strip box").toContain("&-scroll {");
|
|
456
|
+
// The Vue strip IS the HkScrollContainer root. Any overflow declaration
|
|
457
|
+
// in this rule would bring a NATIVE bar back on top of the overlay chrome
|
|
458
|
+
// (the 2026-11-08 regression this migration fixed).
|
|
459
|
+
expect(scrollRule).not.toMatch(/overflow/);
|
|
460
|
+
// The Rust/WASM renderer still renders the plain div, so the sheet keeps
|
|
461
|
+
// a fallback scroller for it — but ONLY off the container form, and the
|
|
462
|
+
// platform bar stays suppressed (the chart family's native-bar invariant;
|
|
463
|
+
// the Rust form is bar-less until it migrates onto the shared script).
|
|
464
|
+
const fallback =
|
|
465
|
+
sheet.match(/&-scroll:not\(\.hk-scroll-container\) \{[\s\S]*?\n \}/)?.[0] ?? "";
|
|
466
|
+
expect(fallback, "the plain-form fallback must exist for the Rust mirror").not.toBe("");
|
|
467
|
+
expect(fallback).toContain("overflow-x: auto");
|
|
468
|
+
expect(fallback).toContain("scrollbar-width: none");
|
|
469
|
+
expect(fallback).toMatch(/&::-webkit-scrollbar\s*\{[\s\S]*?display:\s*none/);
|
|
470
|
+
expect(fallback, "the plain form keeps its centering").toContain("margin-inline: auto");
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("opens focused on the newest end (anchor=end parks the strip)", async () => {
|
|
474
|
+
const c = mount(
|
|
475
|
+
h(HkCalendarHeatmap, { days: daysFrom(CLEAN_HEAD_MON, 7), first: null }),
|
|
476
|
+
);
|
|
477
|
+
const viewport = viewportOf(c);
|
|
478
|
+
stubStrip(viewport, 300, 100);
|
|
479
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
480
|
+
await flushFrames();
|
|
481
|
+
expect(viewport.scrollLeft, "parked at max = 300 - 100").toBe(200);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("forwards anchor=start so a host can opt back to the oldest end", async () => {
|
|
485
|
+
const c = mount(
|
|
486
|
+
h(HkCalendarHeatmap, {
|
|
487
|
+
days: daysFrom(CLEAN_HEAD_MON, 7),
|
|
488
|
+
first: null,
|
|
489
|
+
anchor: "start",
|
|
490
|
+
}),
|
|
491
|
+
);
|
|
492
|
+
const viewport = viewportOf(c);
|
|
493
|
+
stubStrip(viewport, 300, 100);
|
|
494
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
495
|
+
await flushFrames();
|
|
496
|
+
expect(viewport.scrollLeft).toBe(0);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("re-parks at the end when the series is replaced (range switch)", async () => {
|
|
500
|
+
const state = reactive({ days: daysFrom(CLEAN_HEAD_MON, 7) });
|
|
501
|
+
const c = mountLive(() => h(HkCalendarHeatmap, { days: state.days, first: null }));
|
|
502
|
+
const viewport = viewportOf(c);
|
|
503
|
+
stubStrip(viewport, 300, 100);
|
|
504
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
505
|
+
await flushFrames();
|
|
506
|
+
expect(viewport.scrollLeft, "initial end focus").toBe(200);
|
|
507
|
+
|
|
508
|
+
// A reader scrolls back to the oldest day…
|
|
509
|
+
viewport.scrollLeft = 0;
|
|
510
|
+
// …then switches range: the new series must land on ITS newest end
|
|
511
|
+
// (the container's sticky anchor deliberately would not, since the
|
|
512
|
+
// reader is no longer parked — the days watcher is the deterministic
|
|
513
|
+
// re-park).
|
|
514
|
+
state.days = daysFrom("2025-11-01", 30);
|
|
515
|
+
await nextTick();
|
|
516
|
+
await nextTick();
|
|
517
|
+
expect(viewport.scrollLeft).toBe(200);
|
|
518
|
+
});
|
|
519
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { computed, defineComponent, type PropType } from "vue";
|
|
1
|
+
import { computed, defineComponent, ref, watch, type PropType } from "vue";
|
|
2
2
|
|
|
3
3
|
import { useI18n } from "../i18n/context";
|
|
4
|
+
import HkScrollContainer from "./HkScrollContainer";
|
|
4
5
|
import {
|
|
5
6
|
calendarGrid,
|
|
6
7
|
heatmapCellFill,
|
|
@@ -53,6 +54,18 @@ function fill(template: string, vars: Record<string, string | number>): string {
|
|
|
53
54
|
* null = never). All math lives in ./calendarHeatmap (pure, pinned by
|
|
54
55
|
* its own tests). The `toolbar` slot rides right of the legend — the
|
|
55
56
|
* consumer's range tabs / controls belong there.
|
|
57
|
+
*
|
|
58
|
+
* Scrolling (2026-11-08): the calendar body rides HkScrollContainer
|
|
59
|
+
* (axis="horizontal", align="center") so the strip uses hikari's own
|
|
60
|
+
* auto-hiding overlay scrollbar and NEVER a native bar — a chart that
|
|
61
|
+
* paints a platform scrollbar reads as an embedded widget, and the
|
|
62
|
+
* battle heatmap's bar was the last one in the family. The container's
|
|
63
|
+
* `anchor` prop defaults to "end" here: the newest day is the reading
|
|
64
|
+
* interest, so the strip opens focused on it (and the `anchor` prop lets
|
|
65
|
+
* a host opt back to "start"), while the container's overflow glow marks
|
|
66
|
+
* the side that still hides days. A series replacement (range switch,
|
|
67
|
+
* refetch across midnight) re-parks at the end explicitly — see the
|
|
68
|
+
* days watcher.
|
|
56
69
|
*/
|
|
57
70
|
export default defineComponent({
|
|
58
71
|
name: "HkCalendarHeatmap",
|
|
@@ -72,6 +85,15 @@ export default defineComponent({
|
|
|
72
85
|
/** Square-cell scale (GitHub-scale: a 53-week grid stays compact). */
|
|
73
86
|
cellSize: { type: Number, default: 14 },
|
|
74
87
|
cellGap: { type: Number, default: 3 },
|
|
88
|
+
/** Which end of the horizontal strip is focused first: the default
|
|
89
|
+
* "end" opens on the NEWEST day (the reading interest — a year-wide
|
|
90
|
+
* series otherwise starts at the oldest, faded edge, which is exactly
|
|
91
|
+
* the "why is the chart empty-looking?" report this default answers).
|
|
92
|
+
* Forwarded to HkScrollContainer's `anchor`; the days/range watcher
|
|
93
|
+
* still re-parks at the end on a data change, so this prop changes the
|
|
94
|
+
* INITIAL focus (and the sticky behaviour) rather than the re-park
|
|
95
|
+
* contract. Pass "start" to restore the old GitHub-style origin. */
|
|
96
|
+
anchor: { type: String as PropType<"start" | "end">, default: "end" },
|
|
75
97
|
},
|
|
76
98
|
setup(props, { slots }) {
|
|
77
99
|
const { t } = useI18n();
|
|
@@ -160,6 +182,38 @@ export default defineComponent({
|
|
|
160
182
|
: undefined;
|
|
161
183
|
});
|
|
162
184
|
|
|
185
|
+
// The strip's scroll host. Only the two handle members the heatmap
|
|
186
|
+
// uses are named (structural typing, the consumers' house convention):
|
|
187
|
+
// the end park and the sensing refresh.
|
|
188
|
+
const scroller = ref<{
|
|
189
|
+
scrollToEnd: (behavior?: ScrollBehavior) => void;
|
|
190
|
+
refresh: () => void;
|
|
191
|
+
} | null>(null);
|
|
192
|
+
|
|
193
|
+
/** Re-park at the newest day whenever the caller replaces the series —
|
|
194
|
+
* a range switch (the toolbar tabs), a refetch across UTC midnight, a
|
|
195
|
+
* totalled window. The container's `anchor="end"` only keeps a
|
|
196
|
+
* viewport that ALREADY sits at the end, deliberately (it must never
|
|
197
|
+
* fight a reader who scrolled back); switching range from mid-strip is
|
|
198
|
+
* a data replacement, so "land on the newest" is made unconditional
|
|
199
|
+
* here instead.
|
|
200
|
+
*
|
|
201
|
+
* flush: "post" is load-bearing — the grid must have re-rendered (new
|
|
202
|
+
* scrollWidth, possibly a narrower strip) before the target is
|
|
203
|
+
* measured, or the park would aim at the previous range's extent.
|
|
204
|
+
* refresh() then re-derives the overlay chrome from the same new
|
|
205
|
+
* extent: a range that shrinks the series from overflowing to fitting
|
|
206
|
+
* fires no scroll event, so without it the auto-hiding track could
|
|
207
|
+
* stay painted over content that no longer overflows. */
|
|
208
|
+
watch(
|
|
209
|
+
() => props.days,
|
|
210
|
+
() => {
|
|
211
|
+
scroller.value?.scrollToEnd("auto");
|
|
212
|
+
scroller.value?.refresh();
|
|
213
|
+
},
|
|
214
|
+
{ flush: "post" },
|
|
215
|
+
);
|
|
216
|
+
|
|
163
217
|
return () => (
|
|
164
218
|
<div class="hk-calendar-heatmap">
|
|
165
219
|
{/* The legend row: the scale in the caller's one unit on the
|
|
@@ -191,10 +245,23 @@ export default defineComponent({
|
|
|
191
245
|
{slots.toolbar?.({})}
|
|
192
246
|
</div>
|
|
193
247
|
|
|
194
|
-
{/* The
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
|
|
248
|
+
{/* The strip rides HkScrollContainer: hikari's own auto-hiding
|
|
249
|
+
* overlay scrollbar is the ONLY bar (a native one would read as an
|
|
250
|
+
* embedded platform widget — the battle heatmap's last regression),
|
|
251
|
+
* align="center" keeps the whole calendar (rail included) centered
|
|
252
|
+
* while it fits and lets it scroll once it does not, and
|
|
253
|
+
* anchor="end" opens it focused on the newest day. The class stays
|
|
254
|
+
* on the container root, so consumers styling
|
|
255
|
+
* .hk-calendar-heatmap-scroll keep working; overflowGlow lights the
|
|
256
|
+
* side that still hides days. */}
|
|
257
|
+
<HkScrollContainer
|
|
258
|
+
ref={scroller}
|
|
259
|
+
class="hk-calendar-heatmap-scroll"
|
|
260
|
+
axis="horizontal"
|
|
261
|
+
align="center"
|
|
262
|
+
anchor={props.anchor}
|
|
263
|
+
overflowGlow
|
|
264
|
+
>
|
|
198
265
|
<div class="hk-calendar-heatmap-body">
|
|
199
266
|
{/* Weekday rail: the first (Monday) and last (Sunday) rows
|
|
200
267
|
* carry the labels, the wowsp convention. */}
|
|
@@ -271,7 +338,7 @@ export default defineComponent({
|
|
|
271
338
|
</div>
|
|
272
339
|
</div>
|
|
273
340
|
</div>
|
|
274
|
-
</
|
|
341
|
+
</HkScrollContainer>
|
|
275
342
|
</div>
|
|
276
343
|
);
|
|
277
344
|
},
|
|
@@ -9,7 +9,7 @@ import HkTooltip from "./HkTooltip";
|
|
|
9
9
|
import { hasRenderedContent } from "../runtime/hasRenderedContent";
|
|
10
10
|
import { useMediaQuery } from "../runtime/useMediaQuery";
|
|
11
11
|
import type { StatTone } from "./HkStatCard";
|
|
12
|
-
import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressHandle } from "./
|
|
12
|
+
import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressHandle } from "./HkScrollProgressDock";
|
|
13
13
|
import "./HkCardList.scss";
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -47,22 +47,22 @@ export const HkCardList = defineComponent({
|
|
|
47
47
|
props: {
|
|
48
48
|
/** Authoritative row total the scroll-progress pill uses as its
|
|
49
49
|
* denominator (the server count behind a windowed/infinite list).
|
|
50
|
-
* Forwarded to the nearest enclosing
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
50
|
+
* Forwarded to the nearest enclosing enabled HkScrollProgressDock,
|
|
51
|
+
* exactly like HkAdminTablePage's `total`. Omitted: the pill falls
|
|
52
|
+
* back to counting the delivered DOM rows (right for
|
|
53
|
+
* fully-delivered lists, drifting low for windowed ones — pass the
|
|
54
|
+
* server count there). */
|
|
55
55
|
total: { type: Number, default: undefined },
|
|
56
56
|
},
|
|
57
57
|
setup(props, { slots }) {
|
|
58
58
|
// Scroll-progress registration (the HkTable precedent): the NEAREST
|
|
59
|
-
// enclosing
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
59
|
+
// enclosing ENABLED HkScrollProgressDock provides the registrar —
|
|
60
|
+
// provide/inject scoping resolves the shell viewport for console
|
|
61
|
+
// pages and keeps a list registered against an inner dock out of
|
|
62
|
+
// the shell. The handle hands the pill the list element as the row
|
|
63
|
+
// root, the `total` prop live, and the rendered HkListRow elements
|
|
64
|
+
// as the rows (a card list's rows ARE its data rows — no
|
|
65
|
+
// section-band caveat).
|
|
66
66
|
const listRef = ref<HTMLElement | null>(null);
|
|
67
67
|
const progressRegistrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
|
|
68
68
|
const progressHandle: HkScrollProgressHandle = {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import * as barrel from "../index";
|
|
3
|
+
import { asIndexable } from "../utils/typing";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The public barrel guard for the edge-glow painter (same shape as
|
|
7
|
+
* HkCalendarHeatmap.barrel.test.ts): deleting the export lines from
|
|
8
|
+
* index.ts kept the ENTIRE suite green while the component's own tests
|
|
9
|
+
* reach the module through "./HkEdgeGlow" — only an external consumer
|
|
10
|
+
* would have noticed. HkScrollContainer imports the module directly too,
|
|
11
|
+
* so this file is the only thing pinning the public names.
|
|
12
|
+
*
|
|
13
|
+
* This file deliberately does NOT mock "@celestia-island/hikari": the
|
|
14
|
+
* package's self-reference resolves to the very same src/index.ts module
|
|
15
|
+
* as "../index", so a vi.mock here would replace the barrel under test
|
|
16
|
+
* with the mock's own namespace.
|
|
17
|
+
*/
|
|
18
|
+
describe("the public barrel", () => {
|
|
19
|
+
it("exports the edge-glow painter under both family names", () => {
|
|
20
|
+
const hk = asIndexable(barrel.HkEdgeGlow);
|
|
21
|
+
expect(hk, "HkEdgeGlow must be re-exported").toBeTruthy();
|
|
22
|
+
expect(hk.name).toBe("HkEdgeGlow");
|
|
23
|
+
// The H- alias rides the HCard/HkCard family convention: consumers
|
|
24
|
+
// import either name, one component underneath.
|
|
25
|
+
expect(barrel.HEdgeGlow).toBe(barrel.HkEdgeGlow);
|
|
26
|
+
});
|
|
27
|
+
});
|