@celestia-island/hikari 0.55.0 → 0.55.2
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/HkDrawer.tsx +13 -5
- package/src/components/HkModal.contenthold.test.tsx +156 -0
- package/src/components/HkModal.leavepin.test.tsx +169 -0
- package/src/components/HkModal.tsx +61 -11
- package/src/components/HkModalBreadcrumb.overflow.contract.test.ts +47 -2
- package/src/components/HkModalBreadcrumb.scss +52 -11
- package/src/components/HkModalBreadcrumb.test.tsx +254 -5
- package/src/components/HkModalBreadcrumb.tsx +119 -10
- package/src/composables/useSizeMorph.test.ts +75 -3
- package/src/composables/useSizeMorph.ts +51 -4
- package/src/composables/useSurfaceContentHold.ts +58 -0
- package/src/composables/useSurfaceMachine.test.ts +50 -0
- package/src/composables/useSurfaceMachine.ts +35 -1
package/package.json
CHANGED
|
@@ -19,6 +19,7 @@ import { createBackGuard } from "../runtime/backStack";
|
|
|
19
19
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
20
20
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
21
21
|
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
22
|
+
import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
|
|
22
23
|
import HIconButton from "./HkIconButton";
|
|
23
24
|
import HIcon from "./HkIcon";
|
|
24
25
|
import "./window-close.scss";
|
|
@@ -269,6 +270,8 @@ export default defineComponent({
|
|
|
269
270
|
cleanup();
|
|
270
271
|
});
|
|
271
272
|
|
|
273
|
+
const contentHold = useSurfaceContentHold(machine.phase);
|
|
274
|
+
|
|
272
275
|
return () => {
|
|
273
276
|
if (!machine.mounted.value) return null;
|
|
274
277
|
const panelPrefix = `hk-drawer-${props.side}`;
|
|
@@ -295,7 +298,11 @@ export default defineComponent({
|
|
|
295
298
|
else if (e.key === "Tab" && panelRef.value) trapFocus(panelRef.value, e);
|
|
296
299
|
}}
|
|
297
300
|
>
|
|
298
|
-
{
|
|
301
|
+
{/* Same close-fold content hold as HkModal: the sheet's
|
|
302
|
+
slide-away plays over the last live content even when
|
|
303
|
+
the consumer tears its state down on close. */}
|
|
304
|
+
{contentHold.hold(() => [
|
|
305
|
+
props.title || slots.header ? (
|
|
299
306
|
<div class="hk-drawer-header">
|
|
300
307
|
{slots.header ? (
|
|
301
308
|
slots.header()
|
|
@@ -314,13 +321,14 @@ export default defineComponent({
|
|
|
314
321
|
</HIconButton>
|
|
315
322
|
) : null}
|
|
316
323
|
</div>
|
|
317
|
-
) : null
|
|
324
|
+
) : null,
|
|
318
325
|
<div ref={bodyWrapRef} class="hk-drawer-body-wrap">
|
|
319
326
|
<div ref={bodyRef} class="hk-drawer-body">{slots.default?.()}</div>
|
|
320
|
-
</div
|
|
321
|
-
|
|
327
|
+
</div>,
|
|
328
|
+
slots.footer ? (
|
|
322
329
|
<div class="hk-drawer-footer">{slots.footer()}</div>
|
|
323
|
-
) : null
|
|
330
|
+
) : null,
|
|
331
|
+
])}
|
|
324
332
|
</div>
|
|
325
333
|
</Teleport>
|
|
326
334
|
);
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* Source contract for the close-fold content hold (chest field report
|
|
8
|
+
* 2026-09-16): consumers routinely tear down the state that feeds the
|
|
9
|
+
* window's body in the same tick as the close (TodoLogModal's
|
|
10
|
+
* clear-on-close watcher emptied a 697px conversation window to a 200px
|
|
11
|
+
* stub BEFORE the fold started). While the machine is in a closing phase
|
|
12
|
+
* HkModal serves the last live-rendered children, so the fold always
|
|
13
|
+
* plays over the content the user was looking at.
|
|
14
|
+
*
|
|
15
|
+
* happy-dom has no transition engine, so the machine's duration probe
|
|
16
|
+
* reads a stubbed 0.3s (same harness trick as the surface-machine tests)
|
|
17
|
+
* and rAF is frozen — the flip timers drive the phase edges
|
|
18
|
+
* deterministically.
|
|
19
|
+
*/
|
|
20
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
21
|
+
const containers: HTMLElement[] = [];
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
25
|
+
for (const el of containers.splice(0)) el.remove();
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
vi.useRealTimers();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function freezeRaf(): void {
|
|
31
|
+
vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
|
|
32
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stubDurations(): void {
|
|
36
|
+
const realGCS = window.getComputedStyle.bind(window);
|
|
37
|
+
vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
|
|
38
|
+
const style = realGCS(el as Element, ...(rest as []));
|
|
39
|
+
return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Rig {
|
|
44
|
+
open: { value: boolean };
|
|
45
|
+
setLines: (lines: string[]) => void;
|
|
46
|
+
closeWithTeardown: () => void;
|
|
47
|
+
bodyText: () => string;
|
|
48
|
+
frameEl: () => HTMLElement | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Mount HkModal behind a wrapper that models the chest clear-on-close
|
|
52
|
+
* pattern: the body state clears in the SAME handler that flips the
|
|
53
|
+
* v-model false. */
|
|
54
|
+
async function mountRig(initialLines: string[]): Promise<Rig> {
|
|
55
|
+
const container = document.createElement("div");
|
|
56
|
+
document.body.appendChild(container);
|
|
57
|
+
containers.push(container);
|
|
58
|
+
|
|
59
|
+
const open = ref(false);
|
|
60
|
+
const lines = ref<string[]>(initialLines);
|
|
61
|
+
|
|
62
|
+
const Wrapper = defineComponent({
|
|
63
|
+
setup() {
|
|
64
|
+
return () =>
|
|
65
|
+
h(HkModal, {
|
|
66
|
+
modelValue: open.value,
|
|
67
|
+
closable: true,
|
|
68
|
+
title: "hold-test",
|
|
69
|
+
"onUpdate:modelValue": (v: boolean) => {
|
|
70
|
+
open.value = v;
|
|
71
|
+
// The reported chest pattern: destroy the content with the
|
|
72
|
+
// close request, before any leave animation runs.
|
|
73
|
+
if (!v) lines.value = [];
|
|
74
|
+
},
|
|
75
|
+
}, {
|
|
76
|
+
default: () => h("div", lines.value.map((l, i) => h("p", { key: i }, l))),
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
const app = createApp(Wrapper);
|
|
81
|
+
mounts.push(app);
|
|
82
|
+
app.mount(container);
|
|
83
|
+
await nextTick();
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
open,
|
|
87
|
+
setLines: (next: string[]) => { lines.value = next; },
|
|
88
|
+
/** Faithful chest pattern: the store-watcher teardown runs in the SAME
|
|
89
|
+
* tick as the v-model flip, regardless of who flipped it — clearing
|
|
90
|
+
* only inside onUpdate:modelValue would miss programmatic closes and
|
|
91
|
+
* the test would pass vacuously (R1 finding: the hold was never
|
|
92
|
+
* exercised). */
|
|
93
|
+
closeWithTeardown: () => {
|
|
94
|
+
open.value = false;
|
|
95
|
+
lines.value = [];
|
|
96
|
+
},
|
|
97
|
+
bodyText: () => document.querySelector(".hk-modal-body-inner")?.textContent ?? "",
|
|
98
|
+
frameEl: () => document.querySelector<HTMLElement>(".hk-modal-content"),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe("HkModal close-fold content hold", () => {
|
|
103
|
+
it("folds over the last live content even when the consumer clears state on close", async () => {
|
|
104
|
+
vi.useFakeTimers();
|
|
105
|
+
freezeRaf();
|
|
106
|
+
stubDurations();
|
|
107
|
+
const rig = await mountRig(["alpha", "beta"]);
|
|
108
|
+
|
|
109
|
+
// Open the surface to rest (flip timer 120ms + duration 300 + slack).
|
|
110
|
+
rig.open.value = true;
|
|
111
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
112
|
+
expect(rig.bodyText()).toBe("alphabeta");
|
|
113
|
+
|
|
114
|
+
// Close with the SAME-TICK teardown — this is the exact moment the
|
|
115
|
+
// hold must earn its keep (without it the body blanks immediately).
|
|
116
|
+
rig.closeWithTeardown();
|
|
117
|
+
await vi.advanceTimersByTimeAsync(150); // mid-leave (past the flip)
|
|
118
|
+
expect(rig.frameEl()).not.toBeNull();
|
|
119
|
+
// THE contract: the fold plays over the ORIGINAL content — the
|
|
120
|
+
// same-tick teardown must not blank the window mid-fold.
|
|
121
|
+
expect(rig.bodyText()).toBe("alphabeta");
|
|
122
|
+
|
|
123
|
+
// The leave settles and the DOM goes away with it.
|
|
124
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
125
|
+
expect(rig.frameEl()).toBeNull();
|
|
126
|
+
expect(rig.bodyText()).toBe("");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("a reopen interrupt drops the hold and serves live content again", async () => {
|
|
130
|
+
vi.useFakeTimers();
|
|
131
|
+
freezeRaf();
|
|
132
|
+
stubDurations();
|
|
133
|
+
const rig = await mountRig(["alpha", "beta"]);
|
|
134
|
+
|
|
135
|
+
rig.open.value = true;
|
|
136
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
137
|
+
const frameBeforeClose = rig.frameEl();
|
|
138
|
+
|
|
139
|
+
// Close with the same-tick teardown, then interrupt mid-leave.
|
|
140
|
+
rig.closeWithTeardown();
|
|
141
|
+
await vi.advanceTimersByTimeAsync(150);
|
|
142
|
+
expect(rig.bodyText()).toBe("alphabeta");
|
|
143
|
+
|
|
144
|
+
// Reopen with FRESH content — the hold releases with the phase.
|
|
145
|
+
rig.open.value = true;
|
|
146
|
+
rig.setLines(["gamma"]);
|
|
147
|
+
await vi.advanceTimersByTimeAsync(30);
|
|
148
|
+
expect(rig.bodyText()).toBe("gamma");
|
|
149
|
+
|
|
150
|
+
// …and the same element survives the reversal (no remount flash).
|
|
151
|
+
expect(rig.frameEl()).toBe(frameBeforeClose);
|
|
152
|
+
await vi.advanceTimersByTimeAsync(800);
|
|
153
|
+
expect(rig.bodyText()).toBe("gamma");
|
|
154
|
+
expect(rig.frameEl()).not.toBeNull();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* Source contract for the leave-window height pin (chest field report
|
|
8
|
+
* 2026-09-16): the close fold owns the frame's geometry for the whole
|
|
9
|
+
* leave, so HkModal must KEEP the size morph's pin through closingFrom →
|
|
10
|
+
* closingTo (morph.hold()) and only release it on the finalize edge. The
|
|
11
|
+
* composable-level semantics are pinned by useSizeMorph.test.ts; this file
|
|
12
|
+
* pins the HkModal INTEGRATION — the wiring bug class (calling stop()
|
|
13
|
+
* instead of hold()) leaves the composable tests green while every real
|
|
14
|
+
* modal folds over a resizing frame.
|
|
15
|
+
*
|
|
16
|
+
* happy-dom has no layout engine, so the frame's offsetHeight is stubbed
|
|
17
|
+
* at the HTMLElement prototype level BEFORE mount — the morph's
|
|
18
|
+
* openingFrom-tick arm then measures 500px and pins it, and every
|
|
19
|
+
* mid-leave assertion reads the inline height string directly.
|
|
20
|
+
*/
|
|
21
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
22
|
+
const containers: HTMLElement[] = [];
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
26
|
+
for (const el of containers.splice(0)) el.remove();
|
|
27
|
+
vi.unstubAllGlobals();
|
|
28
|
+
vi.useRealTimers();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function freezeRaf(): void {
|
|
32
|
+
vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
|
|
33
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stubDurations(): void {
|
|
37
|
+
const realGCS = window.getComputedStyle.bind(window);
|
|
38
|
+
vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
|
|
39
|
+
const style = realGCS(el as Element, ...(rest as []));
|
|
40
|
+
return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Prototype-level layout stub: every element measures `layoutH` tall.
|
|
45
|
+
* Mutable so a test can simulate content growth mid-enter. */
|
|
46
|
+
let layoutH = 500;
|
|
47
|
+
function stubLayout(initial: number): void {
|
|
48
|
+
layoutH = initial;
|
|
49
|
+
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
|
50
|
+
configurable: true,
|
|
51
|
+
get: () => layoutH,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function restoreLayout(): void {
|
|
56
|
+
delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface Rig {
|
|
60
|
+
open: { value: boolean };
|
|
61
|
+
pinHeight: () => string;
|
|
62
|
+
frameEl: () => HTMLElement | null;
|
|
63
|
+
clickClose: () => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function mountRig(): Promise<Rig> {
|
|
67
|
+
const container = document.createElement("div");
|
|
68
|
+
document.body.appendChild(container);
|
|
69
|
+
containers.push(container);
|
|
70
|
+
|
|
71
|
+
const open = ref(false);
|
|
72
|
+
|
|
73
|
+
const Wrapper = defineComponent({
|
|
74
|
+
setup() {
|
|
75
|
+
return () =>
|
|
76
|
+
h(HkModal, {
|
|
77
|
+
modelValue: open.value,
|
|
78
|
+
closable: true,
|
|
79
|
+
title: "pin-test",
|
|
80
|
+
"onUpdate:modelValue": (v: boolean) => { open.value = v; },
|
|
81
|
+
}, { default: () => h("div", "content") });
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
const app = createApp(Wrapper);
|
|
85
|
+
mounts.push(app);
|
|
86
|
+
app.mount(container);
|
|
87
|
+
await nextTick();
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
open,
|
|
91
|
+
pinHeight: () => document.querySelector<HTMLElement>(".hk-modal-content")?.style.height ?? "",
|
|
92
|
+
frameEl: () => document.querySelector<HTMLElement>(".hk-modal-content"),
|
|
93
|
+
clickClose: () => {
|
|
94
|
+
const btn = document.querySelector<HTMLElement>(".hk-modal-close");
|
|
95
|
+
btn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("HkModal leave-window height pin", () => {
|
|
101
|
+
it("keeps the morph pin through the whole leave and releases at finalize", async () => {
|
|
102
|
+
vi.useFakeTimers();
|
|
103
|
+
freezeRaf();
|
|
104
|
+
stubDurations();
|
|
105
|
+
stubLayout(500);
|
|
106
|
+
try {
|
|
107
|
+
const rig = await mountRig();
|
|
108
|
+
|
|
109
|
+
// Open to rest: the enter-edge arm measured 500px and pinned it.
|
|
110
|
+
rig.open.value = true;
|
|
111
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
112
|
+
expect(rig.frameEl()).not.toBeNull();
|
|
113
|
+
expect(rig.pinHeight()).toBe("500px");
|
|
114
|
+
|
|
115
|
+
// Close through the modal's own close button (the real path).
|
|
116
|
+
rig.clickClose();
|
|
117
|
+
await vi.advanceTimersByTimeAsync(150); // mid-leave, past the flip
|
|
118
|
+
expect(rig.frameEl()).not.toBeNull();
|
|
119
|
+
// THE contract: the pin survives the whole closing window — a
|
|
120
|
+
// stop()-instead-of-hold() wiring drops it here and the fold plays
|
|
121
|
+
// over a frame handed back to `height: auto`.
|
|
122
|
+
expect(rig.pinHeight()).toBe("500px");
|
|
123
|
+
|
|
124
|
+
// Still pinned late in the leave.
|
|
125
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
126
|
+
if (rig.frameEl()) {
|
|
127
|
+
expect(rig.pinHeight()).toBe("500px");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Finalize: the surface unmounts (the release write is invisible).
|
|
131
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
132
|
+
expect(rig.frameEl()).toBeNull();
|
|
133
|
+
} finally {
|
|
134
|
+
restoreLayout();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("flushes growth deferred through the enter with an open-edge remeasure", async () => {
|
|
139
|
+
vi.useFakeTimers();
|
|
140
|
+
freezeRaf();
|
|
141
|
+
stubDurations();
|
|
142
|
+
stubLayout(400);
|
|
143
|
+
try {
|
|
144
|
+
const rig = await mountRig();
|
|
145
|
+
|
|
146
|
+
// Open: the enter-edge arm pins the enter-time height (400px).
|
|
147
|
+
rig.open.value = true;
|
|
148
|
+
await vi.advanceTimersByTimeAsync(150); // mid-enter, past the flip
|
|
149
|
+
expect(rig.pinHeight()).toBe("400px");
|
|
150
|
+
|
|
151
|
+
// Content grows mid-enter — with the enter's defer gate the pin
|
|
152
|
+
// must NOT chase it (the choreography's height-relative geometry
|
|
153
|
+
// stays put; in a real browser the RO fires and is deferred too).
|
|
154
|
+
layoutH = 700;
|
|
155
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
156
|
+
expect(rig.pinHeight()).toBe("400px");
|
|
157
|
+
|
|
158
|
+
// The open edge flushes the deferred growth one tick after the
|
|
159
|
+
// transition classes clear — the pin lands at the grown height.
|
|
160
|
+
await vi.advanceTimersByTimeAsync(450);
|
|
161
|
+
expect(rig.pinHeight()).toBe("700px");
|
|
162
|
+
|
|
163
|
+
// Sanity: the surface really rested open before the flush assertion.
|
|
164
|
+
expect(rig.frameEl()).not.toBeNull();
|
|
165
|
+
} finally {
|
|
166
|
+
restoreLayout();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -21,6 +21,7 @@ import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
|
|
|
21
21
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
22
22
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
23
23
|
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
24
|
+
import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
|
|
24
25
|
import { useSizeMorph } from "../composables/useSizeMorph";
|
|
25
26
|
import HButton from "./HkButton";
|
|
26
27
|
import HFab from "./HkFab";
|
|
@@ -201,8 +202,19 @@ export default defineComponent({
|
|
|
201
202
|
* useSizeMorph). */
|
|
202
203
|
const innerRef = ref<HTMLElement>();
|
|
203
204
|
// Content-driven size morphing: the frame follows content growth with
|
|
204
|
-
// the height transition instead of snapping (see useSizeMorph).
|
|
205
|
-
|
|
205
|
+
// the height transition instead of snapping (see useSizeMorph). The
|
|
206
|
+
// gate freezes resize-driven re-measurement through the enter unfold:
|
|
207
|
+
// the choreography's geometry is height-relative (translateY 5% +
|
|
208
|
+
// bottom-10% clip of the frame height), so content streaming in
|
|
209
|
+
// mid-enter would otherwise recompute the pixel geometry under the
|
|
210
|
+
// running animation (2026-09-16 chest field report — the frame
|
|
211
|
+
// snapped 459→697px mid-unfold). The open phase edge flushes the
|
|
212
|
+
// deferred growth with an animated remeasure.
|
|
213
|
+
const morph = useSizeMorph(contentRef, innerRef, {
|
|
214
|
+
deferRemeasure: () =>
|
|
215
|
+
machine.phase.value === "openingFrom" ||
|
|
216
|
+
machine.phase.value === "openingTo",
|
|
217
|
+
});
|
|
206
218
|
let previouslyFocused: HTMLElement | null = null;
|
|
207
219
|
let unmounted = false;
|
|
208
220
|
|
|
@@ -347,13 +359,29 @@ export default defineComponent({
|
|
|
347
359
|
backGuard.push();
|
|
348
360
|
}
|
|
349
361
|
surfTrack.run();
|
|
362
|
+
// Arm the size morph WITH the enter (one tick later, once the
|
|
363
|
+
// mounting patch landed): the pin locks the frame's height for
|
|
364
|
+
// the whole unfold so late-streaming content cannot move the
|
|
365
|
+
// choreography mid-flight (see the morph's gate above). The
|
|
366
|
+
// zero-duration fast paths reach `open` before the tick — the
|
|
367
|
+
// open branch's own start() covers them.
|
|
368
|
+
void nextTick(() => {
|
|
369
|
+
const p = machine.phase.value;
|
|
370
|
+
if (p === "openingFrom" || p === "openingTo") morph.start();
|
|
371
|
+
});
|
|
350
372
|
} else if (to === "open") {
|
|
351
373
|
surfTrack.cancel();
|
|
352
374
|
onAfterEnter();
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
// transition.
|
|
375
|
+
// Idempotent re-arm for the fast paths (see openingFrom); the
|
|
376
|
+
// morph is usually armed already by the enter-edge tick.
|
|
356
377
|
morph.start();
|
|
378
|
+
// Flush the growth deferred through the unfold. One tick later
|
|
379
|
+
// the transition classes are off the frame, so the base height
|
|
380
|
+
// transition animates the pin change instead of the enter's
|
|
381
|
+
// transform/clip-only transition list swallowing it as a snap.
|
|
382
|
+
void nextTick(() => {
|
|
383
|
+
if (machine.phase.value === "open") morph.remeasure();
|
|
384
|
+
});
|
|
357
385
|
} else if (to === "closingFrom") {
|
|
358
386
|
// Close-request bookkeeping (was the watcher's close arm): the
|
|
359
387
|
// registries forget the surface at request time; the finalize
|
|
@@ -361,8 +389,18 @@ export default defineComponent({
|
|
|
361
389
|
surfTrack.run();
|
|
362
390
|
overlay.close();
|
|
363
391
|
backGuard.release();
|
|
364
|
-
//
|
|
365
|
-
|
|
392
|
+
// Keep the height PIN through the leave: the fold owns the
|
|
393
|
+
// frame's geometry and a mid-leave content change must not
|
|
394
|
+
// resize it. The pin releases on the finalize edge below.
|
|
395
|
+
morph.hold();
|
|
396
|
+
// The leave must not chase a live tail — the window folds over
|
|
397
|
+
// the final frame of content, not over a moving stream. A
|
|
398
|
+
// self-updating child inside the held content could still
|
|
399
|
+
// mutate the DOM mid-fold (the observer would re-pin the
|
|
400
|
+
// scroll), so auto-follow tears down with the close request;
|
|
401
|
+
// a reopen interrupt re-arms it at the open edge
|
|
402
|
+
// (onAfterEnter → setupAutoFollow).
|
|
403
|
+
teardownAutoFollow();
|
|
366
404
|
} else if (
|
|
367
405
|
to === "closed" &&
|
|
368
406
|
(from === "closingFrom" || from === "closingTo") &&
|
|
@@ -373,6 +411,10 @@ export default defineComponent({
|
|
|
373
411
|
event !== "UNMOUNT"
|
|
374
412
|
) {
|
|
375
413
|
surfTrack.cancel();
|
|
414
|
+
// Bookkeeping reset for the NEXT open cycle: zeroes the morph's
|
|
415
|
+
// pin so the next enter starts clean (release() writes on the
|
|
416
|
+
// invisible, about-to-unmount frame — no visual effect).
|
|
417
|
+
morph.stop();
|
|
376
418
|
onAfterLeaveFinalize();
|
|
377
419
|
}
|
|
378
420
|
// to === "closed" via UNMOUNT: teardown is owned by
|
|
@@ -743,6 +785,8 @@ export default defineComponent({
|
|
|
743
785
|
return null;
|
|
744
786
|
}
|
|
745
787
|
|
|
788
|
+
const contentHold = useSurfaceContentHold(machine.phase);
|
|
789
|
+
|
|
746
790
|
return () => {
|
|
747
791
|
if (!machine.mounted.value) return null;
|
|
748
792
|
|
|
@@ -773,7 +817,12 @@ export default defineComponent({
|
|
|
773
817
|
onClick={onContentClick}
|
|
774
818
|
tabindex={-1}
|
|
775
819
|
>
|
|
776
|
-
{
|
|
820
|
+
{/* Content hold: while the machine is in a closing
|
|
821
|
+
phase this serves the last live-rendered children,
|
|
822
|
+
so a consumer tearing down its state on close
|
|
823
|
+
cannot blank the window mid-fold. */}
|
|
824
|
+
{contentHold.hold(() => [
|
|
825
|
+
headerShown ? (
|
|
777
826
|
<>
|
|
778
827
|
<div
|
|
779
828
|
class={[
|
|
@@ -807,7 +856,7 @@ export default defineComponent({
|
|
|
807
856
|
<div class="hk-modal-subheader">{slots.header()}</div>
|
|
808
857
|
)}
|
|
809
858
|
</>
|
|
810
|
-
)
|
|
859
|
+
) : null,
|
|
811
860
|
<div ref={bodyRef} class="hk-modal-body">
|
|
812
861
|
<div
|
|
813
862
|
ref={scrollContainerRef}
|
|
@@ -851,8 +900,9 @@ export default defineComponent({
|
|
|
851
900
|
)}
|
|
852
901
|
</>
|
|
853
902
|
)}
|
|
854
|
-
</div
|
|
855
|
-
|
|
903
|
+
</div>,
|
|
904
|
+
renderFooter(),
|
|
905
|
+
])}
|
|
856
906
|
</div>
|
|
857
907
|
</div>
|
|
858
908
|
</Teleport>
|
|
@@ -109,7 +109,52 @@ describe("HkModalBreadcrumb overflow fence", () => {
|
|
|
109
109
|
it("keeps the fold off the accessibility tree and the names on it", () => {
|
|
110
110
|
// The clone duplicates every label — it must stay invisible to AT.
|
|
111
111
|
expect(tsx).toMatch(/hk-modal-breadcrumb-measure"[^>]*aria-hidden="true"/);
|
|
112
|
-
// A cut label
|
|
113
|
-
|
|
112
|
+
// A cut label IS a button, and a cut string is not a name: the whole
|
|
113
|
+
// layer name travels as its accessible name.
|
|
114
|
+
expect(tsx).toMatch(/aria-label=\{crumb\.label\}/);
|
|
115
|
+
// The tappable label must reach through the pointer-transparent strip.
|
|
116
|
+
expect(rule(".hk-modal-breadcrumb-item-reveal")).toContain("pointer-events: auto");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("keeps the tappable label the same box the ruler measures", () => {
|
|
120
|
+
// The clone renders a plain span per label, so padding on the button
|
|
121
|
+
// would make the fold under-count its own crumb; the ~17px text line is
|
|
122
|
+
// also under half a fingertip, and this is the phone-primary
|
|
123
|
+
// affordance. Grow the hit area, not the chrome.
|
|
124
|
+
const block = rule(".hk-modal-breadcrumb-item-reveal");
|
|
125
|
+
// `padding: 0` exactly — the old `padding: 0 var(--space-4, …)` also
|
|
126
|
+
// satisfied a bare "padding: 0" substring, so the guard was vacuous.
|
|
127
|
+
expect(block).toMatch(/padding:\s*0;/);
|
|
128
|
+
expect(block).toContain("pointer-events: auto");
|
|
129
|
+
expect(block).toContain("text-align: start");
|
|
130
|
+
const hitArea = scss.match(/\.hk-modal-breadcrumb-item-reveal::after\s*{[^}]*}/)?.[0] ?? "";
|
|
131
|
+
expect(hitArea).toContain("position: absolute");
|
|
132
|
+
expect(hitArea).toContain("inset: calc(-1 * var(--space-8");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("leaves the label palette to the item classes", () => {
|
|
136
|
+
// The button carries the item classes too, and an equally specific rule
|
|
137
|
+
// later in the file wins: redeclaring `color` here repainted every cut
|
|
138
|
+
// crumb (and dropped the current layer's accent) in exactly the
|
|
139
|
+
// long-title case this feature exists for (2026-09-16 final review).
|
|
140
|
+
const block = rule(".hk-modal-breadcrumb-item-reveal");
|
|
141
|
+
expect(block).not.toContain("color:");
|
|
142
|
+
expect(rule(".hk-modal-breadcrumb-item")).toContain(
|
|
143
|
+
"color: var(--hi-color-text-secondary",
|
|
144
|
+
);
|
|
145
|
+
expect(rule(".hk-modal-breadcrumb-item-current")).toContain(
|
|
146
|
+
"color: var(--hi-color-primary",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("opens the revealed name in the same popover family as the menu", () => {
|
|
151
|
+
// Two surfaces, one form-factor rule: both are HkPopovers that dock as
|
|
152
|
+
// a sheet on mobile, anchored to the crumb they belong to.
|
|
153
|
+
expect(tsx.match(/<HkPopover/g)).toHaveLength(2);
|
|
154
|
+
expect(tsx.match(/sheetOnMobile/g)).toHaveLength(2);
|
|
155
|
+
// A revealed name wraps instead of cutting.
|
|
156
|
+
const block = rule(".hk-modal-breadcrumb-reveal");
|
|
157
|
+
expect(block).toContain("overflow-wrap: anywhere");
|
|
158
|
+
expect(block).not.toContain("text-overflow: ellipsis");
|
|
114
159
|
});
|
|
115
160
|
});
|
|
@@ -121,19 +121,60 @@
|
|
|
121
121
|
--hk-menu-panel-inset: var(--space-4, 0.25rem);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
/*
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
.hk-modal-breadcrumb-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
margin:
|
|
124
|
+
/* A visible-but-cut label is tappable: it opens the whole name in the same
|
|
125
|
+
popover family as the hidden-layers menu. The strip is pointer-
|
|
126
|
+
transparent, so only these buttons (and the "…" trigger) take the click. */
|
|
127
|
+
.hk-modal-breadcrumb-item-reveal {
|
|
128
|
+
/* No padding: the strip's ruler measures a plain item, so a padded
|
|
129
|
+
button would make the fold under-count its own crumb. The hit area is
|
|
130
|
+
grown by a pseudo-element instead, exactly like the "…" trigger. */
|
|
131
|
+
margin: 0;
|
|
132
132
|
padding: 0;
|
|
133
|
-
overflow: hidden;
|
|
134
|
-
clip: rect(0, 0, 0, 0);
|
|
135
|
-
white-space: nowrap;
|
|
136
133
|
border: 0;
|
|
134
|
+
border-radius: var(--radius-sm, 0.25rem);
|
|
135
|
+
background: transparent;
|
|
136
|
+
/* font-family only: the item classes above own size, weight AND colour —
|
|
137
|
+
redeclaring `color` here would repaint every cut crumb (and drop the
|
|
138
|
+
current layer's accent) because an equally specific rule later in the
|
|
139
|
+
file wins. */
|
|
140
|
+
font-family: inherit;
|
|
141
|
+
text-align: start;
|
|
142
|
+
cursor: pointer;
|
|
143
|
+
pointer-events: auto;
|
|
144
|
+
position: relative;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* A label is one text line tall (~17px) — under half a fingertip, and this
|
|
148
|
+
is the phone-primary affordance. Grow the hit area, not the chrome. */
|
|
149
|
+
.hk-modal-breadcrumb-item-reveal::after {
|
|
150
|
+
content: "";
|
|
151
|
+
position: absolute;
|
|
152
|
+
inset: calc(-1 * var(--space-8, 0.5rem)) 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.hk-modal-breadcrumb-item-reveal:hover,
|
|
156
|
+
.hk-modal-breadcrumb-item-reveal[aria-expanded="true"] {
|
|
157
|
+
background: var(--hk-breadcrumb-more-hover, rgba(0, 0, 0, 0.06));
|
|
158
|
+
color: var(--hi-color-text, #1f2328);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.hk-modal-breadcrumb-item-reveal:focus-visible {
|
|
162
|
+
outline: 2px solid var(--hi-color-primary, #7aa2f7);
|
|
163
|
+
outline-offset: 1px;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* The revealed name itself: the strip's own type, wrapping instead of
|
|
167
|
+
cutting, and selectable (the strip around it is not). */
|
|
168
|
+
.hk-modal-breadcrumb-reveal {
|
|
169
|
+
margin: 0;
|
|
170
|
+
padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
|
|
171
|
+
max-width: min(24rem, calc(100vw - 2 * var(--viewport-gutter, 1rem)));
|
|
172
|
+
font-size: var(--text-xs, 0.75rem);
|
|
173
|
+
font-weight: 600;
|
|
174
|
+
line-height: 1.5;
|
|
175
|
+
color: var(--hi-color-text, #1f2328);
|
|
176
|
+
overflow-wrap: anywhere;
|
|
177
|
+
user-select: text;
|
|
137
178
|
}
|
|
138
179
|
|
|
139
180
|
/* The strip's own ruler: every layer laid out at its natural width so the
|