@celestia-island/hikari 0.55.42 → 0.55.44
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/HkStepFlow.scss +56 -17
- package/src/components/HkStepFlow.slide.contract.test.ts +195 -0
- package/src/components/HkStepFlow.test.tsx +158 -48
- package/src/components/HkStepFlow.tsx +178 -75
- package/src/composables/useContextMenu.test.tsx +16 -0
- package/src/composables/useContextMenu.ts +11 -1
- package/src/components/HkStepFlow.crossfade.contract.test.ts +0 -61
package/package.json
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Direction-aware sliding step bodies for HkStepFlow.
|
|
2
2
|
//
|
|
3
3
|
// A swap renders BOTH bodies for the whole duration: the entering one in
|
|
4
4
|
// the flow (so the container's height — and the hosting sheet's morph —
|
|
5
5
|
// are the NEW geometry from frame one) and the leaving one absolutely
|
|
6
|
-
// positioned over it.
|
|
7
|
-
//
|
|
8
|
-
// and the
|
|
9
|
-
//
|
|
6
|
+
// positioned over it. This stylesheet owns ALL of the motion: direction,
|
|
7
|
+
// travel distance and easings are driven by the `data-direction`
|
|
8
|
+
// attribute and the staged `hk-stepflow-enter-from` / `hk-stepflow-leave-to` classes; the
|
|
9
|
+
// component only toggles those classes on a shared-animation-bus frame
|
|
10
|
+
// so the leave and the enter transitions start on the same style
|
|
11
|
+
// recalculation (2026-09-22 user directive: back to the classic slide,
|
|
12
|
+
// CSS as the base, animation context scheduling, zero black flashes).
|
|
10
13
|
//
|
|
11
|
-
// Duration
|
|
12
|
-
// every phase at once; the
|
|
13
|
-
//
|
|
14
|
+
// Duration and travel resolve from local custom properties so consumers
|
|
15
|
+
// can tune every phase at once; the 0.3s default matches the phone
|
|
16
|
+
// sheet's morph duration so the sweep and the slide land together.
|
|
14
17
|
|
|
15
18
|
.hk-step-flow {
|
|
16
19
|
--hk-stepflow-duration: 0.3s;
|
|
20
|
+
--hk-stepflow-travel: 24px;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
// Breathing room between the step indicator and the step bodies. Without
|
|
@@ -29,7 +33,7 @@
|
|
|
29
33
|
// Sticky header mode: positioning, surface and the whitespace contract
|
|
30
34
|
// live on the shared .hk-scroll-pin rules (HkScrollPin.scss) — the
|
|
31
35
|
// timeline root carries the class plus data-side/data-strategy. This
|
|
32
|
-
// block keeps only what
|
|
36
|
+
// block keeps only what is step-flow-specific: the body gap folded into
|
|
33
37
|
// the pinned header's own padding (so the tint stays continuous while
|
|
34
38
|
// the body scrolls) and the legacy styling knobs mapped onto the pin's
|
|
35
39
|
// custom properties. The doubled class selector must beat
|
|
@@ -49,18 +53,17 @@
|
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
// The swap stage: entering bodies own the flow's height; the leaving one
|
|
52
|
-
// overlays it, out of flow and pointer-transparent for the
|
|
56
|
+
// overlays it, out of flow and pointer-transparent for the slide.
|
|
53
57
|
.hk-stepflow-bodies {
|
|
54
58
|
position: relative;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
.hk-stepflow-body {
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// sheet's clip sweep, the ride and the crossfade stay in lockstep.
|
|
62
|
+
// Enter grammar (the base rule is what an ENTERING body computes once
|
|
63
|
+
// its staged `hk-stepflow-enter-from` class lifts): the classic expressive ease-out.
|
|
61
64
|
transition:
|
|
62
|
-
opacity var(--hk-stepflow-duration, 0.3s)
|
|
63
|
-
transform var(--hk-stepflow-duration, 0.3s)
|
|
65
|
+
opacity var(--hk-stepflow-duration, 0.3s) cubic-bezier(0.16, 1, 0.3, 1),
|
|
66
|
+
transform var(--hk-stepflow-duration, 0.3s) cubic-bezier(0.16, 1, 0.3, 1);
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
.hk-stepflow-body.leaving {
|
|
@@ -69,10 +72,46 @@
|
|
|
69
72
|
left: 0;
|
|
70
73
|
right: 0;
|
|
71
74
|
pointer-events: none;
|
|
75
|
+
// Leave grammar: the sharp ease-in counterpart. Read when the staged
|
|
76
|
+
// `hk-stepflow-leave-to` class lands, so the exit keeps its own easing.
|
|
77
|
+
transition:
|
|
78
|
+
opacity var(--hk-stepflow-duration, 0.3s) cubic-bezier(0.5, 0, 0.75, 0),
|
|
79
|
+
transform var(--hk-stepflow-duration, 0.3s) cubic-bezier(0.5, 0, 0.75, 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Direction-aware travel (the classic slide vocabulary): forward advances
|
|
83
|
+
// exit LEFT and enter from the RIGHT; back mirrors. `hk-stepflow-enter-from` is the
|
|
84
|
+
// entering body's staged start state, `hk-stepflow-leave-to` the leaving body's end
|
|
85
|
+
// state — both removed/applied together one bus frame after staging.
|
|
86
|
+
.hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-enter-from,
|
|
87
|
+
.hk-stepflow-bodies[data-direction="back"] .hk-stepflow-body.hk-stepflow-leave-to {
|
|
88
|
+
opacity: 0;
|
|
89
|
+
transform: translateX(var(--hk-stepflow-travel, 24px));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-leave-to,
|
|
93
|
+
.hk-stepflow-bodies[data-direction="back"] .hk-stepflow-body.hk-stepflow-enter-from {
|
|
94
|
+
opacity: 0;
|
|
95
|
+
transform: translateX(calc(-1 * var(--hk-stepflow-travel, 24px)));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* RTL: reading direction flips, so the slide mirrors — forward enters
|
|
99
|
+
* from the left and exits right (house pattern: HkListTransition.scss
|
|
100
|
+
* [dir="rtl"] form). */
|
|
101
|
+
[dir="rtl"] .hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-enter-from,
|
|
102
|
+
[dir="rtl"] .hk-stepflow-bodies[data-direction="back"] .hk-stepflow-body.hk-stepflow-leave-to {
|
|
103
|
+
transform: translateX(calc(-1 * var(--hk-stepflow-travel, 24px)));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
[dir="rtl"] .hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-leave-to,
|
|
107
|
+
[dir="rtl"] .hk-stepflow-bodies[data-direction="back"] .hk-stepflow-body.hk-stepflow-enter-from {
|
|
108
|
+
transform: translateX(var(--hk-stepflow-travel, 24px));
|
|
72
109
|
}
|
|
73
110
|
|
|
74
|
-
/* Reduced motion: bodies swap instantly
|
|
75
|
-
*
|
|
111
|
+
/* Reduced motion: bodies swap instantly. The component's duration probe
|
|
112
|
+
* reads the zeroed duration and settles without staging, so this block
|
|
113
|
+
* is the single switch for the whole choreography (the sheet morph
|
|
114
|
+
* collapses the same way through its own duration tokens). */
|
|
76
115
|
@media (prefers-reduced-motion: reduce) {
|
|
77
116
|
.hk-stepflow-body {
|
|
78
117
|
transition: none;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the stepflow direction-aware slide (2026-09-22 user
|
|
3
|
+
* directive: back to the classic slide scheme, re-based on pure CSS with
|
|
4
|
+
* the shared animation context driving state, zero black flashes).
|
|
5
|
+
*
|
|
6
|
+
* A swap renders BOTH bodies for the whole duration — the entering one in
|
|
7
|
+
* the flow, the leaving one absolutely positioned — and the stylesheet
|
|
8
|
+
* owns ALL motion: direction, travel and easings are attribute/class
|
|
9
|
+
* driven, so a refactor cannot silently reintroduce inline-style
|
|
10
|
+
* choreography, a vertical ride, or the retired out-in grammar. The
|
|
11
|
+
* component side is pinned to the animation bus (scheduleFrame /
|
|
12
|
+
* reportTransition, no bare rAF) and to deterministic settling where no
|
|
13
|
+
* transition runs (reduced motion / stylesheet-less runtimes).
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const src = readFileSync(join(here, "HkStepFlow.scss"), "utf-8");
|
|
22
|
+
const tsx = readFileSync(join(here, "HkStepFlow.tsx"), "utf-8");
|
|
23
|
+
|
|
24
|
+
/** Top-level stylesheet rules as (selector-list, body) pairs. Nested
|
|
25
|
+
* blocks (e.g. the reduced-motion media query) come out as one rule
|
|
26
|
+
* whose body carries the inner braces — the direction rules this
|
|
27
|
+
* contract binds are all top-level. */
|
|
28
|
+
function extractRules(source: string): { selector: string; body: string }[] {
|
|
29
|
+
const rules: { selector: string; body: string }[] = [];
|
|
30
|
+
let i = 0;
|
|
31
|
+
while (i < source.length) {
|
|
32
|
+
const open = source.indexOf("{", i);
|
|
33
|
+
if (open === -1) break;
|
|
34
|
+
const selector = source
|
|
35
|
+
.slice(i, open)
|
|
36
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
37
|
+
.replace(/\/\/[^\n]*/g, "")
|
|
38
|
+
.trim();
|
|
39
|
+
let depth = 1;
|
|
40
|
+
let j = open + 1;
|
|
41
|
+
while (j < source.length && depth > 0) {
|
|
42
|
+
if (source[j] === "{") depth += 1;
|
|
43
|
+
else if (source[j] === "}") depth -= 1;
|
|
44
|
+
j += 1;
|
|
45
|
+
}
|
|
46
|
+
rules.push({ selector, body: source.slice(open + 1, j - 1) });
|
|
47
|
+
i = j;
|
|
48
|
+
}
|
|
49
|
+
return rules;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const scssRules = extractRules(src);
|
|
53
|
+
|
|
54
|
+
/** The declaration body of the ONE rule whose selector list carries the
|
|
55
|
+
* exact selector — zero hits means the selector vanished, two means the
|
|
56
|
+
* contract's premise broke; both are red. */
|
|
57
|
+
function blockFor(selector: string): string {
|
|
58
|
+
const hits = scssRules.filter((r) =>
|
|
59
|
+
r.selector.split(",").map((s) => s.trim()).includes(selector),
|
|
60
|
+
);
|
|
61
|
+
expect(hits, `exactly one rule carries ${selector}`).toHaveLength(1);
|
|
62
|
+
return hits[0]!.body;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const TRAVEL_POSITIVE = "translateX(var(--hk-stepflow-travel, 24px))";
|
|
66
|
+
const TRAVEL_NEGATIVE = "translateX(calc(-1 * var(--hk-stepflow-travel, 24px))";
|
|
67
|
+
|
|
68
|
+
describe("HkStepFlow slide contract", () => {
|
|
69
|
+
it("pins the shared duration and travel tokens", () => {
|
|
70
|
+
const root = src.match(/\.hk-step-flow\s*\{[^}]*\}/)![0]!;
|
|
71
|
+
expect(root).toContain("--hk-stepflow-duration: 0.3s;");
|
|
72
|
+
expect(root).toContain("--hk-stepflow-travel: 24px;");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("declares the enter grammar on the base body rule", () => {
|
|
76
|
+
const rule = src.match(/\.hk-stepflow-body\s*\{[^}]*\}/)![0]!;
|
|
77
|
+
const transitions = rule.match(/transition:\s*[^;}]+/g) ?? [];
|
|
78
|
+
expect(transitions).toHaveLength(1);
|
|
79
|
+
const decl = transitions[0]!;
|
|
80
|
+
expect(decl).toContain("opacity");
|
|
81
|
+
expect(decl).toContain("transform");
|
|
82
|
+
expect(decl).toContain("var(--hk-stepflow-duration, 0.3s)");
|
|
83
|
+
// The classic expressive ease-out on enter.
|
|
84
|
+
expect(decl).toContain("cubic-bezier(0.16, 1, 0.3, 1)");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("keeps the leaving body out of the flow with the leave grammar", () => {
|
|
88
|
+
const rule = src.match(/\.hk-stepflow-body\.leaving\s*\{[^}]*\}/)![0]!;
|
|
89
|
+
expect(rule).toContain("position: absolute");
|
|
90
|
+
expect(rule).toContain("pointer-events: none");
|
|
91
|
+
// The classic sharp ease-in on leave.
|
|
92
|
+
expect(rule).toContain("cubic-bezier(0.5, 0, 0.75, 0)");
|
|
93
|
+
expect(rule).toContain("var(--hk-stepflow-duration, 0.3s)");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("binds each direction's travel sign to its exact selector", () => {
|
|
97
|
+
// The classic vocabulary, sign-bound (2026-09-22 R1 teeth gap: pinning
|
|
98
|
+
// selector strings plus the EXISTENCE of both translateX forms stayed
|
|
99
|
+
// green under a sign inversion — each selector must bind its sign):
|
|
100
|
+
// forward = enter from the RIGHT (+travel), exit LEFT (−travel);
|
|
101
|
+
// back mirrors; RTL flips the whole mapping.
|
|
102
|
+
const body = ".hk-stepflow-body";
|
|
103
|
+
const stage = ".hk-stepflow-bodies";
|
|
104
|
+
const ltr: Array<[string, string]> = [
|
|
105
|
+
[`${stage}[data-direction="forward"] ${body}.hk-stepflow-enter-from`, TRAVEL_POSITIVE],
|
|
106
|
+
[`${stage}[data-direction="forward"] ${body}.hk-stepflow-leave-to`, TRAVEL_NEGATIVE],
|
|
107
|
+
[`${stage}[data-direction="back"] ${body}.hk-stepflow-enter-from`, TRAVEL_NEGATIVE],
|
|
108
|
+
[`${stage}[data-direction="back"] ${body}.hk-stepflow-leave-to`, TRAVEL_POSITIVE],
|
|
109
|
+
];
|
|
110
|
+
const rtl: Array<[string, string]> = [
|
|
111
|
+
[`[dir="rtl"] ${stage}[data-direction="forward"] ${body}.hk-stepflow-enter-from`, TRAVEL_NEGATIVE],
|
|
112
|
+
[`[dir="rtl"] ${stage}[data-direction="forward"] ${body}.hk-stepflow-leave-to`, TRAVEL_POSITIVE],
|
|
113
|
+
[`[dir="rtl"] ${stage}[data-direction="back"] ${body}.hk-stepflow-enter-from`, TRAVEL_POSITIVE],
|
|
114
|
+
[`[dir="rtl"] ${stage}[data-direction="back"] ${body}.hk-stepflow-leave-to`, TRAVEL_NEGATIVE],
|
|
115
|
+
];
|
|
116
|
+
for (const [selector, sign] of ltr) {
|
|
117
|
+
const block = blockFor(selector);
|
|
118
|
+
expect(block, `${selector} must carry ${sign}`).toContain(sign);
|
|
119
|
+
const other = sign === TRAVEL_POSITIVE ? TRAVEL_NEGATIVE : TRAVEL_POSITIVE;
|
|
120
|
+
expect(block, `${selector} must not carry ${other}`).not.toContain(other);
|
|
121
|
+
expect(block).toContain("opacity: 0");
|
|
122
|
+
}
|
|
123
|
+
// RTL blocks are transform-only overrides over the LTR grammar.
|
|
124
|
+
for (const [selector, sign] of rtl) {
|
|
125
|
+
const block = blockFor(selector);
|
|
126
|
+
expect(block, `${selector} must carry ${sign}`).toContain(sign);
|
|
127
|
+
const other = sign === TRAVEL_POSITIVE ? TRAVEL_NEGATIVE : TRAVEL_POSITIVE;
|
|
128
|
+
expect(block, `${selector} must not carry ${other}`).not.toContain(other);
|
|
129
|
+
}
|
|
130
|
+
// The stage keeps its relative positioning for the overlay grammar.
|
|
131
|
+
expect(src).toMatch(/\.hk-stepflow-bodies\s*\{[^}]*position:\s*relative/);
|
|
132
|
+
// The component renders the direction attribute the CSS keys off.
|
|
133
|
+
expect(tsx).toContain("data-direction={dir}");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("keeps all motion in the stylesheet — no inline style choreography", () => {
|
|
137
|
+
// The 2026-09-22 directive: CSS is the base. The component toggles
|
|
138
|
+
// classes only; it must never write motion styles or compute rides.
|
|
139
|
+
expect(tsx).not.toContain("style.transition");
|
|
140
|
+
expect(tsx).not.toContain("style.transform");
|
|
141
|
+
expect(tsx).not.toContain("style.opacity");
|
|
142
|
+
expect(tsx).not.toContain("translateY");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("schedules state on the shared animation bus, never on bare rAF", () => {
|
|
146
|
+
expect(tsx).toContain('from "../runtime/animationBus"');
|
|
147
|
+
expect(tsx).toContain("scheduleFrame(");
|
|
148
|
+
expect(tsx).toContain("reportTransition(");
|
|
149
|
+
expect(tsx).not.toContain("requestAnimationFrame(");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("settles deterministically where no transition runs", () => {
|
|
153
|
+
// The duration probe: zero (reduced motion / stylesheet-less runtime)
|
|
154
|
+
// selects the atomic instant-settle path — the debt pin for chest's
|
|
155
|
+
// transitionend-less wizard tests.
|
|
156
|
+
expect(tsx).toContain("bodyTransitionMs");
|
|
157
|
+
expect(tsx).toContain("transitionDuration");
|
|
158
|
+
// The motion path keeps a watchdog so a lost transitionend cannot
|
|
159
|
+
// freeze the swap (same grammar as the sheet morph).
|
|
160
|
+
expect(tsx).toContain("SWAP_WATCHDOG_GRACE_MS");
|
|
161
|
+
expect(tsx).toContain("addEventListener(\"transitionend\"");
|
|
162
|
+
// transitionend bubbles — only the leaving body's own transitions may
|
|
163
|
+
// settle the swap (R3 spot mutation: removing this guard survived the
|
|
164
|
+
// suite, so it is pinned here).
|
|
165
|
+
expect(tsx).toContain("event.target === goneEl");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("dispatches the swap passthrough so the host sheet morphs in lockstep", () => {
|
|
169
|
+
// The component side of the lockstep contract: the swap dispatches
|
|
170
|
+
// the bubbling event the moment the entering body owns the flow
|
|
171
|
+
// height, and the modal side short-circuits its settle debounce.
|
|
172
|
+
expect(tsx).toContain("STEPFLOW_SWAP_EVENT");
|
|
173
|
+
expect(tsx).toMatch(/new CustomEvent\(STEPFLOW_SWAP_EVENT/);
|
|
174
|
+
const modal = readFileSync(join(here, "HkModal.tsx"), "utf-8");
|
|
175
|
+
expect(modal).toContain('addEventListener(STEPFLOW_SWAP_EVENT');
|
|
176
|
+
expect(modal).toMatch(/removeEventListener\(STEPFLOW_SWAP_EVENT/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("retires the crossfade ride and the retired out-in classes", () => {
|
|
180
|
+
// Neither the round-7 vertical ride nor the pre-0.55.38 Vue
|
|
181
|
+
// transition class grammar may sneak back in, and no media block may
|
|
182
|
+
// fork step-body behaviour per viewport.
|
|
183
|
+
expect(tsx).not.toContain("hk-stepflow-fwd");
|
|
184
|
+
expect(tsx).not.toContain("hk-stepflow-back");
|
|
185
|
+
expect(src).not.toContain("hk-stepflow-fwd");
|
|
186
|
+
expect(src).not.toContain("hk-stepflow-back");
|
|
187
|
+
expect(src).not.toMatch(/@media[^{]*max-width/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("zeroes the transition under reduced motion so the probe settles", () => {
|
|
191
|
+
expect(src).toMatch(
|
|
192
|
+
/prefers-reduced-motion:\s*reduce\)\s*\{[\s\S]{0,160}?\.hk-stepflow-body\s*\{\s*transition:\s*none/,
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
3
|
|
|
4
|
-
import HkStepFlow from "./HkStepFlow";
|
|
4
|
+
import HkStepFlow, { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
|
|
5
5
|
import type { StepFlowSlotProps } from "./HkStepFlow";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* HkStepFlow contract tests. House style: no @vue/test-utils dependency —
|
|
9
9
|
* raw createApp mounts on shared containers torn down after each case.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Two environment shapes are exercised:
|
|
12
|
+
*
|
|
13
|
+
* - DEFAULT (happy-dom, no stylesheet): the component's duration probe
|
|
14
|
+
* reads a zero transition duration, so every swap settles INSTANTLY —
|
|
15
|
+
* one body in the DOM, no timers to outlive, no transitionend to wait
|
|
16
|
+
* for. This is the deterministic shape chest's wizard tests rely on.
|
|
17
|
+
* - MOTION STUBBED (`getComputedStyle` reports 0.3s): the full staged
|
|
18
|
+
* slide choreography runs — staged start classes, one bus frame, then
|
|
19
|
+
* the synchronized leave/enter transitions and the watchdog cleanup.
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
const mounts: ReturnType<typeof createApp>[] = [];
|
|
@@ -68,17 +73,38 @@ function mountStepFlow(options: {
|
|
|
68
73
|
return { container, setCurrent: (key) => { current.value = key; } };
|
|
69
74
|
}
|
|
70
75
|
|
|
71
|
-
/** Let the swap's
|
|
72
|
-
async function
|
|
76
|
+
/** Let the swap watch's async flush (post render + internal nextTick) land. */
|
|
77
|
+
async function flushSwap(): Promise<void> {
|
|
78
|
+
await nextTick();
|
|
79
|
+
await nextTick();
|
|
80
|
+
await nextTick();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Wait for the animation bus's staging frame(s) to fire. */
|
|
84
|
+
async function awaitBusFrame(): Promise<void> {
|
|
73
85
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
74
86
|
}
|
|
75
87
|
|
|
76
|
-
/** Outlive the
|
|
88
|
+
/** Outlive the watchdog (0.3s stubbed duration + 350ms grace). */
|
|
77
89
|
async function outliveSwap(): Promise<void> {
|
|
78
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, 720));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Force the motion path: the duration probe sees a 0.3s transition. */
|
|
94
|
+
function stubMotion(duration = "0.3s"): void {
|
|
95
|
+
const real = window.getComputedStyle.bind(window);
|
|
96
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation(
|
|
97
|
+
(el: Element, pseudoElt?: string | null): CSSStyleDeclaration => {
|
|
98
|
+
if (el instanceof HTMLElement && el.classList.contains("hk-stepflow-body")) {
|
|
99
|
+
return { transitionDuration: duration } as CSSStyleDeclaration;
|
|
100
|
+
}
|
|
101
|
+
return real(el, pseudoElt ?? undefined);
|
|
102
|
+
},
|
|
103
|
+
);
|
|
79
104
|
}
|
|
80
105
|
|
|
81
106
|
afterEach(() => {
|
|
107
|
+
vi.restoreAllMocks();
|
|
82
108
|
for (const app of mounts.splice(0)) app.unmount();
|
|
83
109
|
for (const el of containers.splice(0)) el.remove();
|
|
84
110
|
});
|
|
@@ -107,46 +133,42 @@ describe("HkStepFlow", () => {
|
|
|
107
133
|
const t = mountStepFlow({ initial: "a" });
|
|
108
134
|
expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("a-body");
|
|
109
135
|
t.setCurrent("d");
|
|
110
|
-
await
|
|
111
|
-
|
|
112
|
-
|
|
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.
|
|
136
|
+
await flushSwap();
|
|
137
|
+
// Stylesheet-less runtime → instant settle: exactly one body, the new
|
|
138
|
+
// step's, with no leaving residue and no timers to outlive.
|
|
117
139
|
const bodies = t.container.querySelectorAll(".hk-stepflow-body");
|
|
118
140
|
expect(bodies.length).toBe(1);
|
|
119
141
|
expect(bodies[0]!.className).toBe("hk-stepflow-body active");
|
|
120
142
|
expect(bodies[0]!.textContent).toBe("d-body");
|
|
121
143
|
});
|
|
122
144
|
|
|
123
|
-
it("
|
|
145
|
+
it("settles deterministically to a single body where no transition runs", async () => {
|
|
146
|
+
// The 2026-09-22 debt pin: chest's LoginView MFA suite broke because
|
|
147
|
+
// the crossfade kept a leaving body alive for hundreds of ms in
|
|
148
|
+
// transitionend-less environments. With a zero probed duration the
|
|
149
|
+
// swap is atomic — the DOM NEVER carries two steps.
|
|
124
150
|
const t = mountStepFlow({ initial: "a" });
|
|
151
|
+
const events: CustomEvent[] = [];
|
|
152
|
+
t.container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
|
|
153
|
+
events.push(e as CustomEvent);
|
|
154
|
+
});
|
|
125
155
|
t.setCurrent("b");
|
|
126
|
-
await
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
156
|
+
await flushSwap();
|
|
157
|
+
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
158
|
+
expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("b-body");
|
|
159
|
+
expect(t.container.querySelector(".hk-stepflow-body.leaving")).toBeNull();
|
|
160
|
+
// Rapid successive swaps stay single-bodied the whole way through.
|
|
141
161
|
t.setCurrent("c");
|
|
142
|
-
await
|
|
143
|
-
|
|
144
|
-
|
|
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();
|
|
162
|
+
await flushSwap();
|
|
163
|
+
t.setCurrent("d");
|
|
164
|
+
await flushSwap();
|
|
149
165
|
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
166
|
+
expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("d-body");
|
|
167
|
+
// The sheet passthrough fired for every swap, carrying a numeric delta.
|
|
168
|
+
expect(events.length).toBe(3);
|
|
169
|
+
for (const e of events) {
|
|
170
|
+
expect(typeof (e.detail as { delta: number }).delta).toBe("number");
|
|
171
|
+
}
|
|
150
172
|
});
|
|
151
173
|
|
|
152
174
|
it("passes key/index/direction to scoped slots across navigation", async () => {
|
|
@@ -155,18 +177,17 @@ describe("HkStepFlow", () => {
|
|
|
155
177
|
expect(seen.map((s) => `${s.key}:${s.index}:${s.direction}`)).toEqual(["a:0:forward"]);
|
|
156
178
|
|
|
157
179
|
t.setCurrent("c");
|
|
158
|
-
await
|
|
180
|
+
await flushSwap();
|
|
159
181
|
expect(seen.at(-1)).toEqual({ key: "c", index: 2, direction: "forward" });
|
|
160
182
|
|
|
161
183
|
t.setCurrent("b");
|
|
162
|
-
await
|
|
184
|
+
await flushSwap();
|
|
163
185
|
expect(seen.at(-1)).toEqual({ key: "b", index: 1, direction: "back" });
|
|
164
186
|
|
|
165
187
|
// Returning forward again flips the direction back.
|
|
166
188
|
t.setCurrent("d");
|
|
167
|
-
await
|
|
189
|
+
await flushSwap();
|
|
168
190
|
expect(seen.at(-1)?.direction).toBe("forward");
|
|
169
|
-
await settle();
|
|
170
191
|
});
|
|
171
192
|
|
|
172
193
|
it("emits update:modelValue when a clickable completed step is selected", async () => {
|
|
@@ -198,10 +219,9 @@ describe("HkStepFlow", () => {
|
|
|
198
219
|
expect(first?.getAttribute("data-status")).toBe("completed");
|
|
199
220
|
first.dispatchEvent(new MouseEvent("click"));
|
|
200
221
|
await nextTick();
|
|
201
|
-
await
|
|
222
|
+
await flushSwap();
|
|
202
223
|
expect(selected).toEqual(["a"]);
|
|
203
224
|
expect(container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("");
|
|
204
|
-
await settle();
|
|
205
225
|
});
|
|
206
226
|
|
|
207
227
|
it("renders an empty body without warnings for an unknown key", async () => {
|
|
@@ -210,7 +230,7 @@ describe("HkStepFlow", () => {
|
|
|
210
230
|
const t = mountStepFlow({ initial: "a" });
|
|
211
231
|
t.setCurrent("does-not-exist");
|
|
212
232
|
await nextTick();
|
|
213
|
-
await
|
|
233
|
+
await flushSwap();
|
|
214
234
|
const body = t.container.querySelector(".hk-stepflow-body.active");
|
|
215
235
|
expect(body).not.toBeNull();
|
|
216
236
|
expect(body?.textContent).toBe("");
|
|
@@ -262,9 +282,99 @@ describe("HkStepFlow", () => {
|
|
|
262
282
|
// remembered index follows the array to 3, so 1 < 3 correctly reads BACK.
|
|
263
283
|
current.value = "c";
|
|
264
284
|
await nextTick();
|
|
265
|
-
await
|
|
285
|
+
await flushSwap();
|
|
266
286
|
expect(seen.at(-1)).toEqual({ key: "c", index: 1, direction: "back" });
|
|
267
|
-
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ── Motion-stubbed slide choreography ─────────────────────────────────
|
|
291
|
+
// `getComputedStyle` reports a 0.3s duration for step bodies, so the full
|
|
292
|
+
// staged slide runs: staged start classes → one bus frame → synchronized
|
|
293
|
+
// leave/enter transitions → transitionend/watchdog cleanup.
|
|
294
|
+
describe("HkStepFlow slide swap (motion enabled)", () => {
|
|
295
|
+
it("stages both bodies, then starts the synchronized slide on the bus frame", async () => {
|
|
296
|
+
stubMotion();
|
|
297
|
+
const t = mountStepFlow({ initial: "a" });
|
|
298
|
+
const events: CustomEvent[] = [];
|
|
299
|
+
t.container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
|
|
300
|
+
events.push(e as CustomEvent);
|
|
301
|
+
});
|
|
302
|
+
t.setCurrent("b");
|
|
303
|
+
await flushSwap();
|
|
304
|
+
|
|
305
|
+
// Staged: both bodies coexist; the leaving one overlays the flow
|
|
306
|
+
// WITHOUT its end state yet, the entering one holds its start state.
|
|
307
|
+
const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
|
|
308
|
+
const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
309
|
+
expect(leaving).not.toBeNull();
|
|
310
|
+
expect(active).not.toBeNull();
|
|
311
|
+
expect(leaving?.textContent).toBe("a-body");
|
|
312
|
+
expect(active?.textContent).toBe("b-body");
|
|
313
|
+
expect(leaving?.classList.contains("hk-stepflow-leave-to")).toBe(false);
|
|
314
|
+
expect(active?.classList.contains("hk-stepflow-enter-from")).toBe(true);
|
|
315
|
+
expect(
|
|
316
|
+
t.container.querySelector(".hk-stepflow-bodies")?.getAttribute("data-direction"),
|
|
317
|
+
).toBe("forward");
|
|
318
|
+
// The sheet passthrough fired as the entering body took the flow.
|
|
319
|
+
expect(events.length).toBe(1);
|
|
320
|
+
|
|
321
|
+
// The bus frame flips both bodies together: leave end state on, enter
|
|
322
|
+
// start state off — both transitions start on one recalculation.
|
|
323
|
+
await awaitBusFrame();
|
|
324
|
+
expect(leaving?.classList.contains("hk-stepflow-leave-to")).toBe(true);
|
|
325
|
+
expect(active?.classList.contains("hk-stepflow-enter-from")).toBe(false);
|
|
326
|
+
// The slide is still in flight: both bodies remain mounted.
|
|
327
|
+
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(2);
|
|
328
|
+
|
|
329
|
+
// happy-dom never fires transitionend — the watchdog settles the swap.
|
|
330
|
+
await outliveSwap();
|
|
331
|
+
const bodies = t.container.querySelectorAll(".hk-stepflow-body");
|
|
332
|
+
expect(bodies.length).toBe(1);
|
|
333
|
+
expect(bodies[0]!.className).toBe("hk-stepflow-body active");
|
|
334
|
+
expect(bodies[0]!.textContent).toBe("b-body");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("mirrors the direction attribute for back navigation", async () => {
|
|
338
|
+
stubMotion();
|
|
339
|
+
const t = mountStepFlow({ initial: "c" });
|
|
340
|
+
t.setCurrent("a");
|
|
341
|
+
await flushSwap();
|
|
342
|
+
expect(
|
|
343
|
+
t.container.querySelector(".hk-stepflow-bodies")?.getAttribute("data-direction"),
|
|
344
|
+
).toBe("back");
|
|
345
|
+
// Same staged grammar, mirrored travel lives in the stylesheet.
|
|
346
|
+
const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
|
|
347
|
+
const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
348
|
+
expect(leaving?.textContent).toBe("c-body");
|
|
349
|
+
expect(active?.classList.contains("hk-stepflow-enter-from")).toBe(true);
|
|
350
|
+
await awaitBusFrame();
|
|
351
|
+
expect(leaving?.classList.contains("hk-stepflow-leave-to")).toBe(true);
|
|
352
|
+
await outliveSwap();
|
|
353
|
+
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("drops the stale leaving body outright on a rapid re-swap", async () => {
|
|
357
|
+
stubMotion();
|
|
358
|
+
const t = mountStepFlow({ initial: "a" });
|
|
359
|
+
t.setCurrent("b");
|
|
360
|
+
await flushSwap();
|
|
361
|
+
await awaitBusFrame();
|
|
362
|
+
// The first swap is running; re-swap before it settles.
|
|
363
|
+
t.setCurrent("c");
|
|
364
|
+
await flushSwap();
|
|
365
|
+
const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
|
|
366
|
+
const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
|
|
367
|
+
expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(2);
|
|
368
|
+
expect(leaving?.textContent).toBe("b-body");
|
|
369
|
+
expect(active?.textContent).toBe("c-body");
|
|
370
|
+
// The new swap restages: no leftover end state on the new leaving body.
|
|
371
|
+
expect(leaving?.classList.contains("hk-stepflow-leave-to")).toBe(false);
|
|
372
|
+
expect(active?.classList.contains("hk-stepflow-enter-from")).toBe(true);
|
|
373
|
+
await awaitBusFrame();
|
|
374
|
+
await outliveSwap();
|
|
375
|
+
const bodies = t.container.querySelectorAll(".hk-stepflow-body");
|
|
376
|
+
expect(bodies.length).toBe(1);
|
|
377
|
+
expect(bodies[0]!.textContent).toBe("c-body");
|
|
268
378
|
});
|
|
269
379
|
});
|
|
270
380
|
|
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
import HkTimeline from "./HkTimeline";
|
|
12
12
|
import type { TimelineCollapse, TimelineStep } from "./HkTimeline";
|
|
13
13
|
import { SCROLL_HOST_CLASS } from "./HkScrollPin";
|
|
14
|
+
import {
|
|
15
|
+
reportTransition,
|
|
16
|
+
scheduleFrame,
|
|
17
|
+
type AnimationHandle,
|
|
18
|
+
} from "../runtime/animationBus";
|
|
14
19
|
|
|
15
20
|
// Sticky header mode rides the shared scroll-pin contract (class + data
|
|
16
21
|
// attributes on the timeline root); the pin stylesheet ships those
|
|
@@ -35,44 +40,82 @@ interface BodyEntry {
|
|
|
35
40
|
/** The step key this body renders. */
|
|
36
41
|
key: string;
|
|
37
42
|
/** `active` bodies sit in the flow; a `leaving` body is out of flow
|
|
38
|
-
* (absolutely positioned) for the
|
|
43
|
+
* (absolutely positioned) for the slide's duration. */
|
|
39
44
|
phase: "active" | "leaving";
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
/** A swap
|
|
47
|
+
/** A swap in flight: the leaving body slides out while the entering one
|
|
48
|
+
* slides in. All MOTION is stylesheet-owned (the `data-direction`
|
|
49
|
+
* attribute plus the `hk-stepflow-enter-from`/`hk-stepflow-leave-to` classes); script only
|
|
50
|
+
* schedules the staging frame on the shared animation bus and books
|
|
51
|
+
* the cleanup. */
|
|
43
52
|
interface RunningSwap {
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
leavingId: number;
|
|
54
|
+
enteringId: number;
|
|
55
|
+
/** The staging frame handle (disconnected when a re-swap pre-empts). */
|
|
56
|
+
frame: AnimationHandle | null;
|
|
57
|
+
/** Bus bookkeeping for the CSS sweep, held for its duration. */
|
|
58
|
+
report: AnimationHandle | null;
|
|
59
|
+
/** Watchdog: settle even if transitionend never arrives. */
|
|
60
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
61
|
+
listenEl: HTMLElement | null;
|
|
62
|
+
onEnded: ((event: TransitionEvent) => void) | null;
|
|
46
63
|
}
|
|
47
64
|
|
|
48
65
|
export const STEPFLOW_SWAP_EVENT = "hk-stepflow-swap";
|
|
49
66
|
|
|
67
|
+
/** Extra grace on top of the resolved duration before the watchdog
|
|
68
|
+
* settles the swap without a transitionend — the same grammar as the
|
|
69
|
+
* sheet morph's sweep watchdog. */
|
|
70
|
+
const SWAP_WATCHDOG_GRACE_MS = 350;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the body's configured transition duration in milliseconds.
|
|
74
|
+
* A zero result means the swap settles INSTANTLY: reduced-motion users
|
|
75
|
+
* (the stylesheet zeroes the transition) and stylesheet-less runtimes
|
|
76
|
+
* (test environments) both get a deterministic single-body DOM with no
|
|
77
|
+
* timers to outlive and no transitionend to wait for.
|
|
78
|
+
*/
|
|
79
|
+
function bodyTransitionMs(el: HTMLElement | null): number {
|
|
80
|
+
if (!el) return 0;
|
|
81
|
+
const view = el.ownerDocument?.defaultView;
|
|
82
|
+
if (!view) return 0;
|
|
83
|
+
const raw = view.getComputedStyle(el).transitionDuration ?? "";
|
|
84
|
+
// Multiple entries ("0.3s, 0.3s") share one duration here — the first
|
|
85
|
+
// is representative; "0.3s" and "300ms" forms both parse.
|
|
86
|
+
const first = raw.split(",")[0]?.trim() ?? "";
|
|
87
|
+
const match = /^([0-9]*\.?[0-9]+)(ms|s)$/.exec(first);
|
|
88
|
+
if (!match) return 0;
|
|
89
|
+
const value = Number(match[1]);
|
|
90
|
+
return match[2] === "ms" ? value : value * 1000;
|
|
91
|
+
}
|
|
92
|
+
|
|
50
93
|
/**
|
|
51
94
|
* Generic step-flow container: an optional HkTimeline header bound to
|
|
52
|
-
* `modelValue` plus a
|
|
53
|
-
* by step key. Navigation state lives in the consumer — the
|
|
54
|
-
* only translates `modelValue` changes into body swaps and
|
|
55
|
-
* timeline selections upward.
|
|
95
|
+
* `modelValue` plus a direction-aware sliding body fed purely by named
|
|
96
|
+
* slots keyed by step key. Navigation state lives in the consumer — the
|
|
97
|
+
* component only translates `modelValue` changes into body swaps and
|
|
98
|
+
* echoes timeline selections upward.
|
|
56
99
|
*
|
|
57
|
-
* The swap choreography (2026-09-
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
100
|
+
* The swap choreography (2026-09-22 user directive: back to the classic
|
|
101
|
+
* direction-aware slide, re-based on pure CSS with the animation context
|
|
102
|
+
* driving state) — old and new bodies slide SIMULTANEOUSLY for the whole
|
|
103
|
+
* `--hk-stepflow-duration` (default 0.3s): forward advances exit left /
|
|
104
|
+
* enter from the right, back mirrors it. The entering body owns the
|
|
105
|
+
* flow's height from frame one (so the hosting sheet's morph targets the
|
|
106
|
+
* NEW geometry immediately) while the leaving body overlays it out of
|
|
107
|
+
* flow; travel distance, direction and easings all live in the
|
|
108
|
+
* stylesheet. State flips are scheduled on the shared animation bus:
|
|
109
|
+
* one staging frame after the DOM patch commits the start states, both
|
|
110
|
+
* bodies' classes flip in the same patch so leave and enter start on
|
|
111
|
+
* the same style recalculation — the simultaneous grammar is what keeps
|
|
112
|
+
* the retired `out-in` slide's empty mid-frame (and its raster flash)
|
|
113
|
+
* from coming back.
|
|
71
114
|
*
|
|
72
115
|
* Each swap also dispatches `hk-stepflow-swap` (bubbles, detail
|
|
73
116
|
* `{ delta }`) from the flow root — the hosting modal listens for it and
|
|
74
117
|
* re-measures immediately, skipping the morph's settle debounce so the
|
|
75
|
-
* sheet's clip sweep starts on the same frame as the
|
|
118
|
+
* sheet's clip sweep starts on the same frame as the slide.
|
|
76
119
|
*/
|
|
77
120
|
export default defineComponent({
|
|
78
121
|
name: "HkStepFlow",
|
|
@@ -136,19 +179,31 @@ export default defineComponent({
|
|
|
136
179
|
},
|
|
137
180
|
);
|
|
138
181
|
|
|
139
|
-
// ──
|
|
182
|
+
// ── Swap body bookkeeping ─────────────────────────────────────────
|
|
140
183
|
let mountSeq = 0;
|
|
141
184
|
const bodies = ref<BodyEntry[]>([
|
|
142
185
|
{ id: mountSeq++, key: props.modelValue, phase: "active" },
|
|
143
186
|
]);
|
|
187
|
+
// `swap` is a plain let; `swapStage` carries the reactivity. Every
|
|
188
|
+
// mutation of `swap` is paired with either a `swapStage` or a
|
|
189
|
+
// `bodies` write, so renders always re-read the pair (see the watch
|
|
190
|
+
// and endSwap below).
|
|
144
191
|
let swap: RunningSwap | null = null;
|
|
192
|
+
const swapStage = ref<"idle" | "staged" | "running">("idle");
|
|
145
193
|
const flowRef = ref<HTMLDivElement | null>(null);
|
|
146
194
|
|
|
147
195
|
function endSwap(): void {
|
|
148
196
|
if (!swap) return;
|
|
149
|
-
|
|
150
|
-
const id = swap.entryId;
|
|
197
|
+
const handle = swap;
|
|
151
198
|
swap = null;
|
|
199
|
+
swapStage.value = "idle";
|
|
200
|
+
handle.frame?.disconnect();
|
|
201
|
+
if (handle.timer !== null) clearTimeout(handle.timer);
|
|
202
|
+
handle.report?.disconnect();
|
|
203
|
+
if (handle.listenEl && handle.onEnded) {
|
|
204
|
+
handle.listenEl.removeEventListener("transitionend", handle.onEnded);
|
|
205
|
+
}
|
|
206
|
+
const id = handle.leavingId;
|
|
152
207
|
bodies.value = bodies.value.filter((b) => b.id !== id);
|
|
153
208
|
}
|
|
154
209
|
|
|
@@ -168,27 +223,69 @@ export default defineComponent({
|
|
|
168
223
|
() => props.modelValue,
|
|
169
224
|
async (next, prev) => {
|
|
170
225
|
if (next === prev) return;
|
|
171
|
-
// A swap while the previous one is still
|
|
172
|
-
// leaving body outright
|
|
173
|
-
//
|
|
226
|
+
// A swap while the previous one is still sliding: drop the old
|
|
227
|
+
// leaving body outright and let the new swap own the stage. (If
|
|
228
|
+
// the previous entering body had not visibly settled yet it now
|
|
229
|
+
// becomes the leaving one and exits from its mid-flight state —
|
|
230
|
+
// one frame of re-staging, matching the classic grammar.)
|
|
174
231
|
endSwap();
|
|
175
232
|
const leaving = bodies.value.find((b) => b.phase === "active");
|
|
176
233
|
if (!leaving) return;
|
|
177
234
|
// Pre-swap geometry while the leaving body still owns the flow:
|
|
178
|
-
// its height is the "old" reference for the
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
235
|
+
// its height is the "old" reference for the sheet's delta. The
|
|
236
|
+
// same element also answers the motion probe.
|
|
237
|
+
const leavingEl =
|
|
238
|
+
flowRef.value?.querySelector<HTMLElement>(
|
|
239
|
+
".hk-stepflow-body.active",
|
|
240
|
+
) ?? null;
|
|
182
241
|
const oldH = leavingEl?.offsetHeight ?? 0;
|
|
242
|
+
const durationMs = bodyTransitionMs(leavingEl);
|
|
243
|
+
|
|
244
|
+
if (durationMs <= 0) {
|
|
245
|
+
// Instant settle: no transition is configured (reduced motion,
|
|
246
|
+
// or a stylesheet-less runtime such as a test environment) —
|
|
247
|
+
// swap the bodies atomically so the DOM never carries two
|
|
248
|
+
// steps, then still poke the hosting sheet to remeasure.
|
|
249
|
+
bodies.value = [{ id: mountSeq++, key: next, phase: "active" }];
|
|
250
|
+
await nextTick();
|
|
251
|
+
const newEl = flowRef.value?.querySelector<HTMLElement>(
|
|
252
|
+
".hk-stepflow-body.active",
|
|
253
|
+
);
|
|
254
|
+
const delta = (newEl?.offsetHeight ?? oldH) - oldH;
|
|
255
|
+
flowRef.value?.dispatchEvent(
|
|
256
|
+
new CustomEvent(STEPFLOW_SWAP_EVENT, {
|
|
257
|
+
bubbles: true,
|
|
258
|
+
detail: { delta },
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
183
264
|
leaving.phase = "leaving";
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
265
|
+
const entering: BodyEntry = {
|
|
266
|
+
id: mountSeq++,
|
|
267
|
+
key: next,
|
|
268
|
+
phase: "active",
|
|
269
|
+
};
|
|
270
|
+
// Register the swap BEFORE the reactive mutations so the staged
|
|
271
|
+
// render already sees which entry is entering (hk-stepflow-enter-from) —
|
|
272
|
+
// `swap` itself is not reactive.
|
|
273
|
+
const handle: RunningSwap = {
|
|
274
|
+
leavingId: leaving.id,
|
|
275
|
+
enteringId: entering.id,
|
|
276
|
+
frame: null,
|
|
277
|
+
report: null,
|
|
278
|
+
timer: null,
|
|
279
|
+
listenEl: null,
|
|
280
|
+
onEnded: null,
|
|
281
|
+
};
|
|
282
|
+
swap = handle;
|
|
283
|
+
bodies.value = [...bodies.value, entering];
|
|
284
|
+
swapStage.value = "staged";
|
|
188
285
|
await nextTick();
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
286
|
+
// Preempted while the DOM patched? The newer swap owns the stage —
|
|
287
|
+
// this continuation must not measure, dispatch or arm anything.
|
|
288
|
+
if (swap !== handle) return;
|
|
192
289
|
const newEl = flowRef.value?.querySelector<HTMLElement>(
|
|
193
290
|
".hk-stepflow-body.active",
|
|
194
291
|
);
|
|
@@ -196,48 +293,45 @@ export default defineComponent({
|
|
|
196
293
|
".hk-stepflow-body.leaving",
|
|
197
294
|
);
|
|
198
295
|
if (!newEl || !goneEl) {
|
|
296
|
+
// The flow went away mid-swap (unmount) — restore one body.
|
|
297
|
+
swap = null;
|
|
199
298
|
leaving.phase = "active";
|
|
200
|
-
bodies.value =
|
|
299
|
+
bodies.value = [leaving];
|
|
300
|
+
swapStage.value = "idle";
|
|
201
301
|
return;
|
|
202
302
|
}
|
|
203
|
-
const
|
|
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";
|
|
303
|
+
const delta = newEl.offsetHeight - oldH;
|
|
230
304
|
// Tell the hosting sheet to morph NOW (skip the settle debounce)
|
|
231
|
-
// so its clip sweep runs on the same frames as this
|
|
305
|
+
// so its clip sweep runs on the same frames as this slide.
|
|
232
306
|
flowRef.value?.dispatchEvent(
|
|
233
307
|
new CustomEvent(STEPFLOW_SWAP_EVENT, {
|
|
234
308
|
bubbles: true,
|
|
235
309
|
detail: { delta },
|
|
236
310
|
}),
|
|
237
311
|
);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
312
|
+
handle.report = reportTransition(durationMs);
|
|
313
|
+
handle.listenEl = goneEl;
|
|
314
|
+
// Staging frame on the shared bus: commit the staged start states
|
|
315
|
+
// (the forced layout read), then flip BOTH bodies' classes in one
|
|
316
|
+
// patch — the leave and enter transitions start on the very same
|
|
317
|
+
// style recalculation.
|
|
318
|
+
handle.frame = scheduleFrame(() => {
|
|
319
|
+
if (swap !== handle) return;
|
|
320
|
+
handle.frame = null;
|
|
321
|
+
void flowRef.value?.offsetHeight;
|
|
322
|
+
const onEnded = (event: TransitionEvent) => {
|
|
323
|
+
// transitionend bubbles — only the leaving body's own
|
|
324
|
+
// property transitions may settle the swap.
|
|
325
|
+
if (event.target === goneEl) endSwap();
|
|
326
|
+
};
|
|
327
|
+
goneEl.addEventListener("transitionend", onEnded);
|
|
328
|
+
handle.onEnded = onEnded;
|
|
329
|
+
handle.timer = setTimeout(
|
|
330
|
+
endSwap,
|
|
331
|
+
durationMs + SWAP_WATCHDOG_GRACE_MS,
|
|
332
|
+
);
|
|
333
|
+
swapStage.value = "running";
|
|
334
|
+
});
|
|
241
335
|
},
|
|
242
336
|
{ flush: "post" },
|
|
243
337
|
);
|
|
@@ -248,7 +342,7 @@ export default defineComponent({
|
|
|
248
342
|
});
|
|
249
343
|
|
|
250
344
|
onBeforeUnmount(() => {
|
|
251
|
-
|
|
345
|
+
endSwap();
|
|
252
346
|
});
|
|
253
347
|
|
|
254
348
|
return () => {
|
|
@@ -268,11 +362,20 @@ export default defineComponent({
|
|
|
268
362
|
data-strategy={props.stickyHeader ? pinStrategy.value : undefined}
|
|
269
363
|
/>
|
|
270
364
|
)}
|
|
271
|
-
<div class="hk-stepflow-bodies">
|
|
365
|
+
<div class="hk-stepflow-bodies" data-direction={dir}>
|
|
272
366
|
{bodies.value.map((entry) => (
|
|
273
367
|
<div
|
|
274
368
|
key={entry.id}
|
|
275
|
-
class={[
|
|
369
|
+
class={[
|
|
370
|
+
"hk-stepflow-body",
|
|
371
|
+
entry.phase,
|
|
372
|
+
entry.id === swap?.enteringId && swapStage.value === "staged"
|
|
373
|
+
? "hk-stepflow-enter-from"
|
|
374
|
+
: null,
|
|
375
|
+
entry.id === swap?.leavingId && swapStage.value === "running"
|
|
376
|
+
? "hk-stepflow-leave-to"
|
|
377
|
+
: null,
|
|
378
|
+
]}
|
|
276
379
|
>
|
|
277
380
|
{slots[entry.key]?.({
|
|
278
381
|
key: entry.key,
|
|
@@ -385,6 +385,22 @@ describe("useContextMenu + HkContextMenuProvider", () => {
|
|
|
385
385
|
unbind();
|
|
386
386
|
});
|
|
387
387
|
|
|
388
|
+
it("keeps real right-clicks working inside the suppression window (only long-press synthesis is swallowed)", () => {
|
|
389
|
+
const target = document.createElement("div");
|
|
390
|
+
target.setAttribute("data-bind-target", "");
|
|
391
|
+
document.body.appendChild(target);
|
|
392
|
+
const { opens, api } = mockApi();
|
|
393
|
+
const unbind = bindContextMenu(target, api, (point) => {
|
|
394
|
+
return { x: point.x, y: point.y, items: [{ key: "a", label: "A" }] };
|
|
395
|
+
});
|
|
396
|
+
// Two genuine pointer right-clicks, ~0ms apart: BOTH open (a
|
|
397
|
+
// desktop user re-aiming fast must not lose the second menu).
|
|
398
|
+
target.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }));
|
|
399
|
+
target.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 30, clientY: 30 }));
|
|
400
|
+
expect(opens).toHaveLength(2);
|
|
401
|
+
unbind();
|
|
402
|
+
});
|
|
403
|
+
|
|
388
404
|
it("lets a null build defer to outer bindings without preventing", () => {
|
|
389
405
|
const target = document.createElement("div");
|
|
390
406
|
target.setAttribute("data-bind-target", "");
|
|
@@ -110,6 +110,12 @@ export function bindContextMenu(
|
|
|
110
110
|
let holdTimer: ReturnType<typeof setTimeout> | null = null;
|
|
111
111
|
let holdOrigin: { x: number; y: number } | null = null;
|
|
112
112
|
let lastOpenedAt = 0;
|
|
113
|
+
/** The kind that last opened a menu — the suppression window only
|
|
114
|
+
* guards the LONG-PRESS path (platforms synthesize a native
|
|
115
|
+
* contextmenu right after ours). A desktop user right-clicking twice
|
|
116
|
+
* within the window is two REAL gestures: the second one must open
|
|
117
|
+
* its own menu, never be swallowed. */
|
|
118
|
+
let lastOpenKind: ContextTriggerPoint["kind"] | null = null;
|
|
113
119
|
|
|
114
120
|
const cancelHold = () => {
|
|
115
121
|
if (holdTimer !== null) {
|
|
@@ -123,6 +129,7 @@ export function bindContextMenu(
|
|
|
123
129
|
const request = build(point);
|
|
124
130
|
if (!request) return false;
|
|
125
131
|
lastOpenedAt = performance.now();
|
|
132
|
+
lastOpenKind = point.kind;
|
|
126
133
|
api.open(request);
|
|
127
134
|
return true;
|
|
128
135
|
};
|
|
@@ -130,7 +137,10 @@ export function bindContextMenu(
|
|
|
130
137
|
const onContextMenu = (event: MouseEvent) => {
|
|
131
138
|
// Our own long-press just opened the menu; a synthesized native
|
|
132
139
|
// event for the same gesture must not re-open (or replace) it.
|
|
133
|
-
if (
|
|
140
|
+
if (
|
|
141
|
+
lastOpenKind === "longpress"
|
|
142
|
+
&& performance.now() - lastOpenedAt < CONTEXT_SUPPRESS_MS
|
|
143
|
+
) {
|
|
134
144
|
event.preventDefault();
|
|
135
145
|
return;
|
|
136
146
|
}
|
|
@@ -1,61 +0,0 @@
|
|
|
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
|
-
});
|