@celestia-island/hikari 0.55.49 → 0.55.50
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/HkModal.stepflowswap.test.tsx +106 -0
- package/src/components/HkModal.tsx +35 -1
- package/src/components/HkStepFlow.scss +30 -15
- package/src/components/HkStepFlow.slide.contract.test.ts +35 -9
- package/src/components/HkStepFlow.test.tsx +191 -6
- package/src/components/HkStepFlow.tsx +154 -20
- package/src/composables/useSizeMorph.test.ts +240 -0
- package/src/composables/useSizeMorph.ts +179 -0
- package/src/runtime/sheetRide.ts +62 -0
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
|
3
3
|
|
|
4
4
|
import HkModal from "./HkModal";
|
|
5
5
|
import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
|
|
6
|
+
import { useSheetRide } from "../runtime/sheetRide";
|
|
6
7
|
|
|
7
8
|
const SHEET_SWEEP_STAGE_EVENT = "hk-sheet-sweep-stage";
|
|
8
9
|
const SHEET_SWEEP_SETTLE_EVENT = "hk-sheet-sweep-settle";
|
|
@@ -325,4 +326,109 @@ describe("HkModal publishes its own fold for the step flow", () => {
|
|
|
325
326
|
expect(stages).toEqual([]);
|
|
326
327
|
expect(settled).toBe(0);
|
|
327
328
|
});
|
|
329
|
+
|
|
330
|
+
it("rides its chrome and registered descendants with the staged fold", async () => {
|
|
331
|
+
// Round 14: the fold's clip edge must never slice the title. When a
|
|
332
|
+
// sweep stages, the modal's chrome and content probe ride the edge,
|
|
333
|
+
// and a descendant that registered through the sheet-ride registry
|
|
334
|
+
// (the way HkStepFlow registers its entering body) rides as a
|
|
335
|
+
// reveal COUNTER — staged at the mirrored negative offset.
|
|
336
|
+
vi.useFakeTimers();
|
|
337
|
+
freezeRaf();
|
|
338
|
+
stubClip();
|
|
339
|
+
stubBox(500, Number.POSITIVE_INFINITY);
|
|
340
|
+
|
|
341
|
+
let registered = false;
|
|
342
|
+
const Rider = defineComponent({
|
|
343
|
+
setup() {
|
|
344
|
+
const registry = useSheetRide();
|
|
345
|
+
return () =>
|
|
346
|
+
h("div", {
|
|
347
|
+
class: "rider-el",
|
|
348
|
+
ref: (el: unknown) => {
|
|
349
|
+
const node = el as HTMLElement | null;
|
|
350
|
+
if (node && registry && !registered) {
|
|
351
|
+
registered = true;
|
|
352
|
+
registry.register({ el: node, counter: true });
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
}, "rider");
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const container = document.createElement("div");
|
|
360
|
+
document.body.appendChild(container);
|
|
361
|
+
containers.push(container);
|
|
362
|
+
const open = ref(false);
|
|
363
|
+
const Wrapper = defineComponent({
|
|
364
|
+
setup() {
|
|
365
|
+
return () =>
|
|
366
|
+
h(HkModal, {
|
|
367
|
+
modelValue: open.value,
|
|
368
|
+
closable: true,
|
|
369
|
+
title: "ride-test",
|
|
370
|
+
"onUpdate:modelValue": (v: boolean) => { open.value = v; },
|
|
371
|
+
}, {
|
|
372
|
+
default: () => h("div", { class: "swap-body" }, [h(Rider)]),
|
|
373
|
+
});
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
const app = createApp(Wrapper);
|
|
377
|
+
mounts.push(app);
|
|
378
|
+
app.mount(container);
|
|
379
|
+
await nextTick();
|
|
380
|
+
open.value = true;
|
|
381
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
382
|
+
|
|
383
|
+
const frame = () => document.querySelector<HTMLElement>(".hk-modal-content")!;
|
|
384
|
+
const header = () => document.querySelector<HTMLElement>(".hk-modal-header")!;
|
|
385
|
+
const bodyBlock = () => document.querySelector<HTMLElement>(".hk-modal-body")!;
|
|
386
|
+
const riderEl = () => document.querySelector<HTMLElement>(".rider-el")!;
|
|
387
|
+
expect(registered).toBe(true);
|
|
388
|
+
|
|
389
|
+
// The sweep needs real frames from here on.
|
|
390
|
+
const frames: FrameRequestCallback[] = [];
|
|
391
|
+
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
392
|
+
frames.push(cb);
|
|
393
|
+
return frames.length;
|
|
394
|
+
});
|
|
395
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
396
|
+
|
|
397
|
+
// A GROW announced at the flow's exit edge: content 500 -> 700, so
|
|
398
|
+
// the frame folds +200. The chrome rides at the pre-growth offset,
|
|
399
|
+
// the registered counter at the mirrored negative.
|
|
400
|
+
content = 700;
|
|
401
|
+
document
|
|
402
|
+
.querySelector<HTMLElement>(".hk-modal-body .swap-body")!
|
|
403
|
+
.dispatchEvent(
|
|
404
|
+
new CustomEvent(STEPFLOW_SWAP_EVENT, {
|
|
405
|
+
bubbles: true,
|
|
406
|
+
detail: { delta: 200, durationMs: 150, phase: "exit" },
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
await nextTick();
|
|
410
|
+
expect(header().style.transform).toBe("translateY(200px)");
|
|
411
|
+
expect(bodyBlock().style.transform).toBe("translateY(200px)");
|
|
412
|
+
expect(riderEl().style.transform).toBe("translateY(-200px)");
|
|
413
|
+
expect(header().style.willChange).toBe("transform");
|
|
414
|
+
|
|
415
|
+
// Two warmup frames, then the sweep: everything flips to its target
|
|
416
|
+
// under the mirrored transform transition.
|
|
417
|
+
for (let i = 0; i < 2; i += 1) {
|
|
418
|
+
for (const cb of frames.splice(0)) cb(i * 16);
|
|
419
|
+
await vi.advanceTimersByTimeAsync(16);
|
|
420
|
+
}
|
|
421
|
+
expect(header().style.transform).toBe("translateY(0px)");
|
|
422
|
+
expect(riderEl().style.transform).toBe("translateY(0px)");
|
|
423
|
+
expect(header().style.transition).toMatch(/^transform 0\.3s /);
|
|
424
|
+
|
|
425
|
+
// The landing releases the rides with the frame.
|
|
426
|
+
const ev = new Event("transitionend");
|
|
427
|
+
Object.defineProperty(ev, "propertyName", { value: "clip-path" });
|
|
428
|
+
frame().dispatchEvent(ev);
|
|
429
|
+
expect(header().style.transform).toBe("");
|
|
430
|
+
expect(bodyBlock().style.transform).toBe("");
|
|
431
|
+
expect(riderEl().style.transform).toBe("");
|
|
432
|
+
expect(header().style.willChange).toBe("");
|
|
433
|
+
});
|
|
328
434
|
});
|
|
@@ -11,7 +11,8 @@ import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composa
|
|
|
11
11
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
12
12
|
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
13
13
|
import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
|
|
14
|
-
import { useSizeMorph } from "../composables/useSizeMorph";
|
|
14
|
+
import { useSizeMorph, type RideEntry } from "../composables/useSizeMorph";
|
|
15
|
+
import { provideSheetRide } from "../runtime/sheetRide";
|
|
15
16
|
|
|
16
17
|
import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
|
|
17
18
|
|
|
@@ -210,10 +211,43 @@ export default defineComponent({
|
|
|
210
211
|
// running animation (2026-09-16 chest field report — the frame
|
|
211
212
|
// snapped 459→697px mid-unfold). The open phase edge flushes the
|
|
212
213
|
// deferred growth with an animated remeasure.
|
|
214
|
+
// Fold-ride registry (round 14): descendants register elements the
|
|
215
|
+
// fold must not slice or drag (the stepflow's entering body rides as
|
|
216
|
+
// a counter); merged into collectRide below.
|
|
217
|
+
const sheetRide = provideSheetRide();
|
|
213
218
|
const morph = useSizeMorph(contentRef, innerRef, {
|
|
214
219
|
deferRemeasure: () =>
|
|
215
220
|
machine.phase.value === "openingFrom" ||
|
|
216
221
|
machine.phase.value === "openingTo",
|
|
222
|
+
// The sheet's chrome and content block ride the fold edge in
|
|
223
|
+
// lockstep with the clip sweep: the title bar glides with the edge
|
|
224
|
+
// instead of being sliced by it mid-sweep, the content block starts
|
|
225
|
+
// at its pre-morph position (a grow no longer teleports the stack
|
|
226
|
+
// upward behind the staged clip), and a shrink's landing re-pin is
|
|
227
|
+
// visually a no-op instead of a delta snap (2026-09-23 round-14
|
|
228
|
+
// chest report: "the title collapses — clipped mid-text, never
|
|
229
|
+
// dragged with the fold").
|
|
230
|
+
collectRide: (): RideEntry[] => {
|
|
231
|
+
const out: RideEntry[] = [];
|
|
232
|
+
const frame = contentRef.value;
|
|
233
|
+
if (frame) {
|
|
234
|
+
const header = frame.querySelector<HTMLElement>(".hk-modal-header");
|
|
235
|
+
if (header) out.push({ el: header });
|
|
236
|
+
const subheader = frame.querySelector<HTMLElement>(".hk-modal-subheader");
|
|
237
|
+
if (subheader) out.push({ el: subheader });
|
|
238
|
+
}
|
|
239
|
+
// The BODY BLOCK rides, not the body inner: translating the
|
|
240
|
+
// inner made the scroll container itself scrollable for the
|
|
241
|
+
// sweep's duration (transforms contribute to scrollable
|
|
242
|
+
// overflow), and the transient scrollbar/gutter poisoned the
|
|
243
|
+
// post-settle measurement into a phantom +20px re-sweep (R1 rig
|
|
244
|
+
// finding). Riding the block keeps the scrollport's content
|
|
245
|
+
// untransformed; the block's own poke below the frame lands
|
|
246
|
+
// off-screen on a bottom-docked sheet.
|
|
247
|
+
if (bodyRef.value) out.push({ el: bodyRef.value });
|
|
248
|
+
out.push(...sheetRide.snapshot());
|
|
249
|
+
return out;
|
|
250
|
+
},
|
|
217
251
|
// Republish the morph's real sweep span and its landing on the body
|
|
218
252
|
// element (bubbling), so content choreography can park against the
|
|
219
253
|
// landing geometry and release exactly when it happens instead of
|
|
@@ -47,23 +47,35 @@
|
|
|
47
47
|
// directive asks for, with no pin).
|
|
48
48
|
.hk-stepflow-body {
|
|
49
49
|
grid-area: 1 / 1;
|
|
50
|
-
// ENTER: fade in place with a standard ease — no travel,
|
|
51
|
-
//
|
|
52
|
-
// mounts (opacity: 0 is compositor-only),
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// "
|
|
50
|
+
// ENTER: fade in place from TRUE zero with a standard ease — no travel,
|
|
51
|
+
// no delay tricks. The element is laid out and PAINTED from the moment
|
|
52
|
+
// it mounts (opacity: 0 is compositor-only), and the COMPONENT fires
|
|
53
|
+
// the enter edge at 65% of the exit phase and flips the opacity-hiding
|
|
54
|
+
// class off imperatively, so the fade ramps up under the old body's
|
|
55
|
+
// still-visible tail (recycled at 90%, by then ~0.07) — no empty body
|
|
56
|
+
// ever paints between the phases (round 14, 2026-09-23: the earlier
|
|
57
|
+
// -40ms negative delay traded the blank window for a first-frame 0.53
|
|
58
|
+
// pop — "the model list always flickers once" — while the sequential
|
|
59
|
+
// edge still painted a ~33ms empty body; the overlap edge removes
|
|
60
|
+
// both).
|
|
59
61
|
transition:
|
|
60
|
-
opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.4, 0, 0.2, 1)
|
|
62
|
+
opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.4, 0, 0.2, 1);
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
// The staged new body
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
65
|
+
// The staged new body, staged in two independently-released layers:
|
|
66
|
+
//
|
|
67
|
+
// - `hk-stepflow-enter-pending` = END ANCHORING (align-self: end +
|
|
68
|
+
// pointer-events: none). It holds from mount until the RECYCLE patch —
|
|
69
|
+
// the one that collapses the cell — because a visible body without it
|
|
70
|
+
// stretches to the still-tall cell and jumps a full delta the frame
|
|
71
|
+
// its fade starts.
|
|
72
|
+
// - `hk-stepflow-enter-hidden` = OPACITY 0. It comes off at the ENTER
|
|
73
|
+
// EDGE (0.65 of the exit phase, imperatively) so the fade starts in
|
|
74
|
+
// the same frame the old body's fading tail still covers the stage.
|
|
75
|
+
//
|
|
76
|
+
// Deliberately NO `visibility: hidden` anywhere — that property changes
|
|
77
|
+
// the render tree and the phone GPU re-rasters the layer when it flips,
|
|
78
|
+
// which was the flash the user kept seeing (round 12 directive).
|
|
67
79
|
// `align-self: end` does two things with zero positioning tricks: it stops
|
|
68
80
|
// the grid from STRETCHING the shorter entering body to the old cell height
|
|
69
81
|
// (so its measured height is the true content height, and the shrink's
|
|
@@ -72,11 +84,14 @@
|
|
|
72
84
|
// appears at its final line instead of being wiped by the descending fold
|
|
73
85
|
// edge and jumping at the landing.
|
|
74
86
|
.hk-stepflow-body.hk-stepflow-enter-pending {
|
|
75
|
-
opacity: 0;
|
|
76
87
|
pointer-events: none;
|
|
77
88
|
align-self: end;
|
|
78
89
|
}
|
|
79
90
|
|
|
91
|
+
.hk-stepflow-body.hk-stepflow-enter-hidden {
|
|
92
|
+
opacity: 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
// The leaving body: fades out and slides in its direction. The slide is
|
|
81
96
|
// the classic accelerating ease; the fade gets its own gentler curve so
|
|
82
97
|
// the old content eases out over several frames.
|
|
@@ -76,18 +76,41 @@ describe("HkStepFlow simplified swap contract", () => {
|
|
|
76
76
|
expect(decl).toContain("opacity");
|
|
77
77
|
expect(decl).toContain(PHASE);
|
|
78
78
|
expect(decl).toContain("cubic-bezier(0.4, 0, 0.2, 1)");
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
79
|
+
// No delay tricks: the enter fade starts from TRUE zero (round 14 —
|
|
80
|
+
// the -40ms negative delay landed the first frame at ~0.53 opacity,
|
|
81
|
+
// which read as a pop). The blank window is closed by the component's
|
|
82
|
+
// overlap edge instead (see HkStepFlow.test.tsx).
|
|
83
|
+
expect(decl).not.toMatch(/-?\d*\.?\d+m?s\s*$/);
|
|
82
84
|
expect(decl, "the enter phase never travels").not.toContain("transform");
|
|
83
85
|
});
|
|
84
86
|
|
|
85
|
-
it("
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
expect(
|
|
90
|
-
expect(
|
|
87
|
+
it("registers the entering body as a counter rider of the sheet's fold", () => {
|
|
88
|
+
// The ride channel: the entering body holds its final position while
|
|
89
|
+
// the hosting sheet's content block glides with the fold edge (round
|
|
90
|
+
// 14; vertical travel of the new body was the round-7 rejection).
|
|
91
|
+
expect(tsx).toContain("useSheetRide");
|
|
92
|
+
expect(tsx).toContain("counter: true");
|
|
93
|
+
expect(tsx).toContain("unregisterRide");
|
|
94
|
+
// The reveal itself stays imperative and class-based — no style
|
|
95
|
+
// writes join the component (see the no-styles contract below).
|
|
96
|
+
expect(tsx).toContain('classList.remove("hk-stepflow-enter-hidden")');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("fires the enter edge early so no blank body paints between phases", () => {
|
|
100
|
+
expect(tsx).toContain("ENTER_EDGE_SHARE = 0.65");
|
|
101
|
+
expect(tsx).toContain("earlyTimer");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("stages the new body in two independently-released layers — NO visibility flip", () => {
|
|
105
|
+
const anchor = blockFor(".hk-stepflow-body.hk-stepflow-enter-pending");
|
|
106
|
+
expect(anchor).toContain("pointer-events: none");
|
|
107
|
+
expect(anchor).toContain("align-self: end");
|
|
108
|
+
expect(anchor, "opacity must not ride the anchoring layer").not.toContain("opacity");
|
|
109
|
+
const hidden = blockFor(".hk-stepflow-body.hk-stepflow-enter-hidden");
|
|
110
|
+
expect(hidden).toContain("opacity: 0");
|
|
111
|
+
expect(hidden.trim(), "anchoring must not ride the opacity layer").toBe("opacity: 0;");
|
|
112
|
+
const declarations = scssRules.map((r) => r.body).join("\n");
|
|
113
|
+
expect(declarations, "visibility re-rasters the layer — the flash source").not.toContain("visibility");
|
|
91
114
|
});
|
|
92
115
|
|
|
93
116
|
it("keeps the leaving body's fade gentle while the slide accelerates", () => {
|
|
@@ -151,6 +174,9 @@ describe("HkStepFlow simplified swap contract", () => {
|
|
|
151
174
|
expect(tsx).toContain("SWAP_WATCHDOG_GRACE_MS");
|
|
152
175
|
expect(tsx).toContain('addEventListener("transitionend"');
|
|
153
176
|
expect(tsx).toContain("event.target === el");
|
|
177
|
+
// A riding body's transform leg fires its own transitionend at the
|
|
178
|
+
// fold's landing — only the phase-owning opacity edge may advance.
|
|
179
|
+
expect(tsx).toContain('event.propertyName === "opacity"');
|
|
154
180
|
});
|
|
155
181
|
|
|
156
182
|
it("recycles the old DOM node at the phase boundary", () => {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, provide, ref } from "vue";
|
|
3
3
|
|
|
4
4
|
import HkStepFlow, { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
|
|
5
5
|
import type { StepFlowSlotProps } from "./HkStepFlow";
|
|
6
|
+
import { provideSheetRide, SHEET_RIDE_KEY, type SheetRideRegistry } from "../runtime/sheetRide";
|
|
7
|
+
import type { RideEntry } from "../composables/useSizeMorph";
|
|
6
8
|
|
|
7
9
|
const mounts: ReturnType<typeof createApp>[] = [];
|
|
8
10
|
const containers: HTMLElement[] = [];
|
|
@@ -64,6 +66,15 @@ async function flushSwap(): Promise<void> {
|
|
|
64
66
|
await nextTick();
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
/** Drive the enter phase to its RECYCLE edge (real timers): the enter
|
|
70
|
+
* edge releases the fade, then the leaving body is recycled a share
|
|
71
|
+
* later (0.9 − 0.65 of a 150ms phase ≈ 38ms). */
|
|
72
|
+
async function settleEnter(): Promise<void> {
|
|
73
|
+
await flushSwap();
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, 45));
|
|
75
|
+
await flushSwap();
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
async function outlivePhase(): Promise<void> {
|
|
68
79
|
await new Promise((resolve) => setTimeout(resolve, 430));
|
|
69
80
|
}
|
|
@@ -120,11 +131,16 @@ function restoreHeights(): void {
|
|
|
120
131
|
|
|
121
132
|
function endTransition(el: Element | null | undefined): void {
|
|
122
133
|
if (!el) throw new Error("endTransition: element missing");
|
|
123
|
-
|
|
134
|
+
// Real engines always carry propertyName on transitionend; the phase
|
|
135
|
+
// closers guard on it (a riding body's transform leg fires its own).
|
|
136
|
+
const ev = new Event("transitionend", { bubbles: true });
|
|
137
|
+
Object.defineProperty(ev, "propertyName", { value: "opacity" });
|
|
138
|
+
el.dispatchEvent(ev);
|
|
124
139
|
}
|
|
125
140
|
|
|
126
141
|
afterEach(() => {
|
|
127
142
|
vi.restoreAllMocks();
|
|
143
|
+
vi.useRealTimers();
|
|
128
144
|
restoreHeights();
|
|
129
145
|
for (const app of mounts.splice(0)) app.unmount();
|
|
130
146
|
for (const el of containers.splice(0)) el.remove();
|
|
@@ -195,13 +211,15 @@ describe("HkStepFlow simplified swap (motion enabled)", () => {
|
|
|
195
211
|
const pending = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
196
212
|
expect(leaving?.classList.contains("hk-stepflow-leave-to")).toBe(true);
|
|
197
213
|
expect(pending?.classList.contains("hk-stepflow-enter-pending")).toBe(true);
|
|
214
|
+
expect(pending?.classList.contains("hk-stepflow-enter-hidden")).toBe(true);
|
|
198
215
|
|
|
199
216
|
endTransition(leaving);
|
|
200
|
-
await
|
|
217
|
+
await settleEnter();
|
|
201
218
|
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
202
219
|
const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
203
220
|
expect(active?.textContent).toBe("b-body");
|
|
204
221
|
expect(active?.classList.contains("hk-stepflow-enter-pending")).toBe(false);
|
|
222
|
+
expect(active?.classList.contains("hk-stepflow-enter-hidden")).toBe(false);
|
|
205
223
|
|
|
206
224
|
endTransition(active);
|
|
207
225
|
await flushSwap();
|
|
@@ -225,7 +243,7 @@ describe("HkStepFlow simplified swap (motion enabled)", () => {
|
|
|
225
243
|
expect((events[0]!.detail as { delta: number }).delta).toBe(120);
|
|
226
244
|
// Drive the first swap to completion before starting the second.
|
|
227
245
|
endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
|
|
228
|
-
await
|
|
246
|
+
await settleEnter();
|
|
229
247
|
endTransition(t.container.querySelector(".hk-stepflow-body.active"));
|
|
230
248
|
await flushSwap();
|
|
231
249
|
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
@@ -234,7 +252,7 @@ describe("HkStepFlow simplified swap (motion enabled)", () => {
|
|
|
234
252
|
await flushSwap();
|
|
235
253
|
expect(events.length).toBe(1);
|
|
236
254
|
endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
|
|
237
|
-
await
|
|
255
|
+
await settleEnter();
|
|
238
256
|
expect(events.length).toBe(2);
|
|
239
257
|
expect((events[1]!.detail as { phase: string }).phase).toBe("enter");
|
|
240
258
|
expect((events[1]!.detail as { delta: number }).delta).toBe(-120);
|
|
@@ -266,7 +284,7 @@ describe("HkStepFlow simplified swap (motion enabled)", () => {
|
|
|
266
284
|
expect(leaving?.textContent).toBe("a-body");
|
|
267
285
|
expect(pending?.textContent).toBe("c-body");
|
|
268
286
|
endTransition(leaving);
|
|
269
|
-
await
|
|
287
|
+
await settleEnter();
|
|
270
288
|
endTransition(t.container.querySelector(".hk-stepflow-body.active"));
|
|
271
289
|
await flushSwap();
|
|
272
290
|
expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("c-body");
|
|
@@ -283,6 +301,173 @@ describe("HkStepFlow simplified swap (motion enabled)", () => {
|
|
|
283
301
|
expect(active?.textContent).toBe("c-body");
|
|
284
302
|
expect(active?.classList.contains("hk-stepflow-enter-pending")).toBe(false);
|
|
285
303
|
});
|
|
304
|
+
|
|
305
|
+
it("fires the enter edge at 65% and recycles at 90%, before transitionend", async () => {
|
|
306
|
+
// Round 14: the strictly sequential edge (transitionend) left a
|
|
307
|
+
// recycle→patch→fade pipeline gap that painted an empty body — the
|
|
308
|
+
// "the new list always flickers once" report. The overlap edge must
|
|
309
|
+
// advance the phase on its own timer and release the fade
|
|
310
|
+
// SYNCHRONOUSLY (imperative class flip) while the old body stays on
|
|
311
|
+
// stage; the recycle lands a share later in the same patch that
|
|
312
|
+
// drops the end anchoring.
|
|
313
|
+
vi.useFakeTimers();
|
|
314
|
+
stubMotion();
|
|
315
|
+
const t = mountStepFlow({ initial: "a" });
|
|
316
|
+
t.setCurrent("b");
|
|
317
|
+
await flushSwap();
|
|
318
|
+
const staged = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
319
|
+
expect(staged?.classList.contains("hk-stepflow-enter-pending")).toBe(true);
|
|
320
|
+
expect(staged?.classList.contains("hk-stepflow-enter-hidden")).toBe(true);
|
|
321
|
+
|
|
322
|
+
// 0.65 × 150ms ≈ 98ms — the enter edge fires with no transitionend:
|
|
323
|
+
// the fade is released imperatively, the anchoring HOLDS (the cell is
|
|
324
|
+
// still the old, taller one), and the old body is still on stage.
|
|
325
|
+
vi.advanceTimersByTime(99);
|
|
326
|
+
const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
327
|
+
expect(active?.classList.contains("hk-stepflow-enter-hidden")).toBe(false);
|
|
328
|
+
expect(active?.classList.contains("hk-stepflow-enter-pending")).toBe(true);
|
|
329
|
+
await flushSwap();
|
|
330
|
+
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(2);
|
|
331
|
+
|
|
332
|
+
// 0.9 × 150ms = 135ms — the recycle: the old node goes, and the SAME
|
|
333
|
+
// patch drops the anchoring (no stretch-jump frame between them).
|
|
334
|
+
vi.advanceTimersByTime(37);
|
|
335
|
+
await flushSwap();
|
|
336
|
+
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
337
|
+
const settled = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
338
|
+
expect(settled?.classList.contains("hk-stepflow-enter-pending")).toBe(false);
|
|
339
|
+
expect(settled?.classList.contains("hk-stepflow-enter-hidden")).toBe(false);
|
|
340
|
+
|
|
341
|
+
// A late transitionend from the (already recycled) leaving body is a
|
|
342
|
+
// guarded no-op.
|
|
343
|
+
endTransition(settled);
|
|
344
|
+
await flushSwap();
|
|
345
|
+
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("ignores transitionend events for non-opacity properties", async () => {
|
|
349
|
+
// A riding body's transform leg (the sheet fold) fires its own
|
|
350
|
+
// transitionend at the fold's landing — the swap must not settle
|
|
351
|
+
// while the enter fade is still running. The witness is the ride
|
|
352
|
+
// registration: settling releases it, so a stray transform end must
|
|
353
|
+
// leave it held and only the opacity edge may release it.
|
|
354
|
+
stubMotion();
|
|
355
|
+
const registered: RideEntry[] = [];
|
|
356
|
+
const unregistered: RideEntry[] = [];
|
|
357
|
+
const container = document.createElement("div");
|
|
358
|
+
document.body.appendChild(container);
|
|
359
|
+
containers.push(container);
|
|
360
|
+
const current = ref("a");
|
|
361
|
+
const Wrapper = defineComponent({
|
|
362
|
+
setup() {
|
|
363
|
+
provide(SHEET_RIDE_KEY, {
|
|
364
|
+
register: (entry: RideEntry) => {
|
|
365
|
+
registered.push(entry);
|
|
366
|
+
return () => unregistered.push(entry);
|
|
367
|
+
},
|
|
368
|
+
snapshot: () => registered,
|
|
369
|
+
});
|
|
370
|
+
return () =>
|
|
371
|
+
h(HkStepFlow, {
|
|
372
|
+
steps: STEPS,
|
|
373
|
+
modelValue: current.value,
|
|
374
|
+
"onUpdate:modelValue": (key: string) => { current.value = key; },
|
|
375
|
+
}, {
|
|
376
|
+
a: () => h("p", "a"), b: () => h("p", "b"),
|
|
377
|
+
c: () => h("p", "c"), d: () => h("p", "d"),
|
|
378
|
+
});
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
const app = createApp(Wrapper);
|
|
382
|
+
mounts.push(app);
|
|
383
|
+
app.mount(container);
|
|
384
|
+
|
|
385
|
+
current.value = "b";
|
|
386
|
+
await flushSwap();
|
|
387
|
+
endTransition(container.querySelector(".hk-stepflow-body.leaving"));
|
|
388
|
+
await settleEnter();
|
|
389
|
+
const active = container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
390
|
+
expect(active).not.toBeNull();
|
|
391
|
+
expect(unregistered).toHaveLength(0);
|
|
392
|
+
|
|
393
|
+
// The fold lands mid-fade: its transform transitionend must not
|
|
394
|
+
// settle the swap (the registration stays held).
|
|
395
|
+
const stray = new Event("transitionend", { bubbles: true });
|
|
396
|
+
Object.defineProperty(stray, "propertyName", { value: "transform" });
|
|
397
|
+
active!.dispatchEvent(stray);
|
|
398
|
+
await flushSwap();
|
|
399
|
+
expect(unregistered).toHaveLength(0);
|
|
400
|
+
|
|
401
|
+
// The opacity edge owns the settle.
|
|
402
|
+
endTransition(active);
|
|
403
|
+
await flushSwap();
|
|
404
|
+
expect(unregistered).toHaveLength(1);
|
|
405
|
+
expect(container.querySelector(".hk-stepflow-body")?.className).toBe(
|
|
406
|
+
"hk-stepflow-body active",
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("registers the entering body as a counter rider with the hosting sheet", async () => {
|
|
411
|
+
stubMotion();
|
|
412
|
+
// The REAL registry (not a mock): the swap must actually hold and
|
|
413
|
+
// release its registration — a mock's unregister callback could be a
|
|
414
|
+
// no-op and every assertion here would still pass (R2 finding 2).
|
|
415
|
+
let registry: SheetRideRegistry | null = null;
|
|
416
|
+
const container = document.createElement("div");
|
|
417
|
+
document.body.appendChild(container);
|
|
418
|
+
containers.push(container);
|
|
419
|
+
const current = ref("a");
|
|
420
|
+
const Wrapper = defineComponent({
|
|
421
|
+
setup() {
|
|
422
|
+
registry = provideSheetRide();
|
|
423
|
+
return () =>
|
|
424
|
+
h(HkStepFlow, {
|
|
425
|
+
steps: STEPS,
|
|
426
|
+
modelValue: current.value,
|
|
427
|
+
"onUpdate:modelValue": (key: string) => { current.value = key; },
|
|
428
|
+
}, {
|
|
429
|
+
a: () => h("p", "a"),
|
|
430
|
+
b: () => h("p", "b"),
|
|
431
|
+
c: () => h("p", "c"),
|
|
432
|
+
d: () => h("p", "d"),
|
|
433
|
+
});
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
const app = createApp(Wrapper);
|
|
437
|
+
mounts.push(app);
|
|
438
|
+
app.mount(container);
|
|
439
|
+
|
|
440
|
+
current.value = "b";
|
|
441
|
+
await flushSwap();
|
|
442
|
+
// Registered before the sheet could stage any sweep, as a reveal
|
|
443
|
+
// COUNTER (the entering body holds its final position while the
|
|
444
|
+
// sheet's block rides the fold).
|
|
445
|
+
const held = registry!.snapshot();
|
|
446
|
+
expect(held).toHaveLength(1);
|
|
447
|
+
expect(held[0]!.counter).toBe(true);
|
|
448
|
+
expect(held[0]!.el.classList.contains("hk-stepflow-body")).toBe(true);
|
|
449
|
+
// Settle the swap: the registration is released — the registry the
|
|
450
|
+
// sheet reads is actually empty again.
|
|
451
|
+
endTransition(container.querySelector(".hk-stepflow-body.leaving"));
|
|
452
|
+
await settleEnter();
|
|
453
|
+
endTransition(container.querySelector(".hk-stepflow-body.active"));
|
|
454
|
+
await flushSwap();
|
|
455
|
+
expect(registry!.snapshot()).toHaveLength(0);
|
|
456
|
+
|
|
457
|
+
// A preempted swap (exit phase interrupted by the next navigation,
|
|
458
|
+
// twice in a row) must not leak registrations either.
|
|
459
|
+
current.value = "c";
|
|
460
|
+
await flushSwap();
|
|
461
|
+
expect(registry!.snapshot()).toHaveLength(1);
|
|
462
|
+
current.value = "d";
|
|
463
|
+
await flushSwap();
|
|
464
|
+
endTransition(container.querySelector(".hk-stepflow-body.leaving"));
|
|
465
|
+
await settleEnter();
|
|
466
|
+
endTransition(container.querySelector(".hk-stepflow-body.active"));
|
|
467
|
+
await flushSwap();
|
|
468
|
+
expect(registry!.snapshot()).toHaveLength(0);
|
|
469
|
+
expect(container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
470
|
+
});
|
|
286
471
|
});
|
|
287
472
|
|
|
288
473
|
describe("HkStepFlow sticky-header pin strategy", () => {
|