@celestia-island/hikari 0.45.8 → 0.45.9
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
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* HkCrossfade — same-position simultaneous dissolve host.
|
|
2
|
+
*
|
|
3
|
+
* Geometry: the host is a one-cell grid and every item occupies THAT cell
|
|
4
|
+
* (grid-area: 1/1). During a swap the leaving item keeps the cell, so old
|
|
5
|
+
* and new stack in the SAME box: no exit blank phase, no side-by-side
|
|
6
|
+
* reflow, and the host stays as tall as the taller of the two items until
|
|
7
|
+
* the leave completes — variable-height content never collapses mid-
|
|
8
|
+
* dissolve. This is the height-safe generalization of HkPickerPane's fixed
|
|
9
|
+
* stage + absolute leave (no known stage size needed, so whole pages can
|
|
10
|
+
* swap through it).
|
|
11
|
+
*
|
|
12
|
+
* Consumers size the host from the outside (width/height/flex are theirs);
|
|
13
|
+
* the component only guarantees the stacking geometry and the dissolve.
|
|
14
|
+
*/
|
|
15
|
+
.hk-crossfade {
|
|
16
|
+
display: grid;
|
|
17
|
+
|
|
18
|
+
> .hk-crossfade-item {
|
|
19
|
+
grid-area: 1 / 1;
|
|
20
|
+
width: 100%;
|
|
21
|
+
min-width: 0;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* Dissolve curves: BOTH sides run --duration-normal so the exchange is a
|
|
26
|
+
* true cross-fade (not a fade-out-then-in), and both ease OUT (fast
|
|
27
|
+
* start) — midway through the swap the incoming layer already carries most
|
|
28
|
+
* of the opacity while the outgoing one has already shed most of its, so
|
|
29
|
+
* the composite never dips toward the background (the classic
|
|
30
|
+
* double-exposure artifact of symmetric linear fades). The leaving item
|
|
31
|
+
* also drops pointer events: it is dead content and must not intercept
|
|
32
|
+
* clicks while it dissolves under the incoming one. */
|
|
33
|
+
.hk-crossfade-enter-active {
|
|
34
|
+
transition:
|
|
35
|
+
opacity
|
|
36
|
+
var(--duration-normal, 0.3s)
|
|
37
|
+
var(--ease-out-expo, cubic-bezier(0.19, 1, 0.22, 1));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.hk-crossfade-leave-active {
|
|
41
|
+
transition: opacity var(--duration-normal, 0.3s) ease-out;
|
|
42
|
+
pointer-events: none;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.hk-crossfade-enter-from,
|
|
46
|
+
.hk-crossfade-leave-to {
|
|
47
|
+
opacity: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Reduced motion: keep the opacity exchange (a fade carries no movement —
|
|
51
|
+
* HkPhaseTransition precedent), just shorter. Hosts driving the global
|
|
52
|
+
* animation switch (`html[data-css-animations="0"]`) already pin every
|
|
53
|
+
* transition via theme.scss's global rule, so no extra variant is needed
|
|
54
|
+
* here (that variant exists for ANIMATIONS, which the global rule pauses
|
|
55
|
+
* per-play-state; transitions die through its transition-duration clamp). */
|
|
56
|
+
@media (prefers-reduced-motion: reduce) {
|
|
57
|
+
.hk-crossfade-enter-active,
|
|
58
|
+
.hk-crossfade-leave-active {
|
|
59
|
+
transition: opacity var(--duration-fast, 0.15s) ease;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import * as sass from "sass";
|
|
7
|
+
|
|
8
|
+
import HkCrossfade, { CROSSFADE_WATCHDOG_MS } from "./HkCrossfade";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* HkCrossfade contract tests. House style: no @vue/test-utils dependency —
|
|
12
|
+
* raw createApp mounts on shared containers torn down after each case.
|
|
13
|
+
*
|
|
14
|
+
* Timing model (mirrors ModeMorphTabs.test): happy-dom resolves no CSS
|
|
15
|
+
* transition durations, so with a working (timer-driven) rAF the swap
|
|
16
|
+
* completes inside fake-timer advances. Freezing rAF via stubGlobal holds
|
|
17
|
+
* both transition generations in the DOM — which is exactly the state the
|
|
18
|
+
* simultaneous dissolve and the watchdog are about.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const mounts: App[] = [];
|
|
22
|
+
const containers: HTMLElement[] = [];
|
|
23
|
+
|
|
24
|
+
interface Harness {
|
|
25
|
+
container: HTMLElement;
|
|
26
|
+
set: (key: string) => Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mountCrossfade(opts: {
|
|
30
|
+
initial?: string;
|
|
31
|
+
appear?: boolean;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
} = {}): Harness {
|
|
34
|
+
const container = document.createElement("div");
|
|
35
|
+
document.body.appendChild(container);
|
|
36
|
+
containers.push(container);
|
|
37
|
+
|
|
38
|
+
const current = ref(opts.initial ?? "a");
|
|
39
|
+
const Wrapper = defineComponent({
|
|
40
|
+
setup() {
|
|
41
|
+
return () =>
|
|
42
|
+
h(
|
|
43
|
+
HkCrossfade,
|
|
44
|
+
{
|
|
45
|
+
swapKey: current.value,
|
|
46
|
+
appear: opts.appear ?? false,
|
|
47
|
+
disabled: opts.disabled ?? false,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
default: () => h("p", { class: "body" }, `body-${current.value}`),
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const app = createApp(Wrapper);
|
|
56
|
+
mounts.push(app);
|
|
57
|
+
app.mount(container);
|
|
58
|
+
return {
|
|
59
|
+
container,
|
|
60
|
+
set: async (key: string) => {
|
|
61
|
+
current.value = key;
|
|
62
|
+
await nextTick();
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function items(host: HTMLElement): HTMLElement[] {
|
|
68
|
+
// The mount container holds the .hk-crossfade host; items are the HOST's
|
|
69
|
+
// direct children (Transition renders no DOM of its own).
|
|
70
|
+
return Array.from(
|
|
71
|
+
host.querySelectorAll<HTMLElement>(":scope > .hk-crossfade > .hk-crossfade-item"),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function keys(host: HTMLElement): Array<string | undefined> {
|
|
76
|
+
return items(host).map((el) => el.dataset.crossfadeKey);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function assertNoFrozenClasses(el: HTMLElement): void {
|
|
80
|
+
for (const cls of Array.from(el.classList)) {
|
|
81
|
+
expect(cls.startsWith("hk-crossfade-enter") || cls.startsWith("hk-crossfade-leave")).toBe(false);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function freezeRaf(): void {
|
|
86
|
+
vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
|
|
87
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
for (const app of mounts) app.unmount();
|
|
92
|
+
mounts.length = 0;
|
|
93
|
+
document.body.innerHTML = "";
|
|
94
|
+
containers.length = 0;
|
|
95
|
+
vi.unstubAllGlobals();
|
|
96
|
+
vi.useRealTimers();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("HkCrossfade — DOM contract", () => {
|
|
100
|
+
it("renders the active item silently on first paint (appear off)", () => {
|
|
101
|
+
vi.useFakeTimers();
|
|
102
|
+
const { container } = mountCrossfade({ initial: "a" });
|
|
103
|
+
const list = items(container);
|
|
104
|
+
expect(list).toHaveLength(1);
|
|
105
|
+
expect(list[0]!.dataset.crossfadeKey).toBe("a");
|
|
106
|
+
expect(list[0]!.textContent).toContain("body-a");
|
|
107
|
+
assertNoFrozenClasses(list[0]!);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("healthy swap completes through the Transition and settles on the new item", async () => {
|
|
111
|
+
vi.useFakeTimers();
|
|
112
|
+
const { container, set } = mountCrossfade();
|
|
113
|
+
await set("b");
|
|
114
|
+
// Past the watchdog budget: the swap completed through the Transition
|
|
115
|
+
// long before, and the watchdog fired into a settled DOM as a no-op.
|
|
116
|
+
await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS + 100);
|
|
117
|
+
const list = items(container);
|
|
118
|
+
expect(list).toHaveLength(1);
|
|
119
|
+
expect(list[0]!.dataset.crossfadeKey).toBe("b");
|
|
120
|
+
expect(list[0]!.textContent).toContain("body-b");
|
|
121
|
+
assertNoFrozenClasses(list[0]!);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("dissolves SIMULTANEOUSLY: old and new share the same host mid-swap (no out-in gap)", async () => {
|
|
125
|
+
vi.useFakeTimers();
|
|
126
|
+
freezeRaf();
|
|
127
|
+
const { container, set } = mountCrossfade();
|
|
128
|
+
await set("b");
|
|
129
|
+
await vi.advanceTimersByTimeAsync(200); // inside the watchdog budget
|
|
130
|
+
const list = items(container);
|
|
131
|
+
// Both generations mounted at once — the default-mode contract. With
|
|
132
|
+
// `mode="out-in"` the old item would already be gone here (blank phase);
|
|
133
|
+
// with a bare default Transition they would sit side by side instead of
|
|
134
|
+
// stacked in one host cell.
|
|
135
|
+
expect(keys(container)).toEqual(["a", "b"]);
|
|
136
|
+
expect(list[0]!.classList.contains("hk-crossfade-leave-active")).toBe(true);
|
|
137
|
+
expect(list[1]!.classList.contains("hk-crossfade-enter-from")).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("watchdog completes a stalled swap: sweeps frozen classes, drops the stale item", async () => {
|
|
141
|
+
vi.useFakeTimers();
|
|
142
|
+
freezeRaf();
|
|
143
|
+
const { container, set } = mountCrossfade();
|
|
144
|
+
await set("b");
|
|
145
|
+
await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS - 100); // still wedged
|
|
146
|
+
expect(keys(container)).toEqual(["a", "b"]);
|
|
147
|
+
await vi.advanceTimersByTimeAsync(200); // past the budget
|
|
148
|
+
const list = items(container);
|
|
149
|
+
expect(list).toHaveLength(1);
|
|
150
|
+
expect(list[0]!.dataset.crossfadeKey).toBe("b");
|
|
151
|
+
assertNoFrozenClasses(list[0]!);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("re-arms a fresh Transition on the next swap after a wedged one (dissolves resume)", async () => {
|
|
155
|
+
vi.useFakeTimers();
|
|
156
|
+
freezeRaf();
|
|
157
|
+
const { container, set } = mountCrossfade();
|
|
158
|
+
await set("b");
|
|
159
|
+
await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS + 100); // recovered once
|
|
160
|
+
expect(keys(container)).toEqual(["b"]);
|
|
161
|
+
// The next swap restores the Transition. The swept DOM left no previous
|
|
162
|
+
// child inside the fresh instance, so that one swap lands as a
|
|
163
|
+
// synchronous hard cut (the one-time cost of a stall) — settled and
|
|
164
|
+
// correct, no wedge loop.
|
|
165
|
+
await set("c");
|
|
166
|
+
const list = items(container);
|
|
167
|
+
expect(list).toHaveLength(1);
|
|
168
|
+
expect(list[0]!.dataset.crossfadeKey).toBe("c");
|
|
169
|
+
assertNoFrozenClasses(list[0]!);
|
|
170
|
+
// From there on, swaps run the dissolve again: with a previous child
|
|
171
|
+
// in the fresh Transition, the still-frozen rAF wedges this one, and
|
|
172
|
+
// the budget recovers it — every swap lands correct, at most one
|
|
173
|
+
// budget late.
|
|
174
|
+
await set("d");
|
|
175
|
+
await vi.advanceTimersByTimeAsync(200); // mid-swap: both generations
|
|
176
|
+
expect(keys(container)).toEqual(["c", "d"]);
|
|
177
|
+
await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS); // recovered
|
|
178
|
+
const settled = items(container);
|
|
179
|
+
expect(settled).toHaveLength(1);
|
|
180
|
+
expect(settled[0]!.dataset.crossfadeKey).toBe("d");
|
|
181
|
+
assertNoFrozenClasses(settled[0]!);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("appear dissolves the first item in; the watchdog un-freezes it under rAF starvation", async () => {
|
|
185
|
+
vi.useFakeTimers();
|
|
186
|
+
freezeRaf();
|
|
187
|
+
const { container } = mountCrossfade({ initial: "a", appear: true });
|
|
188
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
189
|
+
expect(items(container)[0]!.classList.contains("hk-crossfade-enter-from")).toBe(true);
|
|
190
|
+
await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS + 100);
|
|
191
|
+
const list = items(container);
|
|
192
|
+
expect(list).toHaveLength(1);
|
|
193
|
+
expect(list[0]!.dataset.crossfadeKey).toBe("a");
|
|
194
|
+
assertNoFrozenClasses(list[0]!);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("disabled swaps synchronously with zero transition classes", async () => {
|
|
198
|
+
vi.useFakeTimers();
|
|
199
|
+
const { container, set } = mountCrossfade({ disabled: true });
|
|
200
|
+
await set("b");
|
|
201
|
+
const list = items(container);
|
|
202
|
+
expect(list).toHaveLength(1);
|
|
203
|
+
expect(list[0]!.dataset.crossfadeKey).toBe("b");
|
|
204
|
+
assertNoFrozenClasses(list[0]!);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("re-rendering with the same swapKey swaps nothing", async () => {
|
|
208
|
+
vi.useFakeTimers();
|
|
209
|
+
const { container, set } = mountCrossfade({ initial: "a" });
|
|
210
|
+
await set("a");
|
|
211
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
212
|
+
const list = items(container);
|
|
213
|
+
expect(list).toHaveLength(1);
|
|
214
|
+
assertNoFrozenClasses(list[0]!);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("HkCrossfade — stylesheet contract", () => {
|
|
219
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
220
|
+
|
|
221
|
+
it("stacks both generations in one grid cell — the same-position guarantee", () => {
|
|
222
|
+
const css = sass.compile(join(here, "HkCrossfade.scss")).css;
|
|
223
|
+
expect(css).toMatch(/\.hk-crossfade\s*\{[^}]*display:\s*grid/);
|
|
224
|
+
const item = css.match(/\.hk-crossfade-item[^{]*\{[^}]*\}/)?.[0] ?? "";
|
|
225
|
+
expect(item).toContain("grid-area: 1/1");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("dissolves opacity only — no transform anywhere, dead layer drops pointer events", () => {
|
|
229
|
+
const css = sass.compile(join(here, "HkCrossfade.scss")).css;
|
|
230
|
+
expect(css).not.toContain("transform");
|
|
231
|
+
const leave = css.match(/\.hk-crossfade-leave-active[^{]*\{[^}]*\}/)?.[0] ?? "";
|
|
232
|
+
expect(leave).toContain("transition: opacity");
|
|
233
|
+
expect(leave).toContain("pointer-events: none");
|
|
234
|
+
expect(leave).toContain("var(--duration-normal, 0.3s)");
|
|
235
|
+
const enter = css.match(/\.hk-crossfade-enter-active[^{]*\{[^}]*\}/)?.[0] ?? "";
|
|
236
|
+
expect(enter).toContain("transition: opacity");
|
|
237
|
+
expect(enter).toContain("var(--duration-normal, 0.3s)");
|
|
238
|
+
for (const phase of ["enter-from", "leave-to"]) {
|
|
239
|
+
const block = css.match(new RegExp(`\\.hk-crossfade-${phase}[^{]*\\{[^}]*\\}`))?.[0] ?? "";
|
|
240
|
+
expect(block, `hk-crossfade-${phase} rule missing`).toContain("opacity: 0");
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("reduced motion keeps the opacity exchange, just shorter (fade survives, motion dies)", () => {
|
|
245
|
+
const css = sass.compile(join(here, "HkCrossfade.scss")).css;
|
|
246
|
+
const media = css.match(/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*\}\s*$/)?.[0] ?? "";
|
|
247
|
+
expect(media).toContain(".hk-crossfade-enter-active");
|
|
248
|
+
expect(media).toContain(".hk-crossfade-leave-active");
|
|
249
|
+
expect(media).toContain("var(--duration-fast, 0.15s)");
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("HkCrossfade — wiring contract", () => {
|
|
254
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
255
|
+
const src = readFileSync(join(here, "HkCrossfade.tsx"), "utf-8");
|
|
256
|
+
|
|
257
|
+
it("runs the Transition in default (simultaneous) mode and never overrides its duration", () => {
|
|
258
|
+
expect(src).toContain('<Transition\n name="hk-crossfade"');
|
|
259
|
+
expect(src).not.toMatch(/<Transition[^>]*\bmode=/);
|
|
260
|
+
expect(src).not.toContain("duration={{");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("pins the watchdog wiring and the animation-bus report", () => {
|
|
264
|
+
expect(src).toContain("export const CROSSFADE_WATCHDOG_MS = 450;");
|
|
265
|
+
expect(src).toContain("suppress.value = true;");
|
|
266
|
+
expect(src).toContain("el.remove();");
|
|
267
|
+
expect(src).toContain("useReportedTransition(REPORT_MS)");
|
|
268
|
+
expect(src).toContain("armReport(); armWatchdog();");
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { defineComponent, onBeforeUnmount, ref, Transition, watch, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import { useReportedTransition } from "../composables/useReportedTransition";
|
|
4
|
+
|
|
5
|
+
import "./HkCrossfade.scss";
|
|
6
|
+
|
|
7
|
+
/** Swap-stall watchdog budget. Must sit above ANY healthy completion of the
|
|
8
|
+
* dissolve (--duration-normal 0.3s; 0s under reduced motion or the global
|
|
9
|
+
* animation switch) so the sweep only ever bites a genuinely stalled swap.
|
|
10
|
+
* Exported for tests and budget-pinning. */
|
|
11
|
+
export const CROSSFADE_WATCHDOG_MS = 450;
|
|
12
|
+
|
|
13
|
+
/** Animation-bus report window — covers the watchdog budget so JS
|
|
14
|
+
* choreography reacting to a swap outlives the CSS work under every
|
|
15
|
+
* timing, including a stalled one. */
|
|
16
|
+
const REPORT_MS = CROSSFADE_WATCHDOG_MS;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* HkCrossfade — same-position simultaneous dissolve for view/mode swaps.
|
|
20
|
+
*
|
|
21
|
+
* The library-level primitive for "the old content fades out WHILE the new
|
|
22
|
+
* content fades in, in place": a default-mode (NO `mode` prop) `<Transition>`
|
|
23
|
+
* whose enter and leave items share ONE grid cell, so the two generations
|
|
24
|
+
* stack at the same geometry for the duration of the dissolve — no exit
|
|
25
|
+
* blank phase (the `out-in` gap of HkPhaseTransition/HkStepFlow), no
|
|
26
|
+
* side-by-side reflow (a bare default-mode Transition), and no height
|
|
27
|
+
* collapse while both are mounted (the host stays as tall as the taller
|
|
28
|
+
* item until the leave completes). This is the height-safe generalization
|
|
29
|
+
* of HkPickerPane's fixed stage + absolute leave: it needs no known stage
|
|
30
|
+
* size, so whole pages (admin views, auth cards) can swap through it.
|
|
31
|
+
*
|
|
32
|
+
* Contract:
|
|
33
|
+
* - `swapKey` is the identity of the content on stage. The component owns
|
|
34
|
+
* the keyed item wrapper (like HkPhaseTransition owns its key) so the
|
|
35
|
+
* watchdog can tell stale items from the active one; rendering with the
|
|
36
|
+
* same value swaps nothing.
|
|
37
|
+
* - Pure opacity — no transform, ever. Same-position means same-position.
|
|
38
|
+
* Both sides run `--duration-normal` so the exchange is a true dissolve;
|
|
39
|
+
* both ease out (fast start), which keeps the incoming (top) layer's
|
|
40
|
+
* opacity ahead of the outgoing one's decay and stops the composite from
|
|
41
|
+
* dipping toward the background mid-swap.
|
|
42
|
+
* - The leaving item drops pointer events: it is dead content and must not
|
|
43
|
+
* intercept clicks while it dissolves under the incoming one.
|
|
44
|
+
* - `appear` dissolves the initially rendered item in; off by default —
|
|
45
|
+
* first paint should never animate (view shells, restored state).
|
|
46
|
+
* - `disabled` renders the active item bare (synchronous swap, no watchdog):
|
|
47
|
+
* the test escape hatch and the watchdog's degraded render path.
|
|
48
|
+
* - Reduced motion keeps the opacity exchange at `--duration-fast` (a fade
|
|
49
|
+
* carries no movement — HkPhaseTransition precedent); hosts driving the
|
|
50
|
+
* global animation switch (`html[data-css-animations="0"]`) already pin
|
|
51
|
+
* these transitions via theme.scss.
|
|
52
|
+
* - Every swap is REPORTED to the runtime animation bus
|
|
53
|
+
* (`useReportedTransition`, refcounted across the concurrent enter+leave)
|
|
54
|
+
* so JS choreography reacting to the swap is not cut short by the CSS
|
|
55
|
+
* work ending early.
|
|
56
|
+
* - The swap-stall watchdog bounds the rAF-starvation wedge (occluded or
|
|
57
|
+
* backgrounded webview): Vue's Transition engine is rAF-gated, so a
|
|
58
|
+
* starved swap freezes the leaving item on top of the new one (field
|
|
59
|
+
* report 2026-09 in chest's ModeMorphTabs — the design this watchdog
|
|
60
|
+
* inherits, alongside the surfaceMachine AXIOM A2 deadline rule). The
|
|
61
|
+
* 450ms budget sits above ANY healthy completion, so on healthy surfaces
|
|
62
|
+
* the sweep fires into an already-settled DOM and is a no-op. After a
|
|
63
|
+
* real stall it strips the frozen transition classes, removes the stale
|
|
64
|
+
* item Vue can no longer unmount, and degrades to synchronous rendering
|
|
65
|
+
* until the next `swapKey` change re-arms the Transition fresh.
|
|
66
|
+
*/
|
|
67
|
+
export default defineComponent({
|
|
68
|
+
name: "HkCrossfade",
|
|
69
|
+
props: {
|
|
70
|
+
swapKey: { type: [String, Number] as PropType<string | number>, required: true },
|
|
71
|
+
tag: { type: String, default: "div" },
|
|
72
|
+
appear: { type: Boolean, default: false },
|
|
73
|
+
disabled: { type: Boolean, default: false },
|
|
74
|
+
},
|
|
75
|
+
setup(props, { slots }) {
|
|
76
|
+
const hostRef = ref<HTMLElement | null>(null);
|
|
77
|
+
// Set after a watchdog sweep proved the Transition engine wedged; the
|
|
78
|
+
// render drops the Transition wrapper so keys apply synchronously until
|
|
79
|
+
// the next swap re-arms it (a one-tick toggle re-enters the wedged
|
|
80
|
+
// internal state otherwise — ModeMorphTabs lesson).
|
|
81
|
+
const suppress = ref(false);
|
|
82
|
+
|
|
83
|
+
// ── Animation-bus participation ──────────────────────────────────
|
|
84
|
+
// One swap runs an enter AND a leave concurrently; refcount them so
|
|
85
|
+
// the shared track stays armed until BOTH settle (HkListTransition
|
|
86
|
+
// pattern).
|
|
87
|
+
const report = useReportedTransition(REPORT_MS);
|
|
88
|
+
let pending = 0;
|
|
89
|
+
const armReport = () => {
|
|
90
|
+
pending++;
|
|
91
|
+
report.run();
|
|
92
|
+
};
|
|
93
|
+
const settleReport = () => {
|
|
94
|
+
pending = Math.max(0, pending - 1);
|
|
95
|
+
if (pending === 0) report.cancel();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// ── Swap-stall watchdog ──────────────────────────────────────────
|
|
99
|
+
let watchdog: ReturnType<typeof setTimeout> | null = null;
|
|
100
|
+
|
|
101
|
+
function armWatchdog(): void {
|
|
102
|
+
// Re-arm per swap event (enter or leave start); a healthy swap lets
|
|
103
|
+
// the timer fire into a settled DOM, where the sweep is a no-op.
|
|
104
|
+
if (watchdog !== null) clearTimeout(watchdog);
|
|
105
|
+
watchdog = setTimeout(onWatchdog, CROSSFADE_WATCHDOG_MS);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sweepFrozenClasses(el: Element): boolean {
|
|
109
|
+
const frozenLeave = el.classList.contains("hk-crossfade-leave-active")
|
|
110
|
+
|| el.classList.contains("hk-crossfade-leave-from");
|
|
111
|
+
el.classList.remove(
|
|
112
|
+
"hk-crossfade-enter-active",
|
|
113
|
+
"hk-crossfade-enter-from",
|
|
114
|
+
"hk-crossfade-enter-to",
|
|
115
|
+
"hk-crossfade-leave-active",
|
|
116
|
+
"hk-crossfade-leave-from",
|
|
117
|
+
"hk-crossfade-leave-to",
|
|
118
|
+
);
|
|
119
|
+
return frozenLeave;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function onWatchdog(): void {
|
|
123
|
+
watchdog = null;
|
|
124
|
+
const host = hostRef.value;
|
|
125
|
+
if (!host) return;
|
|
126
|
+
let wedged = false;
|
|
127
|
+
for (const el of Array.from(host.querySelectorAll<HTMLElement>(":scope > .hk-crossfade-item"))) {
|
|
128
|
+
const frozenLeave = sweepFrozenClasses(el);
|
|
129
|
+
// A stale item still holding a leave class is one Vue's rAF-gated
|
|
130
|
+
// engine can never unmount — remove it by hand and mark the engine
|
|
131
|
+
// wedged so this and future swaps render synchronously.
|
|
132
|
+
if (frozenLeave && el.dataset.crossfadeKey !== String(props.swapKey)) {
|
|
133
|
+
el.remove();
|
|
134
|
+
wedged = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (wedged) suppress.value = true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
watch(() => props.swapKey, () => {
|
|
141
|
+
// Fresh swap: restore the Transition (the wedged one was discarded
|
|
142
|
+
// with the suppressed render). The watchdog re-arms from the
|
|
143
|
+
// beforeEnter/beforeLeave hooks of the new Transition instance.
|
|
144
|
+
suppress.value = false;
|
|
145
|
+
});
|
|
146
|
+
onBeforeUnmount(() => {
|
|
147
|
+
if (watchdog !== null) {
|
|
148
|
+
clearTimeout(watchdog);
|
|
149
|
+
watchdog = null;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return () => {
|
|
154
|
+
const Tag = props.tag as "div" | "section" | "main" | "aside";
|
|
155
|
+
const item = () => (
|
|
156
|
+
<div
|
|
157
|
+
key={props.swapKey}
|
|
158
|
+
class="hk-crossfade-item"
|
|
159
|
+
data-crossfade-key={String(props.swapKey)}
|
|
160
|
+
>
|
|
161
|
+
{slots.default?.()}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (props.disabled || suppress.value) {
|
|
166
|
+
// Watchdog recovery / explicit opt-out: no Transition wrapper, so
|
|
167
|
+
// the key swap applies synchronously even with rAF frozen.
|
|
168
|
+
return (
|
|
169
|
+
<Tag ref={hostRef} class="hk-crossfade">
|
|
170
|
+
{item()}
|
|
171
|
+
</Tag>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<Tag ref={hostRef} class="hk-crossfade">
|
|
177
|
+
{/* Deliberately NO `mode`: enter and leave run CONCURRENTLY in
|
|
178
|
+
the same grid cell — that is the whole point of this component.
|
|
179
|
+
A `mode="out-in"` here would reintroduce the exit blank phase;
|
|
180
|
+
`mode="in-out"` would double-stack full-opacity content. */}
|
|
181
|
+
<Transition
|
|
182
|
+
name="hk-crossfade"
|
|
183
|
+
appear={props.appear}
|
|
184
|
+
onBeforeEnter={() => { armReport(); armWatchdog(); }}
|
|
185
|
+
onAfterEnter={settleReport}
|
|
186
|
+
onEnterCancelled={settleReport}
|
|
187
|
+
onBeforeLeave={() => { armReport(); armWatchdog(); }}
|
|
188
|
+
onAfterLeave={settleReport}
|
|
189
|
+
onLeaveCancelled={settleReport}
|
|
190
|
+
>
|
|
191
|
+
{item()}
|
|
192
|
+
</Transition>
|
|
193
|
+
</Tag>
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export { default as HColorPicker } from "./components/HkColorPicker";
|
|
|
16
16
|
export { HColorSchemeDialog, type HCustomTheme } from "./components/HkColorSchemeDialog";
|
|
17
17
|
export { HkColorSchemeEditor } from "./components/HkColorSchemeEditor";
|
|
18
18
|
export { default as HConfirmDialog } from "./components/HkConfirmDialog";
|
|
19
|
+
export { default as HCrossfade } from "./components/HkCrossfade";
|
|
19
20
|
export { default as HDivider } from "./components/HkDivider";
|
|
20
21
|
export { default as HDrawer } from "./components/HkDrawer";
|
|
21
22
|
export { default as HEmptyState } from "./components/HkEmptyState";
|