@celestia-island/hikari 0.49.7 → 0.50.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/HkDatePicker.test.ts +30 -24
- package/src/components/HkDateTimePicker.test.ts +15 -15
- package/src/components/HkFileBrowserDialog.scss +8 -4
- package/src/components/HkFileBrowserDialog.tsx +13 -1
- package/src/components/HkImageLightbox.scss +8 -0
- package/src/components/HkModal.bodyrhythm.test.ts +8 -1
- package/src/components/HkModal.scss +54 -3
- package/src/components/HkModal.sheetgap.test.ts +4 -1
- package/src/components/HkModal.tsx +7 -1
- package/src/components/HkScrollContainer.tsx +10 -1
- package/src/components/HkScrollPin.scrollcontract.test.ts +204 -0
- package/src/components/HkScrollPin.scss +109 -0
- package/src/components/HkScrollPin.test.tsx +113 -0
- package/src/components/HkScrollPin.tsx +150 -0
- package/src/components/HkStepFlow.scss +15 -9
- package/src/components/HkStepFlow.test.tsx +49 -0
- package/src/components/HkStepFlow.tsx +35 -2
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -41,13 +41,6 @@ function panel(): HTMLElement | null {
|
|
|
41
41
|
return document.querySelector<HTMLElement>(".hk-dp-panel");
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/** Let Vue's leave transitions (frame/timeout based) finish in happy-dom. */
|
|
45
|
-
async function settle() {
|
|
46
|
-
await nextTick();
|
|
47
|
-
await new Promise((r) => setTimeout(r, 20));
|
|
48
|
-
await nextTick();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
44
|
/** Poll until the drilled view's title button reads `expected`. The drill
|
|
52
45
|
* transition is frame/timeout based and a fixed 20 ms settle raced it on
|
|
53
46
|
* the CI runner (the year-grid test once read the previous view's title),
|
|
@@ -67,10 +60,23 @@ async function waitForTitle(expected: string): Promise<void> {
|
|
|
67
60
|
}
|
|
68
61
|
}
|
|
69
62
|
|
|
70
|
-
/** Generic state poll over the same drill race: wait until `probe` holds
|
|
63
|
+
/** Generic state poll over the same drill race: wait until `probe` holds
|
|
64
|
+
* STABLY. A single true evaluation can be a mid-transition transient
|
|
65
|
+
* (the leaving pane's cells vanish one tick before its container
|
|
66
|
+
* unmounts), so the probe must hold across a 10 ms window before the
|
|
67
|
+
* wait resolves — otherwise the raw asserts after it race the teardown
|
|
68
|
+
* timers (observed on the hosted runner: pickCount read 24 right after
|
|
69
|
+
* a "settled" poll). */
|
|
71
70
|
async function waitForView(desc: string, probe: () => boolean): Promise<void> {
|
|
72
71
|
const deadline = Date.now() + 2000;
|
|
73
|
-
|
|
72
|
+
let holdSince: number | null = null;
|
|
73
|
+
for (;;) {
|
|
74
|
+
if (probe()) {
|
|
75
|
+
holdSince ??= Date.now();
|
|
76
|
+
if (Date.now() - holdSince >= 10) return;
|
|
77
|
+
} else {
|
|
78
|
+
holdSince = null;
|
|
79
|
+
}
|
|
74
80
|
if (Date.now() > deadline) throw new Error(`view never reached: ${desc}`);
|
|
75
81
|
await new Promise((r) => setTimeout(r, 10));
|
|
76
82
|
}
|
|
@@ -163,8 +169,7 @@ describe("HkDatePicker", () => {
|
|
|
163
169
|
expect(panel()?.querySelectorAll(".hk-dp-cell").length).toBe(42);
|
|
164
170
|
|
|
165
171
|
panel()?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
166
|
-
await
|
|
167
|
-
expect(panel()).toBeNull();
|
|
172
|
+
await waitForView("the popup to close on Escape", () => panel() === null);
|
|
168
173
|
});
|
|
169
174
|
|
|
170
175
|
it("derives weekday header labels from Intl for the locale", async () => {
|
|
@@ -185,8 +190,7 @@ describe("HkDatePicker", () => {
|
|
|
185
190
|
clickDay(20);
|
|
186
191
|
await nextTick();
|
|
187
192
|
expect(p.emitted).toEqual(["2026-08-20"]);
|
|
188
|
-
await
|
|
189
|
-
expect(panel()).toBeNull();
|
|
193
|
+
await waitForView("the popup to close after picking a day", () => panel() === null);
|
|
190
194
|
});
|
|
191
195
|
|
|
192
196
|
it("disables days outside the inclusive min/max bounds", async () => {
|
|
@@ -244,8 +248,7 @@ describe("HkDatePicker", () => {
|
|
|
244
248
|
await nextTick();
|
|
245
249
|
expect(panel()).not.toBeNull();
|
|
246
250
|
trigger?.click();
|
|
247
|
-
await
|
|
248
|
-
expect(panel()).toBeNull();
|
|
251
|
+
await waitForView("the popup to close on trigger toggle", () => panel() === null);
|
|
249
252
|
|
|
250
253
|
const d = mountPicker({ modelValue: "2026-08-16", disabled: true });
|
|
251
254
|
const disabledTrigger = d.container.querySelector<HTMLElement>(".hk-dp-trigger");
|
|
@@ -345,7 +348,10 @@ describe("HkDatePicker", () => {
|
|
|
345
348
|
await nextTick();
|
|
346
349
|
const stage = panel()?.querySelector<HTMLElement>(".hk-dp-stage");
|
|
347
350
|
panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.click();
|
|
348
|
-
|
|
351
|
+
// Wait out the drill transition before reading the grid: a raced
|
|
352
|
+
// read still sees the leaving days pane (54 cells).
|
|
353
|
+
await waitForView("the months grid settled to one pane", () =>
|
|
354
|
+
panel()?.querySelectorAll(".hk-dp-cell").length === 12);
|
|
349
355
|
expect(stage?.getAttribute("data-dir")).toBe("fwd");
|
|
350
356
|
const picks = Array.from(panel()?.querySelectorAll<HTMLButtonElement>(".hk-dp-cell[data-variant='pick']") ?? []);
|
|
351
357
|
expect(picks.length).toBe(12);
|
|
@@ -354,9 +360,9 @@ describe("HkDatePicker", () => {
|
|
|
354
360
|
new Intl.DateTimeFormat("en", { month: "short" }).format(new Date(2024, i, 15))),
|
|
355
361
|
);
|
|
356
362
|
panel()?.querySelector<HTMLButtonElement>(".hk-dp-back")?.click();
|
|
357
|
-
await
|
|
363
|
+
await waitForView("the days grid settled to one pane", () =>
|
|
364
|
+
panel()?.querySelectorAll(".hk-dp-cell").length === 42);
|
|
358
365
|
expect(stage?.getAttribute("data-dir")).toBe("back");
|
|
359
|
-
expect(panel()?.querySelectorAll(".hk-dp-cell").length).toBe(42);
|
|
360
366
|
});
|
|
361
367
|
|
|
362
368
|
it("picks a year from the year grid and lands back on days with that year", async () => {
|
|
@@ -378,7 +384,12 @@ describe("HkDatePicker", () => {
|
|
|
378
384
|
pickCells().find((c) => c.textContent === "2027")?.click();
|
|
379
385
|
// Picking a year lands on the months grid of that year.
|
|
380
386
|
await waitForTitle("2027");
|
|
381
|
-
|
|
387
|
+
// Click July BY LABEL: a positional click would hit whatever the
|
|
388
|
+
// grid shows if the view drifted (hosted CI once clicked the 2022
|
|
389
|
+
// year cell here and the title read "August 2022").
|
|
390
|
+
await waitForView("the July cell in the months grid", () =>
|
|
391
|
+
pickCells().some((c) => c.textContent === "Jul"));
|
|
392
|
+
pickCells().find((c) => c.textContent === "Jul")?.click();
|
|
382
393
|
// ...and picking a month lands back on the days grid.
|
|
383
394
|
await waitForView("the days grid", () => panel()?.querySelectorAll(".hk-dp-cell").length === 42);
|
|
384
395
|
const fmt = new Intl.DateTimeFormat("en", { year: "numeric", month: "long" });
|
|
@@ -392,7 +403,6 @@ describe("HkDatePicker", () => {
|
|
|
392
403
|
openViaEnter(p);
|
|
393
404
|
await nextTick();
|
|
394
405
|
panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.click();
|
|
395
|
-
await settle();
|
|
396
406
|
await waitForTitle("2026");
|
|
397
407
|
const navs = () => panel()?.querySelectorAll<HTMLButtonElement>(".hk-dp-nav");
|
|
398
408
|
navs()?.[1].click();
|
|
@@ -423,25 +433,21 @@ describe("HkDatePicker", () => {
|
|
|
423
433
|
stage?.querySelectorAll<HTMLButtonElement>(".hk-dp-cell:not([data-variant])").length === 42
|
|
424
434
|
: pickCount() === 12 && stage?.children.length === 1;
|
|
425
435
|
panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.click();
|
|
426
|
-
await settle();
|
|
427
436
|
await waitForView("the months grid settled to one pane", settledDrill(false));
|
|
428
437
|
expect(panel()?.querySelector<HTMLElement>(".hk-dp-stage")).toBe(stage);
|
|
429
438
|
expect(stage?.getAttribute("data-dir")).toBe("fwd");
|
|
430
439
|
expect(stage?.children.length).toBe(1); // one pane at a time after settle
|
|
431
440
|
expect(pickCount()).toBe(12);
|
|
432
441
|
panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.click();
|
|
433
|
-
await settle();
|
|
434
442
|
await waitForView("the years grid settled to one pane", settledDrill(false));
|
|
435
443
|
expect(stage?.getAttribute("data-dir")).toBe("fwd");
|
|
436
444
|
expect(pickCount()).toBe(12);
|
|
437
445
|
// back steps down the stack one level at a time: years → months → days.
|
|
438
446
|
panel()?.querySelector<HTMLButtonElement>(".hk-dp-back")?.click();
|
|
439
|
-
await settle();
|
|
440
447
|
await waitForView("the months grid again, one pane", settledDrill(false));
|
|
441
448
|
expect(stage?.getAttribute("data-dir")).toBe("back");
|
|
442
449
|
expect(pickCount()).toBe(12);
|
|
443
450
|
panel()?.querySelector<HTMLButtonElement>(".hk-dp-back")?.click();
|
|
444
|
-
await settle();
|
|
445
451
|
await waitForView("the days grid settled to one pane", settledDrill(true));
|
|
446
452
|
expect(stage?.getAttribute("data-dir")).toBe("back");
|
|
447
453
|
expect(stage?.querySelectorAll<HTMLButtonElement>(".hk-dp-cell:not([data-variant])").length).toBe(42);
|
|
@@ -60,13 +60,6 @@ function mountPicker(props: Record<string, unknown> = {}): PickerHarness {
|
|
|
60
60
|
return { container, emitted };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/** Let Vue's leave transitions (frame/timeout based) finish in happy-dom. */
|
|
64
|
-
async function settle() {
|
|
65
|
-
await nextTick();
|
|
66
|
-
await new Promise((r) => setTimeout(r, 20));
|
|
67
|
-
await nextTick();
|
|
68
|
-
}
|
|
69
|
-
|
|
70
63
|
/** Poll until the drilled view's title button reads `expected`. The drill
|
|
71
64
|
* transition is frame/timeout based and a fixed 20 ms settle raced it
|
|
72
65
|
* under full-suite load (the assertion once read the days-view title),
|
|
@@ -88,10 +81,23 @@ function pickCells(): HTMLButtonElement[] {
|
|
|
88
81
|
return Array.from(picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-cell[data-variant='pick']") ?? []);
|
|
89
82
|
}
|
|
90
83
|
|
|
91
|
-
/** Generic state poll over the same drill race: wait until `probe` holds
|
|
84
|
+
/** Generic state poll over the same drill race: wait until `probe` holds
|
|
85
|
+
* STABLY. A single true evaluation can be a mid-transition transient
|
|
86
|
+
* (the leaving pane's cells vanish one tick before its container
|
|
87
|
+
* unmounts), so the probe must hold across a 10 ms window before the
|
|
88
|
+
* wait resolves — otherwise the raw asserts after it race the teardown
|
|
89
|
+
* timers (observed on the hosted runner: pickCount read 24 right after
|
|
90
|
+
* a "settled" poll). */
|
|
92
91
|
async function waitForView(desc: string, probe: () => boolean): Promise<void> {
|
|
93
92
|
const deadline = Date.now() + 2000;
|
|
94
|
-
|
|
93
|
+
let holdSince: number | null = null;
|
|
94
|
+
for (;;) {
|
|
95
|
+
if (probe()) {
|
|
96
|
+
holdSince ??= Date.now();
|
|
97
|
+
if (Date.now() - holdSince >= 10) return;
|
|
98
|
+
} else {
|
|
99
|
+
holdSince = null;
|
|
100
|
+
}
|
|
95
101
|
if (Date.now() > deadline) throw new Error(`view never reached: ${desc}`);
|
|
96
102
|
await new Promise((r) => setTimeout(r, 10));
|
|
97
103
|
}
|
|
@@ -191,7 +197,6 @@ describe("HkDateTimePicker", () => {
|
|
|
191
197
|
mountPicker();
|
|
192
198
|
const monthBtn = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-title-btn")[0];
|
|
193
199
|
monthBtn?.click();
|
|
194
|
-
await settle();
|
|
195
200
|
// Destination state = the months pane mounted AND the leaving days
|
|
196
201
|
// pane gone — mid-transition both conditions half-hold.
|
|
197
202
|
await waitForView("the months grid settled to one pane", () =>
|
|
@@ -213,7 +218,6 @@ describe("HkDateTimePicker", () => {
|
|
|
213
218
|
expect(stage?.children.length).toBe(1); // the single days pane
|
|
214
219
|
const monthBtn = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-title-btn")[0];
|
|
215
220
|
monthBtn?.click();
|
|
216
|
-
await settle();
|
|
217
221
|
// Destination state = months pane mounted AND the leaving days pane
|
|
218
222
|
// gone — mid-transition the cell counts already read final while the
|
|
219
223
|
// stage still carries both panes.
|
|
@@ -223,13 +227,11 @@ describe("HkDateTimePicker", () => {
|
|
|
223
227
|
stage?.children.length === 1);
|
|
224
228
|
expect(stage?.getAttribute("data-dir")).toBe("fwd");
|
|
225
229
|
expect(picker()?.querySelector<HTMLElement>(".hk-dtp-stage")).toBe(stage);
|
|
226
|
-
expect(stage?.children.length).toBe(1); // one pane at a time after settle
|
|
227
230
|
// The time row lives outside the transitioned pane and stays in every
|
|
228
231
|
// view, so the picker's footprint never changes.
|
|
229
232
|
expect(picker()?.querySelectorAll(".hk-dtp-time").length).toBe(1);
|
|
230
233
|
expect(picker()?.querySelectorAll(".hk-dtp-step").length).toBe(2);
|
|
231
234
|
picker()?.querySelector<HTMLButtonElement>(".hk-dtp-back")?.click();
|
|
232
|
-
await settle();
|
|
233
235
|
await waitForView("the days grid settled to one pane", () =>
|
|
234
236
|
dayCells().length === 42 && stage?.children.length === 1);
|
|
235
237
|
expect(stage?.getAttribute("data-dir")).toBe("back");
|
|
@@ -239,7 +241,6 @@ describe("HkDateTimePicker", () => {
|
|
|
239
241
|
mountPicker();
|
|
240
242
|
const monthBtn = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-title-btn")[0];
|
|
241
243
|
monthBtn?.click();
|
|
242
|
-
await settle();
|
|
243
244
|
await waitForTitle(String(BASE.getFullYear()));
|
|
244
245
|
const navs = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-nav");
|
|
245
246
|
navs?.[1].click();
|
|
@@ -254,7 +255,6 @@ describe("HkDateTimePicker", () => {
|
|
|
254
255
|
mountPicker();
|
|
255
256
|
const monthBtn = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-title-btn")[0];
|
|
256
257
|
monthBtn?.click();
|
|
257
|
-
await settle();
|
|
258
258
|
// Destination state = months pane mounted AND the leaving days pane
|
|
259
259
|
// gone — mid-transition the cell counts already read final while the
|
|
260
260
|
// stage still carries both panes.
|
|
@@ -385,10 +385,14 @@
|
|
|
385
385
|
text-align: start;
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
388
|
+
// The head is a scroll pin (class + data-side/data-strategy on the
|
|
389
|
+
// element): positioning/z ride the shared .hk-scroll-pin rules, and the
|
|
390
|
+
// doubled class keeps this explicit surface above the pin's default tint
|
|
391
|
+
// regardless of stylesheet import order. Pinned under a gutter-cover
|
|
392
|
+
// host (HkModal) it stops below the body's top whitespace instead of
|
|
393
|
+
// jamming against the window chrome; the cover masks rows while they
|
|
394
|
+
// pass under the gutter.
|
|
395
|
+
.hk-file-browser-list-head.hk-scroll-pin {
|
|
392
396
|
font-size: var(--text-xs);
|
|
393
397
|
font-weight: 600;
|
|
394
398
|
color: rgb(var(--color-muted));
|
|
@@ -30,6 +30,9 @@ import {
|
|
|
30
30
|
} from "./filePicker";
|
|
31
31
|
import HButton from "./HkButton";
|
|
32
32
|
import HModal from "./HkModal";
|
|
33
|
+
// The list head rides the scroll-pin contract; its stylesheet ships the
|
|
34
|
+
// pin rules.
|
|
35
|
+
import "./HkScrollPin.scss";
|
|
33
36
|
import HSelect, { type HkSelectOption } from "./HkSelect";
|
|
34
37
|
import HSpinner from "./HkSpinner";
|
|
35
38
|
import "./HkFileBrowserDialog.scss";
|
|
@@ -657,7 +660,16 @@ export default defineComponent({
|
|
|
657
660
|
function renderList(rows: RemoteFileEntry[]) {
|
|
658
661
|
return (
|
|
659
662
|
<div class="hk-file-browser-list">
|
|
660
|
-
|
|
663
|
+
{/* Scroll pin (static "offset"): rides the scroll host's gutter
|
|
664
|
+
contract so the pinned head keeps the body's top whitespace.
|
|
665
|
+
Under a host without the contract it degrades to a plain
|
|
666
|
+
flush sticky (the vars resolve to 0). */}
|
|
667
|
+
<div
|
|
668
|
+
class="hk-file-browser-list-head hk-scroll-pin"
|
|
669
|
+
data-side="top"
|
|
670
|
+
data-strategy="offset"
|
|
671
|
+
aria-hidden="true"
|
|
672
|
+
>
|
|
661
673
|
<span class="hk-file-browser-col-name">
|
|
662
674
|
{t("hikari::filePicker.name", "Name")}
|
|
663
675
|
</span>
|
|
@@ -29,6 +29,14 @@
|
|
|
29
29
|
max-height: none;
|
|
30
30
|
display: flex;
|
|
31
31
|
flex-direction: column;
|
|
32
|
+
|
|
33
|
+
// Immersive override: the body is un-padded here, so the pin contract's
|
|
34
|
+
// gutters (and the covers with them) collapse to zero too. Declared on
|
|
35
|
+
// the body so its own covers read them (inheritance runs downward).
|
|
36
|
+
--hk-scroll-pad-top: 0px;
|
|
37
|
+
--hk-scroll-pad-right: 0px;
|
|
38
|
+
--hk-scroll-pad-bottom: 0px;
|
|
39
|
+
--hk-scroll-pad-left: 0px;
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
.hk-modal-body-scroll {
|
|
@@ -35,6 +35,13 @@ describe("HkModal body rhythm contract", () => {
|
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
it("keeps the body padding hook unchanged", () => {
|
|
38
|
-
|
|
38
|
+
// 2026-09-14 scroll-pin contract: the padding consumes the declared
|
|
39
|
+
// --hk-scroll-pad-* vars (single source with the gutter covers and
|
|
40
|
+
// the pins); the vars themselves derive from --hk-modal-padding-body,
|
|
41
|
+
// so the host-tunable hook keeps working.
|
|
42
|
+
expect(inner).toContain("padding: var(--hk-scroll-pad-top");
|
|
43
|
+
// The old direct source must stay retired — a stale literal here
|
|
44
|
+
// would desync the pins from the padding.
|
|
45
|
+
expect(inner).not.toContain("padding: var(--hk-modal-padding-body");
|
|
39
46
|
});
|
|
40
47
|
});
|
|
@@ -240,6 +240,51 @@
|
|
|
240
240
|
color: var(--hk-modal-body-color, var(--hi-color-text-primary, #1e1e1e));
|
|
241
241
|
flex: 1;
|
|
242
242
|
min-height: 0;
|
|
243
|
+
|
|
244
|
+
// Scroll-pin host contract: the body padding physically lives on
|
|
245
|
+
// .hk-modal-body-inner (a child of the scroller), where sticky can
|
|
246
|
+
// never hold it in place — pins read these instead, and the gutter
|
|
247
|
+
// covers below consume the same vars. Declared HERE (the scroller's
|
|
248
|
+
// parent): custom properties inherit downward, so the covers, the
|
|
249
|
+
// scroller and the inner all resolve them. The mobile sheet block
|
|
250
|
+
// retunes the vars only.
|
|
251
|
+
--hk-scroll-pad-top: var(--hk-modal-padding-body, 1.5rem);
|
|
252
|
+
--hk-scroll-pad-right: var(--hk-modal-padding-body, 1.5rem);
|
|
253
|
+
--hk-scroll-pad-bottom: var(--hk-modal-padding-body, 1.5rem);
|
|
254
|
+
--hk-scroll-pad-left: var(--hk-modal-padding-body, 1.5rem);
|
|
255
|
+
|
|
256
|
+
// Gutter covers (scroll-pin contract, 2026-09-14): the body's top and
|
|
257
|
+
// bottom whitespace stays paintable OUTSIDE the scroll flow, so a
|
|
258
|
+
// pinned child (HkScrollPin strategy="offset") stops below the gutter
|
|
259
|
+
// with the whitespace intact, and scrolled content vanishes under the
|
|
260
|
+
// cover instead of sliding through a see-through gap. At rest the
|
|
261
|
+
// covers sit over empty padding — same surface color, invisible.
|
|
262
|
+
&::before,
|
|
263
|
+
&::after {
|
|
264
|
+
content: "";
|
|
265
|
+
position: absolute;
|
|
266
|
+
left: 0;
|
|
267
|
+
right: 0;
|
|
268
|
+
z-index: 3;
|
|
269
|
+
pointer-events: none;
|
|
270
|
+
// Same paint source as the content frame by default; a host that
|
|
271
|
+
// re-themes the body itself overrides --hk-scroll-pin-cover-bg so the
|
|
272
|
+
// covers never surface as foreign bands over the padding.
|
|
273
|
+
background: var(
|
|
274
|
+
--hk-scroll-pin-cover-bg,
|
|
275
|
+
var(--hk-modal-bg, var(--hi-color-surface, rgba(240, 244, 248, 0.95)))
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
&::before {
|
|
280
|
+
top: 0;
|
|
281
|
+
height: var(--hk-scroll-pad-top, 0px);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
&::after {
|
|
285
|
+
bottom: 0;
|
|
286
|
+
height: var(--hk-scroll-pad-bottom, 0px);
|
|
287
|
+
}
|
|
243
288
|
}
|
|
244
289
|
|
|
245
290
|
.hk-modal-body-scroll {
|
|
@@ -258,7 +303,8 @@
|
|
|
258
303
|
}
|
|
259
304
|
|
|
260
305
|
.hk-modal-body-inner {
|
|
261
|
-
padding: var(--hk-
|
|
306
|
+
padding: var(--hk-scroll-pad-top, 1.5rem) var(--hk-scroll-pad-right, 1.5rem)
|
|
307
|
+
var(--hk-scroll-pad-bottom, 1.5rem) var(--hk-scroll-pad-left, 1.5rem);
|
|
262
308
|
|
|
263
309
|
// Default vertical rhythm between bare stacked children (user direction
|
|
264
310
|
// 2026-09-08: window bodies keep a little distance between their elements
|
|
@@ -428,8 +474,13 @@
|
|
|
428
474
|
padding: 0.625rem 1rem 0.625rem;
|
|
429
475
|
}
|
|
430
476
|
|
|
431
|
-
|
|
432
|
-
|
|
477
|
+
// The gutter contract retunes here too — the covers and the inner's
|
|
478
|
+
// padding both consume the vars, so one override moves both.
|
|
479
|
+
.hk-modal-body {
|
|
480
|
+
--hk-scroll-pad-top: 1rem;
|
|
481
|
+
--hk-scroll-pad-right: 1rem;
|
|
482
|
+
--hk-scroll-pad-bottom: 1rem;
|
|
483
|
+
--hk-scroll-pad-left: 1rem;
|
|
433
484
|
}
|
|
434
485
|
|
|
435
486
|
// Lift the desktop 70vh body cap on phones: with a docked sheet the cap
|
|
@@ -52,7 +52,10 @@ describe("HkModal mobile sheet spacing contract", () => {
|
|
|
52
52
|
beforeAll(() => {
|
|
53
53
|
block = src.slice(src.indexOf("@media (max-width: 767px)"));
|
|
54
54
|
content = block.match(/\.hk-modal-content\s*{[^}]*}/)?.[0] ?? "";
|
|
55
|
-
|
|
55
|
+
// 2026-09-14: the sheet block carries more than one .hk-modal-body
|
|
56
|
+
// rule (the scroll-pin gutter-var override + the body-cap lift) —
|
|
57
|
+
// assertions below must see both, so concatenate them all.
|
|
58
|
+
body = (block.match(/\.hk-modal-body\s*{[^}]*}/g) ?? []).join("\n");
|
|
56
59
|
footer = block.match(/\.hk-modal-footer\s*{[\s\S]*?^ }/m)?.[0] ?? "";
|
|
57
60
|
});
|
|
58
61
|
|
|
@@ -804,7 +804,13 @@ export default defineComponent({
|
|
|
804
804
|
<div ref={bodyRef} class="hk-modal-body">
|
|
805
805
|
<div
|
|
806
806
|
ref={scrollContainerRef}
|
|
807
|
-
class="hk-modal-body-scroll"
|
|
807
|
+
class="hk-modal-body-scroll hk-scroll-pin-host"
|
|
808
|
+
data-scroll-axis="vertical"
|
|
809
|
+
// The body gutters (top/bottom whitespace) are painted
|
|
810
|
+
// by .hk-modal-body's covers, so pins stop at the
|
|
811
|
+
// gutter line instead of absorbing the padding (see
|
|
812
|
+
// HkScrollPin's strategy contract).
|
|
813
|
+
data-pad-cover=""
|
|
808
814
|
onScroll={onBodyScroll}
|
|
809
815
|
>
|
|
810
816
|
<div ref={innerRef} class="hk-modal-body-inner">
|
|
@@ -15,6 +15,7 @@ import "./HkScrollContainer.scss";
|
|
|
15
15
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
16
16
|
import { useApproachEnd, type ApproachEndHandle } from "../composables/useApproachEnd";
|
|
17
17
|
import { provideScrollWindow } from "../composables/useScrollWindow";
|
|
18
|
+
import { SCROLL_HOST_CLASS } from "./HkScrollPin";
|
|
18
19
|
import { scheduleFrame, notifyScrollStart, onceFrame, type AnimationHandle } from "../runtime/animationBus";
|
|
19
20
|
import HFab from "./HkFab";
|
|
20
21
|
|
|
@@ -437,7 +438,15 @@ export default defineComponent({
|
|
|
437
438
|
data-align={alignCenter() ? "center" : undefined}
|
|
438
439
|
data-fade={props.fade ? "true" : undefined}
|
|
439
440
|
>
|
|
440
|
-
|
|
441
|
+
{/* Scroll-pin host marker: the viewport participates in the pin
|
|
442
|
+
contract with its live axis; generic containers declare no
|
|
443
|
+
standard padding, so pins inside resolve their strategy from
|
|
444
|
+
whatever --hk-scroll-pad-* the consumer put on this viewport. */}
|
|
445
|
+
<div
|
|
446
|
+
ref={viewportRef}
|
|
447
|
+
class={["hk-scroll-container-viewport", SCROLL_HOST_CLASS]}
|
|
448
|
+
data-scroll-axis={props.axis}
|
|
449
|
+
>
|
|
441
450
|
{content}
|
|
442
451
|
</div>
|
|
443
452
|
{showAutoTag.value && (
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract: THE SCROLL-PIN WHITESPACE CONTRACT (2026-09-14 user
|
|
3
|
+
* report — the wizard's step header pinned flush against the modal
|
|
4
|
+
* header because the body padding lives on .hk-modal-body-inner, a
|
|
5
|
+
* wrapper child of the scroller; CSS sticky resolves its offsets against
|
|
6
|
+
* the scrollport and never sees wrapper padding, so the pinned header
|
|
7
|
+
* lost its whitespace the moment it engaged).
|
|
8
|
+
*
|
|
9
|
+
* The contract has two halves that must stay in sync:
|
|
10
|
+
* 1. HOSTS declare `--hk-scroll-pad-*` on the scrolling viewport (class
|
|
11
|
+
* .hk-scroll-pin-host) and, when they paint their gutters themselves,
|
|
12
|
+
* carry `data-pad-cover` — pins then stop at the gutter line.
|
|
13
|
+
* 2. PINS (HkScrollPin / .hk-scroll-pin) absorb or respect the declared
|
|
14
|
+
* padding per strategy; the bleed strategy's negative margin + padding
|
|
15
|
+
* pair is the load-bearing geometry and must never lose a side.
|
|
16
|
+
*
|
|
17
|
+
* Verified live in headless Chromium before pinning here: padding on the
|
|
18
|
+
* scroller itself pins sticky children BELOW the padding (probe 2);
|
|
19
|
+
* padding on a wrapper does not (probe 1) — hence the declared-vars
|
|
20
|
+
* contract instead of "just move the padding".
|
|
21
|
+
*/
|
|
22
|
+
import { describe, expect, it } from "vitest";
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const read = (name: string) => readFileSync(join(here, name), "utf-8");
|
|
29
|
+
|
|
30
|
+
const pinScss = read("HkScrollPin.scss");
|
|
31
|
+
const modalScss = read("HkModal.scss");
|
|
32
|
+
const modalTsx = read("HkModal.tsx");
|
|
33
|
+
const stepflowScss = read("HkStepFlow.scss");
|
|
34
|
+
const stepflowTsx = read("HkStepFlow.tsx");
|
|
35
|
+
const fileBrowserScss = read("HkFileBrowserDialog.scss");
|
|
36
|
+
const fileBrowserTsx = read("HkFileBrowserDialog.tsx");
|
|
37
|
+
const scrollContainerTsx = read("HkScrollContainer.tsx");
|
|
38
|
+
|
|
39
|
+
/** Extract ONE balanced `{...}` declaration block following the given
|
|
40
|
+
* selector — a naive `[^}]*` would stop at the first nested `}` and let
|
|
41
|
+
* properties appended after a future nested block evade the pin. */
|
|
42
|
+
function cssBlock(src: string, selector: string): string {
|
|
43
|
+
const at = src.indexOf(selector);
|
|
44
|
+
expect(at, `${selector} block exists`).toBeGreaterThanOrEqual(0);
|
|
45
|
+
const open = src.indexOf("{", at);
|
|
46
|
+
let depth = 0;
|
|
47
|
+
for (let i = open; i < src.length; i++) {
|
|
48
|
+
if (src[i] === "{") depth++;
|
|
49
|
+
else if (src[i] === "}") {
|
|
50
|
+
depth--;
|
|
51
|
+
if (depth === 0) return src.slice(open, i + 1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("scroll-pin whitespace contract", () => {
|
|
58
|
+
it("the pin base is sticky with a floating-chrome surface", () => {
|
|
59
|
+
const base = cssBlock(pinScss, ".hk-scroll-pin {");
|
|
60
|
+
expect(base).toMatch(/position: sticky/);
|
|
61
|
+
expect(base).toMatch(/z-index: var\(--hk-scroll-pin-z/);
|
|
62
|
+
expect(base).toMatch(/background: var\(\s*--hk-scroll-pin-bg/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("every side x strategy pairing exists (12 total)", () => {
|
|
66
|
+
for (const strategy of ["offset", "bleed", "none"]) {
|
|
67
|
+
for (const side of ["top", "bottom", "left", "right"]) {
|
|
68
|
+
const selector = `.hk-scroll-pin[data-strategy="${strategy}"][data-side="${side}"]`;
|
|
69
|
+
expect(pinScss, selector).toContain(selector);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("the bleed strategy pairs padding with the negative margin per side", () => {
|
|
75
|
+
// The load-bearing geometry: absorb the host's declared padding so the
|
|
76
|
+
// whitespace travels with the pin. Losing any half of a pair would
|
|
77
|
+
// shift the pin's content at rest (padding without margin) or overlap
|
|
78
|
+
// siblings (margin without padding).
|
|
79
|
+
for (const side of ["top", "bottom", "left", "right"]) {
|
|
80
|
+
const block = cssBlock(
|
|
81
|
+
pinScss,
|
|
82
|
+
`.hk-scroll-pin[data-strategy="bleed"][data-side="${side}"]`,
|
|
83
|
+
);
|
|
84
|
+
expect(block, `bleed ${side} padding`).toMatch(
|
|
85
|
+
new RegExp(`padding-${side}: var\\(--hk-scroll-pad-${side}`),
|
|
86
|
+
);
|
|
87
|
+
expect(block, `bleed ${side} negative margin`).toMatch(
|
|
88
|
+
new RegExp(
|
|
89
|
+
`margin-${side}: calc\\(-1 \\* var\\(--hk-scroll-pad-${side}`,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("the offset strategy stops at the declared gutter line per side", () => {
|
|
96
|
+
for (const side of ["top", "bottom", "left", "right"]) {
|
|
97
|
+
const block = cssBlock(
|
|
98
|
+
pinScss,
|
|
99
|
+
`.hk-scroll-pin[data-strategy="offset"][data-side="${side}"]`,
|
|
100
|
+
);
|
|
101
|
+
expect(block).toMatch(new RegExp(`${side}: var\\(--hk-scroll-pad-${side}`));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("HkModal declares the four pad vars on .hk-modal-body (host contract)", () => {
|
|
106
|
+
// Declared on the BODY — the covers are the body's own pseudo rules
|
|
107
|
+
// and custom properties inherit downward only, so the scroller (a
|
|
108
|
+
// child) could never feed them. The FULL literal is pinned (var name
|
|
109
|
+
// AND the 1.5rem fallback): a silently changed default would desync
|
|
110
|
+
// the covers from the padding while every selector still matches.
|
|
111
|
+
const body = cssBlock(modalScss, ".hk-modal-body {");
|
|
112
|
+
for (const side of ["top", "right", "bottom", "left"]) {
|
|
113
|
+
expect(body, `--hk-scroll-pad-${side}`).toContain(
|
|
114
|
+
`--hk-scroll-pad-${side}: var(--hk-modal-padding-body, 1.5rem);`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("HkModal's body-inner consumes the same vars (one source of truth)", () => {
|
|
120
|
+
const inner = cssBlock(modalScss, ".hk-modal-body-inner {");
|
|
121
|
+
expect(inner).toMatch(/padding: var\(--hk-scroll-pad-top/);
|
|
122
|
+
expect(inner).toMatch(/var\(--hk-scroll-pad-left/);
|
|
123
|
+
// The old direct fallback must be gone — a stale `1.5rem` literal in
|
|
124
|
+
// the shorthand would desync the gutter covers from the padding.
|
|
125
|
+
expect(inner).not.toMatch(/padding: 1\.5rem/);
|
|
126
|
+
expect(inner).not.toMatch(/padding: var\(--hk-modal-padding-body/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("HkModal paints gutter covers from the same vars", () => {
|
|
130
|
+
expect(modalScss).toMatch(/&::before,\s*\n\s*&::after/);
|
|
131
|
+
// Anchor on the standalone pseudo blocks INSIDE .hk-modal-body — the
|
|
132
|
+
// combined selector ("&::before,\n &::after {") contains "&::after {"
|
|
133
|
+
// as a literal, so a bare indexOf would match the shared rule instead.
|
|
134
|
+
const bodyBlock = cssBlock(modalScss, ".hk-modal-body {");
|
|
135
|
+
expect(bodyBlock).toMatch(
|
|
136
|
+
/&::before \{\s*top: 0;\s*height: var\(--hk-scroll-pad-top/,
|
|
137
|
+
);
|
|
138
|
+
expect(bodyBlock).toMatch(
|
|
139
|
+
/&::after \{\s*bottom: 0;\s*height: var\(--hk-scroll-pad-bottom/,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("the mobile sheet retunes the vars instead of the inner padding", () => {
|
|
144
|
+
// Grab the media-query block's body override.
|
|
145
|
+
const at = modalScss.indexOf(".hk-modal-body {", modalScss.indexOf("@media (max-width: 767px)"));
|
|
146
|
+
expect(at).toBeGreaterThan(-1);
|
|
147
|
+
const block = cssBlock(modalScss.slice(at), ".hk-modal-body {");
|
|
148
|
+
expect(block).toMatch(/--hk-scroll-pad-top: 1rem;/);
|
|
149
|
+
expect(block).toMatch(/--hk-scroll-pad-left: 1rem;/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("HkModal marks the scroller as a cover host in the tsx", () => {
|
|
153
|
+
expect(modalTsx).toMatch(/class="hk-modal-body-scroll hk-scroll-pin-host"/);
|
|
154
|
+
expect(modalTsx).toMatch(/data-scroll-axis="vertical"/);
|
|
155
|
+
expect(modalTsx).toMatch(/data-pad-cover=""/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("HkImageLightbox zeroes the contract for its un-padded body", () => {
|
|
159
|
+
const lightbox = readFileSync(join(here, "HkImageLightbox.scss"), "utf-8");
|
|
160
|
+
expect(lightbox).toMatch(/--hk-scroll-pad-top: 0px;/);
|
|
161
|
+
expect(lightbox).toMatch(/--hk-scroll-pad-bottom: 0px;/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("HkStepFlow delegates sticky positioning to the pin class", () => {
|
|
165
|
+
const sticky = cssBlock(
|
|
166
|
+
stepflowScss,
|
|
167
|
+
".hk-step-flow[data-sticky-header] > .hk-timeline.hk-scroll-pin {",
|
|
168
|
+
);
|
|
169
|
+
expect(sticky).toBeTruthy();
|
|
170
|
+
// The delegation: no own position/sticky — that lives on .hk-scroll-pin.
|
|
171
|
+
expect(sticky).not.toMatch(/position:\s*sticky/);
|
|
172
|
+
expect(sticky).not.toMatch(/backdrop-filter/);
|
|
173
|
+
// The step-flow specifics stay: the folded body gap + legacy knobs.
|
|
174
|
+
expect(sticky).toMatch(/padding-bottom: var\(--hk-stepflow-header-gap/);
|
|
175
|
+
expect(sticky).toMatch(/--hk-scroll-pin-z: var\(--hk-stepflow-sticky-z/);
|
|
176
|
+
expect(sticky).toMatch(/--hk-scroll-pin-bg: var\(\s*--hk-stepflow-sticky-bg/);
|
|
177
|
+
// And the raw selector is gone (it would double-apply without the pin).
|
|
178
|
+
expect(stepflowScss).not.toMatch(/\.hk-step-flow\[data-sticky-header\] > \.hk-timeline \{/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("HkStepFlow stamps the pin class and side on the timeline root", () => {
|
|
182
|
+
expect(stepflowTsx).toMatch(/class=\{props\.stickyHeader \? "hk-scroll-pin" : undefined\}/);
|
|
183
|
+
expect(stepflowTsx).toMatch(/data-side=\{props\.stickyHeader \? "top" : undefined\}/);
|
|
184
|
+
// Strategy resolves on mount (cover host → "offset", else "bleed").
|
|
185
|
+
expect(stepflowTsx).toMatch(/data-strategy=\{props\.stickyHeader \? pinStrategy\.value : undefined\}/);
|
|
186
|
+
expect(stepflowTsx).toMatch(/pinStrategy\.value = "offset"/);
|
|
187
|
+
expect(stepflowTsx).toMatch(/hasAttribute\("data-pad-cover"\)/);
|
|
188
|
+
expect(stepflowTsx).toMatch(/import "\.\/HkScrollPin\.scss";/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("the file-browser list head is a pin and owns no sticky of its own", () => {
|
|
192
|
+
const head = cssBlock(fileBrowserScss, ".hk-file-browser-list-head.hk-scroll-pin {");
|
|
193
|
+
expect(head).toBeTruthy();
|
|
194
|
+
expect(head).not.toMatch(/position:\s*sticky/);
|
|
195
|
+
expect(head).not.toMatch(/top:\s*0/);
|
|
196
|
+
expect(head).toMatch(/background: rgb\(var\(--color-surface\)\)/);
|
|
197
|
+
expect(fileBrowserTsx).toMatch(/data-strategy="offset"/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("HkScrollContainer marks its viewport as a pin host", () => {
|
|
201
|
+
expect(scrollContainerTsx).toMatch(/SCROLL_HOST_CLASS/);
|
|
202
|
+
expect(scrollContainerTsx).toMatch(/data-scroll-axis=\{props\.axis\}/);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// HkScrollPin.scss
|
|
2
|
+
// The pinned-content contract between a scroll host and its pins.
|
|
3
|
+
//
|
|
4
|
+
// HOST declares (on the scrolling viewport, class .hk-scroll-pin-host):
|
|
5
|
+
// --hk-scroll-pad-top/right/bottom/left the padding that pinned
|
|
6
|
+
// children must keep between themselves and the host's edge;
|
|
7
|
+
// data-scroll-axis="vertical|horizontal|both" which sides may pin;
|
|
8
|
+
// data-pad-cover present when the host paints its gutters itself
|
|
9
|
+
// (HkModal) — pins may simply stop at the gutter line ("offset").
|
|
10
|
+
// PIN declares: data-side, data-strategy (resolved by the component).
|
|
11
|
+
//
|
|
12
|
+
// The 2026-09-14 bug this standardizes away: a sticky child pinned
|
|
13
|
+
// inside a host whose padding lives on an in-flow wrapper loses the
|
|
14
|
+
// padding the moment it pins (CSS sticky offsets resolve against the
|
|
15
|
+
// scrollport, never against a wrapper's padding), so the pinned element
|
|
16
|
+
// sat flush against the window chrome.
|
|
17
|
+
|
|
18
|
+
.hk-scroll-pin {
|
|
19
|
+
position: sticky;
|
|
20
|
+
z-index: var(--hk-scroll-pin-z, 10);
|
|
21
|
+
// Floating-chrome surface: near-opaque surface tint + blur so scrolled
|
|
22
|
+
// content never bleeds through the pinned row (same look the step-flow
|
|
23
|
+
// sticky header introduced; hosts retune through --hk-scroll-pin-bg).
|
|
24
|
+
background: var(
|
|
25
|
+
--hk-scroll-pin-bg,
|
|
26
|
+
color-mix(in srgb, var(--hi-color-surface, #f0f4f8) 95%, transparent)
|
|
27
|
+
);
|
|
28
|
+
backdrop-filter: var(--hk-scroll-pin-blur, blur(6px));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ------
|
|
32
|
+
// "offset" — the host paints its gutters; the pin stops at the gutter
|
|
33
|
+
// line. Works for pins anywhere in the flow (no margin games), and the
|
|
34
|
+
// host's cover hides scrolled content while it passes under the gutter.
|
|
35
|
+
// ------
|
|
36
|
+
|
|
37
|
+
.hk-scroll-pin[data-strategy="offset"][data-side="top"] {
|
|
38
|
+
top: var(--hk-scroll-pad-top, 0px);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.hk-scroll-pin[data-strategy="offset"][data-side="bottom"] {
|
|
42
|
+
bottom: var(--hk-scroll-pad-bottom, 0px);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.hk-scroll-pin[data-strategy="offset"][data-side="left"] {
|
|
46
|
+
left: var(--hk-scroll-pad-left, 0px);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.hk-scroll-pin[data-strategy="offset"][data-side="right"] {
|
|
50
|
+
right: var(--hk-scroll-pad-right, 0px);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ------
|
|
54
|
+
// "bleed" — the host's padding lives in the scroll flow (a wrapper
|
|
55
|
+
// child); the pin absorbs it with padding + negative margin so its own
|
|
56
|
+
// background paints over the padding zone and the whitespace pins
|
|
57
|
+
// together with the content. Verified geometry (headless Chromium):
|
|
58
|
+
// at rest the pin's CONTENT stays at its natural position (the negative
|
|
59
|
+
// margin cancels the added padding exactly); pinned, the border box
|
|
60
|
+
// rests flush at the scrollport edge with the padding painted.
|
|
61
|
+
// Boundary contract: a top bleed pin must be the first element of the
|
|
62
|
+
// scroll content, a bottom bleed pin the last — otherwise the negative
|
|
63
|
+
// margin overlaps a sibling.
|
|
64
|
+
// ------
|
|
65
|
+
|
|
66
|
+
.hk-scroll-pin[data-strategy="bleed"][data-side="top"] {
|
|
67
|
+
top: 0;
|
|
68
|
+
padding-top: var(--hk-scroll-pad-top, 0px);
|
|
69
|
+
margin-top: calc(-1 * var(--hk-scroll-pad-top, 0px));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.hk-scroll-pin[data-strategy="bleed"][data-side="bottom"] {
|
|
73
|
+
bottom: 0;
|
|
74
|
+
padding-bottom: var(--hk-scroll-pad-bottom, 0px);
|
|
75
|
+
margin-bottom: calc(-1 * var(--hk-scroll-pad-bottom, 0px));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.hk-scroll-pin[data-strategy="bleed"][data-side="left"] {
|
|
79
|
+
left: 0;
|
|
80
|
+
padding-left: var(--hk-scroll-pad-left, 0px);
|
|
81
|
+
margin-left: calc(-1 * var(--hk-scroll-pad-left, 0px));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.hk-scroll-pin[data-strategy="bleed"][data-side="right"] {
|
|
85
|
+
right: 0;
|
|
86
|
+
padding-right: var(--hk-scroll-pad-right, 0px);
|
|
87
|
+
margin-right: calc(-1 * var(--hk-scroll-pad-right, 0px));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ------
|
|
91
|
+
// "none" — plain sticky flush at the edge: hosts without a declared
|
|
92
|
+
// padding contract, and consumers opting out.
|
|
93
|
+
// ------
|
|
94
|
+
|
|
95
|
+
.hk-scroll-pin[data-strategy="none"][data-side="top"] {
|
|
96
|
+
top: 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.hk-scroll-pin[data-strategy="none"][data-side="bottom"] {
|
|
100
|
+
bottom: 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.hk-scroll-pin[data-strategy="none"][data-side="left"] {
|
|
104
|
+
left: 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.hk-scroll-pin[data-strategy="none"][data-side="right"] {
|
|
108
|
+
right: 0;
|
|
109
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkScrollPin, { SCROLL_HOST_CLASS, type ScrollPinSide, type ScrollPinStrategy } from "./HkScrollPin";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkScrollPin contract tests. House style: raw createApp mounts on shared
|
|
8
|
+
* containers torn down after each case (no @vue/test-utils).
|
|
9
|
+
*
|
|
10
|
+
* Geometry is NOT asserted here — happy-dom performs no real sticky
|
|
11
|
+
* layout. The whitespace contract (bleed absorption, offset gutter line)
|
|
12
|
+
* is pinned by the source-contract test and was verified in headless
|
|
13
|
+
* Chromium (scroll-padding-on-wrapper vs on-scroller probes).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
17
|
+
const containers: HTMLElement[] = [];
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const app of mounts) app.unmount();
|
|
21
|
+
mounts.length = 0;
|
|
22
|
+
for (const c of containers) c.remove();
|
|
23
|
+
containers.length = 0;
|
|
24
|
+
vi.restoreAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
async function mountPin(options: {
|
|
28
|
+
side?: ScrollPinSide;
|
|
29
|
+
strategy?: ScrollPinStrategy;
|
|
30
|
+
hostAttrs?: Record<string, string>;
|
|
31
|
+
noHost?: boolean;
|
|
32
|
+
content?: string;
|
|
33
|
+
} = {}): Promise<{ container: HTMLElement; el: HTMLElement | null }> {
|
|
34
|
+
const container = document.createElement("div");
|
|
35
|
+
document.body.appendChild(container);
|
|
36
|
+
containers.push(container);
|
|
37
|
+
|
|
38
|
+
const host = document.createElement("div");
|
|
39
|
+
host.className = SCROLL_HOST_CLASS;
|
|
40
|
+
for (const [k, v] of Object.entries(options.hostAttrs ?? {})) {
|
|
41
|
+
host.setAttribute(k, v);
|
|
42
|
+
}
|
|
43
|
+
const slot = ref(options.content ?? "pinned");
|
|
44
|
+
const Wrapper = defineComponent({
|
|
45
|
+
setup() {
|
|
46
|
+
return () =>
|
|
47
|
+
h(HkScrollPin, {
|
|
48
|
+
side: options.side ?? "top",
|
|
49
|
+
strategy: options.strategy ?? "auto",
|
|
50
|
+
}, { default: () => h("span", slot.value) });
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
if (!options.noHost) host.appendChild(container);
|
|
54
|
+
const app = createApp(Wrapper);
|
|
55
|
+
app.mount(container);
|
|
56
|
+
mounts.push(app);
|
|
57
|
+
// Strategy resolution runs onMounted and flips data-strategy via a
|
|
58
|
+
// reactive re-render — the mutation lands one flush AFTER the mount
|
|
59
|
+
// job, so it takes two ticks to reach the DOM.
|
|
60
|
+
await nextTick();
|
|
61
|
+
await nextTick();
|
|
62
|
+
return { container, el: container.querySelector(".hk-scroll-pin") };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("HkScrollPin", () => {
|
|
66
|
+
it("renders the pin wrapper with its side and slot content", async () => {
|
|
67
|
+
const { el } = await mountPin({ side: "bottom", content: "x" });
|
|
68
|
+
expect(el).toBeTruthy();
|
|
69
|
+
expect(el!.dataset.side).toBe("bottom");
|
|
70
|
+
expect(el!.textContent).toContain("x");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("auto resolves to offset under a cover host with a declared pad", async () => {
|
|
74
|
+
const { el } = await mountPin({
|
|
75
|
+
side: "top",
|
|
76
|
+
hostAttrs: { "data-scroll-axis": "vertical", "data-pad-cover": "" },
|
|
77
|
+
});
|
|
78
|
+
// happy-dom resolves no custom properties; the component treats a
|
|
79
|
+
// cover host WITHOUT a resolvable pad as not covered — bleed is the
|
|
80
|
+
// safe default (a zero-var bleed is a plain sticky).
|
|
81
|
+
expect(["offset", "bleed"]).toContain(el!.dataset.strategy);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("auto resolves to bleed for a plain host", async () => {
|
|
85
|
+
const { el } = await mountPin({ hostAttrs: { "data-scroll-axis": "vertical" } });
|
|
86
|
+
expect(el!.dataset.strategy).toBe("bleed");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("explicit strategy passes through untouched", async () => {
|
|
90
|
+
const { el } = await mountPin({ strategy: "none", hostAttrs: { "data-pad-cover": "" } });
|
|
91
|
+
expect(el!.dataset.strategy).toBe("none");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("warns in dev when the side contradicts the host axis", async () => {
|
|
95
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
96
|
+
await mountPin({ side: "left", hostAttrs: { "data-scroll-axis": "vertical" } });
|
|
97
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
98
|
+
expect(String(warn.mock.calls[0][0])).toMatch(/side="left".*vertical/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does not warn when the side agrees with the host axis", async () => {
|
|
102
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
103
|
+
await mountPin({ side: "top", hostAttrs: { "data-scroll-axis": "vertical" } });
|
|
104
|
+
expect(warn).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("warns when there is no marked host under auto resolution", async () => {
|
|
108
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
109
|
+
await mountPin({ side: "top", noHost: true });
|
|
110
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(String(warn.mock.calls[0][0])).toMatch(/no .hk-scroll-pin-host ancestor/);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { defineComponent, onMounted, ref, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import "./HkScrollPin.scss";
|
|
4
|
+
|
|
5
|
+
/** Which edge of the nearest scroll host the pinned content rides. A
|
|
6
|
+
* vertical scroller pins top/bottom; a horizontal scroller pins
|
|
7
|
+
* left/right (the component warns in dev when a side contradicts the
|
|
8
|
+
* host's declared `data-scroll-axis`). */
|
|
9
|
+
export type ScrollPinSide = "top" | "bottom" | "left" | "right";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How the pin keeps the host's whitespace:
|
|
13
|
+
*
|
|
14
|
+
* - `"auto"` (default) — read the host: a host that paints its gutters
|
|
15
|
+
* itself (`data-pad-cover`, HkModal) gets `"offset"`; every other host
|
|
16
|
+
* gets `"bleed"`.
|
|
17
|
+
* - `"offset"` — stop at the host's declared gutter line
|
|
18
|
+
* (`--hk-scroll-pad-*`); the host paints the zone above/below the pin,
|
|
19
|
+
* so scrolled content vanishes under the whitespace instead of showing
|
|
20
|
+
* through a gap. Works for pins anywhere in the flow (toolbars may
|
|
21
|
+
* precede them).
|
|
22
|
+
* - `"bleed"` — absorb the host's in-flow padding into the pin itself
|
|
23
|
+
* (`padding: var(--hk-scroll-pad-…) + negative margin`): the pin's own
|
|
24
|
+
* background paints over the padding zone so the whitespace travels
|
|
25
|
+
* with the pinned content. Only safe for boundary pins — a top bleed
|
|
26
|
+
* pin must be the first element of the scroll content (a bottom bleed
|
|
27
|
+
* pin the last) — a negative margin would otherwise overlap preceding
|
|
28
|
+
* siblings.
|
|
29
|
+
* - `"none"` — plain `position: sticky` flush at the edge; the host has
|
|
30
|
+
* no declared padding or the consumer opts out of the contract.
|
|
31
|
+
*/
|
|
32
|
+
export type ScrollPinStrategy = "auto" | "offset" | "bleed" | "none";
|
|
33
|
+
|
|
34
|
+
/** Scroll hosts that participate in the pin contract mark their
|
|
35
|
+
* viewport with this class (HkModal body scroller, HkScrollContainer
|
|
36
|
+
* viewport) and may declare `--hk-scroll-pad-top/right/bottom/left`
|
|
37
|
+
* plus `data-scroll-axis="vertical|horizontal|both"`. */
|
|
38
|
+
export const SCROLL_HOST_CLASS = "hk-scroll-pin-host";
|
|
39
|
+
|
|
40
|
+
const AXIS_OK: Record<string, ScrollPinSide[]> = {
|
|
41
|
+
vertical: ["top", "bottom"],
|
|
42
|
+
horizontal: ["left", "right"],
|
|
43
|
+
both: ["top", "bottom", "left", "right"],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pinned scroll content with a guaranteed whitespace contract: content
|
|
48
|
+
* placed in an `HkScrollPin` rides one edge of the nearest scroll host
|
|
49
|
+
* and never scrolls away — and the host's padding travels WITH it, so
|
|
50
|
+
* the pinned element keeps its breathing room (the 2026-09-14 wizard
|
|
51
|
+
* report: the step header pinned flush against the modal header because
|
|
52
|
+
* the body padding scrolled away beneath it).
|
|
53
|
+
*
|
|
54
|
+
* The pin is a plain `position: sticky` wrapper — it must sit inside the
|
|
55
|
+
* host's scroll flow (not inside a `position: absolute`/`fixed`
|
|
56
|
+
* ancestor, and not inside an `overflow: hidden` ancestor between it and
|
|
57
|
+
* the host, which would break stickiness).
|
|
58
|
+
*/
|
|
59
|
+
export default defineComponent({
|
|
60
|
+
name: "HkScrollPin",
|
|
61
|
+
props: {
|
|
62
|
+
/** Edge of the scroll host to pin against. */
|
|
63
|
+
side: {
|
|
64
|
+
type: String as PropType<ScrollPinSide>,
|
|
65
|
+
default: "top",
|
|
66
|
+
validator: (v: string) => ["top", "bottom", "left", "right"].includes(v),
|
|
67
|
+
},
|
|
68
|
+
/** Whitespace strategy — see the type doc. `"auto"` resolves from
|
|
69
|
+
* the host at mount and is what every consumer should default to. */
|
|
70
|
+
strategy: {
|
|
71
|
+
type: String as PropType<ScrollPinStrategy>,
|
|
72
|
+
default: "auto",
|
|
73
|
+
validator: (v: string) => ["auto", "offset", "bleed", "none"].includes(v),
|
|
74
|
+
},
|
|
75
|
+
/** Override the default stacking height (pins float above scroll
|
|
76
|
+
* content; hosts retune through `--hk-scroll-pin-z`). */
|
|
77
|
+
z: { type: Number, default: undefined },
|
|
78
|
+
},
|
|
79
|
+
setup(props, { slots }) {
|
|
80
|
+
const rootRef = ref<HTMLElement | null>(null);
|
|
81
|
+
/** Strategy after `"auto"` resolution — rendered as data-strategy so
|
|
82
|
+
* the SCSS has one total switch to key on. */
|
|
83
|
+
const resolved = ref<"offset" | "bleed" | "none">("bleed");
|
|
84
|
+
let warned = false;
|
|
85
|
+
|
|
86
|
+
function warnOnce(message: string): void {
|
|
87
|
+
if (warned || !import.meta.env?.DEV) return;
|
|
88
|
+
warned = true;
|
|
89
|
+
console.warn(`[HkScrollPin] ${message}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveStrategy(): void {
|
|
93
|
+
const el = rootRef.value;
|
|
94
|
+
if (!el) return;
|
|
95
|
+
const host = el.closest(`.${SCROLL_HOST_CLASS}`);
|
|
96
|
+
if (host) {
|
|
97
|
+
const axis = host.getAttribute("data-scroll-axis");
|
|
98
|
+
const allowed = AXIS_OK[axis ?? ""] ?? AXIS_OK.both;
|
|
99
|
+
if (!allowed.includes(props.side)) {
|
|
100
|
+
warnOnce(
|
|
101
|
+
`side="${props.side}" contradicts the nearest scroll host axis "${axis ?? "unmarked"}" — ` +
|
|
102
|
+
`vertical scrollers pin top/bottom, horizontal scrollers pin left/right.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (props.strategy !== "auto") {
|
|
107
|
+
resolved.value = props.strategy;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const covered =
|
|
111
|
+
host !== null &&
|
|
112
|
+
host.hasAttribute("data-pad-cover") &&
|
|
113
|
+
padDeclared(host, props.side);
|
|
114
|
+
resolved.value = covered ? "offset" : "bleed";
|
|
115
|
+
if (host === null) {
|
|
116
|
+
// No marked host: the pin still sticks (nearest scroll ancestor),
|
|
117
|
+
// but the whitespace contract cannot resolve — tell the author.
|
|
118
|
+
warnOnce(
|
|
119
|
+
"no .hk-scroll-pin-host ancestor found — the pin sticks to its nearest scrollable ancestor " +
|
|
120
|
+
"without a whitespace contract; mark the scroll viewport (SCROLL_HOST_CLASS) to opt in.",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function padDeclared(host: Element, side: ScrollPinSide): boolean {
|
|
126
|
+
const value = getComputedStyle(host).getPropertyValue(`--hk-scroll-pad-${side}`).trim();
|
|
127
|
+
return value !== "" && value !== "0px";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
onMounted(resolveStrategy);
|
|
131
|
+
|
|
132
|
+
// Runtime host swaps (teleports, conditional wrappers) re-resolve on
|
|
133
|
+
// the next mount only — a pin that changes host must be re-keyed by
|
|
134
|
+
// the consumer. No listeners are held, so unmount needs no teardown.
|
|
135
|
+
|
|
136
|
+
return () => {
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
ref={rootRef}
|
|
140
|
+
class="hk-scroll-pin"
|
|
141
|
+
data-side={props.side}
|
|
142
|
+
data-strategy={resolved.value}
|
|
143
|
+
style={props.z !== undefined ? { zIndex: String(props.z) } : undefined}
|
|
144
|
+
>
|
|
145
|
+
{slots.default?.()}
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
});
|
|
@@ -20,18 +20,24 @@
|
|
|
20
20
|
margin-bottom: var(--hk-stepflow-header-gap, var(--space-16, 1rem));
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
// Sticky header mode:
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
// Sticky header mode: positioning, surface and the whitespace contract
|
|
24
|
+
// live on the shared .hk-scroll-pin rules (HkScrollPin.scss) — the
|
|
25
|
+
// timeline root carries the class plus data-side/data-strategy. This
|
|
26
|
+
// block keeps only what is step-flow-specific: the body gap folded into
|
|
27
|
+
// the pinned header's own padding (so the tint stays continuous while
|
|
28
|
+
// the body scrolls underneath) and the legacy styling knobs mapped onto
|
|
29
|
+
// the pin's custom properties. The doubled class selector must beat
|
|
30
|
+
// .hk-scroll-pin's own declarations regardless of import order.
|
|
31
|
+
.hk-step-flow[data-sticky-header] > .hk-timeline.hk-scroll-pin {
|
|
32
|
+
--hk-scroll-pin-z: var(--hk-stepflow-sticky-z, 10);
|
|
33
|
+
--hk-scroll-pin-bg: var(
|
|
31
34
|
--hk-stepflow-sticky-bg,
|
|
32
35
|
color-mix(in srgb, var(--hi-color-surface, #f0f4f8) 95%, transparent)
|
|
33
36
|
);
|
|
34
|
-
|
|
37
|
+
// --hk-stepflow-sticky-top lifts the pin below a host chrome edge; this
|
|
38
|
+
// doubled selector outranks .hk-scroll-pin's own `top:` in every
|
|
39
|
+
// strategy, so the knob always wins when set.
|
|
40
|
+
top: var(--hk-stepflow-sticky-top, 0px);
|
|
35
41
|
margin-bottom: 0;
|
|
36
42
|
padding-bottom: var(--hk-stepflow-header-gap, var(--space-16, 1rem));
|
|
37
43
|
}
|
|
@@ -244,3 +244,52 @@ describe("HkStepFlow", () => {
|
|
|
244
244
|
await settle();
|
|
245
245
|
});
|
|
246
246
|
});
|
|
247
|
+
|
|
248
|
+
// ── Sticky-header pin strategy resolution (2026-09-14 scroll-pin wave) ──
|
|
249
|
+
// The timeline root rides HkScrollPin's contract; the strategy must flip
|
|
250
|
+
// to "offset" when the nearest host paints its gutters (data-pad-cover,
|
|
251
|
+
// HkModal) and stay "bleed" under a plain host. Behavioral, not
|
|
252
|
+
// tautological: happy-dom resolves the attribute scan fine (no custom
|
|
253
|
+
// properties involved).
|
|
254
|
+
describe("HkStepFlow sticky-header pin strategy", () => {
|
|
255
|
+
async function mountWithHost(hostAttrs: Record<string, string> | null) {
|
|
256
|
+
const container = document.createElement("div");
|
|
257
|
+
const host = document.createElement("div");
|
|
258
|
+
host.className = "hk-scroll-pin-host";
|
|
259
|
+
for (const [k, v] of Object.entries(hostAttrs ?? {})) host.setAttribute(k, v);
|
|
260
|
+
host.appendChild(container);
|
|
261
|
+
document.body.appendChild(host);
|
|
262
|
+
containers.push(host);
|
|
263
|
+
const current = ref("a");
|
|
264
|
+
const Wrapper = defineComponent({
|
|
265
|
+
setup() {
|
|
266
|
+
return () =>
|
|
267
|
+
h(HkStepFlow, {
|
|
268
|
+
steps: STEPS,
|
|
269
|
+
modelValue: current.value,
|
|
270
|
+
stickyHeader: true,
|
|
271
|
+
"onUpdate:modelValue": (key: string) => { current.value = key; },
|
|
272
|
+
}, { a: () => h("p", "a"), b: () => h("p", "b"), c: () => h("p", "c"), d: () => h("p", "d") });
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
const app = createApp(Wrapper);
|
|
276
|
+
app.mount(container);
|
|
277
|
+
mounts.push(app);
|
|
278
|
+
// Resolution runs onMounted; the flip lands one flush later.
|
|
279
|
+
await nextTick();
|
|
280
|
+
await nextTick();
|
|
281
|
+
return container.querySelector<HTMLElement>(".hk-timeline");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
it("resolves offset under a cover host (HkModal-style)", async () => {
|
|
285
|
+
const tl = await mountWithHost({ "data-pad-cover": "", "data-scroll-axis": "vertical" });
|
|
286
|
+
expect(tl!.dataset.strategy).toBe("offset");
|
|
287
|
+
expect(tl!.dataset.side).toBe("top");
|
|
288
|
+
expect(tl!.classList.contains("hk-scroll-pin")).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("keeps bleed under a plain host", async () => {
|
|
292
|
+
const tl = await mountWithHost({ "data-scroll-axis": "vertical" });
|
|
293
|
+
expect(tl!.dataset.strategy).toBe("bleed");
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { defineComponent, Transition,
|
|
1
|
+
import { defineComponent, Transition, onMounted, ref, watch, type PropType } from "vue";
|
|
2
2
|
|
|
3
3
|
import HkTimeline from "./HkTimeline";
|
|
4
4
|
import type { TimelineCollapse, TimelineStep } from "./HkTimeline";
|
|
5
|
+
import { SCROLL_HOST_CLASS } from "./HkScrollPin";
|
|
5
6
|
|
|
7
|
+
// Sticky header mode rides the shared scroll-pin contract (class + data
|
|
8
|
+
// attributes on the timeline root); the pin stylesheet ships those
|
|
9
|
+
// styles, so it is imported here directly.
|
|
10
|
+
import "./HkScrollPin.scss";
|
|
6
11
|
import "./HkStepFlow.scss";
|
|
7
12
|
|
|
8
13
|
/** Scoped argument every step-keyed slot receives. */
|
|
@@ -34,7 +39,13 @@ export default defineComponent({
|
|
|
34
39
|
/**
|
|
35
40
|
* Pin the header to the top of the nearest scroll container (modal
|
|
36
41
|
* body hosts) so the step indicator stays visible over long bodies.
|
|
37
|
-
*
|
|
42
|
+
* The header rides the shared scroll-pin contract (HkScrollPin's
|
|
43
|
+
* class + data attributes on the timeline root): inside a host that
|
|
44
|
+
* declares the padding contract the header keeps the body's top
|
|
45
|
+
* whitespace when pinned instead of sitting flush against the
|
|
46
|
+
* window chrome (2026-09-14 wizard report). Legacy styling knobs
|
|
47
|
+
* --hk-stepflow-sticky-top/-z/-bg keep working through the pin's
|
|
48
|
+
* custom properties.
|
|
38
49
|
*/
|
|
39
50
|
stickyHeader: { type: Boolean, default: false },
|
|
40
51
|
collapse: {
|
|
@@ -78,6 +89,24 @@ export default defineComponent({
|
|
|
78
89
|
},
|
|
79
90
|
);
|
|
80
91
|
|
|
92
|
+
// Sticky-header whitespace strategy (2026-09-14): the timeline is the
|
|
93
|
+
// flow's boundary element, but content may sit ABOVE the whole flow
|
|
94
|
+
// inside the same scroll body — a bleed pin's negative margin would
|
|
95
|
+
// overlap it. Inside a host that paints its gutters (`data-pad-cover`,
|
|
96
|
+
// HkModal) the pin therefore stops at the gutter line ("offset",
|
|
97
|
+
// overlap-free anywhere in the flow); every other host keeps "bleed",
|
|
98
|
+
// where the flow is the de-facto boundary element. Resolution happens
|
|
99
|
+
// on mount (attribute scan only — no geometry), so CSR surfaces see
|
|
100
|
+
// the attribute flip once right after hydration; hikari renders
|
|
101
|
+
// client-side only.
|
|
102
|
+
const flowRef = ref<HTMLDivElement | null>(null);
|
|
103
|
+
const pinStrategy = ref<"offset" | "bleed">("bleed");
|
|
104
|
+
|
|
105
|
+
onMounted(() => {
|
|
106
|
+
const host = flowRef.value?.closest(`.${SCROLL_HOST_CLASS}`);
|
|
107
|
+
if (host?.hasAttribute("data-pad-cover")) pinStrategy.value = "offset";
|
|
108
|
+
});
|
|
109
|
+
|
|
81
110
|
return () => {
|
|
82
111
|
const index = indexOf(props.modelValue);
|
|
83
112
|
// An unknown key finds no slot: the body simply renders empty, no
|
|
@@ -87,6 +116,7 @@ export default defineComponent({
|
|
|
87
116
|
|
|
88
117
|
return (
|
|
89
118
|
<div
|
|
119
|
+
ref={flowRef}
|
|
90
120
|
class="hk-step-flow"
|
|
91
121
|
data-sticky-header={props.stickyHeader || undefined}
|
|
92
122
|
>
|
|
@@ -97,6 +127,9 @@ export default defineComponent({
|
|
|
97
127
|
clickable={props.timelineClickable}
|
|
98
128
|
collapse={props.collapse}
|
|
99
129
|
onSelect={(key: string) => emit("update:modelValue", key)}
|
|
130
|
+
class={props.stickyHeader ? "hk-scroll-pin" : undefined}
|
|
131
|
+
data-side={props.stickyHeader ? "top" : undefined}
|
|
132
|
+
data-strategy={props.stickyHeader ? pinStrategy.value : undefined}
|
|
100
133
|
/>
|
|
101
134
|
)}
|
|
102
135
|
<Transition
|
package/src/index.ts
CHANGED
|
@@ -99,6 +99,8 @@ export { default as HTimeline } from "./components/HkTimeline";
|
|
|
99
99
|
export { default as HTitleBar } from "./components/HkTitleBar";
|
|
100
100
|
export { default as HStepFlow } from "./components/HkStepFlow";
|
|
101
101
|
export type { StepFlowSlotProps } from "./components/HkStepFlow";
|
|
102
|
+
export { default as HScrollPin, SCROLL_HOST_CLASS } from "./components/HkScrollPin";
|
|
103
|
+
export type { ScrollPinSide, ScrollPinStrategy } from "./components/HkScrollPin";
|
|
102
104
|
|
|
103
105
|
// Media player kit
|
|
104
106
|
export { default as HMediaPlayer, MEDIA_RATES } from "./components/HkMediaPlayer";
|