@celestia-island/hikari 0.40.18 → 0.40.21
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/HkColorSchemeEditor.test.ts +24 -1
- package/src/components/HkColorSchemeEditor.tsx +19 -7
- package/src/components/HkDrawer.tsx +97 -85
- package/src/components/HkModal.enter-watchdog.test.tsx +42 -19
- package/src/components/HkModal.transition-completion.test.tsx +21 -19
- package/src/components/HkModal.tsx +91 -199
- package/src/components/HkPopover.scss +18 -0
- package/src/components/HkPopover.surfacevars.test.ts +14 -0
- package/src/components/HkPopover.tsx +120 -89
- package/src/components/HkSelectPanel.test.tsx +7 -5
- package/src/components/HkSelectPanel.tsx +163 -231
- package/src/components/HkStatusBar.test.tsx +8 -8
- package/src/components/HkThemeToggle.test.ts +6 -2
- package/src/components/scrim-fade.contract.test.ts +14 -9
- package/src/composables/useSurfaceMachine.test.ts +259 -0
- package/src/composables/useSurfaceMachine.ts +289 -0
- package/src/runtime/surfaceMachine.test.ts +186 -0
- package/src/runtime/surfaceMachine.ts +229 -0
- package/src/runtime/transitionWatchdog.test.ts +0 -79
- package/src/runtime/transitionWatchdog.ts +0 -73
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import { useSurfaceMachine } from "./useSurfaceMachine";
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.useRealTimers();
|
|
8
|
+
vi.unstubAllGlobals();
|
|
9
|
+
document.body.innerHTML = "";
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
interface Rig {
|
|
13
|
+
phase: () => string;
|
|
14
|
+
mounted: () => boolean;
|
|
15
|
+
classesFor: (prefix: string) => string[];
|
|
16
|
+
send: (e: "OPEN" | "CLOSE" | "FLIP" | "DEADLINE" | "TEND" | "UNMOUNT") => void;
|
|
17
|
+
edges: Array<[string, string, string]>;
|
|
18
|
+
unmount: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Mount a machine with two test layers (like a modal's scrim+panel)
|
|
22
|
+
* and capture every phase edge it walks. `probed` wires live element
|
|
23
|
+
* accessors — in happy-dom the probe reads a 0 duration, which selects
|
|
24
|
+
* the no-transition fast path; without probes the machine runs on the
|
|
25
|
+
* configured fallback budgets (the starvation-era behavior). */
|
|
26
|
+
function mountRig(probed = false): Rig {
|
|
27
|
+
const container = document.createElement("div");
|
|
28
|
+
document.body.appendChild(container);
|
|
29
|
+
const edges: Array<[string, string, string]> = [];
|
|
30
|
+
const machineRef: { current: ReturnType<typeof useSurfaceMachine> | null } = { current: null };
|
|
31
|
+
const app = createApp(defineComponent({
|
|
32
|
+
setup() {
|
|
33
|
+
machineRef.current = useSurfaceMachine({
|
|
34
|
+
layers: [
|
|
35
|
+
{
|
|
36
|
+
prefix: "rig-scrim",
|
|
37
|
+
...(probed ? { el: () => document.querySelector<HTMLElement>(".scrim") } : {}),
|
|
38
|
+
enterMs: () => 300,
|
|
39
|
+
leaveMs: () => 300,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
prefix: "rig-panel",
|
|
43
|
+
...(probed ? { el: () => document.querySelector<HTMLElement>(".panel") } : {}),
|
|
44
|
+
enterMs: () => 300,
|
|
45
|
+
leaveMs: () => 250,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
onPhase: (from, to, event) => { edges.push([from, to, event]); },
|
|
49
|
+
});
|
|
50
|
+
const m = machineRef.current;
|
|
51
|
+
return () =>
|
|
52
|
+
h("div", [
|
|
53
|
+
m.mounted.value ? h("div", { class: ["scrim", ...m.classesFor("rig-scrim")] }) : null,
|
|
54
|
+
m.mounted.value ? h("div", { class: ["panel", ...m.classesFor("rig-panel")] }) : null,
|
|
55
|
+
]);
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
app.mount(container);
|
|
59
|
+
const m = machineRef.current!;
|
|
60
|
+
return {
|
|
61
|
+
phase: () => m.phase.value,
|
|
62
|
+
mounted: () => m.mounted.value,
|
|
63
|
+
classesFor: m.classesFor,
|
|
64
|
+
send: m.send,
|
|
65
|
+
edges,
|
|
66
|
+
unmount: () => app.unmount(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function scrimClasses(rig: Rig): string[] {
|
|
71
|
+
return Array.from(document.querySelector<HTMLElement>(".scrim")?.classList ?? [])
|
|
72
|
+
.filter((c) => c.startsWith("rig-scrim-"));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function flush(): Promise<void> {
|
|
76
|
+
await nextTick();
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
78
|
+
await nextTick();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("useSurfaceMachine driver", () => {
|
|
82
|
+
it("renders mounted with enter-from classes on OPEN, flips on rAF, rests on deadline", async () => {
|
|
83
|
+
vi.useFakeTimers();
|
|
84
|
+
const rig = mountRig();
|
|
85
|
+
rig.send("OPEN");
|
|
86
|
+
await nextTick();
|
|
87
|
+
expect(rig.mounted()).toBe(true);
|
|
88
|
+
expect(rig.phase()).toBe("openingFrom");
|
|
89
|
+
expect(scrimClasses(rig)).toEqual(["rig-scrim-enter-from", "rig-scrim-enter-active"]);
|
|
90
|
+
|
|
91
|
+
// Frame flip: the clock drives the (faked) rAF frames — well before
|
|
92
|
+
// the flip timer's own budget, so this exercises the frame path.
|
|
93
|
+
await vi.advanceTimersByTimeAsync(40);
|
|
94
|
+
expect(rig.phase()).toBe("openingTo");
|
|
95
|
+
expect(scrimClasses(rig)).toEqual(["rig-scrim-enter-to", "rig-scrim-enter-active"]);
|
|
96
|
+
|
|
97
|
+
// Deadline settles the surface at rest with no transition classes.
|
|
98
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
99
|
+
expect(rig.phase()).toBe("open");
|
|
100
|
+
expect(scrimClasses(rig)).toEqual([]);
|
|
101
|
+
// No late events: nothing fires into the resting surface.
|
|
102
|
+
const edgesAtRest = rig.edges.length;
|
|
103
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
104
|
+
expect(rig.phase()).toBe("open");
|
|
105
|
+
expect(scrimClasses(rig)).toEqual([]);
|
|
106
|
+
expect(rig.edges.length).toBe(edgesAtRest);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("settles open under total rAF starvation (flip timer + deadline only)", async () => {
|
|
110
|
+
vi.useFakeTimers();
|
|
111
|
+
vi.stubGlobal("requestAnimationFrame", () => 0 as unknown as number);
|
|
112
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
113
|
+
const rig = mountRig();
|
|
114
|
+
rig.send("OPEN");
|
|
115
|
+
await nextTick();
|
|
116
|
+
expect(rig.phase()).toBe("openingFrom");
|
|
117
|
+
// No frames ever arrive; the flip timer forces FLIP…
|
|
118
|
+
await vi.advanceTimersByTimeAsync(120);
|
|
119
|
+
expect(rig.phase()).toBe("openingTo");
|
|
120
|
+
// …and the deadline walks to rest. Bounded, no watchdogs.
|
|
121
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
122
|
+
expect(rig.phase()).toBe("open");
|
|
123
|
+
expect(scrimClasses(rig)).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("close → deadline finalizes to closed and unmounts the DOM", async () => {
|
|
127
|
+
vi.useFakeTimers();
|
|
128
|
+
const rig = mountRig();
|
|
129
|
+
rig.send("OPEN");
|
|
130
|
+
await vi.advanceTimersByTimeAsync(800);
|
|
131
|
+
expect(rig.phase()).toBe("open");
|
|
132
|
+
|
|
133
|
+
rig.send("CLOSE");
|
|
134
|
+
await nextTick();
|
|
135
|
+
expect(rig.phase()).toBe("closingFrom");
|
|
136
|
+
rig.send("FLIP");
|
|
137
|
+
await nextTick();
|
|
138
|
+
expect(rig.phase()).toBe("closingTo");
|
|
139
|
+
expect(scrimClasses(rig)).toEqual(["rig-scrim-leave-to", "rig-scrim-leave-active"]);
|
|
140
|
+
|
|
141
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
142
|
+
expect(rig.phase()).toBe("closed");
|
|
143
|
+
expect(rig.mounted()).toBe(false);
|
|
144
|
+
expect(document.querySelector(".panel")).toBeNull();
|
|
145
|
+
// No late events resurrect anything.
|
|
146
|
+
const edgesAtRest = rig.edges.length;
|
|
147
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
148
|
+
expect(rig.phase()).toBe("closed");
|
|
149
|
+
expect(rig.edges.length).toBe(edgesAtRest);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("a reopen during the closing window reverses on the same element", async () => {
|
|
153
|
+
vi.useFakeTimers();
|
|
154
|
+
const rig = mountRig();
|
|
155
|
+
rig.send("OPEN");
|
|
156
|
+
await vi.advanceTimersByTimeAsync(800);
|
|
157
|
+
rig.send("CLOSE");
|
|
158
|
+
await vi.advanceTimersByTimeAsync(50); // mid-flight (still *.from window)
|
|
159
|
+
expect(["closingFrom", "closingTo"]).toContain(rig.phase());
|
|
160
|
+
|
|
161
|
+
rig.send("OPEN");
|
|
162
|
+
await nextTick();
|
|
163
|
+
expect(rig.phase()).toBe("openingFrom");
|
|
164
|
+
// Element identity preserved across the reversal.
|
|
165
|
+
const panel = document.querySelector(".panel")!;
|
|
166
|
+
expect(panel).not.toBeNull();
|
|
167
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
168
|
+
expect(rig.phase()).toBe("open");
|
|
169
|
+
expect(document.querySelector(".panel")).toBe(panel);
|
|
170
|
+
// The close never finalized — exactly one open edge, no ghost edges.
|
|
171
|
+
const closes = rig.edges.filter(([, to]) => to === "closed");
|
|
172
|
+
expect(closes).toHaveLength(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("honors a measured CSS duration: settles only after duration+slack", async () => {
|
|
176
|
+
vi.useFakeTimers();
|
|
177
|
+
vi.stubGlobal("requestAnimationFrame", () => 0 as unknown as number);
|
|
178
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
179
|
+
// Real-browser shape: computed transition-duration of 0.3s. The
|
|
180
|
+
// probe tightens the deadline to flip(120)+300+slack(80)=500 for the
|
|
181
|
+
// from-phase and 300+slack for the to-phase — never the configured
|
|
182
|
+
// fallback, never the zero-duration fast path.
|
|
183
|
+
const realGCS = window.getComputedStyle.bind(window);
|
|
184
|
+
vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
|
|
185
|
+
const style = realGCS(el as Element, ...(rest as []));
|
|
186
|
+
return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
|
|
187
|
+
});
|
|
188
|
+
const rig = mountRig(true);
|
|
189
|
+
rig.send("OPEN");
|
|
190
|
+
await nextTick();
|
|
191
|
+
await vi.advanceTimersByTimeAsync(130); // flip timer forced the FLIP
|
|
192
|
+
expect(rig.phase()).toBe("openingTo");
|
|
193
|
+
// Inside duration+slack (380ms from the flip) nothing settles…
|
|
194
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
195
|
+
expect(rig.phase()).toBe("openingTo");
|
|
196
|
+
// …past it, the deadline completes the open.
|
|
197
|
+
await vi.advanceTimersByTimeAsync(120);
|
|
198
|
+
expect(rig.phase()).toBe("open");
|
|
199
|
+
expect(scrimClasses(rig)).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("TEND: a matching transitionend completes the phase early", async () => {
|
|
203
|
+
vi.useFakeTimers();
|
|
204
|
+
vi.stubGlobal("requestAnimationFrame", () => 0 as unknown as number);
|
|
205
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
206
|
+
const realGCS = window.getComputedStyle.bind(window);
|
|
207
|
+
vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
|
|
208
|
+
const style = realGCS(el as Element, ...(rest as []));
|
|
209
|
+
return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
|
|
210
|
+
});
|
|
211
|
+
const rig = mountRig(true);
|
|
212
|
+
rig.send("OPEN");
|
|
213
|
+
await nextTick();
|
|
214
|
+
await vi.advanceTimersByTimeAsync(130); // flip forced → openingTo
|
|
215
|
+
expect(rig.phase()).toBe("openingTo");
|
|
216
|
+
const panel = document.querySelector<HTMLElement>(".panel")!;
|
|
217
|
+
|
|
218
|
+
// happy-dom's TransitionEvent ignores its init dict — synthesize
|
|
219
|
+
// the event and stamp elapsedTime on the instance.
|
|
220
|
+
const endEvent = (elapsedSeconds: number): Event => {
|
|
221
|
+
const ev = new Event("transitionend");
|
|
222
|
+
Object.defineProperty(ev, "elapsedTime", { value: elapsedSeconds });
|
|
223
|
+
return ev;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// A fast property's end event (elapsed < measured budget) is ignored…
|
|
227
|
+
panel.dispatchEvent(endEvent(0.1));
|
|
228
|
+
expect(rig.phase()).toBe("openingTo");
|
|
229
|
+
|
|
230
|
+
// …the slowest property's end event lands → the surface settles open
|
|
231
|
+
// immediately, ahead of the deadline timer.
|
|
232
|
+
panel.dispatchEvent(endEvent(0.3));
|
|
233
|
+
expect(rig.phase()).toBe("open");
|
|
234
|
+
await nextTick();
|
|
235
|
+
expect(scrimClasses(rig)).toEqual([]);
|
|
236
|
+
// No late deadline fires afterwards.
|
|
237
|
+
const edgesAtRest = rig.edges.length;
|
|
238
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
239
|
+
expect(rig.phase()).toBe("open");
|
|
240
|
+
expect(rig.edges.length).toBe(edgesAtRest);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("UNMOUNT from any phase clears every clock and walks to closed", async () => {
|
|
244
|
+
vi.useFakeTimers();
|
|
245
|
+
const rig = mountRig();
|
|
246
|
+
rig.send("OPEN");
|
|
247
|
+
await vi.advanceTimersByTimeAsync(50); // mid opening
|
|
248
|
+
expect(rig.phase()).not.toBe("closed");
|
|
249
|
+
rig.unmount();
|
|
250
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
251
|
+
// Nothing fires after teardown (no late timers).
|
|
252
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
253
|
+
expect(rig.edges.at(-1)).toEqual([
|
|
254
|
+
expect.any(String),
|
|
255
|
+
"closed",
|
|
256
|
+
"UNMOUNT",
|
|
257
|
+
]);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { computed, nextTick, onBeforeUnmount, shallowRef, type ComputedRef } from "vue";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
classPairOf,
|
|
5
|
+
classesForPair,
|
|
6
|
+
isMounted,
|
|
7
|
+
surfaceTransition,
|
|
8
|
+
type SurfaceEventType,
|
|
9
|
+
type SurfacePhase,
|
|
10
|
+
} from "../runtime/surfaceMachine";
|
|
11
|
+
|
|
12
|
+
export interface SurfaceMachineLayer {
|
|
13
|
+
/** Vue-style transition-name prefix (e.g. "hk-modal-overlay"). */
|
|
14
|
+
prefix: string;
|
|
15
|
+
/** Live element accessor — lets the driver read the layer's EFFECTIVE
|
|
16
|
+
* CSS duration (themes, reduced-motion, `data-css-animations="0"`). */
|
|
17
|
+
el?: () => HTMLElement | null | undefined;
|
|
18
|
+
/** Fallback enter budget, ms — the A2 bound when the element (or its
|
|
19
|
+
* computed style) cannot be read. */
|
|
20
|
+
enterMs: () => number;
|
|
21
|
+
/** Fallback leave budget, ms. */
|
|
22
|
+
leaveMs: () => number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SurfaceMachineOptions {
|
|
26
|
+
layers: SurfaceMachineLayer[];
|
|
27
|
+
/**
|
|
28
|
+
* How long a `.from` phase waits for a frame flip before the timer
|
|
29
|
+
* forces it (ms). Under A2 (rAF starved) this bounds the from-pair's
|
|
30
|
+
* lifetime; the forced flip snaps (nothing is painting anyway).
|
|
31
|
+
*/
|
|
32
|
+
flipBudgetMs?: number;
|
|
33
|
+
/** Extra ms on top of the CSS duration before a deadline fires. */
|
|
34
|
+
deadlineSlackMs?: number;
|
|
35
|
+
/** Phase-edge side effects (registration, focus, morph, reports). */
|
|
36
|
+
onPhase?: (from: SurfacePhase, to: SurfacePhase, event: SurfaceEventType) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SurfaceMachine {
|
|
40
|
+
/** Current phase (reactive — render-time reads track it). */
|
|
41
|
+
phase: ComputedRef<SurfacePhase>;
|
|
42
|
+
/** Whether the surface DOM should render (v-if anchor). */
|
|
43
|
+
mounted: ComputedRef<boolean>;
|
|
44
|
+
/** Reactive transition classes for one layer's prefix. */
|
|
45
|
+
classesFor: (prefix: string) => string[];
|
|
46
|
+
/** Dispatch an event through the total transition table. */
|
|
47
|
+
send: (event: SurfaceEventType) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* useSurfaceMachine — the Vue driver for the pure surface lifecycle
|
|
52
|
+
* machine (runtime/surfaceMachine.ts).
|
|
53
|
+
*
|
|
54
|
+
* Ownership rules that make the machine's proofs carry over:
|
|
55
|
+
* - Classes are bound REACTIVELY from the phase (render reads
|
|
56
|
+
* `classesFor`), so the initial mount paints with its from-pair — no
|
|
57
|
+
* write-after-mount race, no Vue `<Transition>` engine to diverge.
|
|
58
|
+
* - The ONLY correctness clock is setTimeout: every animation phase arms
|
|
59
|
+
* exactly one deadline timer; the frame flip is double-rAF (smooth)
|
|
60
|
+
* with a flip timer as the starved fallback. Duplicate FLIPs are
|
|
61
|
+
* absorbed by the table (idempotent self-loops).
|
|
62
|
+
* - Phase changes clear every pending timer/rAF before arming the next
|
|
63
|
+
* phase's — at most one flip + one deadline exist at any moment, so
|
|
64
|
+
* timers cannot leak or fire stale (the machine has no "late
|
|
65
|
+
* completion" callbacks at all).
|
|
66
|
+
*/
|
|
67
|
+
export function useSurfaceMachine(options: SurfaceMachineOptions): SurfaceMachine {
|
|
68
|
+
const flipBudget = options.flipBudgetMs ?? 120;
|
|
69
|
+
const slack = options.deadlineSlackMs ?? 80;
|
|
70
|
+
|
|
71
|
+
const phase = shallowRef<SurfacePhase>("closed");
|
|
72
|
+
const mounted = computed(() => isMounted(phase.value));
|
|
73
|
+
|
|
74
|
+
let flipTimer: ReturnType<typeof setTimeout> | null = null;
|
|
75
|
+
let deadlineTimer: ReturnType<typeof setTimeout> | null = null;
|
|
76
|
+
let raf1 = 0;
|
|
77
|
+
let raf2 = 0;
|
|
78
|
+
|
|
79
|
+
/** transitionend listeners for the TEND early completion (see
|
|
80
|
+
* armTend) — cleared with every clock reset so a phase change can
|
|
81
|
+
* never inherit the previous phase's listeners. */
|
|
82
|
+
let tendCleanups: Array<() => void> = [];
|
|
83
|
+
|
|
84
|
+
function clearClock(): void {
|
|
85
|
+
if (flipTimer !== null) {
|
|
86
|
+
clearTimeout(flipTimer);
|
|
87
|
+
flipTimer = null;
|
|
88
|
+
}
|
|
89
|
+
if (deadlineTimer !== null) {
|
|
90
|
+
clearTimeout(deadlineTimer);
|
|
91
|
+
deadlineTimer = null;
|
|
92
|
+
}
|
|
93
|
+
for (const off of tendCleanups) off();
|
|
94
|
+
tendCleanups = [];
|
|
95
|
+
if (raf1) cancelAnimationFrame(raf1);
|
|
96
|
+
if (raf2) cancelAnimationFrame(raf2);
|
|
97
|
+
raf1 = 0;
|
|
98
|
+
raf2 = 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** TEND wiring — the advisory early-completion event. Real browsers
|
|
102
|
+
* fire transitionend when the slowest property lands; the deadline
|
|
103
|
+
* timer stays armed as the bound (starved or cancelled transitions
|
|
104
|
+
* never fire it — the machine stays correct either way, TEND only
|
|
105
|
+
* shortens the settle latency). Filter rules:
|
|
106
|
+
* - e.target must be the layer element itself (child bubbles — the
|
|
107
|
+
* content's own transitions — must not complete the surface);
|
|
108
|
+
* - elapsedTime must match the measured budget (the SLOWEST
|
|
109
|
+
* property's end event; faster properties of the same element are
|
|
110
|
+
* ignored). The table absorbs a TEND in any phase, so a late or
|
|
111
|
+
* duplicated event is a no-op. */
|
|
112
|
+
function armTend(expectedMs: number): void {
|
|
113
|
+
// Several layers may share one element (drawer sides, popover
|
|
114
|
+
// branches) — one listener per ELEMENT, not per layer.
|
|
115
|
+
const seen = new Set<HTMLElement>();
|
|
116
|
+
for (const layer of options.layers) {
|
|
117
|
+
const el = layer.el?.();
|
|
118
|
+
if (!el || !el.isConnected || seen.has(el)) continue;
|
|
119
|
+
seen.add(el);
|
|
120
|
+
const onEnd = (e: TransitionEvent) => {
|
|
121
|
+
if (e.target !== el) return;
|
|
122
|
+
// An event without a numeric elapsedTime cannot be validated
|
|
123
|
+
// against the budget — ignore it (the deadline stays the bound;
|
|
124
|
+
// TEND is strictly an optimization).
|
|
125
|
+
if (typeof e.elapsedTime !== "number") return;
|
|
126
|
+
const elapsedMs = e.elapsedTime * 1000;
|
|
127
|
+
// Two-sided epsilon: only the SLOWEST property's end event — the
|
|
128
|
+
// one landing at the measured budget — completes the surface.
|
|
129
|
+
// Faster properties are below the window; an unrelated LONGER
|
|
130
|
+
// transition on the element (consumer classes ride the panel)
|
|
131
|
+
// is above it and must not fire the surface's completion.
|
|
132
|
+
if (elapsedMs + 60 < expectedMs || elapsedMs - 60 > expectedMs) return;
|
|
133
|
+
send("TEND");
|
|
134
|
+
};
|
|
135
|
+
el.addEventListener("transitionend", onEnd);
|
|
136
|
+
tendCleanups.push(() => el.removeEventListener("transitionend", onEnd));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Worst-case deadline for the phase being entered, from the CONFIGURED
|
|
141
|
+
* budgets (the A2 bound when the live CSS cannot be read). */
|
|
142
|
+
function budgetFor(next: SurfacePhase): number {
|
|
143
|
+
switch (next) {
|
|
144
|
+
case "openingFrom":
|
|
145
|
+
return flipBudget + Math.max(...options.layers.map((l) => l.enterMs())) + slack;
|
|
146
|
+
case "openingTo":
|
|
147
|
+
return Math.max(...options.layers.map((l) => l.enterMs())) + slack;
|
|
148
|
+
case "closingFrom":
|
|
149
|
+
return flipBudget + Math.max(...options.layers.map((l) => l.leaveMs())) + slack;
|
|
150
|
+
case "closingTo":
|
|
151
|
+
return Math.max(...options.layers.map((l) => l.leaveMs())) + slack;
|
|
152
|
+
default:
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Effective CSS transition duration of one layer, ms — the maximum
|
|
158
|
+
* across the comma-separated duration list ("0.3s, 0.25s"). Reduced
|
|
159
|
+
* motion and `html[data-css-animations="0"]` both collapse this to 0,
|
|
160
|
+
* which is exactly the fast-path signal.
|
|
161
|
+
* Caveat: `transition-delay` is deliberately ignored (no current
|
|
162
|
+
* surface staggers its layers) — a future adopter adding delays must
|
|
163
|
+
* fold them into the layer's enter/leave fallback budgets. */
|
|
164
|
+
function cssDurationMs(el: HTMLElement): number {
|
|
165
|
+
const raw = window.getComputedStyle(el).transitionDuration;
|
|
166
|
+
if (!raw) return 0;
|
|
167
|
+
let max = 0;
|
|
168
|
+
for (const part of raw.split(",")) {
|
|
169
|
+
const value = parseFloat(part);
|
|
170
|
+
if (!Number.isFinite(value)) continue;
|
|
171
|
+
max = Math.max(max, part.includes("ms") ? value : value * 1000);
|
|
172
|
+
}
|
|
173
|
+
return max;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Max effective CSS duration across layers, or null when no layer
|
|
177
|
+
* element is readable (surface mid-mount) — the caller falls back to
|
|
178
|
+
* the configured budget. */
|
|
179
|
+
function measuredBudget(): number | null {
|
|
180
|
+
let max: number | null = null;
|
|
181
|
+
for (const layer of options.layers) {
|
|
182
|
+
const el = layer.el?.();
|
|
183
|
+
if (!el || !el.isConnected) continue;
|
|
184
|
+
max = Math.max(max ?? 0, cssDurationMs(el));
|
|
185
|
+
}
|
|
186
|
+
return max;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function rearmDeadline(ms: number): void {
|
|
190
|
+
if (deadlineTimer !== null) clearTimeout(deadlineTimer);
|
|
191
|
+
deadlineTimer = setTimeout(() => {
|
|
192
|
+
deadlineTimer = null;
|
|
193
|
+
send("DEADLINE");
|
|
194
|
+
}, Math.max(0, ms));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Zero-duration completion: no CSS transitions are running, so the
|
|
198
|
+
* surface's logical open/close can settle on the MICROtask queue —
|
|
199
|
+
* ahead of every macrotask (a 0ms timer loses macrotask-order races
|
|
200
|
+
* against caller setTimeout(0)s, e.g. test harnesses; real browsers
|
|
201
|
+
* never take this path). The table absorbs a DEADLINE in any phase,
|
|
202
|
+
* so a microtask that lands after an unrelated phase change is a
|
|
203
|
+
* no-op. */
|
|
204
|
+
function completeNow(): void {
|
|
205
|
+
if (deadlineTimer !== null) {
|
|
206
|
+
clearTimeout(deadlineTimer);
|
|
207
|
+
deadlineTimer = null;
|
|
208
|
+
}
|
|
209
|
+
queueMicrotask(() => { send("DEADLINE"); });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function armClock(next: SurfacePhase): void {
|
|
213
|
+
clearClock();
|
|
214
|
+
if (next === "closed" || next === "open") return;
|
|
215
|
+
// Frame flip: double-rAF guarantees a style recalc between the
|
|
216
|
+
// from-pair and the to-pair (a same-recalc flip would coalesce into
|
|
217
|
+
// an instant jump); the flip timer forces the same event when rAF
|
|
218
|
+
// starves. Both may fire — the table absorbs the duplicate.
|
|
219
|
+
if (next === "openingFrom" || next === "closingFrom") {
|
|
220
|
+
raf1 = requestAnimationFrame(() => {
|
|
221
|
+
raf1 = 0;
|
|
222
|
+
raf2 = requestAnimationFrame(() => {
|
|
223
|
+
raf2 = 0;
|
|
224
|
+
send("FLIP");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
flipTimer = setTimeout(() => {
|
|
228
|
+
flipTimer = null;
|
|
229
|
+
send("FLIP");
|
|
230
|
+
}, flipBudget);
|
|
231
|
+
}
|
|
232
|
+
deadlineTimer = setTimeout(() => {
|
|
233
|
+
deadlineTimer = null;
|
|
234
|
+
send("DEADLINE");
|
|
235
|
+
}, budgetFor(next));
|
|
236
|
+
|
|
237
|
+
// Effective-duration probe: once the layer elements are live (one
|
|
238
|
+
// microtask after the mounting patch), replace the configured bound
|
|
239
|
+
// with what the CSS actually says. A zero duration (no transitions
|
|
240
|
+
// — happy-dom, reduced motion, the global animation switch) makes
|
|
241
|
+
// the deadline fire on the next macrotask, restoring the legacy
|
|
242
|
+
// instant-completion semantics; a long theme gets its real budget.
|
|
243
|
+
// The phase guard makes a late probe a no-op.
|
|
244
|
+
if (next === "openingFrom" || next === "closingFrom") {
|
|
245
|
+
const phaseAtArm = next;
|
|
246
|
+
void nextTick(() => {
|
|
247
|
+
if (phase.value !== phaseAtArm) return;
|
|
248
|
+
const measured = measuredBudget();
|
|
249
|
+
if (measured === null) return;
|
|
250
|
+
if (measured > 0) rearmDeadline(flipBudget + measured + slack);
|
|
251
|
+
else completeNow();
|
|
252
|
+
});
|
|
253
|
+
} else {
|
|
254
|
+
// *.to phases: the elements exist and styles settled at the flip.
|
|
255
|
+
const measured = measuredBudget();
|
|
256
|
+
if (measured !== null) {
|
|
257
|
+
if (measured > 0) {
|
|
258
|
+
rearmDeadline(measured + slack);
|
|
259
|
+
armTend(measured);
|
|
260
|
+
} else {
|
|
261
|
+
completeNow();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function send(event: SurfaceEventType): void {
|
|
268
|
+
const from = phase.value;
|
|
269
|
+
const to = surfaceTransition(from, event);
|
|
270
|
+
if (to === from) return; // table-idempotent absorb (no effects)
|
|
271
|
+
phase.value = to;
|
|
272
|
+
armClock(to);
|
|
273
|
+
options.onPhase?.(from, to, event);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
onBeforeUnmount(() => {
|
|
277
|
+
clearClock();
|
|
278
|
+
// UNMOUNT walks any phase to `closed` so onPhase observers see a
|
|
279
|
+
// consistent final state even mid-animation.
|
|
280
|
+
send("UNMOUNT");
|
|
281
|
+
clearClock();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
function classesFor(prefix: string): string[] {
|
|
285
|
+
return classesForPair(prefix, classPairOf(phase.value));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { phase: computed(() => phase.value), mounted, classesFor, send };
|
|
289
|
+
}
|