@celestia-island/hikari 0.55.47 → 0.55.49
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 +4 -5
- package/src/components/HkModal.tsx +10 -5
- package/src/components/HkStepFlow.scss +54 -92
- package/src/components/HkStepFlow.slide.contract.test.ts +57 -198
- package/src/components/HkStepFlow.test.tsx +18 -869
- package/src/components/HkStepFlow.tsx +60 -427
|
@@ -1,32 +1,9 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import HkStepFlow, {
|
|
6
|
-
SHEET_SWEEP_SETTLE_EVENT,
|
|
7
|
-
SHEET_SWEEP_STAGE_EVENT,
|
|
8
|
-
STEPFLOW_SWAP_EVENT,
|
|
9
|
-
} from "./HkStepFlow";
|
|
4
|
+
import HkStepFlow, { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
|
|
10
5
|
import type { StepFlowSlotProps } from "./HkStepFlow";
|
|
11
6
|
|
|
12
|
-
/**
|
|
13
|
-
* HkStepFlow contract tests. House style: no @vue/test-utils dependency —
|
|
14
|
-
* raw createApp mounts on shared containers torn down after each case.
|
|
15
|
-
*
|
|
16
|
-
* Two environment shapes are exercised:
|
|
17
|
-
*
|
|
18
|
-
* - DEFAULT (happy-dom, no stylesheet): the component's duration probe
|
|
19
|
-
* reads a zero transition duration, so every swap settles INSTANTLY —
|
|
20
|
-
* one body in the DOM, no timers to outlive, no transitionend to wait
|
|
21
|
-
* for. This is the deterministic shape chest's wizard tests rely on.
|
|
22
|
-
* - MOTION STUBBED (`getComputedStyle` reports the PHASE duration): the
|
|
23
|
-
* two-phase choreography runs — the exit phase keeps only the old body
|
|
24
|
-
* visible (`hk-stepflow-leave-to`) while the new one is mounted but
|
|
25
|
-
* `hk-stepflow-enter-pending`, then the enter phase drops the old body
|
|
26
|
-
* and fades the new one in place. The phases advance on the acting
|
|
27
|
-
* body's own transitionend or the duration+grace watchdog.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
7
|
const mounts: ReturnType<typeof createApp>[] = [];
|
|
31
8
|
const containers: HTMLElement[] = [];
|
|
32
9
|
|
|
@@ -48,40 +25,9 @@ function mountStepFlow(options: {
|
|
|
48
25
|
timelineClickable?: boolean;
|
|
49
26
|
collapse?: string;
|
|
50
27
|
seen?: StepFlowSlotProps[];
|
|
51
|
-
/** Mount inside a modal body host (HkModal's scroll container), i.e. the
|
|
52
|
-
* host shape whose sheet publishes sweep spans. */
|
|
53
|
-
sheetHost?: boolean;
|
|
54
|
-
/** Span the simulated sheet publishes when it stages a fold. */
|
|
55
|
-
sweepSpan?: { from: number; to: number; sweep?: number } | null;
|
|
56
28
|
} = {}): StepFlowHarness {
|
|
57
29
|
const container = document.createElement("div");
|
|
58
|
-
|
|
59
|
-
const body = document.createElement("div");
|
|
60
|
-
body.className = "hk-modal-body";
|
|
61
|
-
body.appendChild(container);
|
|
62
|
-
document.body.appendChild(body);
|
|
63
|
-
containers.push(body);
|
|
64
|
-
// Stand in for the hosting modal: when the flow announces the enter
|
|
65
|
-
// edge of a shrink, publish the fold span the sheet would publish.
|
|
66
|
-
if (options.sweepSpan) {
|
|
67
|
-
const span = options.sweepSpan;
|
|
68
|
-
container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
|
|
69
|
-
if ((e as CustomEvent).detail?.phase !== "enter") return;
|
|
70
|
-
body.dispatchEvent(
|
|
71
|
-
new CustomEvent(SHEET_SWEEP_STAGE_EVENT, {
|
|
72
|
-
detail: {
|
|
73
|
-
direction: "conceal",
|
|
74
|
-
from: span.from,
|
|
75
|
-
to: span.to,
|
|
76
|
-
sweep: span.sweep ?? 1,
|
|
77
|
-
},
|
|
78
|
-
}),
|
|
79
|
-
);
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
} else {
|
|
83
|
-
document.body.appendChild(container);
|
|
84
|
-
}
|
|
30
|
+
document.body.appendChild(container);
|
|
85
31
|
containers.push(container);
|
|
86
32
|
|
|
87
33
|
const current = ref(options.initial ?? "a");
|
|
@@ -112,26 +58,16 @@ function mountStepFlow(options: {
|
|
|
112
58
|
return { container, setCurrent: (key) => { current.value = key; } };
|
|
113
59
|
}
|
|
114
60
|
|
|
115
|
-
/** Let the swap watch's async flush (post render + internal nextTick) land. */
|
|
116
61
|
async function flushSwap(): Promise<void> {
|
|
117
62
|
await nextTick();
|
|
118
63
|
await nextTick();
|
|
119
64
|
await nextTick();
|
|
120
65
|
}
|
|
121
66
|
|
|
122
|
-
/** Wait past the watchdog of ONE phase (duration + 350ms grace). */
|
|
123
67
|
async function outlivePhase(): Promise<void> {
|
|
124
68
|
await new Promise((resolve) => setTimeout(resolve, 430));
|
|
125
69
|
}
|
|
126
70
|
|
|
127
|
-
/** Outlive BOTH phases' watchdogs. */
|
|
128
|
-
async function outliveSwap(): Promise<void> {
|
|
129
|
-
await new Promise((resolve) => setTimeout(resolve, 900));
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Force the motion path: the duration probe sees a phase-length transition.
|
|
133
|
-
* `leavingOpacity` models how far an exit has run (the pre-emption fast
|
|
134
|
-
* path keys off a body that has already faded to nothing). */
|
|
135
71
|
function stubMotion(
|
|
136
72
|
duration = "0.15s",
|
|
137
73
|
opts: { leavingOpacity?: string } = {},
|
|
@@ -151,14 +87,9 @@ function stubMotion(
|
|
|
151
87
|
return real(el, pseudoElt ?? undefined);
|
|
152
88
|
},
|
|
153
89
|
);
|
|
154
|
-
return (next: string) => {
|
|
155
|
-
current = next;
|
|
156
|
-
};
|
|
90
|
+
return (next: string) => { current = next; };
|
|
157
91
|
}
|
|
158
92
|
|
|
159
|
-
/** happy-dom reports every box as 0px tall and never transitions, so the
|
|
160
|
-
* choreography's grow/shrink branches and its phase edges are driven here
|
|
161
|
-
* explicitly: heights by body text, edges by synthesized transitionend. */
|
|
162
93
|
const realOffsetHeight = Object.getOwnPropertyDescriptor(
|
|
163
94
|
HTMLElement.prototype,
|
|
164
95
|
"offsetHeight",
|
|
@@ -168,16 +99,11 @@ function stubHeights(byText: (text: string) => number): void {
|
|
|
168
99
|
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
|
169
100
|
configurable: true,
|
|
170
101
|
get(this: HTMLElement) {
|
|
171
|
-
// The stage's height is its in-flow body's (the leaving one is out of
|
|
172
|
-
// flow) held up by its own pin, exactly as the browser resolves it —
|
|
173
|
-
// the component reads it as the swap's "old" reference.
|
|
174
102
|
if (this.classList?.contains("hk-stepflow-bodies")) {
|
|
175
103
|
const inFlow = Array.from(this.children).find(
|
|
176
104
|
(child) => !(child as HTMLElement).classList.contains("leaving"),
|
|
177
105
|
) as HTMLElement | undefined;
|
|
178
|
-
|
|
179
|
-
const pinned = Number.parseFloat(this.style.minHeight) || 0;
|
|
180
|
-
return Math.max(pinned, flowH);
|
|
106
|
+
return inFlow ? byText(inFlow.textContent ?? "") : 0;
|
|
181
107
|
}
|
|
182
108
|
return this.classList?.contains("hk-stepflow-body")
|
|
183
109
|
? byText(this.textContent ?? "")
|
|
@@ -192,7 +118,6 @@ function restoreHeights(): void {
|
|
|
192
118
|
}
|
|
193
119
|
}
|
|
194
120
|
|
|
195
|
-
/** Fire the transitionend a real engine would send for this element. */
|
|
196
121
|
function endTransition(el: Element | null | undefined): void {
|
|
197
122
|
if (!el) throw new Error("endTransition: element missing");
|
|
198
123
|
el.dispatchEvent(new Event("transitionend", { bubbles: true }));
|
|
@@ -208,30 +133,14 @@ afterEach(() => {
|
|
|
208
133
|
describe("HkStepFlow", () => {
|
|
209
134
|
it("renders the timeline header bound to modelValue plus the slotted body", () => {
|
|
210
135
|
const t = mountStepFlow({ initial: "b" });
|
|
211
|
-
|
|
212
|
-
expect(header).not.toBeNull();
|
|
213
|
-
expect(header?.querySelector('[data-el="label"]')?.textContent).toBe("Alpha");
|
|
214
|
-
expect(
|
|
215
|
-
t.container.querySelector(".hk-timeline-step[aria-current='step'] [data-el='label']")
|
|
216
|
-
?.textContent,
|
|
217
|
-
).toBe("Beta");
|
|
136
|
+
expect(t.container.querySelector(".hk-timeline")).not.toBeNull();
|
|
218
137
|
expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("b-body");
|
|
219
138
|
});
|
|
220
139
|
|
|
221
|
-
it("
|
|
222
|
-
const t = mountStepFlow({ hideTimeline: true });
|
|
223
|
-
expect(t.container.querySelector(".hk-timeline")).toBeNull();
|
|
224
|
-
expect(t.container.querySelector(".hk-step-flow")).not.toBeNull();
|
|
225
|
-
expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("a-body");
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it("swaps the named-slot content per step key", async () => {
|
|
140
|
+
it("swaps the named-slot content per step key (instant path)", async () => {
|
|
229
141
|
const t = mountStepFlow({ initial: "a" });
|
|
230
|
-
expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("a-body");
|
|
231
142
|
t.setCurrent("d");
|
|
232
143
|
await flushSwap();
|
|
233
|
-
// Stylesheet-less runtime → instant settle: exactly one body, the new
|
|
234
|
-
// step's, with no leaving residue and no timers to outlive.
|
|
235
144
|
const bodies = t.container.querySelectorAll(".hk-stepflow-body");
|
|
236
145
|
expect(bodies.length).toBe(1);
|
|
237
146
|
expect(bodies[0]!.className).toBe("hk-stepflow-body active");
|
|
@@ -239,10 +148,6 @@ describe("HkStepFlow", () => {
|
|
|
239
148
|
});
|
|
240
149
|
|
|
241
150
|
it("settles deterministically to a single body where no transition runs", async () => {
|
|
242
|
-
// The 2026-09-22 debt pin: chest's LoginView MFA suite broke because
|
|
243
|
-
// the crossfade kept a leaving body alive for hundreds of ms in
|
|
244
|
-
// transitionend-less environments. With a zero probed duration the
|
|
245
|
-
// swap is atomic — the DOM NEVER carries two steps.
|
|
246
151
|
const t = mountStepFlow({ initial: "a" });
|
|
247
152
|
const events: CustomEvent[] = [];
|
|
248
153
|
t.container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
|
|
@@ -251,16 +156,11 @@ describe("HkStepFlow", () => {
|
|
|
251
156
|
t.setCurrent("b");
|
|
252
157
|
await flushSwap();
|
|
253
158
|
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
254
|
-
expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("b-body");
|
|
255
|
-
expect(t.container.querySelector(".hk-stepflow-body.leaving")).toBeNull();
|
|
256
|
-
// Rapid successive swaps stay single-bodied the whole way through.
|
|
257
159
|
t.setCurrent("c");
|
|
258
160
|
await flushSwap();
|
|
259
161
|
t.setCurrent("d");
|
|
260
162
|
await flushSwap();
|
|
261
163
|
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
262
|
-
expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("d-body");
|
|
263
|
-
// The sheet passthrough fired for every swap, carrying a numeric delta.
|
|
264
164
|
expect(events.length).toBe(3);
|
|
265
165
|
for (const e of events) {
|
|
266
166
|
expect(typeof (e.detail as { delta: number }).delta).toBe("number");
|
|
@@ -270,156 +170,32 @@ describe("HkStepFlow", () => {
|
|
|
270
170
|
it("passes key/index/direction to scoped slots across navigation", async () => {
|
|
271
171
|
const seen: StepFlowSlotProps[] = [];
|
|
272
172
|
const t = mountStepFlow({ initial: "a", seen });
|
|
273
|
-
expect(seen.map((s) => `${s.key}:${s.index}:${s.direction}`)).toEqual(["a:0:forward"]);
|
|
274
|
-
|
|
275
173
|
t.setCurrent("c");
|
|
276
174
|
await flushSwap();
|
|
277
175
|
expect(seen.at(-1)).toEqual({ key: "c", index: 2, direction: "forward" });
|
|
278
|
-
|
|
279
176
|
t.setCurrent("b");
|
|
280
177
|
await flushSwap();
|
|
281
178
|
expect(seen.at(-1)).toEqual({ key: "b", index: 1, direction: "back" });
|
|
282
|
-
|
|
283
|
-
// Returning forward again flips the direction back.
|
|
284
|
-
t.setCurrent("d");
|
|
285
|
-
await flushSwap();
|
|
286
|
-
expect(seen.at(-1)?.direction).toBe("forward");
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it("emits update:modelValue when a clickable completed step is selected", async () => {
|
|
290
|
-
const selected: string[] = [];
|
|
291
|
-
const container = document.createElement("div");
|
|
292
|
-
document.body.appendChild(container);
|
|
293
|
-
containers.push(container);
|
|
294
|
-
|
|
295
|
-
const current = ref("c");
|
|
296
|
-
const app = createApp(defineComponent({
|
|
297
|
-
setup() {
|
|
298
|
-
return () =>
|
|
299
|
-
h(HkStepFlow, {
|
|
300
|
-
steps: STEPS,
|
|
301
|
-
modelValue: current.value,
|
|
302
|
-
timelineClickable: true,
|
|
303
|
-
"onUpdate:modelValue": (key: string) => { selected.push(key); current.value = key; },
|
|
304
|
-
}, {
|
|
305
|
-
c: () => h("p", "C-body"),
|
|
306
|
-
b: () => h("p", "B-body"),
|
|
307
|
-
});
|
|
308
|
-
},
|
|
309
|
-
}));
|
|
310
|
-
mounts.push(app);
|
|
311
|
-
app.mount(container);
|
|
312
|
-
|
|
313
|
-
// Full-row mode with plenty of room: click the first completed step.
|
|
314
|
-
const first = container.querySelector(".hk-timeline-step[data-clickable]") as HTMLElement;
|
|
315
|
-
expect(first?.getAttribute("data-status")).toBe("completed");
|
|
316
|
-
first.dispatchEvent(new MouseEvent("click"));
|
|
317
|
-
await nextTick();
|
|
318
|
-
await flushSwap();
|
|
319
|
-
expect(selected).toEqual(["a"]);
|
|
320
|
-
expect(container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("");
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it("renders an empty body without warnings for an unknown key", async () => {
|
|
324
|
-
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
325
|
-
try {
|
|
326
|
-
const t = mountStepFlow({ initial: "a" });
|
|
327
|
-
t.setCurrent("does-not-exist");
|
|
328
|
-
await nextTick();
|
|
329
|
-
await flushSwap();
|
|
330
|
-
const body = t.container.querySelector(".hk-stepflow-body.active");
|
|
331
|
-
expect(body).not.toBeNull();
|
|
332
|
-
expect(body?.textContent).toBe("");
|
|
333
|
-
expect(warnSpy.mock.calls.some((args) => String(args[0]).includes("Slot"))).toBe(false);
|
|
334
|
-
} finally {
|
|
335
|
-
warnSpy.mockRestore();
|
|
336
|
-
}
|
|
337
179
|
});
|
|
338
180
|
|
|
339
181
|
it("pins the collapse option onto the header timeline", () => {
|
|
340
182
|
const t = mountStepFlow({ collapse: "always" });
|
|
341
|
-
// Four steps with collapse=always must render the window (compact) form.
|
|
342
183
|
expect(t.container.querySelector(".hk-timeline")?.getAttribute("data-mode")).toBe("window");
|
|
343
|
-
const bare = mountStepFlow({ collapse: "never" });
|
|
344
|
-
expect(bare.container.querySelector(".hk-timeline")?.getAttribute("data-mode")).toBe("full");
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
it("resyncs the direction memory when the steps array is swapped in place", async () => {
|
|
348
|
-
const seen: StepFlowSlotProps[] = [];
|
|
349
|
-
const container = document.createElement("div");
|
|
350
|
-
document.body.appendChild(container);
|
|
351
|
-
containers.push(container);
|
|
352
|
-
|
|
353
|
-
const current = ref("a");
|
|
354
|
-
const steps = ref(STEPS.map((s) => ({ ...s })));
|
|
355
|
-
const app = createApp(defineComponent({
|
|
356
|
-
setup() {
|
|
357
|
-
return () =>
|
|
358
|
-
h(HkStepFlow, {
|
|
359
|
-
steps: steps.value,
|
|
360
|
-
modelValue: current.value,
|
|
361
|
-
"onUpdate:modelValue": (key: string) => { current.value = key; },
|
|
362
|
-
}, Object.fromEntries(steps.value.map((s) => [
|
|
363
|
-
s.key,
|
|
364
|
-
(arg: StepFlowSlotProps) => { seen.push(arg); return h("p", `${s.key}-body`); },
|
|
365
|
-
])));
|
|
366
|
-
},
|
|
367
|
-
}));
|
|
368
|
-
mounts.push(app);
|
|
369
|
-
app.mount(container);
|
|
370
|
-
|
|
371
|
-
// Swap the array (same current key) — e.g. a locale switch rebuilding it.
|
|
372
|
-
steps.value = [...steps.value].reverse().map((s) => ({ ...s }));
|
|
373
|
-
await nextTick();
|
|
374
|
-
|
|
375
|
-
// Discriminating case: WITHOUT the resync watch the remembered index
|
|
376
|
-
// stays 0 ("a"'s pre-swap slot); landing on "c" (now index 1) would then
|
|
377
|
-
// compare 1 < 0 and mislabel the move FORWARD. With the resync the
|
|
378
|
-
// remembered index follows the array to 3, so 1 < 3 correctly reads BACK.
|
|
379
|
-
current.value = "c";
|
|
380
|
-
await nextTick();
|
|
381
|
-
await flushSwap();
|
|
382
|
-
expect(seen.at(-1)).toEqual({ key: "c", index: 1, direction: "back" });
|
|
383
184
|
});
|
|
384
185
|
});
|
|
385
186
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
// full two-phase swap runs: the exit phase keeps only the old body
|
|
389
|
-
// visible (sliding out) with the new one mounted but invisible, then the
|
|
390
|
-
// enter phase drops the old body and fades the new one in place. Phases
|
|
391
|
-
// advance on the acting body's transitionend or the watchdog.
|
|
392
|
-
describe("HkStepFlow two-phase swap (motion enabled)", () => {
|
|
393
|
-
it("keeps the new body invisible through the exit phase", async () => {
|
|
187
|
+
describe("HkStepFlow simplified swap (motion enabled)", () => {
|
|
188
|
+
it("shows only the old body through the exit phase, then fades the new one in", async () => {
|
|
394
189
|
stubMotion();
|
|
395
190
|
const t = mountStepFlow({ initial: "a" });
|
|
396
|
-
const events: CustomEvent[] = [];
|
|
397
|
-
t.container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
|
|
398
|
-
events.push(e as CustomEvent);
|
|
399
|
-
});
|
|
400
191
|
t.setCurrent("b");
|
|
401
192
|
await flushSwap();
|
|
402
193
|
|
|
403
194
|
const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
|
|
404
195
|
const pending = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
405
|
-
expect(leaving?.textContent).toBe("a-body");
|
|
406
|
-
expect(pending?.textContent).toBe("b-body");
|
|
407
|
-
// The old body carries its end state from the swap patch itself; the
|
|
408
|
-
// new one is mounted (so the flow owns the new height) but staged
|
|
409
|
-
// invisible — the two can never be on screen together.
|
|
410
196
|
expect(leaving?.classList.contains("hk-stepflow-leave-to")).toBe(true);
|
|
411
197
|
expect(pending?.classList.contains("hk-stepflow-enter-pending")).toBe(true);
|
|
412
|
-
expect(t.container.querySelector(".hk-stepflow-enter-from")).toBeNull();
|
|
413
|
-
expect(
|
|
414
|
-
t.container.querySelector(".hk-stepflow-bodies")?.getAttribute("data-direction"),
|
|
415
|
-
).toBe("forward");
|
|
416
|
-
// happy-dom heights read 0 → the GROW edge: the sheet hears about the
|
|
417
|
-
// new geometry while the old body is still leaving.
|
|
418
|
-
expect(events.length).toBe(1);
|
|
419
|
-
expect((events[0]!.detail as { phase: string }).phase).toBe("exit");
|
|
420
|
-
expect((events[0]!.detail as { durationMs: number }).durationMs).toBe(150);
|
|
421
198
|
|
|
422
|
-
// Enter edge: the old body goes, the new one is revealed in place.
|
|
423
199
|
endTransition(leaving);
|
|
424
200
|
await flushSwap();
|
|
425
201
|
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
@@ -427,16 +203,14 @@ describe("HkStepFlow two-phase swap (motion enabled)", () => {
|
|
|
427
203
|
expect(active?.textContent).toBe("b-body");
|
|
428
204
|
expect(active?.classList.contains("hk-stepflow-enter-pending")).toBe(false);
|
|
429
205
|
|
|
430
|
-
// The enter phase's own end settles the swap; a grow announces once.
|
|
431
206
|
endTransition(active);
|
|
432
207
|
await flushSwap();
|
|
433
|
-
expect(events.length).toBe(1);
|
|
434
208
|
expect(t.container.querySelector(".hk-stepflow-body")?.className).toBe(
|
|
435
209
|
"hk-stepflow-body active",
|
|
436
210
|
);
|
|
437
211
|
});
|
|
438
212
|
|
|
439
|
-
it("
|
|
213
|
+
it("announces a GROW at the exit edge and a SHRINK at the enter edge", async () => {
|
|
440
214
|
stubMotion();
|
|
441
215
|
stubHeights((text) => (text === "a-body" ? 100 : 220));
|
|
442
216
|
const t = mountStepFlow({ initial: "a" });
|
|
@@ -447,597 +221,23 @@ describe("HkStepFlow two-phase swap (motion enabled)", () => {
|
|
|
447
221
|
t.setCurrent("b");
|
|
448
222
|
await flushSwap();
|
|
449
223
|
expect(events.length).toBe(1);
|
|
224
|
+
expect((events[0]!.detail as { phase: string }).phase).toBe("exit");
|
|
450
225
|
expect((events[0]!.detail as { delta: number }).delta).toBe(120);
|
|
451
|
-
|
|
452
|
-
expect(stage?.style.minHeight).toBe("");
|
|
453
|
-
// An in-flow stage (a non-modal host such as chest's LoginView) keeps
|
|
454
|
-
// the default TOP anchor: its top line is the fixed one, so a bottom
|
|
455
|
-
// anchor would push the old body down instead of holding it.
|
|
456
|
-
expect(stage?.getAttribute("data-anchor")).toBeNull();
|
|
457
|
-
// A grow never parks the new body in the tail band and never pins: the
|
|
458
|
-
// flow owns the new height from frame one.
|
|
459
|
-
expect(
|
|
460
|
-
t.container
|
|
461
|
-
.querySelector<HTMLElement>(".hk-stepflow-body.active")
|
|
462
|
-
?.classList.contains("hk-stepflow-enter-tail"),
|
|
463
|
-
).toBe(false);
|
|
464
|
-
endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
|
|
465
|
-
await flushSwap();
|
|
466
|
-
// No second announcement: the sheet already owns the new geometry.
|
|
467
|
-
expect(events.length).toBe(1);
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
it("holds a SHRINK, then parks the new body on the line the sheet lands on", async () => {
|
|
471
|
-
stubMotion();
|
|
472
|
-
stubHeights((text) => (text === "a-body" ? 220 : 100));
|
|
473
|
-
const t = mountStepFlow({
|
|
474
|
-
initial: "a",
|
|
475
|
-
sheetHost: true,
|
|
476
|
-
sweepSpan: { from: 220, to: 100 },
|
|
477
|
-
});
|
|
478
|
-
const events: CustomEvent[] = [];
|
|
479
|
-
// What the hosting sheet would measure when it handles the announce:
|
|
480
|
-
// the pin must be OFF at that instant (the sheet reads the frame's NEW
|
|
481
|
-
// natural height) and back ON immediately after.
|
|
482
|
-
const pinAtAnnounce: string[] = [];
|
|
483
|
-
t.container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
|
|
484
|
-
events.push(e as CustomEvent);
|
|
485
|
-
pinAtAnnounce.push(
|
|
486
|
-
(t.container.querySelector<HTMLElement>(".hk-stepflow-bodies")?.style
|
|
487
|
-
.minHeight) ?? "missing",
|
|
488
|
-
);
|
|
489
|
-
});
|
|
490
|
-
t.setCurrent("b");
|
|
491
|
-
await flushSwap();
|
|
492
|
-
// Exit phase: nothing morphs yet — the old body plays at its own
|
|
493
|
-
// height (the flow is pinned so the sheet cannot fold under it).
|
|
494
|
-
expect(events.length).toBe(0);
|
|
495
|
-
const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
|
|
496
|
-
expect(bodies?.style.minHeight).toBe("220px");
|
|
497
|
-
|
|
498
|
-
// Enter edge: the sheet is told to fold, publishes its real span, and
|
|
499
|
-
// the flow parks the new body exactly on the landing line.
|
|
226
|
+
// Drive the first swap to completion before starting the second.
|
|
500
227
|
endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
|
|
501
228
|
await flushSwap();
|
|
502
|
-
expect(events.length).toBe(1);
|
|
503
|
-
const detail = events[0]!.detail as {
|
|
504
|
-
delta: number;
|
|
505
|
-
durationMs: number;
|
|
506
|
-
phase: string;
|
|
507
|
-
};
|
|
508
|
-
expect(detail.delta).toBe(-120);
|
|
509
|
-
expect(detail.phase).toBe("enter");
|
|
510
|
-
expect(detail.durationMs).toBe(150);
|
|
511
|
-
expect(pinAtAnnounce).toEqual([""]);
|
|
512
|
-
expect(bodies?.style.minHeight).toBe("220px");
|
|
513
|
-
const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
514
|
-
expect(active?.textContent).toBe("b-body");
|
|
515
|
-
expect(active?.classList.contains("hk-stepflow-enter-pending")).toBe(false);
|
|
516
|
-
expect(active?.classList.contains("hk-stepflow-enter-tail")).toBe(true);
|
|
517
|
-
expect(active?.style.top).toBe("120px");
|
|
518
|
-
|
|
519
|
-
// The sheet's landing releases the parking and the pin together — not
|
|
520
|
-
// the body's own fade (which would leave the frame clipped).
|
|
521
|
-
t.container
|
|
522
|
-
.closest(".hk-modal-body")!
|
|
523
|
-
.dispatchEvent(new CustomEvent(SHEET_SWEEP_SETTLE_EVENT));
|
|
524
|
-
await flushSwap();
|
|
525
|
-
expect(bodies?.style.minHeight).toBe("");
|
|
526
|
-
const settled = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
527
|
-
expect(settled?.classList.contains("hk-stepflow-enter-tail")).toBe(false);
|
|
528
|
-
expect(settled?.style.top).toBe("");
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
it("does not park anything when the sheet reports a zero span (capped box)", async () => {
|
|
532
|
-
stubMotion();
|
|
533
|
-
stubHeights((text) => (text === "a-body" ? 220 : 100));
|
|
534
|
-
// A capped sheet cannot fold: it publishes from == to. Parking against
|
|
535
|
-
// a guessed delta instead cut 276px off the outgoing step (R2 finding).
|
|
536
|
-
const t = mountStepFlow({
|
|
537
|
-
initial: "a",
|
|
538
|
-
sheetHost: true,
|
|
539
|
-
sweepSpan: { from: 220, to: 220 },
|
|
540
|
-
});
|
|
541
|
-
const events: CustomEvent[] = [];
|
|
542
|
-
t.container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
|
|
543
|
-
events.push(e as CustomEvent);
|
|
544
|
-
});
|
|
545
|
-
t.setCurrent("b");
|
|
546
|
-
await flushSwap();
|
|
547
|
-
endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
|
|
548
|
-
await flushSwap();
|
|
549
|
-
expect(events.length).toBe(1);
|
|
550
|
-
const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
|
|
551
|
-
// Nothing is held: the stage takes the new height immediately and the
|
|
552
|
-
// new body fades in in place.
|
|
553
|
-
expect(bodies?.style.minHeight).toBe("");
|
|
554
|
-
const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
555
|
-
expect(active?.classList.contains("hk-stepflow-enter-tail")).toBe(false);
|
|
556
|
-
expect(active?.style.top ?? "").toBe("");
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
it("releases a parked fold on the watchdog when the sheet never lands", async () => {
|
|
560
|
-
// The enter-phase watchdog is load-bearing: a host that never publishes
|
|
561
|
-
// its landing must not strand the pin and the parked body forever
|
|
562
|
-
// (removing it survived the suite before this test existed).
|
|
563
|
-
stubMotion("20ms");
|
|
564
|
-
stubHeights((text) => (text === "a-body" ? 220 : 100));
|
|
565
|
-
const t = mountStepFlow({
|
|
566
|
-
initial: "a",
|
|
567
|
-
sheetHost: true,
|
|
568
|
-
sweepSpan: { from: 220, to: 100 },
|
|
569
|
-
});
|
|
570
|
-
t.setCurrent("b");
|
|
571
|
-
await flushSwap();
|
|
572
|
-
endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
|
|
573
|
-
await flushSwap();
|
|
574
|
-
const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
|
|
575
|
-
expect(bodies?.style.minHeight).toBe("220px");
|
|
576
|
-
expect(
|
|
577
|
-
t.container
|
|
578
|
-
.querySelector<HTMLElement>(".hk-stepflow-body.active")
|
|
579
|
-
?.classList.contains("hk-stepflow-enter-tail"),
|
|
580
|
-
).toBe(true);
|
|
581
|
-
// 20ms phase + 700ms tail grace, with no settle event at all.
|
|
582
|
-
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
583
|
-
expect(bodies?.style.minHeight).toBe("");
|
|
584
|
-
const settled = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
585
|
-
expect(settled?.classList.contains("hk-stepflow-enter-tail")).toBe(false);
|
|
586
|
-
expect(settled?.style.top).toBe("");
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
it("ignores a stale phase watchdog from the previous swap", async () => {
|
|
590
|
-
// endSwap must disarm the outgoing phase: a stale watchdog otherwise
|
|
591
|
-
// tears the NEXT swap down 184ms early and clips its content (the
|
|
592
|
-
// mutation that removes disarmPhase survived the suite before this).
|
|
593
|
-
stubMotion("20ms");
|
|
594
|
-
const t = mountStepFlow({ initial: "a" });
|
|
595
|
-
t.setCurrent("b");
|
|
596
|
-
await flushSwap();
|
|
597
|
-
endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
|
|
598
|
-
await flushSwap();
|
|
599
|
-
endTransition(t.container.querySelector(".hk-stepflow-body.active"));
|
|
600
|
-
await flushSwap();
|
|
601
|
-
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
602
|
-
|
|
603
|
-
// Let most of the settled swap's watchdog window pass, THEN start a
|
|
604
|
-
// fresh swap: its own deadline lands at ~300ms + 370ms, while the
|
|
605
|
-
// first swap's (now stale) timer is due at ~370ms — so a missing
|
|
606
|
-
// disarm would advance the live swap roughly 300ms early.
|
|
607
|
-
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
608
|
-
t.setCurrent("c");
|
|
609
|
-
await flushSwap();
|
|
610
|
-
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(2);
|
|
611
|
-
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
612
|
-
// The stale closer must not have ended the live swap early.
|
|
613
|
-
const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
|
|
614
|
-
expect(leaving?.textContent).toBe("b-body");
|
|
615
|
-
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(2);
|
|
616
|
-
endTransition(leaving);
|
|
617
|
-
await flushSwap();
|
|
618
229
|
endTransition(t.container.querySelector(".hk-stepflow-body.active"));
|
|
619
230
|
await flushSwap();
|
|
620
|
-
expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("c-body");
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
it("keeps the outgoing body on its pre-swap screen line by measurement", async () => {
|
|
624
|
-
// The host decides where a growing box's lines sit, so the shift is
|
|
625
|
-
// measured: this stubs the two rect reads the component performs (the
|
|
626
|
-
// body before the patch and after it) and pins the inline offset.
|
|
627
|
-
stubMotion();
|
|
628
|
-
const rects = { active: 500, leaving: 380 };
|
|
629
|
-
const proto = HTMLElement.prototype as unknown as {
|
|
630
|
-
getBoundingClientRect: () => DOMRect;
|
|
631
|
-
};
|
|
632
|
-
const realRect = proto.getBoundingClientRect;
|
|
633
|
-
proto.getBoundingClientRect = function (this: HTMLElement): DOMRect {
|
|
634
|
-
const top = this.classList.contains("hk-stepflow-body")
|
|
635
|
-
? this.classList.contains("leaving")
|
|
636
|
-
? rects.leaving
|
|
637
|
-
: rects.active
|
|
638
|
-
: 0;
|
|
639
|
-
return { top, left: 0, bottom: top, right: 0, width: 0, height: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect;
|
|
640
|
-
};
|
|
641
|
-
try {
|
|
642
|
-
const t = mountStepFlow({ initial: "a" });
|
|
643
|
-
t.setCurrent("b");
|
|
644
|
-
await flushSwap();
|
|
645
|
-
const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
|
|
646
|
-
expect(leaving?.style.top).toBe("120px");
|
|
647
|
-
} finally {
|
|
648
|
-
proto.getBoundingClientRect = realRect;
|
|
649
|
-
}
|
|
650
|
-
});
|
|
651
|
-
|
|
652
|
-
it("never empties the queue when a pre-emption lands inside the fast-path frame", async () => {
|
|
653
|
-
// The skipExit fast path drops its outgoing body and commits the new
|
|
654
|
-
// one's staged state on the next bus frame. A navigation INSIDE that
|
|
655
|
-
// frame used to re-enter the exit branch, drop the only remaining body
|
|
656
|
-
// and leave the queue permanently empty (R3 audit finding L1). The
|
|
657
|
-
// phase now flips to "enter" immediately and preemptSwap falls back to
|
|
658
|
-
// endSwap when the body it names is already gone.
|
|
659
|
-
stubMotion("0.15s", { leavingOpacity: "0" });
|
|
660
|
-
const frames: FrameRequestCallback[] = [];
|
|
661
|
-
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
662
|
-
frames.push(cb);
|
|
663
|
-
return frames.length;
|
|
664
|
-
});
|
|
665
|
-
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
666
|
-
|
|
667
|
-
const t = mountStepFlow({ initial: "a" });
|
|
668
|
-
t.setCurrent("b");
|
|
669
|
-
await flushSwap();
|
|
670
|
-
// b is pending; swap again: the leaving body reads as fully faded, so
|
|
671
|
-
// the new swap takes the fast path and queues its staging frame.
|
|
672
|
-
t.setCurrent("c");
|
|
673
|
-
await flushSwap();
|
|
674
231
|
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
675
|
-
expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("c-body");
|
|
676
232
|
|
|
677
|
-
// A change INSIDE that frame must not empty the queue…
|
|
678
|
-
t.setCurrent("d");
|
|
679
|
-
await flushSwap();
|
|
680
|
-
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBeGreaterThan(0);
|
|
681
|
-
const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
682
|
-
expect(active?.textContent).toBe("d-body");
|
|
683
|
-
// …and the swap still settles once the queued bus frame runs.
|
|
684
|
-
for (const cb of frames.splice(0)) cb(0);
|
|
685
|
-
await flushSwap();
|
|
686
|
-
endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
|
|
687
|
-
await flushSwap();
|
|
688
|
-
endTransition(t.container.querySelector(".hk-stepflow-body.active"));
|
|
689
|
-
await flushSwap();
|
|
690
|
-
// Still navigable afterwards (the failure mode was permanent blankness).
|
|
691
233
|
t.setCurrent("a");
|
|
692
234
|
await flushSwap();
|
|
693
|
-
expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("a-body");
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
it("compensates the shift the sheet causes when it lands the new height", async () => {
|
|
697
|
-
// The stage only moves when the HOST reacts to the handoff (a
|
|
698
|
-
// bottom-docked sheet pins the new height the moment it is told), so
|
|
699
|
-
// the shift must be measured AFTER the announce. Measuring before it
|
|
700
|
-
// left the compensation at zero on exactly that surface and the
|
|
701
|
-
// outgoing body rode the sheet's instant growth out of view (real
|
|
702
|
-
// browser verification finding). This stub moves the leaving body only
|
|
703
|
-
// once the swap event fires, so a pre-announce measurement reads zero.
|
|
704
|
-
stubMotion();
|
|
705
|
-
let leaveTop = 500;
|
|
706
|
-
const proto = HTMLElement.prototype as unknown as {
|
|
707
|
-
getBoundingClientRect: () => DOMRect;
|
|
708
|
-
};
|
|
709
|
-
const realRect = proto.getBoundingClientRect;
|
|
710
|
-
proto.getBoundingClientRect = function (this: HTMLElement): DOMRect {
|
|
711
|
-
const top = this.classList.contains("hk-stepflow-body")
|
|
712
|
-
? this.classList.contains("leaving")
|
|
713
|
-
? leaveTop
|
|
714
|
-
: 500
|
|
715
|
-
: 0;
|
|
716
|
-
return { top, left: 0, bottom: top, right: 0, width: 0, height: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect;
|
|
717
|
-
};
|
|
718
|
-
try {
|
|
719
|
-
const t = mountStepFlow({ initial: "a" });
|
|
720
|
-
t.container.addEventListener(STEPFLOW_SWAP_EVENT, () => {
|
|
721
|
-
// What the sheet's instant pin does to the content: the stage jumps
|
|
722
|
-
// up by the delta.
|
|
723
|
-
leaveTop = 100;
|
|
724
|
-
});
|
|
725
|
-
t.setCurrent("b");
|
|
726
|
-
await flushSwap();
|
|
727
|
-
const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
|
|
728
|
-
expect(leaving?.style.top).toBe("400px");
|
|
729
|
-
} finally {
|
|
730
|
-
proto.getBoundingClientRect = realRect;
|
|
731
|
-
}
|
|
732
|
-
});
|
|
733
|
-
|
|
734
|
-
it("parks the new body too when a shrink takes the faded fast path", async () => {
|
|
735
|
-
// The fast path IS the enter edge for a faded outgoing body, so a
|
|
736
|
-
// shrink must run the same measurement + parking handshake there
|
|
737
|
-
// instead of morphing in the exit slot (real-browser finding P5).
|
|
738
|
-
stubMotion("0.15s", { leavingOpacity: "0" });
|
|
739
|
-
stubHeights((text) => (text === "a-body" ? 220 : 100));
|
|
740
|
-
const frames: FrameRequestCallback[] = [];
|
|
741
|
-
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
742
|
-
frames.push(cb);
|
|
743
|
-
return frames.length;
|
|
744
|
-
});
|
|
745
|
-
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
746
|
-
const t = mountStepFlow({
|
|
747
|
-
initial: "a",
|
|
748
|
-
sheetHost: true,
|
|
749
|
-
sweepSpan: { from: 220, to: 100 },
|
|
750
|
-
});
|
|
751
|
-
t.setCurrent("b");
|
|
752
|
-
await flushSwap();
|
|
753
|
-
// Swap again while the exiting body reads as fully faded: the new swap
|
|
754
|
-
// drops it and goes straight to its enter edge — as a shrink.
|
|
755
|
-
t.setCurrent("c");
|
|
756
|
-
await flushSwap();
|
|
757
|
-
// The staged frame commits the fade's "from" and applies the park.
|
|
758
|
-
for (const cb of frames.splice(0)) cb(0);
|
|
759
|
-
await flushSwap();
|
|
760
|
-
const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
|
|
761
|
-
const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
762
|
-
expect(active?.textContent).toBe("c-body");
|
|
763
|
-
expect(active?.classList.contains("hk-stepflow-enter-tail")).toBe(true);
|
|
764
|
-
expect(active?.style.top).toBe("120px");
|
|
765
|
-
expect(bodies?.style.minHeight).toBe("220px");
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
it("keeps the outgoing body's measured line across a pre-emption", async () => {
|
|
769
|
-
// F1a: the survivor is still on stage and the sheet is still at the
|
|
770
|
-
// height it grew to, so its compensating offset must survive the
|
|
771
|
-
// pre-emption and only be cleared when that swap ends (clearing it
|
|
772
|
-
// teleported the visible body by the whole delta in one frame).
|
|
773
|
-
stubMotion();
|
|
774
|
-
let leaveTop = 500;
|
|
775
|
-
const proto = HTMLElement.prototype as unknown as {
|
|
776
|
-
getBoundingClientRect: () => DOMRect;
|
|
777
|
-
};
|
|
778
|
-
const realRect = proto.getBoundingClientRect;
|
|
779
|
-
proto.getBoundingClientRect = function (this: HTMLElement): DOMRect {
|
|
780
|
-
const top = this.classList.contains("hk-stepflow-body")
|
|
781
|
-
? this.classList.contains("leaving")
|
|
782
|
-
? leaveTop
|
|
783
|
-
: 500
|
|
784
|
-
: 0;
|
|
785
|
-
return { top, left: 0, bottom: top, right: 0, width: 0, height: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect;
|
|
786
|
-
};
|
|
787
|
-
try {
|
|
788
|
-
const t = mountStepFlow({ initial: "a" });
|
|
789
|
-
let announces = 0;
|
|
790
|
-
t.container.addEventListener(STEPFLOW_SWAP_EVENT, () => {
|
|
791
|
-
// The sheet pins its new height once; after that the layout does not
|
|
792
|
-
// move again, so the second swap's own measurement reads zero and
|
|
793
|
-
// ONLY a preserved offset can keep the body on its line.
|
|
794
|
-
announces += 1;
|
|
795
|
-
if (announces === 1) leaveTop = 100;
|
|
796
|
-
});
|
|
797
|
-
t.setCurrent("b");
|
|
798
|
-
await flushSwap();
|
|
799
|
-
const first = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
|
|
800
|
-
expect(first?.style.top).toBe("400px");
|
|
801
|
-
// Navigate again while that body is still exiting.
|
|
802
|
-
t.setCurrent("c");
|
|
803
|
-
await flushSwap();
|
|
804
|
-
const survivor = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
|
|
805
|
-
expect(survivor?.textContent).toBe("a-body");
|
|
806
|
-
expect(survivor?.style.top).toBe("400px");
|
|
807
|
-
// …and it is dropped with the swap, offset and all.
|
|
808
|
-
endTransition(survivor);
|
|
809
|
-
await flushSwap();
|
|
810
|
-
endTransition(t.container.querySelector(".hk-stepflow-body.active"));
|
|
811
|
-
await flushSwap();
|
|
812
|
-
expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("c-body");
|
|
813
|
-
expect(
|
|
814
|
-
t.container.querySelector<HTMLElement>(".hk-stepflow-body.active")?.style.top ?? "",
|
|
815
|
-
).toBe("");
|
|
816
|
-
} finally {
|
|
817
|
-
proto.getBoundingClientRect = realRect;
|
|
818
|
-
}
|
|
819
|
-
});
|
|
820
|
-
|
|
821
|
-
it("keeps the park when another surface publishes a landing", async () => {
|
|
822
|
-
// The flow listens for landings on ITS host body only, so another
|
|
823
|
-
// surface's settle cannot reach the park at all. (The two guard clauses
|
|
824
|
-
// inside the listener — target and swap identity — are formally
|
|
825
|
-
// redundant with that scoping and with disarmPhase; they are kept as
|
|
826
|
-
// belt-and-braces and are not mutation-reachable.)
|
|
827
|
-
stubMotion();
|
|
828
|
-
stubHeights((text) => (text === "a-body" ? 220 : 100));
|
|
829
|
-
const t = mountStepFlow({
|
|
830
|
-
initial: "a",
|
|
831
|
-
sheetHost: true,
|
|
832
|
-
sweepSpan: { from: 220, to: 100 },
|
|
833
|
-
});
|
|
834
|
-
t.setCurrent("b");
|
|
835
|
-
await flushSwap();
|
|
836
|
-
endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
|
|
837
|
-
await flushSwap();
|
|
838
|
-
const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
|
|
839
|
-
expect(
|
|
840
|
-
t.container
|
|
841
|
-
.querySelector<HTMLElement>(".hk-stepflow-body.active")
|
|
842
|
-
?.classList.contains("hk-stepflow-enter-tail"),
|
|
843
|
-
).toBe(true);
|
|
844
|
-
expect(bodies?.style.minHeight).toBe("220px");
|
|
845
|
-
|
|
846
|
-
const foreign = document.createElement("div");
|
|
847
|
-
foreign.className = "hk-modal-body";
|
|
848
|
-
document.body.appendChild(foreign);
|
|
849
|
-
containers.push(foreign);
|
|
850
|
-
foreign.dispatchEvent(new CustomEvent(SHEET_SWEEP_SETTLE_EVENT));
|
|
851
|
-
await flushSwap();
|
|
852
|
-
expect(bodies?.style.minHeight).toBe("220px");
|
|
853
|
-
expect(
|
|
854
|
-
t.container
|
|
855
|
-
.querySelector<HTMLElement>(".hk-stepflow-body.active")
|
|
856
|
-
?.classList.contains("hk-stepflow-enter-tail"),
|
|
857
|
-
).toBe(true);
|
|
858
|
-
});
|
|
859
|
-
|
|
860
|
-
it("measures a pre-empted swap against the stage the sheet is pinned to", async () => {
|
|
861
|
-
// F1b: the stage still carries the DROPPED pending body's height (that is
|
|
862
|
-
// what the sheet pinned itself to), so a body-based delta reads the
|
|
863
|
-
// shorter survivor, mis-signs as a grow, and skips the park handshake —
|
|
864
|
-
// leaving the new body unparked under a running fold.
|
|
865
|
-
stubMotion();
|
|
866
|
-
stubHeights((text) => (text === "b-body" ? 220 : 100));
|
|
867
|
-
const t = mountStepFlow({
|
|
868
|
-
initial: "a",
|
|
869
|
-
sheetHost: true,
|
|
870
|
-
sweepSpan: { from: 220, to: 100 },
|
|
871
|
-
});
|
|
872
|
-
const events: CustomEvent[] = [];
|
|
873
|
-
t.container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
|
|
874
|
-
events.push(e as CustomEvent);
|
|
875
|
-
});
|
|
876
|
-
t.setCurrent("b");
|
|
877
|
-
await flushSwap();
|
|
878
|
-
// Second navigation mid-exit (the survivor is NOT faded): the ordinary
|
|
879
|
-
// exit → enter path, with the stage's pin still standing.
|
|
880
|
-
t.setCurrent("c");
|
|
881
|
-
await flushSwap();
|
|
882
|
-
// Only the first swap's grow announcement so far: a shrink announces at
|
|
883
|
-
// its enter edge.
|
|
884
235
|
expect(events.length).toBe(1);
|
|
885
236
|
endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
|
|
886
237
|
await flushSwap();
|
|
887
238
|
expect(events.length).toBe(2);
|
|
888
|
-
|
|
889
|
-
expect(detail.delta).toBe(-120);
|
|
890
|
-
expect(detail.phase).toBe("enter");
|
|
891
|
-
const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
892
|
-
expect(active?.classList.contains("hk-stepflow-enter-tail")).toBe(true);
|
|
893
|
-
expect(active?.style.top).toBe("120px");
|
|
894
|
-
});
|
|
895
|
-
|
|
896
|
-
it("releases a park only on the sweep it parked against, and only after the layout is back", async () => {
|
|
897
|
-
// Phase 4 of the real-engine teardown chain: an interrupting dance
|
|
898
|
-
// publishes the settle of the sweep IT tore down, so a park must ignore
|
|
899
|
-
// a foreign sweep's settle — and when its own arrives, the held pin and
|
|
900
|
-
// offset are released a flush later, once the parked body is back in
|
|
901
|
-
// flow (releasing them while it is still absolute let a remeasure read
|
|
902
|
-
// the collapsed height and fold the sheet 300px too far).
|
|
903
|
-
stubMotion();
|
|
904
|
-
stubHeights((text) => (text === "a-body" ? 220 : 100));
|
|
905
|
-
const t = mountStepFlow({
|
|
906
|
-
initial: "a",
|
|
907
|
-
sheetHost: true,
|
|
908
|
-
sweepSpan: { from: 220, to: 100, sweep: 7 },
|
|
909
|
-
});
|
|
910
|
-
t.setCurrent("b");
|
|
911
|
-
await flushSwap();
|
|
912
|
-
endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
|
|
913
|
-
await flushSwap();
|
|
914
|
-
const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
|
|
915
|
-
const body = t.container.closest(".hk-modal-body")!;
|
|
916
|
-
const parked = () =>
|
|
917
|
-
t.container
|
|
918
|
-
.querySelector<HTMLElement>(".hk-stepflow-body.active")
|
|
919
|
-
?.classList.contains("hk-stepflow-enter-tail") ?? false;
|
|
920
|
-
expect(parked()).toBe(true);
|
|
921
|
-
expect(bodies?.style.minHeight).toBe("220px");
|
|
922
|
-
|
|
923
|
-
// A settle from ANOTHER sweep (the dance it interrupted): ignored.
|
|
924
|
-
body.dispatchEvent(
|
|
925
|
-
new CustomEvent(SHEET_SWEEP_SETTLE_EVENT, {
|
|
926
|
-
detail: { sweep: 6 },
|
|
927
|
-
}),
|
|
928
|
-
);
|
|
929
|
-
await flushSwap();
|
|
930
|
-
expect(parked()).toBe(true);
|
|
931
|
-
expect(bodies?.style.minHeight).toBe("220px");
|
|
932
|
-
|
|
933
|
-
// Its own sweep's settle: released, and the geometry goes a flush later.
|
|
934
|
-
body.dispatchEvent(
|
|
935
|
-
new CustomEvent(SHEET_SWEEP_SETTLE_EVENT, {
|
|
936
|
-
detail: { sweep: 7 },
|
|
937
|
-
}),
|
|
938
|
-
);
|
|
939
|
-
expect(bodies?.style.minHeight).toBe("220px");
|
|
940
|
-
await flushSwap();
|
|
941
|
-
expect(parked()).toBe(false);
|
|
942
|
-
expect(bodies?.style.minHeight).toBe("");
|
|
943
|
-
});
|
|
944
|
-
|
|
945
|
-
it("holds a park across a navigation and only lets go when its own sweep lands", async () => {
|
|
946
|
-
// The enter-phase pre-emption path: navigating while a parked fold is
|
|
947
|
-
// still sweeping must NOT release the pin or the parked body's offset
|
|
948
|
-
// (clearing them synchronously moved a visible body by the whole fold
|
|
949
|
-
// span in one frame — audit finding NEW-C, the F1a defect on the other
|
|
950
|
-
// pre-emption route).
|
|
951
|
-
stubMotion();
|
|
952
|
-
stubHeights((text) => (text === "a-body" ? 220 : 100));
|
|
953
|
-
const t = mountStepFlow({
|
|
954
|
-
initial: "a",
|
|
955
|
-
sheetHost: true,
|
|
956
|
-
sweepSpan: { from: 220, to: 100, sweep: 7 },
|
|
957
|
-
});
|
|
958
|
-
t.setCurrent("b");
|
|
959
|
-
await flushSwap();
|
|
960
|
-
endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
|
|
961
|
-
await flushSwap();
|
|
962
|
-
const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
|
|
963
|
-
const body = t.container.closest(".hk-modal-body")!;
|
|
964
|
-
expect(bodies?.style.minHeight).toBe("220px");
|
|
965
|
-
|
|
966
|
-
// The parked body's own measured offset, which only survives if the
|
|
967
|
-
// pre-emption leaves the held geometry alone.
|
|
968
|
-
const parkedEl = t.container.querySelector<HTMLElement>(
|
|
969
|
-
".hk-stepflow-body.active",
|
|
970
|
-
);
|
|
971
|
-
expect(parkedEl?.style.top).toBe("120px");
|
|
972
|
-
|
|
973
|
-
// Navigate while the fold is still sweeping.
|
|
974
|
-
t.setCurrent("c");
|
|
975
|
-
await flushSwap();
|
|
976
|
-
// The held geometry survived the pre-emption: the body that was parked
|
|
977
|
-
// is now the outgoing one and still carries its line.
|
|
978
|
-
const survivor = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
|
|
979
|
-
expect(survivor?.style.top).toBe("120px");
|
|
980
|
-
expect(bodies?.style.minHeight).not.toBe("");
|
|
981
|
-
// …and the swap that owns it finally releases it.
|
|
982
|
-
body.dispatchEvent(
|
|
983
|
-
new CustomEvent(SHEET_SWEEP_SETTLE_EVENT, { detail: { sweep: 7 } }),
|
|
984
|
-
);
|
|
985
|
-
await flushSwap();
|
|
986
|
-
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBeGreaterThan(0);
|
|
987
|
-
});
|
|
988
|
-
|
|
989
|
-
it("releases a pin a pre-emption preserved when motion collapses mid-flight", async () => {
|
|
990
|
-
// The pinned stage outlives the swap that owned it if the next
|
|
991
|
-
// navigation resolves to the instant path (motion collapsed mid-flight,
|
|
992
|
-
// e.g. a reduced-motion flip): that path creates no successor, so it has
|
|
993
|
-
// to release the pin itself or the stage stays stuck at the old height.
|
|
994
|
-
const setDuration = stubMotion();
|
|
995
|
-
stubHeights((text) => (text === "a-body" ? 220 : 100));
|
|
996
|
-
const t = mountStepFlow({ initial: "a", sheetHost: true });
|
|
997
|
-
t.setCurrent("b");
|
|
998
|
-
await flushSwap();
|
|
999
|
-
const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
|
|
1000
|
-
expect(bodies?.style.minHeight).toBe("220px");
|
|
1001
|
-
|
|
1002
|
-
// Motion collapses before the next navigation.
|
|
1003
|
-
setDuration("0s");
|
|
1004
|
-
t.setCurrent("c");
|
|
1005
|
-
await flushSwap();
|
|
1006
|
-
expect(bodies?.style.minHeight).toBe("");
|
|
1007
|
-
expect(t.container.querySelector<HTMLElement>(".hk-stepflow-body.active")?.textContent).toBe(
|
|
1008
|
-
"c-body",
|
|
1009
|
-
);
|
|
1010
|
-
});
|
|
1011
|
-
|
|
1012
|
-
it("leaves no orphaned transition booked when a fast-path park releases", async () => {
|
|
1013
|
-
// The park books the longer window its fold needs; the fast path must not
|
|
1014
|
-
// re-book over it (that orphans the handle and keeps the animation bus —
|
|
1015
|
-
// and its registry — reporting a transition nobody owns).
|
|
1016
|
-
stubMotion("0.15s", { leavingOpacity: "0" });
|
|
1017
|
-
stubHeights((text) => (text === "a-body" ? 220 : 100));
|
|
1018
|
-
const frames: FrameRequestCallback[] = [];
|
|
1019
|
-
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
1020
|
-
frames.push(cb);
|
|
1021
|
-
return frames.length;
|
|
1022
|
-
});
|
|
1023
|
-
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
1024
|
-
const t = mountStepFlow({
|
|
1025
|
-
initial: "a",
|
|
1026
|
-
sheetHost: true,
|
|
1027
|
-
sweepSpan: { from: 220, to: 100, sweep: 5 },
|
|
1028
|
-
});
|
|
1029
|
-
t.setCurrent("b");
|
|
1030
|
-
await flushSwap();
|
|
1031
|
-
t.setCurrent("c");
|
|
1032
|
-
await flushSwap();
|
|
1033
|
-
for (const cb of frames.splice(0)) cb(0);
|
|
1034
|
-
await flushSwap();
|
|
1035
|
-
const body = t.container.closest(".hk-modal-body")!;
|
|
1036
|
-
body.dispatchEvent(
|
|
1037
|
-
new CustomEvent(SHEET_SWEEP_SETTLE_EVENT, { detail: { sweep: 5 } }),
|
|
1038
|
-
);
|
|
1039
|
-
await flushSwap();
|
|
1040
|
-
expect(readHkRuntime("animationBus")?.transitions ?? 0).toBe(0);
|
|
239
|
+
expect((events[1]!.detail as { phase: string }).phase).toBe("enter");
|
|
240
|
+
expect((events[1]!.detail as { delta: number }).delta).toBe(-120);
|
|
1041
241
|
});
|
|
1042
242
|
|
|
1043
243
|
it("advances both phases on the watchdog when transitionend never arrives", async () => {
|
|
@@ -1046,93 +246,45 @@ describe("HkStepFlow two-phase swap (motion enabled)", () => {
|
|
|
1046
246
|
t.setCurrent("b");
|
|
1047
247
|
await flushSwap();
|
|
1048
248
|
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(2);
|
|
1049
|
-
// Phase-1 watchdog (20ms + 350ms grace).
|
|
1050
249
|
await outlivePhase();
|
|
1051
250
|
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
1052
|
-
expect(
|
|
1053
|
-
t.container
|
|
1054
|
-
.querySelector<HTMLElement>(".hk-stepflow-body.active")
|
|
1055
|
-
?.classList.contains("hk-stepflow-enter-pending"),
|
|
1056
|
-
).toBe(false);
|
|
1057
|
-
// Phase-2 watchdog settles the swap entirely.
|
|
1058
251
|
await outlivePhase();
|
|
1059
252
|
expect(t.container.querySelector(".hk-stepflow-body")?.className).toBe(
|
|
1060
253
|
"hk-stepflow-body active",
|
|
1061
254
|
);
|
|
1062
255
|
});
|
|
1063
256
|
|
|
1064
|
-
it("mirrors the direction attribute for back navigation", async () => {
|
|
1065
|
-
stubMotion();
|
|
1066
|
-
const t = mountStepFlow({ initial: "c" });
|
|
1067
|
-
t.setCurrent("a");
|
|
1068
|
-
await flushSwap();
|
|
1069
|
-
expect(
|
|
1070
|
-
t.container.querySelector(".hk-stepflow-bodies")?.getAttribute("data-direction"),
|
|
1071
|
-
).toBe("back");
|
|
1072
|
-
expect(
|
|
1073
|
-
t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving")?.textContent,
|
|
1074
|
-
).toBe("c-body");
|
|
1075
|
-
expect(
|
|
1076
|
-
t.container
|
|
1077
|
-
.querySelector<HTMLElement>(".hk-stepflow-body.active")
|
|
1078
|
-
?.classList.contains("hk-stepflow-enter-pending"),
|
|
1079
|
-
).toBe(true);
|
|
1080
|
-
await outliveSwap();
|
|
1081
|
-
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
1082
|
-
});
|
|
1083
|
-
|
|
1084
257
|
it("keeps the visible body on stage when a re-swap pre-empts the exit phase", async () => {
|
|
1085
258
|
stubMotion();
|
|
1086
259
|
const t = mountStepFlow({ initial: "a" });
|
|
1087
260
|
t.setCurrent("b");
|
|
1088
261
|
await flushSwap();
|
|
1089
|
-
// b is still pending (invisible); swap again mid-exit.
|
|
1090
262
|
t.setCurrent("c");
|
|
1091
263
|
await flushSwap();
|
|
1092
264
|
const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
|
|
1093
265
|
const pending = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
1094
|
-
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(2);
|
|
1095
|
-
// The body the user is looking at keeps leaving; the never-painted one
|
|
1096
|
-
// is dropped (promoting it blanked the stage for 134ms).
|
|
1097
266
|
expect(leaving?.textContent).toBe("a-body");
|
|
1098
|
-
expect(leaving?.classList.contains("hk-stepflow-leave-to")).toBe(true);
|
|
1099
267
|
expect(pending?.textContent).toBe("c-body");
|
|
1100
|
-
expect(pending?.classList.contains("hk-stepflow-enter-pending")).toBe(true);
|
|
1101
268
|
endTransition(leaving);
|
|
1102
269
|
await flushSwap();
|
|
1103
270
|
endTransition(t.container.querySelector(".hk-stepflow-body.active"));
|
|
1104
271
|
await flushSwap();
|
|
1105
|
-
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
1106
272
|
expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("c-body");
|
|
1107
273
|
});
|
|
1108
274
|
|
|
1109
|
-
it("
|
|
1110
|
-
stubMotion();
|
|
275
|
+
it("fades the new body in immediately when the outgoing one already faded", async () => {
|
|
276
|
+
stubMotion("0.15s", { leavingOpacity: "0" });
|
|
1111
277
|
const t = mountStepFlow({ initial: "a" });
|
|
1112
278
|
t.setCurrent("b");
|
|
1113
279
|
await flushSwap();
|
|
1114
|
-
endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
|
|
1115
|
-
await flushSwap();
|
|
1116
|
-
// Enter phase running (b is fading in place); swap to c mid-fade.
|
|
1117
280
|
t.setCurrent("c");
|
|
1118
281
|
await flushSwap();
|
|
1119
|
-
const
|
|
1120
|
-
|
|
1121
|
-
expect(
|
|
1122
|
-
expect(pending?.textContent).toBe("c-body");
|
|
1123
|
-
expect(pending?.classList.contains("hk-stepflow-enter-pending")).toBe(true);
|
|
1124
|
-
await outliveSwap();
|
|
1125
|
-
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
1126
|
-
expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("c-body");
|
|
282
|
+
const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
283
|
+
expect(active?.textContent).toBe("c-body");
|
|
284
|
+
expect(active?.classList.contains("hk-stepflow-enter-pending")).toBe(false);
|
|
1127
285
|
});
|
|
1128
286
|
});
|
|
1129
287
|
|
|
1130
|
-
// ── Sticky-header pin strategy resolution (2026-09-14 scroll-pin wave) ──
|
|
1131
|
-
// The timeline root rides HkScrollPin's contract; the strategy must flip
|
|
1132
|
-
// to "offset" when the nearest host paints its gutters (data-pad-cover,
|
|
1133
|
-
// HkModal) and stay "bleed" under a plain host. Behavioral, not
|
|
1134
|
-
// tautological: happy-dom resolves the attribute scan fine (no custom
|
|
1135
|
-
// properties involved).
|
|
1136
288
|
describe("HkStepFlow sticky-header pin strategy", () => {
|
|
1137
289
|
async function mountWithHost(hostAttrs: Record<string, string> | null) {
|
|
1138
290
|
const container = document.createElement("div");
|
|
@@ -1157,7 +309,6 @@ describe("HkStepFlow sticky-header pin strategy", () => {
|
|
|
1157
309
|
const app = createApp(Wrapper);
|
|
1158
310
|
app.mount(container);
|
|
1159
311
|
mounts.push(app);
|
|
1160
|
-
// Resolution runs onMounted; the flip lands one flush later.
|
|
1161
312
|
await nextTick();
|
|
1162
313
|
await nextTick();
|
|
1163
314
|
return container.querySelector<HTMLElement>(".hk-timeline");
|
|
@@ -1166,8 +317,6 @@ describe("HkStepFlow sticky-header pin strategy", () => {
|
|
|
1166
317
|
it("resolves offset under a cover host (HkModal-style)", async () => {
|
|
1167
318
|
const tl = await mountWithHost({ "data-pad-cover": "", "data-scroll-axis": "vertical" });
|
|
1168
319
|
expect(tl!.dataset.strategy).toBe("offset");
|
|
1169
|
-
expect(tl!.dataset.side).toBe("top");
|
|
1170
|
-
expect(tl!.classList.contains("hk-scroll-pin")).toBe(true);
|
|
1171
320
|
});
|
|
1172
321
|
|
|
1173
322
|
it("keeps bleed under a plain host", async () => {
|