@celestia-island/hikari 0.55.39 → 0.55.41
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.scss +6 -1
- package/src/components/HkModal.sheetgap.test.ts +12 -0
- package/src/components/HkModal.sheetmorph.test.ts +10 -0
- package/src/components/HkModal.tsx +19 -12
- package/src/components/HkStepFlow.crossfade.contract.test.ts +61 -0
- package/src/components/HkStepFlow.scss +36 -73
- package/src/components/HkStepFlow.test.tsx +41 -18
- package/src/components/HkStepFlow.tsx +165 -23
- package/src/composables/useSizeMorph.test.ts +39 -4
- package/src/composables/useSizeMorph.ts +27 -4
- package/src/components/HkStepFlow.phonesnap.test.ts +0 -105
package/package.json
CHANGED
|
@@ -475,8 +475,13 @@
|
|
|
475
475
|
// clip-path sweep (the only transition left on phones).
|
|
476
476
|
//
|
|
477
477
|
// Desktop keeps the full base list (height + max-height + clip-path).
|
|
478
|
+
// 0.3s = the stepflow crossfade's family standard (2026-09-21 user
|
|
479
|
+
// spec, round 7): the clip sweep, the riding body and the fade must
|
|
480
|
+
// land together. Keyboard-driven height changes never animate
|
|
481
|
+
// through this list (height/max-height are not transitioned), so
|
|
482
|
+
// only content morphs ride this duration.
|
|
478
483
|
transition:
|
|
479
|
-
clip-path var(--duration
|
|
484
|
+
clip-path var(--hk-modal-morph-duration, 0.3s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
|
|
480
485
|
// !important beats the inline maxWidth (width prop); base width: 100%
|
|
481
486
|
// already applies at every viewport, so only the cap is re-asserted.
|
|
482
487
|
max-width: 100% !important;
|
|
@@ -121,3 +121,15 @@ describe("HkModal mobile sheet spacing contract", () => {
|
|
|
121
121
|
});
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
|
+
|
|
125
|
+
// 2026-09-21 user spec, round 7: the phone sheet's clip sweep shares the
|
|
126
|
+
// stepflow crossfade's 0.3s family duration (the pair must land together).
|
|
127
|
+
describe("HkModal phone morph duration contract", () => {
|
|
128
|
+
it("rides the clip sweep on --hk-modal-morph-duration (0.3s default)", () => {
|
|
129
|
+
const block = src.slice(src.indexOf("@media (max-width: 767px)"));
|
|
130
|
+
const content = block.match(/\.hk-modal-content\s*{[^}]*}/)?.[0] ?? "";
|
|
131
|
+
expect(content).toMatch(
|
|
132
|
+
/clip-path var\(--hk-modal-morph-duration, 0\.3s\) var\(--ease-standard, cubic-bezier\(0\.4, 0, 0\.2, 1\)\)/,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -63,6 +63,16 @@ describe("sheet family morph-performance contract", () => {
|
|
|
63
63
|
);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
it("slows the phone sheet's clip sweep to the crossfade's 0.3s", () => {
|
|
67
|
+
// 2026-09-21 user spec, round 7: the clip sweep, the riding body and
|
|
68
|
+
// the fade must land together — the phone block overrides the fast
|
|
69
|
+
// desktop timing with the stepflow family standard. Desktop keeps
|
|
70
|
+
// the fast pair above.
|
|
71
|
+
expect(mobileModal).toMatch(
|
|
72
|
+
/^[ \t]*clip-path var\(--hk-modal-morph-duration, 0\.3s\) var\(--ease-standard, cubic-bezier\(0\.4, 0, 0\.2, 1\)\);$/m,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
66
76
|
it("never backdrop-filters the docked modal sheet or its scrim", () => {
|
|
67
77
|
expect(mobileModal).toMatch(
|
|
68
78
|
/^[ \t]*backdrop-filter:[ \t]*var\(--hk-modal-blur-mobile, none\);$/m,
|
|
@@ -1,15 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
computed,
|
|
3
|
-
defineComponent,
|
|
4
|
-
nextTick,
|
|
5
|
-
onBeforeUnmount,
|
|
6
|
-
onMounted,
|
|
7
|
-
ref,
|
|
8
|
-
shallowRef,
|
|
9
|
-
Teleport,
|
|
10
|
-
watch,
|
|
11
|
-
type PropType,
|
|
12
|
-
} from "vue";
|
|
1
|
+
import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, Teleport, watch, type PropType, } from "vue";
|
|
13
2
|
|
|
14
3
|
import { useI18n } from "../i18n/context";
|
|
15
4
|
import "./HkModal.scss";
|
|
@@ -23,6 +12,8 @@ import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
|
23
12
|
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
24
13
|
import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
|
|
25
14
|
import { useSizeMorph } from "../composables/useSizeMorph";
|
|
15
|
+
|
|
16
|
+
import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
|
|
26
17
|
import HButton from "./HkButton";
|
|
27
18
|
import HFab from "./HkFab";
|
|
28
19
|
import HSpinner from "./HkSpinner";
|
|
@@ -218,6 +209,22 @@ export default defineComponent({
|
|
|
218
209
|
let previouslyFocused: HTMLElement | null = null;
|
|
219
210
|
let unmounted = false;
|
|
220
211
|
|
|
212
|
+
// HkStepFlow swaps dispatch STEPFLOW_SWAP_EVENT (bubbling) from the
|
|
213
|
+
// flow root the moment the entering body owns the flow height. The
|
|
214
|
+
// morph's settle debounce is tuned for streaming bursts, but a step
|
|
215
|
+
// swap is one clean change whose sheet sweep must start on the SAME
|
|
216
|
+
// frames as the crossfade (2026-09-21 user spec, round 7) — so the
|
|
217
|
+
// event short-circuits straight into remeasure().
|
|
218
|
+
const onStepflowSwap = (): void => {
|
|
219
|
+
if (machine.phase.value === "open") morph.remeasure();
|
|
220
|
+
};
|
|
221
|
+
onMounted(() => {
|
|
222
|
+
bodyRef.value?.addEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
|
|
223
|
+
});
|
|
224
|
+
onBeforeUnmount(() => {
|
|
225
|
+
bodyRef.value?.removeEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
|
|
226
|
+
});
|
|
227
|
+
|
|
221
228
|
const overlayZ = computed(() => handle.value?.zIndex ?? 0);
|
|
222
229
|
const contentZ = computed(() => (handle.value?.zIndex ?? 0) + 1);
|
|
223
230
|
const resolvedWidth = computed(() =>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the stepflow crossfade (2026-09-21 user spec,
|
|
3
|
+
* round 7): a swap renders BOTH bodies for the whole duration — the
|
|
4
|
+
* entering one in the flow, the leaving one absolutely positioned — and
|
|
5
|
+
* the ride + fade share one duration/ease pair (0.3s family default) so
|
|
6
|
+
* the sheet's clip sweep, the ride and the crossfade land together.
|
|
7
|
+
*
|
|
8
|
+
* Pinned here so a refactor cannot silently regress to the retired
|
|
9
|
+
* out-in slide (hk-stepflow-fwd/back classes) or strand a phone-only
|
|
10
|
+
* stand-down: the crossfade is the ONE grammar, on every viewport.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const src = readFileSync(join(here, "HkStepFlow.scss"), "utf-8");
|
|
19
|
+
|
|
20
|
+
describe("HkStepFlow crossfade contract", () => {
|
|
21
|
+
it("rides and fades on one shared duration/ease pair", () => {
|
|
22
|
+
const rule = src.match(/\.hk-stepflow-body\s*\{[^}]*\}/)![0]!;
|
|
23
|
+
const transitions = rule.match(/transition:\s*[^;}]+/g) ?? [];
|
|
24
|
+
expect(transitions).toHaveLength(1);
|
|
25
|
+
const decl = transitions[0]!;
|
|
26
|
+
expect(decl).toContain("opacity");
|
|
27
|
+
expect(decl).toContain("transform");
|
|
28
|
+
expect(decl).toContain("var(--hk-stepflow-duration, 0.3s)");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("defaults the family duration to 0.3s", () => {
|
|
32
|
+
const root = src.match(/\.hk-step-flow\s*\{[^}]*\}/)![0]!;
|
|
33
|
+
expect(root).toContain("--hk-stepflow-duration: 0.3s;");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("keeps the leaving body out of the flow and pointer-transparent", () => {
|
|
37
|
+
const rule = src.match(/\.hk-stepflow-body\.leaving\s*\{[^}]*\}/)![0]!;
|
|
38
|
+
expect(rule).toContain("position: absolute");
|
|
39
|
+
expect(rule).toContain("pointer-events: none");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("dispatches the swap passthrough so the host sheet morphs in lockstep", () => {
|
|
43
|
+
// The component side of the lockstep contract: the swap dispatches
|
|
44
|
+
// the bubbling event the moment the entering body owns the flow
|
|
45
|
+
// height, and the modal side short-circuits its settle debounce.
|
|
46
|
+
const tsx = readFileSync(join(here, "HkStepFlow.tsx"), "utf-8");
|
|
47
|
+
expect(tsx).toContain("STEPFLOW_SWAP_EVENT");
|
|
48
|
+
expect(tsx).toMatch(/new CustomEvent\(STEPFLOW_SWAP_EVENT/);
|
|
49
|
+
const modal = readFileSync(join(here, "HkModal.tsx"), "utf-8");
|
|
50
|
+
expect(modal).toContain('addEventListener(STEPFLOW_SWAP_EVENT');
|
|
51
|
+
expect(modal).toMatch(/removeEventListener\(STEPFLOW_SWAP_EVENT/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("retires the out-in slide classes and every viewport stand-down", () => {
|
|
55
|
+
// The retired grammar must not sneak back in, and the crossfade is
|
|
56
|
+
// unconditional — no media block may fork step-body behaviour.
|
|
57
|
+
expect(src).not.toContain("hk-stepflow-fwd");
|
|
58
|
+
expect(src).not.toContain("hk-stepflow-back");
|
|
59
|
+
expect(src).not.toMatch(/@media[^{]*max-width/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Crossfading step bodies for HkStepFlow.
|
|
2
|
+
//
|
|
3
|
+
// A swap renders BOTH bodies for the whole duration: the entering one in
|
|
4
|
+
// the flow (so the container's height — and the hosting sheet's morph —
|
|
5
|
+
// are the NEW geometry from frame one) and the leaving one absolutely
|
|
6
|
+
// positioned over it. Exactly one body rides the sheet's moving top edge
|
|
7
|
+
// while the other stays pinned (2026-09-21 user spec, round 7); the ride
|
|
8
|
+
// and the opacity crossfade share one duration/ease pair so the sheet's
|
|
9
|
+
// clip sweep, the ride and the fade all land together.
|
|
2
10
|
//
|
|
3
|
-
// Forward slides the body leftward (the flow advances to a later step),
|
|
4
|
-
// back mirrors it toward the right. Timings follow the phase-transition
|
|
5
|
-
// family — an expressive ease-out on enter, a sharp ease-in on leave.
|
|
6
11
|
// Duration resolves from one local custom property so consumers can tune
|
|
7
|
-
// every phase at once.
|
|
12
|
+
// every phase at once; the default is the 0.3s family standard the spec
|
|
13
|
+
// asked for.
|
|
8
14
|
|
|
9
15
|
.hk-step-flow {
|
|
10
|
-
--hk-stepflow-duration: 0.
|
|
16
|
+
--hk-stepflow-duration: 0.3s;
|
|
11
17
|
}
|
|
12
18
|
|
|
13
|
-
// Breathing room between the step indicator and the step
|
|
14
|
-
// the timeline row butts directly against the first body element (modal
|
|
19
|
+
// Breathing room between the step indicator and the step bodies. Without
|
|
20
|
+
// it the timeline row butts directly against the first body element (modal
|
|
15
21
|
// wizards read as cramped). Tune or remove per host through
|
|
16
22
|
// --hk-stepflow-header-gap; the sticky mode folds the gap into the pinned
|
|
17
23
|
// header's own padding so the surface tint stays continuous while the
|
|
@@ -23,10 +29,10 @@
|
|
|
23
29
|
// Sticky header mode: positioning, surface and the whitespace contract
|
|
24
30
|
// live on the shared .hk-scroll-pin rules (HkScrollPin.scss) — the
|
|
25
31
|
// timeline root carries the class plus data-side/data-strategy. This
|
|
26
|
-
// block keeps only what
|
|
32
|
+
// block keeps only what's step-flow-specific: the body gap folded into
|
|
27
33
|
// the pinned header's own padding (so the tint stays continuous while
|
|
28
|
-
// the body scrolls
|
|
29
|
-
//
|
|
34
|
+
// the body scrolls) and the legacy styling knobs mapped onto the pin's
|
|
35
|
+
// custom properties. The doubled class selector must beat
|
|
30
36
|
// .hk-scroll-pin's own declarations regardless of import order.
|
|
31
37
|
.hk-step-flow[data-sticky-header] > .hk-timeline.hk-scroll-pin {
|
|
32
38
|
--hk-scroll-pin-z: var(--hk-stepflow-sticky-z, 10);
|
|
@@ -42,76 +48,33 @@
|
|
|
42
48
|
padding-bottom: var(--hk-stepflow-header-gap, var(--space-16, 1rem));
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
transform var(--hk-stepflow-duration, 0.2s) cubic-bezier(0.16, 1, 0.3, 1);
|
|
51
|
+
// The swap stage: entering bodies own the flow's height; the leaving one
|
|
52
|
+
// overlays it, out of flow and pointer-transparent for the fade.
|
|
53
|
+
.hk-stepflow-bodies {
|
|
54
|
+
position: relative;
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
.hk-stepflow-
|
|
53
|
-
|
|
57
|
+
.hk-stepflow-body {
|
|
58
|
+
// The ride + fade (armed from script with a staged start state — see
|
|
59
|
+
// the component's swap dance): same tokens for both properties so the
|
|
60
|
+
// sheet's clip sweep, the ride and the crossfade stay in lockstep.
|
|
54
61
|
transition:
|
|
55
|
-
opacity var(--hk-stepflow-duration, 0.
|
|
56
|
-
transform var(--hk-stepflow-duration, 0.
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
.hk-stepflow-fwd-enter-from {
|
|
60
|
-
opacity: 0;
|
|
61
|
-
transform: translateX(24px);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
.hk-stepflow-fwd-leave-to {
|
|
65
|
-
opacity: 0;
|
|
66
|
-
transform: translateX(-24px);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
.hk-stepflow-back-enter-from {
|
|
70
|
-
opacity: 0;
|
|
71
|
-
transform: translateX(-24px);
|
|
62
|
+
opacity var(--hk-stepflow-duration, 0.3s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
|
|
63
|
+
transform var(--hk-stepflow-duration, 0.3s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
|
|
72
64
|
}
|
|
73
65
|
|
|
74
|
-
.hk-stepflow-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
* from the left and exits right (house pattern: HkListTransition.scss
|
|
81
|
-
* [dir="rtl"] form). */
|
|
82
|
-
[dir="rtl"] .hk-stepflow-fwd-enter-from,
|
|
83
|
-
[dir="rtl"] .hk-stepflow-back-leave-to {
|
|
84
|
-
transform: translateX(-24px);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
[dir="rtl"] .hk-stepflow-fwd-leave-to,
|
|
88
|
-
[dir="rtl"] .hk-stepflow-back-enter-from {
|
|
89
|
-
transform: translateX(24px);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/* Phone (≤767px): the body snaps between steps instead of sliding. On
|
|
93
|
-
* the mobile sheet the frame's own size morph already carries the
|
|
94
|
-
* spatial feedback, and the slide's transient layer promotion/demotion
|
|
95
|
-
* costs a one-frame raster gap at both ends on phone GPUs — the
|
|
96
|
-
* "content blinks once on every step" report (2026-09-21 chest field
|
|
97
|
-
* report, AddProviderWizard Prev/Next). Same shape as the
|
|
98
|
-
* reduced-motion block below; desktop keeps the directional slide. */
|
|
99
|
-
@media (max-width: 767px) {
|
|
100
|
-
.hk-stepflow-fwd-enter-active,
|
|
101
|
-
.hk-stepflow-back-enter-active,
|
|
102
|
-
.hk-stepflow-fwd-leave-active,
|
|
103
|
-
.hk-stepflow-back-leave-active {
|
|
104
|
-
transition: none;
|
|
105
|
-
}
|
|
66
|
+
.hk-stepflow-body.leaving {
|
|
67
|
+
position: absolute;
|
|
68
|
+
top: 0;
|
|
69
|
+
left: 0;
|
|
70
|
+
right: 0;
|
|
71
|
+
pointer-events: none;
|
|
106
72
|
}
|
|
107
73
|
|
|
108
|
-
/* Reduced motion:
|
|
109
|
-
*
|
|
74
|
+
/* Reduced motion: bodies swap instantly — no ride, no fade (the sheet
|
|
75
|
+
* morph collapses the same way through its own duration tokens). */
|
|
110
76
|
@media (prefers-reduced-motion: reduce) {
|
|
111
|
-
.hk-stepflow-
|
|
112
|
-
.hk-stepflow-back-enter-active,
|
|
113
|
-
.hk-stepflow-fwd-leave-active,
|
|
114
|
-
.hk-stepflow-back-leave-active {
|
|
77
|
+
.hk-stepflow-body {
|
|
115
78
|
transition: none;
|
|
116
79
|
}
|
|
117
80
|
}
|
|
@@ -68,11 +68,16 @@ function mountStepFlow(options: {
|
|
|
68
68
|
return { container, setCurrent: (key) => { current.value = key; } };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/** Let
|
|
71
|
+
/** Let the swap's choreography timers (480ms sweep cleanup) settle. */
|
|
72
72
|
async function settle(): Promise<void> {
|
|
73
73
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** Outlive the 480ms leaving-body cleanup timer. */
|
|
77
|
+
async function outliveSwap(): Promise<void> {
|
|
78
|
+
await new Promise((resolve) => setTimeout(resolve, 540));
|
|
79
|
+
}
|
|
80
|
+
|
|
76
81
|
afterEach(() => {
|
|
77
82
|
for (const app of mounts.splice(0)) app.unmount();
|
|
78
83
|
for (const el of containers.splice(0)) el.remove();
|
|
@@ -105,25 +110,43 @@ describe("HkStepFlow", () => {
|
|
|
105
110
|
await nextTick();
|
|
106
111
|
await nextTick();
|
|
107
112
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
108
|
-
expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("d-body");
|
|
109
|
-
await
|
|
113
|
+
expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("d-body");
|
|
114
|
+
await outliveSwap();
|
|
115
|
+
// The leaving body is gone after the sweep window; exactly one body
|
|
116
|
+
// remains and it is the new step's.
|
|
117
|
+
const bodies = t.container.querySelectorAll(".hk-stepflow-body");
|
|
118
|
+
expect(bodies.length).toBe(1);
|
|
119
|
+
expect(bodies[0]!.className).toBe("hk-stepflow-body active");
|
|
120
|
+
expect(bodies[0]!.textContent).toBe("d-body");
|
|
110
121
|
});
|
|
111
122
|
|
|
112
|
-
it("
|
|
113
|
-
const t = mountStepFlow({ initial: "
|
|
114
|
-
const bodyClass = (): string =>
|
|
115
|
-
t.container.querySelector(".hk-stepflow-body")?.className ?? "";
|
|
116
|
-
|
|
117
|
-
t.setCurrent("c");
|
|
118
|
-
await nextTick();
|
|
119
|
-
// out-in: the old body is mid-leave on this tick.
|
|
120
|
-
expect(bodyClass()).toContain("hk-stepflow-fwd-leave-active");
|
|
121
|
-
|
|
122
|
-
await settle();
|
|
123
|
+
it("crossfades both bodies for the whole swap window (2026-09-21 spec)", async () => {
|
|
124
|
+
const t = mountStepFlow({ initial: "a" });
|
|
123
125
|
t.setCurrent("b");
|
|
124
126
|
await nextTick();
|
|
125
|
-
|
|
126
|
-
await
|
|
127
|
+
await nextTick();
|
|
128
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
129
|
+
const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
|
|
130
|
+
const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
131
|
+
// Both bodies coexist; the leaving one is out of the flow and the
|
|
132
|
+
// pair carries the crossfade's END opacities (armed inline —
|
|
133
|
+
// happy-dom does not run the stylesheet transition).
|
|
134
|
+
expect(leaving).not.toBeNull();
|
|
135
|
+
expect(active).not.toBeNull();
|
|
136
|
+
expect(leaving?.textContent).toBe("a-body");
|
|
137
|
+
expect(active?.textContent).toBe("b-body");
|
|
138
|
+
expect(leaving?.style.opacity).toBe("0");
|
|
139
|
+
expect(active?.style.opacity).toBe("1");
|
|
140
|
+
// A rapid re-swap mid-fade drops the stale leaving body outright.
|
|
141
|
+
t.setCurrent("c");
|
|
142
|
+
await nextTick();
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
144
|
+
const after = t.container.querySelectorAll(".hk-stepflow-body");
|
|
145
|
+
expect(after.length).toBe(2);
|
|
146
|
+
expect(t.container.querySelector(".hk-stepflow-body.leaving")?.textContent).toBe("b-body");
|
|
147
|
+
expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("c-body");
|
|
148
|
+
await outliveSwap();
|
|
149
|
+
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
127
150
|
});
|
|
128
151
|
|
|
129
152
|
it("passes key/index/direction to scoped slots across navigation", async () => {
|
|
@@ -177,7 +200,7 @@ describe("HkStepFlow", () => {
|
|
|
177
200
|
await nextTick();
|
|
178
201
|
await settle();
|
|
179
202
|
expect(selected).toEqual(["a"]);
|
|
180
|
-
expect(container.querySelector(".hk-stepflow-body")?.textContent).toBe("");
|
|
203
|
+
expect(container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("");
|
|
181
204
|
await settle();
|
|
182
205
|
});
|
|
183
206
|
|
|
@@ -188,7 +211,7 @@ describe("HkStepFlow", () => {
|
|
|
188
211
|
t.setCurrent("does-not-exist");
|
|
189
212
|
await nextTick();
|
|
190
213
|
await settle();
|
|
191
|
-
const body = t.container.querySelector(".hk-stepflow-body");
|
|
214
|
+
const body = t.container.querySelector(".hk-stepflow-body.active");
|
|
192
215
|
expect(body).not.toBeNull();
|
|
193
216
|
expect(body?.textContent).toBe("");
|
|
194
217
|
expect(warnSpy.mock.calls.some((args) => String(args[0]).includes("Slot"))).toBe(false);
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
defineComponent,
|
|
3
|
+
nextTick,
|
|
4
|
+
onBeforeUnmount,
|
|
5
|
+
onMounted,
|
|
6
|
+
ref,
|
|
7
|
+
watch,
|
|
8
|
+
type PropType,
|
|
9
|
+
} from "vue";
|
|
2
10
|
|
|
3
11
|
import HkTimeline from "./HkTimeline";
|
|
4
12
|
import type { TimelineCollapse, TimelineStep } from "./HkTimeline";
|
|
@@ -20,12 +28,51 @@ export interface StepFlowSlotProps {
|
|
|
20
28
|
direction: "forward" | "back";
|
|
21
29
|
}
|
|
22
30
|
|
|
31
|
+
/** One rendered body: the active step plus, during a swap, the leaving one. */
|
|
32
|
+
interface BodyEntry {
|
|
33
|
+
/** Unique mount id — the v-for key, so a re-entering step remounts. */
|
|
34
|
+
id: number;
|
|
35
|
+
/** The step key this body renders. */
|
|
36
|
+
key: string;
|
|
37
|
+
/** `active` bodies sit in the flow; a `leaving` body is out of flow
|
|
38
|
+
* (absolutely positioned) for the crossfade's duration. */
|
|
39
|
+
phase: "active" | "leaving";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A swap whose leaving body was handed its end state (transition armed). */
|
|
43
|
+
interface RunningSwap {
|
|
44
|
+
entryId: number;
|
|
45
|
+
timer: ReturnType<typeof setTimeout>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const STEPFLOW_SWAP_EVENT = "hk-stepflow-swap";
|
|
49
|
+
|
|
23
50
|
/**
|
|
24
51
|
* Generic step-flow container: an optional HkTimeline header bound to
|
|
25
|
-
* `modelValue` plus a
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
52
|
+
* `modelValue` plus a crossfading body fed purely by named slots keyed
|
|
53
|
+
* by step key. Navigation state lives in the consumer — the component
|
|
54
|
+
* only translates `modelValue` changes into body swaps and echoes
|
|
55
|
+
* timeline selections upward.
|
|
56
|
+
*
|
|
57
|
+
* The swap choreography (2026-09-21 user spec, round 7): old and new
|
|
58
|
+
* bodies cross-fade SIMULTANEOUSLY for the whole `--hk-stepflow-duration`
|
|
59
|
+
* (default 0.3s), and exactly one of them rides the sheet's moving top
|
|
60
|
+
* edge while the other stays pinned at the post-swap geometry:
|
|
61
|
+
*
|
|
62
|
+
* - SHRINK (old body taller): the sheet's top edge folds DOWN, so the
|
|
63
|
+
* OLD body rides it (translateY −Δ → 0, glued to the edge) while it
|
|
64
|
+
* fades out; the NEW body is already at its final top and only fades
|
|
65
|
+
* in. (The container's layout height is the new body's from frame
|
|
66
|
+
* one, so the sheet morph and the crossfade start together.)
|
|
67
|
+
* - GROW: mirrored — the OLD body stays where it was (its top is below
|
|
68
|
+
* the container's new top by Δ, i.e. exactly the band the sheet's
|
|
69
|
+
* staged clip keeps visible) and fades out; the NEW body rides the
|
|
70
|
+
* edge upward (translateY +Δ → 0) while fading in.
|
|
71
|
+
*
|
|
72
|
+
* Each swap also dispatches `hk-stepflow-swap` (bubbles, detail
|
|
73
|
+
* `{ delta }`) from the flow root — the hosting modal listens for it and
|
|
74
|
+
* re-measures immediately, skipping the morph's settle debounce so the
|
|
75
|
+
* sheet's clip sweep starts on the same frame as the crossfade.
|
|
29
76
|
*/
|
|
30
77
|
export default defineComponent({
|
|
31
78
|
name: "HkStepFlow",
|
|
@@ -89,6 +136,22 @@ export default defineComponent({
|
|
|
89
136
|
},
|
|
90
137
|
);
|
|
91
138
|
|
|
139
|
+
// ── Crossfade body bookkeeping ────────────────────────────────────
|
|
140
|
+
let mountSeq = 0;
|
|
141
|
+
const bodies = ref<BodyEntry[]>([
|
|
142
|
+
{ id: mountSeq++, key: props.modelValue, phase: "active" },
|
|
143
|
+
]);
|
|
144
|
+
let swap: RunningSwap | null = null;
|
|
145
|
+
const flowRef = ref<HTMLDivElement | null>(null);
|
|
146
|
+
|
|
147
|
+
function endSwap(): void {
|
|
148
|
+
if (!swap) return;
|
|
149
|
+
clearTimeout(swap.timer);
|
|
150
|
+
const id = swap.entryId;
|
|
151
|
+
swap = null;
|
|
152
|
+
bodies.value = bodies.value.filter((b) => b.id !== id);
|
|
153
|
+
}
|
|
154
|
+
|
|
92
155
|
// Sticky-header whitespace strategy (2026-09-14): the timeline is the
|
|
93
156
|
// flow's boundary element, but content may sit ABOVE the whole flow
|
|
94
157
|
// inside the same scroll body — a bleed pin's negative margin would
|
|
@@ -99,27 +162,100 @@ export default defineComponent({
|
|
|
99
162
|
// on mount (attribute scan only — no geometry), so CSR surfaces see
|
|
100
163
|
// the attribute flip once right after hydration; hikari renders
|
|
101
164
|
// client-side only.
|
|
102
|
-
const flowRef = ref<HTMLDivElement | null>(null);
|
|
103
165
|
const pinStrategy = ref<"offset" | "bleed">("bleed");
|
|
104
166
|
|
|
167
|
+
watch(
|
|
168
|
+
() => props.modelValue,
|
|
169
|
+
async (next, prev) => {
|
|
170
|
+
if (next === prev) return;
|
|
171
|
+
// A swap while the previous one is still fading: drop the old
|
|
172
|
+
// leaving body outright (its content is gone from the flow) and
|
|
173
|
+
// let the new swap own the choreography.
|
|
174
|
+
endSwap();
|
|
175
|
+
const leaving = bodies.value.find((b) => b.phase === "active");
|
|
176
|
+
if (!leaving) return;
|
|
177
|
+
// Pre-swap geometry while the leaving body still owns the flow:
|
|
178
|
+
// its height is the "old" reference for the ride distance.
|
|
179
|
+
const leavingEl = flowRef.value?.querySelector<HTMLElement>(
|
|
180
|
+
".hk-stepflow-body.active",
|
|
181
|
+
);
|
|
182
|
+
const oldH = leavingEl?.offsetHeight ?? 0;
|
|
183
|
+
leaving.phase = "leaving";
|
|
184
|
+
bodies.value = [
|
|
185
|
+
...bodies.value,
|
|
186
|
+
{ id: mountSeq++, key: next, phase: "active" },
|
|
187
|
+
];
|
|
188
|
+
await nextTick();
|
|
189
|
+
// Post-swap geometry: the container's flow height is the new
|
|
190
|
+
// body's; the leaving body (already absolute) keeps reporting
|
|
191
|
+
// its own height for the delta.
|
|
192
|
+
const newEl = flowRef.value?.querySelector<HTMLElement>(
|
|
193
|
+
".hk-stepflow-body.active",
|
|
194
|
+
);
|
|
195
|
+
const goneEl = flowRef.value?.querySelector<HTMLElement>(
|
|
196
|
+
".hk-stepflow-body.leaving",
|
|
197
|
+
);
|
|
198
|
+
if (!newEl || !goneEl) {
|
|
199
|
+
leaving.phase = "active";
|
|
200
|
+
bodies.value = bodies.value.filter((b) => b.phase === "active");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const newH = newEl.offsetHeight;
|
|
204
|
+
const delta = newH - oldH;
|
|
205
|
+
// Ride distance (px): one body rides the sheet's moving top edge
|
|
206
|
+
// by exactly |Δ|; the other stays put. Grow: new rides from +Δ
|
|
207
|
+
// (the old top) to 0. Shrink: old rides from −Δ (its original
|
|
208
|
+
// top, above the container's new top) to 0.
|
|
209
|
+
const mag = Math.abs(delta);
|
|
210
|
+
const rides = delta > 0 ? goneEl : newEl;
|
|
211
|
+
const from = `${delta > 0 ? mag : -mag}px`;
|
|
212
|
+
// Arm the riders: stage the start state with the transition off,
|
|
213
|
+
// flush, then flip to the end state under the live transition
|
|
214
|
+
// (same dance grammar as the sheet morph — no mid-frame paint of
|
|
215
|
+
// the intermediate state).
|
|
216
|
+
for (const el of [goneEl, newEl]) {
|
|
217
|
+
el.style.transition = "none";
|
|
218
|
+
}
|
|
219
|
+
goneEl.style.transform = delta > 0 ? "translateY(0px)" : from;
|
|
220
|
+
newEl.style.transform = delta > 0 ? from : "translateY(0px)";
|
|
221
|
+
goneEl.style.opacity = "1";
|
|
222
|
+
newEl.style.opacity = "0";
|
|
223
|
+
void flowRef.value?.offsetHeight;
|
|
224
|
+
for (const el of [goneEl, newEl]) {
|
|
225
|
+
el.style.transition = "";
|
|
226
|
+
}
|
|
227
|
+
rides.style.transform = "translateY(0px)";
|
|
228
|
+
goneEl.style.opacity = "0";
|
|
229
|
+
newEl.style.opacity = "1";
|
|
230
|
+
// Tell the hosting sheet to morph NOW (skip the settle debounce)
|
|
231
|
+
// so its clip sweep runs on the same frames as this crossfade.
|
|
232
|
+
flowRef.value?.dispatchEvent(
|
|
233
|
+
new CustomEvent(STEPFLOW_SWAP_EVENT, {
|
|
234
|
+
bubbles: true,
|
|
235
|
+
detail: { delta },
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
const entryId = leaving.id;
|
|
239
|
+
const timer = setTimeout(endSwap, 480);
|
|
240
|
+
swap = { entryId, timer };
|
|
241
|
+
},
|
|
242
|
+
{ flush: "post" },
|
|
243
|
+
);
|
|
244
|
+
|
|
105
245
|
onMounted(() => {
|
|
106
246
|
const host = flowRef.value?.closest(`.${SCROLL_HOST_CLASS}`);
|
|
107
247
|
if (host?.hasAttribute("data-pad-cover")) pinStrategy.value = "offset";
|
|
108
248
|
});
|
|
109
249
|
|
|
250
|
+
onBeforeUnmount(() => {
|
|
251
|
+
if (swap) clearTimeout(swap.timer);
|
|
252
|
+
});
|
|
253
|
+
|
|
110
254
|
return () => {
|
|
111
|
-
const index = indexOf(props.modelValue);
|
|
112
|
-
// An unknown key finds no slot: the body simply renders empty, no
|
|
113
|
-
// warnings — consumers may briefly park between two known steps.
|
|
114
|
-
const slotFn = index >= 0 ? slots[props.modelValue] : undefined;
|
|
115
255
|
const dir = direction.value;
|
|
116
256
|
|
|
117
257
|
return (
|
|
118
|
-
<div
|
|
119
|
-
ref={flowRef}
|
|
120
|
-
class="hk-step-flow"
|
|
121
|
-
data-sticky-header={props.stickyHeader || undefined}
|
|
122
|
-
>
|
|
258
|
+
<div ref={flowRef} class="hk-step-flow" data-sticky-header={props.stickyHeader || undefined}>
|
|
123
259
|
{!props.hideTimeline && (
|
|
124
260
|
<HkTimeline
|
|
125
261
|
steps={props.steps}
|
|
@@ -132,14 +268,20 @@ export default defineComponent({
|
|
|
132
268
|
data-strategy={props.stickyHeader ? pinStrategy.value : undefined}
|
|
133
269
|
/>
|
|
134
270
|
)}
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
271
|
+
<div class="hk-stepflow-bodies">
|
|
272
|
+
{bodies.value.map((entry) => (
|
|
273
|
+
<div
|
|
274
|
+
key={entry.id}
|
|
275
|
+
class={["hk-stepflow-body", entry.phase]}
|
|
276
|
+
>
|
|
277
|
+
{slots[entry.key]?.({
|
|
278
|
+
key: entry.key,
|
|
279
|
+
index: indexOf(entry.key),
|
|
280
|
+
direction: dir,
|
|
281
|
+
})}
|
|
282
|
+
</div>
|
|
283
|
+
))}
|
|
284
|
+
</div>
|
|
143
285
|
</div>
|
|
144
286
|
);
|
|
145
287
|
};
|
|
@@ -410,10 +410,15 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
410
410
|
// the box keeps the OLD pin while the top edge folds down through
|
|
411
411
|
// the closing inset — the pin swap lands only at the sweep's end.
|
|
412
412
|
h.setNatural(320);
|
|
413
|
-
|
|
413
|
+
h.remeasure();
|
|
414
|
+
// Mid-warmup: the explicit inset(0) start holds (regression
|
|
415
|
+
// contract — never a cleared/none start).
|
|
416
|
+
await busFrames(1);
|
|
417
|
+
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
418
|
+
expect(h.frame.style.height).toBe("400px");
|
|
414
419
|
await settle();
|
|
415
|
-
//
|
|
416
|
-
//
|
|
420
|
+
// Sweep in flight: clip folding toward inset(80px), height still
|
|
421
|
+
// the old pin.
|
|
417
422
|
expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
|
|
418
423
|
expect(h.frame.style.height).toBe("400px");
|
|
419
424
|
// The sweep ends: atomic re-pin — height jumps to the target with
|
|
@@ -458,6 +463,33 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
458
463
|
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
459
464
|
});
|
|
460
465
|
|
|
466
|
+
it("lands the sweep via the watchdog when transitionend never fires", async () => {
|
|
467
|
+
const h = mountHarness(400);
|
|
468
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
469
|
+
h.start();
|
|
470
|
+
|
|
471
|
+
h.setNatural(320);
|
|
472
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
473
|
+
await settle();
|
|
474
|
+
expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
|
|
475
|
+
expect(h.frame.style.height).toBe("400px");
|
|
476
|
+
|
|
477
|
+
// Round-6 regression: a WebView that never delivers clip-path
|
|
478
|
+
// transitionend used to freeze the frame here forever. The watchdog
|
|
479
|
+
// (duration + 350ms grace; happy-dom resolves no computed duration,
|
|
480
|
+
// so the 150ms fallback applies -> ~500ms) must land the same
|
|
481
|
+
// atomic re-pin.
|
|
482
|
+
await new Promise((resolve) => setTimeout(resolve, 620));
|
|
483
|
+
expect(h.frame.style.height).toBe("320px");
|
|
484
|
+
expect(h.frame.style.clipPath).toBe("");
|
|
485
|
+
expect(h.frame.style.willChange).toBe("clip-path");
|
|
486
|
+
|
|
487
|
+
// The watchdog is disarmed on the normal close: a later unrelated
|
|
488
|
+
// transitionend must be a no-op (no double landing, no throw).
|
|
489
|
+
fireTransitionEnd(h.frame, "clip-path");
|
|
490
|
+
expect(h.frame.style.height).toBe("320px");
|
|
491
|
+
});
|
|
492
|
+
|
|
461
493
|
it("never clips without the mode flag (desktop height morph intact)", async () => {
|
|
462
494
|
const h = mountHarness(300);
|
|
463
495
|
h.start();
|
|
@@ -547,7 +579,10 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
547
579
|
h.setNatural(310);
|
|
548
580
|
h.remeasure();
|
|
549
581
|
expect(h.frame.style.height).toBe("380px");
|
|
550
|
-
|
|
582
|
+
// Explicit inset(0) start — the round-6 regression contract: a
|
|
583
|
+
// cleared (none) start point made Chromium skip the clip transition
|
|
584
|
+
// AND its transitionend, freezing the sheet clipped at its old pin.
|
|
585
|
+
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
551
586
|
await settle();
|
|
552
587
|
expect(h.frame.style.clipPath).toBe("inset(70px 0 0 0 round 0px 0px 0px 0px)");
|
|
553
588
|
fireTransitionEnd(h.frame, "clip-path");
|
|
@@ -160,6 +160,14 @@ export function useSizeMorph(
|
|
|
160
160
|
let revealReport: AnimationHandle | null = null;
|
|
161
161
|
let revealDir: "reveal" | "conceal" | null = null;
|
|
162
162
|
let concealTo: number | null = null;
|
|
163
|
+
/** Sweep watchdog: transitionend is the normal closer, but a lost
|
|
164
|
+
* event (a WebView that skips clip-path end events entirely — the
|
|
165
|
+
* 2026-09-21 round-6 regression: a none→inset() start point made
|
|
166
|
+
* Chromium drop the transition AND its events, freezing the sheet
|
|
167
|
+
* clipped at its old pin) must not freeze the frame forever. The
|
|
168
|
+
* timer fires the exact same landing path and is disarmed on every
|
|
169
|
+
* normal close. */
|
|
170
|
+
let revealWatchdog: ReturnType<typeof setTimeout> | null = null;
|
|
163
171
|
/** Resident layer promotion on clip-mode surfaces: applied at arm time
|
|
164
172
|
* and cleared on hold/release. Promoting at the STEP-change moment
|
|
165
173
|
* (0.55.38's per-sweep will-change) destroyed the old layer one frame
|
|
@@ -205,6 +213,10 @@ export function useSizeMorph(
|
|
|
205
213
|
revealReport.disconnect();
|
|
206
214
|
revealReport = null;
|
|
207
215
|
}
|
|
216
|
+
if (revealWatchdog) {
|
|
217
|
+
clearTimeout(revealWatchdog);
|
|
218
|
+
revealWatchdog = null;
|
|
219
|
+
}
|
|
208
220
|
if (revealEl && revealEnd) {
|
|
209
221
|
revealEl.removeEventListener("transitionend", revealEnd);
|
|
210
222
|
}
|
|
@@ -278,7 +290,13 @@ export function useSizeMorph(
|
|
|
278
290
|
revealEl = f;
|
|
279
291
|
revealEnd = onEnd;
|
|
280
292
|
revealDir = dir;
|
|
281
|
-
|
|
293
|
+
const durationMs = transitionDurationMs(f);
|
|
294
|
+
revealReport = reportTransition(durationMs);
|
|
295
|
+
// Watchdog (see revealWatchdog): same landing, forced, if the
|
|
296
|
+
// transitionend never arrives.
|
|
297
|
+
revealWatchdog = setTimeout(() => {
|
|
298
|
+
if (revealEl === f && revealDir === dir) stopReveal();
|
|
299
|
+
}, durationMs + 350);
|
|
282
300
|
f.style.clipPath =
|
|
283
301
|
dir === "reveal"
|
|
284
302
|
? `inset(0px 0 0 0 round ${radii})`
|
|
@@ -423,10 +441,15 @@ export function useSizeMorph(
|
|
|
423
441
|
f.style.clipPath = `inset(${delta}px 0 0 0 round ${radii})`;
|
|
424
442
|
} else if (conceal) {
|
|
425
443
|
radii = cornerRadii(f);
|
|
426
|
-
// The box stays pinned at the OLD height
|
|
427
|
-
//
|
|
428
|
-
// clip
|
|
444
|
+
// The box stays pinned at the OLD height, and the clip start is an
|
|
445
|
+
// EXPLICIT inset(0) — not a cleared inline clip. Chromium does not
|
|
446
|
+
// interpolate clip-path between none and inset(), so a none start
|
|
447
|
+
// made the sweep jump to its end state and never fire
|
|
448
|
+
// transitionend, freezing the sheet clipped at its old pin
|
|
449
|
+
// (2026-09-21 round-6 regression report: "the top half is just cut
|
|
450
|
+
// off and then it stays there"). inset(0)→inset(N) interpolates.
|
|
429
451
|
f.style.height = `${pinned}px`;
|
|
452
|
+
f.style.clipPath = `inset(0px 0 0 0 round ${radii})`;
|
|
430
453
|
} else if (pinned > 0) {
|
|
431
454
|
f.style.height = `${pinned}px`;
|
|
432
455
|
}
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Source contract for the phone step-swap snap (2026-09-21 chest field
|
|
3
|
-
* report, AddProviderWizard Prev/Next "content blinks once").
|
|
4
|
-
*
|
|
5
|
-
* On ≤767px the step body's slide+fade must be inert: inside the mobile
|
|
6
|
-
* bottom sheet the frame's own size morph already carries the spatial
|
|
7
|
-
* feedback, and the slide's transient layer promotion/demotion costs a
|
|
8
|
-
* one-frame raster gap at both ends on phone GPUs. Desktop keeps the
|
|
9
|
-
* directional slide; reduced motion already snaps globally.
|
|
10
|
-
*
|
|
11
|
-
* Pinned here so a refactor cannot silently restore the phone slide —
|
|
12
|
-
* the class names are the same, so only the media-scoped rule proves it.
|
|
13
|
-
*/
|
|
14
|
-
import { describe, expect, it } from "vitest";
|
|
15
|
-
import { readFileSync } from "node:fs";
|
|
16
|
-
import { dirname, join } from "node:path";
|
|
17
|
-
import { fileURLToPath } from "node:url";
|
|
18
|
-
|
|
19
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
-
const src = readFileSync(join(here, "HkStepFlow.scss"), "utf-8");
|
|
21
|
-
|
|
22
|
-
/** Brace-aware `@media <query>` block extractor: a naive slice at the
|
|
23
|
-
* first `}` would cut at the first nested rule's closing brace. */
|
|
24
|
-
function mediaBlocks(source: string, query: string): string[] {
|
|
25
|
-
const blocks: string[] = [];
|
|
26
|
-
let from = 0;
|
|
27
|
-
for (;;) {
|
|
28
|
-
const at = source.indexOf(`@media ${query}`, from);
|
|
29
|
-
if (at < 0) break;
|
|
30
|
-
const open = source.indexOf("{", at);
|
|
31
|
-
let depth = 0;
|
|
32
|
-
let i = open;
|
|
33
|
-
for (; i < source.length; i++) {
|
|
34
|
-
if (source[i] === "{") depth++;
|
|
35
|
-
else if (source[i] === "}") {
|
|
36
|
-
depth--;
|
|
37
|
-
if (depth === 0) break;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
blocks.push(source.slice(at, i + 1));
|
|
41
|
-
from = i + 1;
|
|
42
|
-
}
|
|
43
|
-
return blocks;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const PHONE_QUERY = "(max-width: 767px)";
|
|
47
|
-
/** The four transition-class selectors Vue's <Transition> toggles. */
|
|
48
|
-
const ACTIVE_SELECTORS = [
|
|
49
|
-
".hk-stepflow-fwd-enter-active",
|
|
50
|
-
".hk-stepflow-back-enter-active",
|
|
51
|
-
".hk-stepflow-fwd-leave-active",
|
|
52
|
-
".hk-stepflow-back-leave-active",
|
|
53
|
-
];
|
|
54
|
-
|
|
55
|
-
/** The single ≤767px block, asserted present IN the test that needs it
|
|
56
|
-
* (a beforeAll assertion would downgrade a missing block to "skipped"
|
|
57
|
-
* instead of a hard failure). */
|
|
58
|
-
function phoneBlock(): string {
|
|
59
|
-
const blocks = mediaBlocks(src, PHONE_QUERY);
|
|
60
|
-
expect(blocks.length).toBe(1);
|
|
61
|
-
return blocks[0]!;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
describe("HkStepFlow phone snap contract", () => {
|
|
65
|
-
it("disables the slide/fade transition on ≤767px", () => {
|
|
66
|
-
const block = phoneBlock();
|
|
67
|
-
for (const selector of ACTIVE_SELECTORS) {
|
|
68
|
-
expect(block).toContain(selector);
|
|
69
|
-
}
|
|
70
|
-
// transition:none on every active class — the whole point: no
|
|
71
|
-
// property animates, so no promotion window exists to blink.
|
|
72
|
-
expect(block).toMatch(/transition:\s*none\s*;/);
|
|
73
|
-
// …and it must be the ONLY transition declaration in the block
|
|
74
|
-
// (a stray `transition: opacity …` would re-open the blink).
|
|
75
|
-
const declarations = block.match(/transition:\s*[^;]+;/g) ?? [];
|
|
76
|
-
expect(declarations).toHaveLength(1);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("keeps the desktop slide outside the phone block", () => {
|
|
80
|
-
// Mutation guard: the phone rule must be ADDITIVE. The base
|
|
81
|
-
// (desktop) enter/leave rules still carry the opacity+transform
|
|
82
|
-
// transition, so deleting them fails here instead of passing
|
|
83
|
-
// vacuously.
|
|
84
|
-
const baseEnter = src.match(
|
|
85
|
-
/\.hk-stepflow-fwd-enter-active\s*,\s*\n\.hk-stepflow-back-enter-active\s*\{[^}]*\}/,
|
|
86
|
-
);
|
|
87
|
-
expect(baseEnter).not.toBeNull();
|
|
88
|
-
expect(baseEnter![0]).toContain("transition:");
|
|
89
|
-
expect(baseEnter![0]).toContain("opacity");
|
|
90
|
-
expect(baseEnter![0]).toContain("transform");
|
|
91
|
-
|
|
92
|
-
const baseLeave = src.match(
|
|
93
|
-
/\.hk-stepflow-fwd-leave-active\s*,\s*\n\.hk-stepflow-back-leave-active\s*\{[^}]*\}/,
|
|
94
|
-
);
|
|
95
|
-
expect(baseLeave).not.toBeNull();
|
|
96
|
-
expect(baseLeave![0]).toContain("opacity");
|
|
97
|
-
expect(baseLeave![0]).toContain("transform");
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("keeps the reduce-motion snap block (house pattern) intact", () => {
|
|
101
|
-
const blocks = mediaBlocks(src, "(prefers-reduced-motion: reduce)");
|
|
102
|
-
expect(blocks.length).toBe(1);
|
|
103
|
-
expect(blocks[0]).toMatch(/transition:\s*none\s*;/);
|
|
104
|
-
});
|
|
105
|
-
});
|