@celestia-island/hikari 0.40.16 → 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 +23 -9
- package/src/components/HkDrawer.tsx +97 -85
- package/src/components/HkModal.enter-watchdog.test.tsx +183 -0
- package/src/components/HkModal.transition-completion.test.tsx +21 -19
- package/src/components/HkModal.tsx +91 -125
- package/src/components/HkPopover.tsx +120 -89
- package/src/components/HkSelectPanel.test.tsx +58 -0
- package/src/components/HkSelectPanel.tsx +167 -129
- package/src/components/HkStatusBar.test.tsx +8 -8
- package/src/components/HkThemeToggle.scss +9 -1
- package/src/components/HkThemeToggle.test.ts +41 -2
- package/src/components/HkThemeToggle.tsx +12 -1
- package/src/components/scrim-fade.contract.test.ts +14 -7
- package/src/composables/useSizeMorph.test.ts +100 -1
- package/src/composables/useSizeMorph.ts +62 -1
- 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/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
|
});
|
|
@@ -145,6 +145,14 @@ export const HkColorSchemeEditor = defineComponent({
|
|
|
145
145
|
initialLight: { type: Object as PropType<ThemeSchemeTokens>, default: undefined },
|
|
146
146
|
/** Prefill extension token groups (per mode); defaults to registry defaults. */
|
|
147
147
|
initialGroups: { type: Object as PropType<ThemeTokenGroupModes>, default: undefined },
|
|
148
|
+
/** Prefill the scheme name input (edit/fork flows); empty by default. */
|
|
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 },
|
|
148
156
|
},
|
|
149
157
|
setup(props, { expose }) {
|
|
150
158
|
const { t } = useI18n();
|
|
@@ -153,7 +161,7 @@ export const HkColorSchemeEditor = defineComponent({
|
|
|
153
161
|
// through a computed so config-file labels follow the live locale.
|
|
154
162
|
const activeLocale = computed(() => useI18n().locale);
|
|
155
163
|
const modeTab = ref<string>("dark");
|
|
156
|
-
const themeName = ref("");
|
|
164
|
+
const themeName = ref(props.initialName ?? "");
|
|
157
165
|
|
|
158
166
|
const dark = reactive<ThemeSchemeTokens>({ ...defaultDark });
|
|
159
167
|
const light = reactive<ThemeSchemeTokens>({ ...defaultLight });
|
|
@@ -196,7 +204,7 @@ export const HkColorSchemeEditor = defineComponent({
|
|
|
196
204
|
|
|
197
205
|
function reset(): void {
|
|
198
206
|
modeTab.value = useTheme().effectiveMode.value;
|
|
199
|
-
themeName.value = t("hikari::theme.customThemeName");
|
|
207
|
+
themeName.value = props.initialName ?? t("hikari::theme.customThemeName");
|
|
200
208
|
Object.assign(dark, props.initialDark ?? defaultDark);
|
|
201
209
|
Object.assign(light, props.initialLight ?? defaultLight);
|
|
202
210
|
// Optional slots: a legacy prefill omitting them must reset to white
|
|
@@ -212,7 +220,11 @@ export const HkColorSchemeEditor = defineComponent({
|
|
|
212
220
|
|
|
213
221
|
onMounted(() => reset());
|
|
214
222
|
|
|
215
|
-
|
|
223
|
+
function setThemeName(name: string): void {
|
|
224
|
+
themeName.value = name;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
expose({ reset, getDraft, setThemeName });
|
|
216
228
|
|
|
217
229
|
watch(
|
|
218
230
|
() => [
|
|
@@ -355,12 +367,14 @@ export const HkColorSchemeEditor = defineComponent({
|
|
|
355
367
|
|
|
356
368
|
return () => (
|
|
357
369
|
<div class="s-scheme-dialog">
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
+
)}
|
|
364
378
|
<HTabs
|
|
365
379
|
variant="segmented"
|
|
366
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
|
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkModal from "./HkModal";
|
|
5
|
+
|
|
6
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
7
|
+
const containers: HTMLElement[] = [];
|
|
8
|
+
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
11
|
+
for (const el of containers.splice(0)) el.remove();
|
|
12
|
+
vi.unstubAllGlobals();
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** Freeze rAF entirely — Vue's <Transition> engine double-raf's the
|
|
17
|
+
* enter-from → enter-to class flip, so a frozen rAF reproduces the
|
|
18
|
+
* occluded-webview pathology exactly: the enter can NEVER complete on
|
|
19
|
+
* its own, the from-pair (opacity: 0 / translateY(100%)) freezes on the
|
|
20
|
+
* layer, and only the enter watchdog can repair it. Mirror of the
|
|
21
|
+
* leave-completion test's freezeRaf. */
|
|
22
|
+
function freezeRaf(): void {
|
|
23
|
+
vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
|
|
24
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Mount an open modal wired to an `open` ref we can flip from the test. */
|
|
28
|
+
async function mountOpenModal() {
|
|
29
|
+
const container = document.createElement("div");
|
|
30
|
+
document.body.appendChild(container);
|
|
31
|
+
containers.push(container);
|
|
32
|
+
|
|
33
|
+
const open = ref(true);
|
|
34
|
+
const afterLeaveEvents: number[] = [];
|
|
35
|
+
const Wrapper = defineComponent({
|
|
36
|
+
setup() {
|
|
37
|
+
return () =>
|
|
38
|
+
h(HkModal, {
|
|
39
|
+
modelValue: open.value,
|
|
40
|
+
closable: true,
|
|
41
|
+
"onUpdate:modelValue": (v: boolean) => { open.value = v; },
|
|
42
|
+
onAfterLeave: () => { afterLeaveEvents.push(1); },
|
|
43
|
+
}, { default: () => h("div", "content") });
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
const app = createApp(Wrapper);
|
|
47
|
+
mounts.push(app);
|
|
48
|
+
app.mount(container);
|
|
49
|
+
await nextTick();
|
|
50
|
+
return {
|
|
51
|
+
open,
|
|
52
|
+
afterLeaveEvents,
|
|
53
|
+
unmount: () => { app.unmount(); },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("HkModal enter-class watchdog", () => {
|
|
58
|
+
// Regression for the 2026-09 mobile report: a starved enter froze the
|
|
59
|
+
// scrim's enter-from pair (opacity: 0) while the panel stayed open —
|
|
60
|
+
// the dim curtain vanished, and the eventual close flashed it back at
|
|
61
|
+
// full opacity (the "black rectangle"). The watchdog must strip the
|
|
62
|
+
// stuck classes on BOTH layers within its budget while the modal stays
|
|
63
|
+
// open and functional.
|
|
64
|
+
it("strips frozen enter classes on overlay and content within the budget", async () => {
|
|
65
|
+
vi.useFakeTimers();
|
|
66
|
+
freezeRaf();
|
|
67
|
+
await mountOpenModal();
|
|
68
|
+
|
|
69
|
+
const overlay = document.querySelector<HTMLElement>(".hk-modal-overlay");
|
|
70
|
+
const content = document.querySelector<HTMLElement>(".hk-modal-content");
|
|
71
|
+
expect(overlay).not.toBeNull();
|
|
72
|
+
expect(content).not.toBeNull();
|
|
73
|
+
// The frozen enter left its from-pair on both layers.
|
|
74
|
+
expect(overlay!.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
|
|
75
|
+
expect(content!.classList.contains("hk-modal-content-enter-from")).toBe(true);
|
|
76
|
+
|
|
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);
|
|
82
|
+
// Both layers snapped to their resting (class-less) state.
|
|
83
|
+
expect(overlay!.className).toBe("hk-modal-overlay");
|
|
84
|
+
expect(content!.className).toBe("hk-modal-content");
|
|
85
|
+
// …and the surface is still open and intact.
|
|
86
|
+
expect(document.querySelector(".hk-modal-content")).not.toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("leaves a healthy enter untouched (watchdog disarmed on completion)", async () => {
|
|
90
|
+
// Real rAF: the enter completes on its own, the after-enter disarms
|
|
91
|
+
// the watchdog, and no strip ever fires past the budget.
|
|
92
|
+
vi.useFakeTimers();
|
|
93
|
+
await mountOpenModal();
|
|
94
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
95
|
+
|
|
96
|
+
const overlay = document.querySelector<HTMLElement>(".hk-modal-overlay");
|
|
97
|
+
expect(overlay).not.toBeNull();
|
|
98
|
+
expect(Array.from(overlay!.classList).some((c) => c.startsWith("hk-modal-overlay-"))).toBe(
|
|
99
|
+
false,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("a repaired modal still closes normally afterwards", async () => {
|
|
104
|
+
vi.useFakeTimers();
|
|
105
|
+
freezeRaf();
|
|
106
|
+
const { open } = await mountOpenModal();
|
|
107
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
108
|
+
expect(document.querySelector(".hk-modal-content")).not.toBeNull();
|
|
109
|
+
|
|
110
|
+
open.value = false;
|
|
111
|
+
await nextTick();
|
|
112
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
113
|
+
await nextTick();
|
|
114
|
+
expect(document.querySelector(".hk-modal-content")).toBeNull();
|
|
115
|
+
expect(document.querySelector(".hk-modal-overlay")).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
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
|
+
|
|
139
|
+
describe("HkModal enter-class watchdog across an interrupted leave", () => {
|
|
140
|
+
// The exact field-report sequence (2026-09-07 recording, f108-f112):
|
|
141
|
+
// the modal started closing (scrim fading out), a reopen patched over
|
|
142
|
+
// the still-live leave on the SAME element, and rAF starvation froze
|
|
143
|
+
// the re-enter's from-pair — the scrim stayed invisible for seconds
|
|
144
|
+
// and the eventual close flashed it back at full opacity. The
|
|
145
|
+
// watchdog must repair both layers of the reopened surface.
|
|
146
|
+
it("repairs a reopen that froze mid-re-enter on the same element", async () => {
|
|
147
|
+
vi.useFakeTimers();
|
|
148
|
+
// Phase 1: open with WORKING rAF so the initial enter completes.
|
|
149
|
+
const { open } = await mountOpenModal();
|
|
150
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
151
|
+
await nextTick();
|
|
152
|
+
|
|
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).
|
|
157
|
+
freezeRaf();
|
|
158
|
+
open.value = false;
|
|
159
|
+
await nextTick();
|
|
160
|
+
open.value = true;
|
|
161
|
+
await nextTick();
|
|
162
|
+
|
|
163
|
+
const overlay = document.querySelector<HTMLElement>(".hk-modal-overlay")!;
|
|
164
|
+
const content = document.querySelector<HTMLElement>(".hk-modal-content")!;
|
|
165
|
+
expect(overlay).not.toBeNull();
|
|
166
|
+
expect(content).not.toBeNull();
|
|
167
|
+
|
|
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);
|
|
172
|
+
expect(overlay.className).toBe("hk-modal-overlay");
|
|
173
|
+
expect(content.className).toBe("hk-modal-content");
|
|
174
|
+
// The surface is still open, and closing it afterwards runs a NORMAL
|
|
175
|
+
// leave (fade from visible) instead of the full-opacity pop.
|
|
176
|
+
open.value = false;
|
|
177
|
+
await nextTick();
|
|
178
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
179
|
+
await nextTick();
|
|
180
|
+
expect(document.querySelector(".hk-modal-content")).toBeNull();
|
|
181
|
+
expect(document.querySelector(".hk-modal-overlay")).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -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
|
});
|