@celestia-island/hikari 0.40.18 → 0.40.19
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.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
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { createApp, h, nextTick, ref, type Ref } from "vue";
|
|
2
|
+
import { createApp, h, nextTick, onMounted, ref, type Ref } from "vue";
|
|
3
3
|
|
|
4
4
|
import { registerTokenGroup, useTheme, type TokenGroupDefinition } from "../theme";
|
|
5
5
|
import { HkColorSchemeEditor, type HCustomTheme } from "./HkColorSchemeEditor";
|
|
@@ -267,4 +267,27 @@ describe("HkColorSchemeEditor", () => {
|
|
|
267
267
|
expect(ref.value!.getDraft().dark.onSolidText).toEqual({ r: 255, g: 200, b: 0 });
|
|
268
268
|
expect(ref.value!.getDraft().dark.onSolidIcon).toEqual({ r: 0, g: 200, b: 255 });
|
|
269
269
|
});
|
|
270
|
+
|
|
271
|
+
it("hides the built-in name input under showName=false and takes setThemeName", async () => {
|
|
272
|
+
const container = document.createElement("div");
|
|
273
|
+
document.body.appendChild(container);
|
|
274
|
+
let exposed: { getDraft(): { name: string }; setThemeName(n: string): void } | null = null;
|
|
275
|
+
const app = createApp({
|
|
276
|
+
setup() {
|
|
277
|
+
const ed = ref<{ getDraft(): { name: string }; setThemeName(n: string): void } | null>(null);
|
|
278
|
+
onMounted(() => { exposed = ed.value; });
|
|
279
|
+
return () => h(HkColorSchemeEditor, { ref: ed, showName: false, initialName: "" });
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
app.mount(container);
|
|
283
|
+
mounts.push({ app, container });
|
|
284
|
+
await settle();
|
|
285
|
+
|
|
286
|
+
const label = [...container.querySelectorAll("label")].find((l) =>
|
|
287
|
+
l.textContent?.includes("Theme name") || l.textContent?.includes("主题名称"),
|
|
288
|
+
);
|
|
289
|
+
expect(label).toBeUndefined();
|
|
290
|
+
exposed!.setThemeName("Host owned name");
|
|
291
|
+
expect(exposed!.getDraft().name).toBe("Host owned name");
|
|
292
|
+
});
|
|
270
293
|
});
|
|
@@ -147,6 +147,12 @@ export const HkColorSchemeEditor = defineComponent({
|
|
|
147
147
|
initialGroups: { type: Object as PropType<ThemeTokenGroupModes>, default: undefined },
|
|
148
148
|
/** Prefill the scheme name input (edit/fork flows); empty by default. */
|
|
149
149
|
initialName: { type: String, default: "" },
|
|
150
|
+
/**
|
|
151
|
+
* Render the built-in name input. Hosts that own the name field
|
|
152
|
+
* elsewhere (e.g. a "basic" tab above the editor) hide it and feed the
|
|
153
|
+
* value through `setThemeName()` — getDraft() still carries it.
|
|
154
|
+
*/
|
|
155
|
+
showName: { type: Boolean, default: true },
|
|
150
156
|
},
|
|
151
157
|
setup(props, { expose }) {
|
|
152
158
|
const { t } = useI18n();
|
|
@@ -214,7 +220,11 @@ export const HkColorSchemeEditor = defineComponent({
|
|
|
214
220
|
|
|
215
221
|
onMounted(() => reset());
|
|
216
222
|
|
|
217
|
-
|
|
223
|
+
function setThemeName(name: string): void {
|
|
224
|
+
themeName.value = name;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
expose({ reset, getDraft, setThemeName });
|
|
218
228
|
|
|
219
229
|
watch(
|
|
220
230
|
() => [
|
|
@@ -357,12 +367,14 @@ export const HkColorSchemeEditor = defineComponent({
|
|
|
357
367
|
|
|
358
368
|
return () => (
|
|
359
369
|
<div class="s-scheme-dialog">
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
370
|
+
{props.showName && (
|
|
371
|
+
<HInput
|
|
372
|
+
modelValue={themeName.value}
|
|
373
|
+
onUpdate:modelValue={(v: string) => { themeName.value = v; }}
|
|
374
|
+
label={t("hikari::theme.themeName")}
|
|
375
|
+
placeholder={t("hikari::theme.customThemeName")}
|
|
376
|
+
/>
|
|
377
|
+
)}
|
|
366
378
|
<HTabs
|
|
367
379
|
variant="segmented"
|
|
368
380
|
class="s-scheme-mode-switch"
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
onMounted,
|
|
7
7
|
ref,
|
|
8
8
|
Teleport,
|
|
9
|
-
Transition,
|
|
10
9
|
watch,
|
|
11
10
|
type PropType,
|
|
12
11
|
} from "vue";
|
|
@@ -19,6 +18,7 @@ import { usePopupManager } from "../runtime/usePopupManager";
|
|
|
19
18
|
import { createBackGuard } from "../runtime/backStack";
|
|
20
19
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
21
20
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
21
|
+
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
22
22
|
import HIconButton from "./HkIconButton";
|
|
23
23
|
import HIcon from "./HkIcon";
|
|
24
24
|
import "./window-close.scss";
|
|
@@ -57,10 +57,9 @@ export default defineComponent({
|
|
|
57
57
|
const { t } = useI18n();
|
|
58
58
|
const manager = usePopupManager();
|
|
59
59
|
// Open/close motion reported into the unified animation context
|
|
60
|
-
// (animationBus) —
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const panelHooks = surf.hooks("panel");
|
|
60
|
+
// (animationBus) — one track for the whole surface, armed on the
|
|
61
|
+
// machine's animation-phase edges.
|
|
62
|
+
const surfTrack = useSurfaceTransition(320).track("surface");
|
|
64
63
|
const overlayHook = useOverlay({
|
|
65
64
|
name: "hk-drawer",
|
|
66
65
|
// A global closeAll() must be able to actually close this drawer
|
|
@@ -89,25 +88,6 @@ export default defineComponent({
|
|
|
89
88
|
bodyScrollbar = null;
|
|
90
89
|
}
|
|
91
90
|
|
|
92
|
-
watch(() => props.modelValue, (open) => {
|
|
93
|
-
if (unmounted) return;
|
|
94
|
-
if (open) {
|
|
95
|
-
void nextTick(() => {
|
|
96
|
-
if (!props.modelValue || !bodyRef.value) return;
|
|
97
|
-
detachBodyScrollbar();
|
|
98
|
-
// The wrapper (not the panel) is the track host: the panel also
|
|
99
|
-
// contains the header/footer bands, and rails spanning those
|
|
100
|
-
// would light up in the wrong place.
|
|
101
|
-
bodyScrollbar = attachOverlayScrollbars(bodyRef.value, {
|
|
102
|
-
axis: "vertical",
|
|
103
|
-
host: bodyWrapRef.value,
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
} else {
|
|
107
|
-
detachBodyScrollbar();
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
|
|
111
91
|
/**
|
|
112
92
|
* Window-first back priority: while this drawer is the topmost open
|
|
113
93
|
* window, the back gesture closes it instead of navigating the
|
|
@@ -121,6 +101,80 @@ export default defineComponent({
|
|
|
121
101
|
},
|
|
122
102
|
});
|
|
123
103
|
|
|
104
|
+
// ── Surface lifecycle machine ─────────────────────────────────────
|
|
105
|
+
// One machine drives the overlay scrim and the sliding panel — the
|
|
106
|
+
// layers are outputs of the shared phase, so the divergent-layer
|
|
107
|
+
// states of the two-<Transition> era are unrepresentable (see
|
|
108
|
+
// runtime/surfaceMachine.ts). The panel prefix follows the side
|
|
109
|
+
// prop; all four sides share the same 0.3s CSS timing.
|
|
110
|
+
const overlayEl = ref<HTMLElement>();
|
|
111
|
+
const machine = useSurfaceMachine({
|
|
112
|
+
layers: [
|
|
113
|
+
// Budgets are the starvation-era bounds over the SCSS truths
|
|
114
|
+
// (overlay 0.3s/0.3s, panel slides 0.3s/0.3s); the driver probes
|
|
115
|
+
// the live CSS durations and tightens them.
|
|
116
|
+
{ prefix: "hk-drawer-overlay", el: () => overlayEl.value, enterMs: () => 320, leaveMs: () => 320 },
|
|
117
|
+
{ prefix: "hk-drawer-left", el: () => panelRef.value, enterMs: () => 320, leaveMs: () => 320 },
|
|
118
|
+
{ prefix: "hk-drawer-right", el: () => panelRef.value, enterMs: () => 320, leaveMs: () => 320 },
|
|
119
|
+
{ prefix: "hk-drawer-top", el: () => panelRef.value, enterMs: () => 320, leaveMs: () => 320 },
|
|
120
|
+
{ prefix: "hk-drawer-bottom", el: () => panelRef.value, enterMs: () => 320, leaveMs: () => 320 },
|
|
121
|
+
],
|
|
122
|
+
onPhase: (from, to, event) => {
|
|
123
|
+
if (to === "openingFrom") {
|
|
124
|
+
surfTrack.run();
|
|
125
|
+
cleanup();
|
|
126
|
+
// Register with the drawer title so the modal-stack breadcrumb
|
|
127
|
+
// labels this layer by name — a drawer is a window on every
|
|
128
|
+
// form factor and must never fall back to a generic label.
|
|
129
|
+
handle.value = manager.register("drawer", true, props.title);
|
|
130
|
+
overlayHook.open();
|
|
131
|
+
previouslyFocused = document.activeElement as HTMLElement | null;
|
|
132
|
+
if (backGuardEnabled() && backGuard.entries === 0) {
|
|
133
|
+
backGuard.push();
|
|
134
|
+
}
|
|
135
|
+
// Scrollbar chrome mounts with the panel (was the modelValue
|
|
136
|
+
// watcher's open arm, nextTick after the DOM lands).
|
|
137
|
+
void nextTick(() => {
|
|
138
|
+
// props.modelValue (not machine.mounted): a same-tick
|
|
139
|
+
// open→close flap is already in closingFrom — still mounted —
|
|
140
|
+
// and must not attach a scrollbar onto the dying surface.
|
|
141
|
+
if (!props.modelValue || !bodyRef.value) return;
|
|
142
|
+
detachBodyScrollbar();
|
|
143
|
+
// The wrapper (not the panel) is the track host: the panel
|
|
144
|
+
// also contains the header/footer bands, and rails spanning
|
|
145
|
+
// those would light up in the wrong place.
|
|
146
|
+
bodyScrollbar = attachOverlayScrollbars(bodyRef.value, {
|
|
147
|
+
axis: "vertical",
|
|
148
|
+
host: bodyWrapRef.value,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
} else if (to === "open") {
|
|
152
|
+
surfTrack.cancel();
|
|
153
|
+
const el = panelRef.value;
|
|
154
|
+
if (el) focusFirst(el);
|
|
155
|
+
} else if (to === "closingFrom") {
|
|
156
|
+
surfTrack.run();
|
|
157
|
+
// Register/unregister WITH the open state so a closed-but-
|
|
158
|
+
// mounted drawer does not linger in the overlay registry
|
|
159
|
+
// (isOverlayOpen must reflect reality). The popup manager
|
|
160
|
+
// handle is torn down by the finalize edge below or by
|
|
161
|
+
// cleanup() on unmount.
|
|
162
|
+
overlayHook.close();
|
|
163
|
+
backGuard.release();
|
|
164
|
+
detachBodyScrollbar();
|
|
165
|
+
} else if (
|
|
166
|
+
to === "closed" &&
|
|
167
|
+
(from === "closingFrom" || from === "closingTo") &&
|
|
168
|
+
// UNMOUNT mid-close is a teardown, not a finalized leave —
|
|
169
|
+
// afterLeave/focus-restore belong to the close lifecycle only.
|
|
170
|
+
event !== "UNMOUNT"
|
|
171
|
+
) {
|
|
172
|
+
surfTrack.cancel();
|
|
173
|
+
onDrawerAfterLeave();
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
124
178
|
const isVertical = computed(
|
|
125
179
|
() => props.side === "left" || props.side === "right",
|
|
126
180
|
);
|
|
@@ -149,11 +203,6 @@ export default defineComponent({
|
|
|
149
203
|
if (props.closable) close();
|
|
150
204
|
}
|
|
151
205
|
|
|
152
|
-
function onDrawerAfterEnter() {
|
|
153
|
-
const el = panelRef.value;
|
|
154
|
-
if (el) focusFirst(el);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
206
|
function onDrawerAfterLeave() {
|
|
158
207
|
cleanup();
|
|
159
208
|
if (previouslyFocused) {
|
|
@@ -174,25 +223,9 @@ export default defineComponent({
|
|
|
174
223
|
() => props.modelValue,
|
|
175
224
|
(val) => {
|
|
176
225
|
if (unmounted) return;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
// labels this layer by name — a drawer is a window on every
|
|
181
|
-
// form factor and must never fall back to a generic label.
|
|
182
|
-
handle.value = manager.register("drawer", true, props.title);
|
|
183
|
-
overlayHook.open();
|
|
184
|
-
previouslyFocused = document.activeElement as HTMLElement | null;
|
|
185
|
-
if (backGuardEnabled() && backGuard.entries === 0) {
|
|
186
|
-
backGuard.push();
|
|
187
|
-
}
|
|
188
|
-
} else {
|
|
189
|
-
// Register/unregister WITH the open state so a closed-but-mounted
|
|
190
|
-
// drawer does not linger in the overlay registry (isOverlayOpen
|
|
191
|
-
// must reflect reality). The popup manager handle is torn down by
|
|
192
|
-
// the leave transition or by cleanup() on unmount.
|
|
193
|
-
overlayHook.close();
|
|
194
|
-
backGuard.release();
|
|
195
|
-
}
|
|
226
|
+
// The machine owns every visual/registry consequence on its
|
|
227
|
+
// phase edges; this watcher is purely the event feed.
|
|
228
|
+
machine.send(val ? "OPEN" : "CLOSE");
|
|
196
229
|
},
|
|
197
230
|
{ immediate: true },
|
|
198
231
|
);
|
|
@@ -231,42 +264,22 @@ export default defineComponent({
|
|
|
231
264
|
cleanup();
|
|
232
265
|
});
|
|
233
266
|
|
|
234
|
-
return () =>
|
|
267
|
+
return () => {
|
|
268
|
+
if (!machine.mounted.value) return null;
|
|
269
|
+
const panelPrefix = `hk-drawer-${props.side}`;
|
|
270
|
+
return (
|
|
235
271
|
<Teleport to="body">
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
<div
|
|
246
|
-
class="hk-drawer-overlay"
|
|
247
|
-
style={{ zIndex: overlayZ.value }}
|
|
248
|
-
onClick={onOverlayClick}
|
|
249
|
-
/>
|
|
250
|
-
) : null}
|
|
251
|
-
</Transition>
|
|
252
|
-
<Transition
|
|
253
|
-
name={`hk-drawer-${props.side}`}
|
|
254
|
-
appear
|
|
255
|
-
onBeforeEnter={panelHooks.onBeforeEnter}
|
|
256
|
-
onAfterEnter={() => {
|
|
257
|
-
panelHooks.onAfterEnter();
|
|
258
|
-
onDrawerAfterEnter();
|
|
259
|
-
}}
|
|
260
|
-
onBeforeLeave={panelHooks.onBeforeLeave}
|
|
261
|
-
onAfterLeave={() => {
|
|
262
|
-
panelHooks.onAfterLeave();
|
|
263
|
-
onDrawerAfterLeave();
|
|
264
|
-
}}
|
|
265
|
-
>
|
|
266
|
-
{props.modelValue ? (
|
|
267
|
-
<div
|
|
272
|
+
{props.overlay ? (
|
|
273
|
+
<div
|
|
274
|
+
ref={overlayEl}
|
|
275
|
+
class={["hk-drawer-overlay", ...machine.classesFor("hk-drawer-overlay")]}
|
|
276
|
+
style={{ zIndex: overlayZ.value }}
|
|
277
|
+
onClick={onOverlayClick}
|
|
278
|
+
/>
|
|
279
|
+
) : null}
|
|
280
|
+
<div
|
|
268
281
|
ref={panelRef}
|
|
269
|
-
class={["hk-drawer-panel", `hk-drawer-${props.side}`, props.panelClass]}
|
|
282
|
+
class={["hk-drawer-panel", `hk-drawer-${props.side}`, props.panelClass, ...machine.classesFor(panelPrefix)]}
|
|
270
283
|
style={panelStyle.value}
|
|
271
284
|
role="dialog"
|
|
272
285
|
aria-label={props.title}
|
|
@@ -304,9 +317,8 @@ export default defineComponent({
|
|
|
304
317
|
<div class="hk-drawer-footer">{slots.footer()}</div>
|
|
305
318
|
) : null}
|
|
306
319
|
</div>
|
|
307
|
-
) : null}
|
|
308
|
-
</Transition>
|
|
309
320
|
</Teleport>
|
|
310
|
-
|
|
321
|
+
);
|
|
322
|
+
};
|
|
311
323
|
},
|
|
312
324
|
});
|
|
@@ -31,6 +31,7 @@ async function mountOpenModal() {
|
|
|
31
31
|
containers.push(container);
|
|
32
32
|
|
|
33
33
|
const open = ref(true);
|
|
34
|
+
const afterLeaveEvents: number[] = [];
|
|
34
35
|
const Wrapper = defineComponent({
|
|
35
36
|
setup() {
|
|
36
37
|
return () =>
|
|
@@ -38,6 +39,7 @@ async function mountOpenModal() {
|
|
|
38
39
|
modelValue: open.value,
|
|
39
40
|
closable: true,
|
|
40
41
|
"onUpdate:modelValue": (v: boolean) => { open.value = v; },
|
|
42
|
+
onAfterLeave: () => { afterLeaveEvents.push(1); },
|
|
41
43
|
}, { default: () => h("div", "content") });
|
|
42
44
|
},
|
|
43
45
|
});
|
|
@@ -45,7 +47,11 @@ async function mountOpenModal() {
|
|
|
45
47
|
mounts.push(app);
|
|
46
48
|
app.mount(container);
|
|
47
49
|
await nextTick();
|
|
48
|
-
return {
|
|
50
|
+
return {
|
|
51
|
+
open,
|
|
52
|
+
afterLeaveEvents,
|
|
53
|
+
unmount: () => { app.unmount(); },
|
|
54
|
+
};
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
describe("HkModal enter-class watchdog", () => {
|
|
@@ -68,11 +74,11 @@ describe("HkModal enter-class watchdog", () => {
|
|
|
68
74
|
expect(overlay!.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
|
|
69
75
|
expect(content!.classList.contains("hk-modal-content-enter-from")).toBe(true);
|
|
70
76
|
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
await vi.advanceTimersByTimeAsync(
|
|
77
|
+
// Past every budget both layers rest at their open state — no
|
|
78
|
+
// frozen classes survive. (Environments without computed CSS
|
|
79
|
+
// durations complete even sooner via the probe; the property under
|
|
80
|
+
// test is the BOUND, not the exact frame.)
|
|
81
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
76
82
|
// Both layers snapped to their resting (class-less) state.
|
|
77
83
|
expect(overlay!.className).toBe("hk-modal-overlay");
|
|
78
84
|
expect(content!.className).toBe("hk-modal-content");
|
|
@@ -85,7 +91,7 @@ describe("HkModal enter-class watchdog", () => {
|
|
|
85
91
|
// the watchdog, and no strip ever fires past the budget.
|
|
86
92
|
vi.useFakeTimers();
|
|
87
93
|
await mountOpenModal();
|
|
88
|
-
await vi.advanceTimersByTimeAsync(
|
|
94
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
89
95
|
|
|
90
96
|
const overlay = document.querySelector<HTMLElement>(".hk-modal-overlay");
|
|
91
97
|
expect(overlay).not.toBeNull();
|
|
@@ -110,6 +116,26 @@ describe("HkModal enter-class watchdog", () => {
|
|
|
110
116
|
});
|
|
111
117
|
});
|
|
112
118
|
|
|
119
|
+
describe("HkModal teardown during the leave window", () => {
|
|
120
|
+
// The machine's UNMOUNT edge walks closing* → closed; that must be a
|
|
121
|
+
// TEARDOWN, never a finalized leave — afterLeave/focus-restore belong
|
|
122
|
+
// to the close lifecycle (the machine's unmount hook runs before this
|
|
123
|
+
// component's onBeforeUnmount flips the `unmounted` guard).
|
|
124
|
+
it("does not emit afterLeave when unmounted mid-close", async () => {
|
|
125
|
+
vi.useFakeTimers();
|
|
126
|
+
const rig = await mountOpenModal();
|
|
127
|
+
await vi.advanceTimersByTimeAsync(700); // at open rest
|
|
128
|
+
expect(rig.afterLeaveEvents).toHaveLength(0);
|
|
129
|
+
|
|
130
|
+
rig.open.value = false;
|
|
131
|
+
await nextTick();
|
|
132
|
+
// Mid-leave teardown (route change, parent v-if) inside the ~540ms
|
|
133
|
+
// closing window.
|
|
134
|
+
rig.unmount();
|
|
135
|
+
expect(rig.afterLeaveEvents).toHaveLength(0);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
113
139
|
describe("HkModal enter-class watchdog across an interrupted leave", () => {
|
|
114
140
|
// The exact field-report sequence (2026-09-07 recording, f108-f112):
|
|
115
141
|
// the modal started closing (scrim fading out), a reopen patched over
|
|
@@ -121,15 +147,16 @@ describe("HkModal enter-class watchdog across an interrupted leave", () => {
|
|
|
121
147
|
vi.useFakeTimers();
|
|
122
148
|
// Phase 1: open with WORKING rAF so the initial enter completes.
|
|
123
149
|
const { open } = await mountOpenModal();
|
|
124
|
-
await vi.advanceTimersByTimeAsync(
|
|
150
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
125
151
|
await nextTick();
|
|
126
152
|
|
|
127
|
-
// Phase 2: starve rAF, start a close, and reopen
|
|
128
|
-
//
|
|
153
|
+
// Phase 2: starve rAF, start a close, and reopen on the microtask
|
|
154
|
+
// boundary — the leave deadline cannot have fired yet, so the
|
|
155
|
+
// reopen patches over the still-live leave (the recording's
|
|
156
|
+
// f108-f112 churn).
|
|
129
157
|
freezeRaf();
|
|
130
158
|
open.value = false;
|
|
131
159
|
await nextTick();
|
|
132
|
-
await vi.advanceTimersByTimeAsync(120); // leave live, nothing finalized
|
|
133
160
|
open.value = true;
|
|
134
161
|
await nextTick();
|
|
135
162
|
|
|
@@ -137,15 +164,11 @@ describe("HkModal enter-class watchdog across an interrupted leave", () => {
|
|
|
137
164
|
const content = document.querySelector<HTMLElement>(".hk-modal-content")!;
|
|
138
165
|
expect(overlay).not.toBeNull();
|
|
139
166
|
expect(content).not.toBeNull();
|
|
140
|
-
// The frozen re-enter left its from-pair on both layers (the scrim
|
|
141
|
-
// invisible at opacity 0 while the surface is logically open).
|
|
142
|
-
expect(overlay.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
|
|
143
|
-
expect(content.classList.contains("hk-modal-content-enter-from")).toBe(true);
|
|
144
|
-
|
|
145
|
-
await vi.advanceTimersByTimeAsync(590);
|
|
146
|
-
expect(overlay.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
|
|
147
167
|
|
|
148
|
-
|
|
168
|
+
// Past every budget the surface rests fully open with no frozen
|
|
169
|
+
// classes — repaired, and closing afterwards runs a NORMAL leave
|
|
170
|
+
// (fade from visible) instead of the full-opacity pop.
|
|
171
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
149
172
|
expect(overlay.className).toBe("hk-modal-overlay");
|
|
150
173
|
expect(content.className).toBe("hk-modal-content");
|
|
151
174
|
// The surface is still open, and closing it afterwards runs a NORMAL
|
|
@@ -61,7 +61,7 @@ describe("HkModal leave-completion watchdog", () => {
|
|
|
61
61
|
// full reload escaped it. The watchdog must finalize within its
|
|
62
62
|
// budget, unmount both surface layers, and emit afterLeave exactly
|
|
63
63
|
// the way a real transition would.
|
|
64
|
-
it("force-finalizes a stalled leave within the
|
|
64
|
+
it("force-finalizes a stalled leave within the deadline budget", async () => {
|
|
65
65
|
vi.useFakeTimers();
|
|
66
66
|
freezeRaf();
|
|
67
67
|
const { open, afterLeaveEvents } = await mountOpenModal();
|
|
@@ -69,12 +69,12 @@ describe("HkModal leave-completion watchdog", () => {
|
|
|
69
69
|
|
|
70
70
|
open.value = false;
|
|
71
71
|
await nextTick();
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
await vi.advanceTimersByTimeAsync(
|
|
72
|
+
// Past the machine's deadline budget the surface must be gone even
|
|
73
|
+
// though not a single frame or transitionend ever arrived — the
|
|
74
|
+
// timer column alone finalizes the leave (environments with live
|
|
75
|
+
// CSS durations exercise the full window; without them the probe
|
|
76
|
+
// completes even sooner — the property is the BOUND).
|
|
77
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
78
78
|
await nextTick();
|
|
79
79
|
expect(document.querySelector(".hk-modal-content")).toBeNull();
|
|
80
80
|
expect(document.querySelector(".hk-modal-overlay")).toBeNull();
|
|
@@ -97,10 +97,11 @@ describe("HkModal leave-completion watchdog", () => {
|
|
|
97
97
|
|
|
98
98
|
open.value = false;
|
|
99
99
|
await nextTick();
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
// Reopen while the leave is still live (before any deadline timer
|
|
101
|
+
// fires) — the reopen patches over the in-flight close.
|
|
102
|
+
open.value = true;
|
|
102
103
|
await nextTick();
|
|
103
|
-
await vi.advanceTimersByTimeAsync(700); // past
|
|
104
|
+
await vi.advanceTimersByTimeAsync(700); // past every budget
|
|
104
105
|
await nextTick();
|
|
105
106
|
// The reopened modal must survive both the stale onAfterLeave and
|
|
106
107
|
// the (disarmed) watchdog from the aborted close.
|
|
@@ -164,16 +165,17 @@ describe("HkModal leave-completion watchdog", () => {
|
|
|
164
165
|
expect(afterLeaveEvents).toHaveLength(2);
|
|
165
166
|
});
|
|
166
167
|
|
|
167
|
-
// Contract pin: the
|
|
168
|
-
//
|
|
169
|
-
// (--hk-modal-duration defaults to 0.
|
|
170
|
-
// refactor that
|
|
171
|
-
//
|
|
172
|
-
|
|
168
|
+
// Contract pin: the lifecycle machine owns finalization now. Its
|
|
169
|
+
// layer budgets must stay ≥ the themed CSS durations
|
|
170
|
+
// (--hk-modal-duration defaults to 0.3s; a theme may raise it) — a
|
|
171
|
+
// refactor that shrinks a budget under the CSS timing would cut the
|
|
172
|
+
// leave short, and one that drops the machine wiring re-opens the
|
|
173
|
+
// frozen-modal failure mode.
|
|
174
|
+
it("pins the machine wiring and deadline budgets in the source", () => {
|
|
173
175
|
const src = readFileSync(join(here, "HkModal.tsx"), "utf-8");
|
|
174
|
-
expect(src).toContain("
|
|
175
|
-
expect(src).toContain("
|
|
176
|
-
expect(src).toContain("
|
|
176
|
+
expect(src).toContain("useSurfaceMachine({");
|
|
177
|
+
expect(src).toContain('{ prefix: "hk-modal-overlay", el: () => overlayEl.value, enterMs: () => 320, leaveMs: () => 340 }');
|
|
178
|
+
expect(src).toContain('{ prefix: "hk-modal-content", el: () => contentRef.value, enterMs: () => 320, leaveMs: () => 300 }');
|
|
177
179
|
expect(src).toContain("onAfterLeaveFinalize();");
|
|
178
180
|
});
|
|
179
181
|
});
|