@celestia-island/hikari 0.55.311 → 0.55.313
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/HkModal.leavepin.test.tsx +53 -12
- package/src/components/HkModal.open-close-scale.test.ts +6 -82
- package/src/components/HkModal.scss +13 -35
- package/src/components/HkModal.stepflowswap.test.tsx +19 -7
- package/src/components/HkModal.tsx +40 -35
- package/src/components/paneSwap.contract.test.ts +0 -9
- package/src/composables/useSizeMorph.ts +35 -24
- package/src/index.ts +0 -3
- package/src/components/HkRideBox.scss +0 -110
- package/src/components/HkRideBox.test.tsx +0 -174
- package/src/components/HkRideBox.tsx +0 -165
- package/src/composables/useHeightRide.ts +0 -198
package/package.json
CHANGED
|
@@ -15,7 +15,7 @@ import HkModal from "./HkModal";
|
|
|
15
15
|
*
|
|
16
16
|
* happy-dom has no layout engine, so the frame's offsetHeight is stubbed
|
|
17
17
|
* at the HTMLElement prototype level BEFORE mount — the morph's
|
|
18
|
-
*
|
|
18
|
+
* open-edge arm then measures 500px and pins it, and every
|
|
19
19
|
* mid-leave assertion reads the inline height string directly.
|
|
20
20
|
*/
|
|
21
21
|
const mounts: ReturnType<typeof createApp>[] = [];
|
|
@@ -128,7 +128,7 @@ describe("HkModal leave-window height pin", () => {
|
|
|
128
128
|
try {
|
|
129
129
|
const rig = await mountRig();
|
|
130
130
|
|
|
131
|
-
// Open to rest: the
|
|
131
|
+
// Open to rest: the open-edge arm measured 500px and pinned it.
|
|
132
132
|
rig.open.value = true;
|
|
133
133
|
await vi.advanceTimersByTimeAsync(600);
|
|
134
134
|
expect(rig.frameEl()).not.toBeNull();
|
|
@@ -157,7 +157,7 @@ describe("HkModal leave-window height pin", () => {
|
|
|
157
157
|
}
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
-
it("
|
|
160
|
+
it("leaves the frame unpinned through the enter; the open edge pins the settled height", async () => {
|
|
161
161
|
vi.useFakeTimers();
|
|
162
162
|
freezeRaf();
|
|
163
163
|
stubPhoneViewport();
|
|
@@ -166,24 +166,65 @@ describe("HkModal leave-window height pin", () => {
|
|
|
166
166
|
try {
|
|
167
167
|
const rig = await mountRig();
|
|
168
168
|
|
|
169
|
-
// Open: the enter
|
|
169
|
+
// Open: nothing arms during the enter — the frame stays height:auto
|
|
170
|
+
// so content that streams in mid-enter reflows it live instead of
|
|
171
|
+
// sitting clipped behind a mount-time pin (2026-11-07 chest report:
|
|
172
|
+
// topic windows fetched their detail mid-enter and the bottom of
|
|
173
|
+
// the window stayed missing for the whole open animation).
|
|
170
174
|
rig.open.value = true;
|
|
171
175
|
await vi.advanceTimersByTimeAsync(150); // mid-enter, past the flip
|
|
172
|
-
expect(rig.
|
|
176
|
+
expect(rig.frameEl()).not.toBeNull();
|
|
177
|
+
expect(rig.pinHeight()).toBe("");
|
|
173
178
|
|
|
174
|
-
// Content grows mid-enter —
|
|
175
|
-
//
|
|
176
|
-
// stays put; in a real browser the RO fires and is deferred too).
|
|
179
|
+
// Content grows mid-enter: still unpinned — the growth IS the
|
|
180
|
+
// frame (auto height follows it frame-by-frame).
|
|
177
181
|
layoutH = 700;
|
|
178
182
|
await vi.advanceTimersByTimeAsync(50);
|
|
179
|
-
expect(rig.pinHeight()).toBe("
|
|
183
|
+
expect(rig.pinHeight()).toBe("");
|
|
180
184
|
|
|
181
|
-
// The open edge
|
|
182
|
-
//
|
|
185
|
+
// The open edge pins the settled natural height directly — no
|
|
186
|
+
// deferred flush, no second growth spurt after the enter.
|
|
183
187
|
await vi.advanceTimersByTimeAsync(450);
|
|
184
188
|
expect(rig.pinHeight()).toBe("700px");
|
|
185
189
|
|
|
186
|
-
// Sanity: the surface really rested open before the
|
|
190
|
+
// Sanity: the surface really rested open before the pin assertion.
|
|
191
|
+
expect(rig.frameEl()).not.toBeNull();
|
|
192
|
+
} finally {
|
|
193
|
+
restoreLayout();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("drops the held pin at a reopen-interrupt edge; the open edge re-pins", async () => {
|
|
198
|
+
vi.useFakeTimers();
|
|
199
|
+
freezeRaf();
|
|
200
|
+
stubPhoneViewport();
|
|
201
|
+
stubDurations();
|
|
202
|
+
stubLayout(500);
|
|
203
|
+
try {
|
|
204
|
+
const rig = await mountRig();
|
|
205
|
+
|
|
206
|
+
// Open to rest (the open-edge arm pins 500px), then request a
|
|
207
|
+
// close: the leave holds the pin (the fold owns the frame).
|
|
208
|
+
rig.open.value = true;
|
|
209
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
210
|
+
expect(rig.pinHeight()).toBe("500px");
|
|
211
|
+
rig.open.value = false;
|
|
212
|
+
await vi.advanceTimersByTimeAsync(150); // mid-leave
|
|
213
|
+
expect(rig.frameEl()).not.toBeNull();
|
|
214
|
+
expect(rig.pinHeight()).toBe("500px");
|
|
215
|
+
|
|
216
|
+
// Reopen mid-leave: the reopening edge must DROP the held pin —
|
|
217
|
+
// otherwise the reopen enter plays at the OLD content's height and
|
|
218
|
+
// clips whatever the new content renders (the sibling of the
|
|
219
|
+
// first-open bug this suite retired).
|
|
220
|
+
rig.open.value = true;
|
|
221
|
+
await vi.advanceTimersByTimeAsync(150); // mid-reopen-enter
|
|
222
|
+
expect(rig.frameEl()).not.toBeNull();
|
|
223
|
+
expect(rig.pinHeight()).toBe("");
|
|
224
|
+
|
|
225
|
+
// The open edge re-pins at rest (content unchanged → same height).
|
|
226
|
+
await vi.advanceTimersByTimeAsync(450);
|
|
227
|
+
expect(rig.pinHeight()).toBe("500px");
|
|
187
228
|
expect(rig.frameEl()).not.toBeNull();
|
|
188
229
|
} finally {
|
|
189
230
|
restoreLayout();
|
|
@@ -2,25 +2,12 @@
|
|
|
2
2
|
* Behavioral contract for the DESKTOP modal open/close choreography — run
|
|
3
3
|
* against the COMPILED stylesheet, not the source text.
|
|
4
4
|
*
|
|
5
|
-
* User direction 2026-11-07 (
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
|
|
11
|
-
* frame maps its surface phases onto `hk-ride--from` / `hk-ride--to`):
|
|
12
|
-
*
|
|
13
|
-
* frame height : chrome + content × 0.9 ⇄ chrome + content
|
|
14
|
-
* frame : translateY(10px) scale(0.97) ⇄ translateY(0) scale(1)
|
|
15
|
-
* content/body : never transformed (the whole window zooms)
|
|
16
|
-
*
|
|
17
|
-
* The retired grammars, oldest first: the 2026-11-04 whole-frame
|
|
18
|
-
* `scale(1, 0.9)`, the 2026-11-05 body-only uniform `scale(0.9)`, the
|
|
19
|
-
* 2026-11-06 `scale(1.1, 1)` overshoot, its same-day `scale(0.95, 1)`
|
|
20
|
-
* pinch, and the 2026-11-06/07 frame-level `scaleY(0.9)` ride (retired
|
|
21
|
-
* because a frame scale squashed the title glyphs and the footer buttons —
|
|
22
|
-
* the user's 2026-11-07 report on 0.55.305).
|
|
23
|
-
*/
|
|
5
|
+
* User direction 2026-11-07 (fifth revision, chosen from a comparison
|
|
6
|
+
* page): the whole window zooms and rises — B3. The frame's transform is
|
|
7
|
+
* the permanent centering translate with a translateY+scale appended, a
|
|
8
|
+
* compositor-only motion; opacity rides the same clock. The body carries
|
|
9
|
+
* NO rule in this choreography (nothing inside is ever scaled on its own).
|
|
10
|
+
*/
|
|
24
11
|
import { describe, expect, it } from "vitest";
|
|
25
12
|
import { dirname, join } from "node:path";
|
|
26
13
|
import { fileURLToPath } from "node:url";
|
|
@@ -338,13 +325,6 @@ const ENTER_TRANSITION =
|
|
|
338
325
|
const LEAVE_TRANSITION =
|
|
339
326
|
"opacity var(--hk-modal-duration, 0.32s) var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)), " +
|
|
340
327
|
"transform var(--hk-modal-duration, 0.32s) var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1))";
|
|
341
|
-
/** The ride's own geometry lives in the shared container sheet; the modal
|
|
342
|
-
* frame maps its phases onto these two ends. */
|
|
343
|
-
const RIDE_SHEET = (() => {
|
|
344
|
-
const path = join(here, "HkRideBox.scss");
|
|
345
|
-
return compile(path, { loadPaths: [here, join(here, "..", "styles")], style: "expanded" }).css;
|
|
346
|
-
})();
|
|
347
|
-
const RIDE_RULES = parseRules(stripComments(RIDE_SHEET));
|
|
348
328
|
|
|
349
329
|
describe("HkModal desktop open/close contract (compiled cascade, user direction 2026-11-07)", () => {
|
|
350
330
|
it("compiles into a stylesheet with the ride rules present (known-positive)", () => {
|
|
@@ -434,49 +414,6 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
434
414
|
expect(leaveWill).toBe("opacity, transform");
|
|
435
415
|
});
|
|
436
416
|
|
|
437
|
-
it("rides the HEIGHT through the shared container contract, chrome untouched", () => {
|
|
438
|
-
// The two ends of the ride are expressed as `height: calc(chrome +
|
|
439
|
-
// content × factor)` on the frame (`!important` beats the size morph's
|
|
440
|
-
// inline pin for the duration of the flight) and as a top-origin
|
|
441
|
-
// `scaleY` on the CONTENT REGION only — so the title bar and the footer
|
|
442
|
-
// keep their natural size, which is the 2026-11-07 user requirement.
|
|
443
|
-
// A selector may be declared by more than one rule (the ride's geometry
|
|
444
|
-
// pairs: the compressed height, the content's natural-height lock and the
|
|
445
|
-
// content's scale). Look the declaration up by property, not by "the
|
|
446
|
-
// first rule with this selector".
|
|
447
|
-
const rideRule = (selector: string, prop = "height"): CssRule | undefined =>
|
|
448
|
-
RIDE_RULES.find((r) => r.selector === selector && r.declarations.has(prop));
|
|
449
|
-
|
|
450
|
-
const from = rideRule(".hk-ride[data-hk-ride-ready].hk-ride--from");
|
|
451
|
-
expect(from, "compressed end rule").toBeTruthy();
|
|
452
|
-
expect(from!.declarations.get("height")).toBe(
|
|
453
|
-
"calc(var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px) * var(--hk-ride-factor, 0.9))",
|
|
454
|
-
);
|
|
455
|
-
expect(from!.important.has("height"), "the ride outranks the morph's inline pin").toBe(true);
|
|
456
|
-
|
|
457
|
-
const to = rideRule(".hk-ride[data-hk-ride-ready].hk-ride--to");
|
|
458
|
-
expect(to, "natural end rule").toBeTruthy();
|
|
459
|
-
expect(to!.declarations.get("height")).toBe(
|
|
460
|
-
"calc(var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px))",
|
|
461
|
-
);
|
|
462
|
-
expect(to!.important.has("height")).toBe(true);
|
|
463
|
-
|
|
464
|
-
// Only the content region is ever scaled — top origin, so the compressed
|
|
465
|
-
// paint lands exactly in the shorter slot the frame leaves for it.
|
|
466
|
-
const scaled = rideRule(".hk-ride[data-hk-ride-ready].hk-ride--from > .hk-ride-content", "transform");
|
|
467
|
-
expect(scaled, "content compress rule").toBeTruthy();
|
|
468
|
-
expect(scaled!.declarations.get("transform")).toBe(
|
|
469
|
-
"scaleY(var(--hk-ride-factor, 0.9))",
|
|
470
|
-
);
|
|
471
|
-
const origin = rideRule(".hk-ride-content", "transform-origin");
|
|
472
|
-
expect(origin!.declarations.get("transform-origin")).toBe("50% 0");
|
|
473
|
-
|
|
474
|
-
// …and the CHROME is never named by any ride rule: the modal's header,
|
|
475
|
-
// sub-header and footer must not be reachable from the ride sheet.
|
|
476
|
-
const CHROME = /hk-modal-(header|subheader|footer|title|close)\b/;
|
|
477
|
-
const offenders = RIDE_RULES.filter((r) => CHROME.test(r.selector)).map((r) => r.selector);
|
|
478
|
-
expect(offenders, "the ride container must not target modal chrome").toEqual([]);
|
|
479
|
-
});
|
|
480
417
|
|
|
481
418
|
it("never transforms the chrome: title bar, sub-header, footer, close button, title glyph", () => {
|
|
482
419
|
for (const state of RIDE) {
|
|
@@ -558,15 +495,6 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
558
495
|
).toBeUndefined();
|
|
559
496
|
});
|
|
560
497
|
|
|
561
|
-
it("maps the surface states onto the ride's two ends", () => {
|
|
562
|
-
// The modal's TSX adds `hk-ride--from` for openingFrom/closingTo and
|
|
563
|
-
// `hk-ride--to` for openingTo/closingFrom; the container sheet keys off
|
|
564
|
-
// exactly those class names. A rename on either side has to fail here.
|
|
565
|
-
const names = RIDE_RULES.map((r) => r.selector).join("\n");
|
|
566
|
-
expect(names).toContain("hk-ride--from");
|
|
567
|
-
expect(names).toContain("hk-ride--to");
|
|
568
|
-
expect(names).toContain("hk-ride-content");
|
|
569
|
-
});
|
|
570
498
|
|
|
571
499
|
it("never lets ANY rule put a ride transform on the content region", () => {
|
|
572
500
|
// Fail-closed sweep, selector-level and media-independent (R2 finding
|
|
@@ -588,8 +516,6 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
588
516
|
// width never does). Everything else — scaleX, a 2-arg scale, a
|
|
589
517
|
// translate, a matrix — is a contract break.
|
|
590
518
|
const LEGAL_BODY_TRANSFORM = new Set([
|
|
591
|
-
"scaleY(var(--hk-ride-factor, 0.9))",
|
|
592
|
-
"scaleY(1)",
|
|
593
519
|
"none",
|
|
594
520
|
]);
|
|
595
521
|
const offenders: string[] = [];
|
|
@@ -657,8 +583,6 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
657
583
|
// The body carries no transform contract any more (2026-11-07): any
|
|
658
584
|
// ride rule targeting it is an offender outright.
|
|
659
585
|
const LEGAL_BODY = new Set<string>([
|
|
660
|
-
"scaleY(var(--hk-ride-factor, 0.9))",
|
|
661
|
-
"scaleY(1)",
|
|
662
586
|
"none",
|
|
663
587
|
]);
|
|
664
588
|
const offenders: string[] = [];
|
|
@@ -99,41 +99,19 @@
|
|
|
99
99
|
// ------
|
|
100
100
|
// Content enter/leave transitions (desktop ≥768px)
|
|
101
101
|
//
|
|
102
|
-
// Default window open/close (user direction 2026-11-07,
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
// sheet maps the surface states onto the ride's two ends. Chrome lives
|
|
116
|
-
// outside the transform, so its type renders at natural size for the whole
|
|
117
|
-
// flight, and the content region keeps its natural LAYOUT box (only its
|
|
118
|
-
// paint is compressed about its own top edge), so the compressed paint
|
|
119
|
-
// lands exactly in the shorter slot the frame leaves for it.
|
|
120
|
-
//
|
|
121
|
-
// The centering translate stays the frame's whole transform in every state
|
|
122
|
-
// (byte-identical between rest and both ride ends, `--hk-modal-shift-*`
|
|
123
|
-
// included), so the ride never re-seats the frame, dragged windows keep
|
|
124
|
-
// their shift, and a box that grows by Δ about its center moves its top
|
|
125
|
-
// edge up by Δ/2 — the symmetric growth the user asked for.
|
|
126
|
-
//
|
|
127
|
-
// Scroll containers are untouched by construction: `HkScrollContainer`,
|
|
128
|
-
// `HkScrollPin` and the overlay scrollbar measure layout boxes, which the
|
|
129
|
-
// content region's transform never changes, and nothing inside the content
|
|
130
|
-
// reflows per frame. Surfaces may read `data-hk-riding` to skip work while
|
|
131
|
-
// a ride is in flight.
|
|
132
|
-
//
|
|
133
|
-
// The `!important` on the two height ends is load-bearing: the size morph
|
|
134
|
-
// pins the frame's height as an inline px value, and the ride — not the pin
|
|
135
|
-
// — owns the box for the duration of the flight. Reduced motion flattens
|
|
136
|
-
// both the height transition and the content scale.
|
|
102
|
+
// Default window open/close (user direction 2026-11-07, fifth revision —
|
|
103
|
+
// chosen from a side-by-side comparison page): the WHOLE WINDOW zooms and
|
|
104
|
+
// rises while it fades. A compositor animation end to end — no layout
|
|
105
|
+
// property moves, nothing reflows per frame, the backdrop-filtered fixed
|
|
106
|
+
// layer never re-rasters. The 3% scale is imperceptible as glyph
|
|
107
|
+
// distortion; the 10px rise gives the motion direction. Chrome, content,
|
|
108
|
+
// sticky bars — everything inside keeps its exact shape at every t.
|
|
109
|
+
// (Retired grammars, oldest first: the 2026-11-04 whole-frame scale(1,0.9);
|
|
110
|
+
// the 2026-11-05 body-only uniform scale; the 0.95 pinch; the frame-level
|
|
111
|
+
// scaleY(0.9); and the 2026-11-07 layout-height ride of 0.55.307–310, whose
|
|
112
|
+
// family container — HkRideBox/useHeightRide — was retired with it: it
|
|
113
|
+
// fought the size morph for the same property and animated layout on a
|
|
114
|
+
// backdrop-filtered fixed layer.)
|
|
137
115
|
// ------
|
|
138
116
|
|
|
139
117
|
@media (min-width: 768px) {
|
|
@@ -456,14 +456,21 @@ describe("HkModal rides its chrome and gates the slide window", () => {
|
|
|
456
456
|
stubClip();
|
|
457
457
|
stubBox(500, Number.POSITIVE_INFINITY);
|
|
458
458
|
stubPhoneViewport();
|
|
459
|
-
const roInstances: Array<{ callback: () => void }> = [];
|
|
459
|
+
const roInstances: Array<{ callback: () => void; target: Element | null }> = [];
|
|
460
|
+
// Select the morph's observer by its OBSERVED TARGET, never by index:
|
|
461
|
+
// the overlay scrollbar attaches its own ResizeObserver at the mounted
|
|
462
|
+
// flip, and since the morph arms at the `open` edge (after the enter)
|
|
463
|
+
// the scrollbar's observer registers first.
|
|
464
|
+
const morphRo = () =>
|
|
465
|
+
roInstances.find((ro) => ro.target?.classList.contains("hk-modal-body-inner"))!;
|
|
460
466
|
vi.stubGlobal("ResizeObserver", class {
|
|
461
467
|
callback: () => void;
|
|
468
|
+
target: Element | null = null;
|
|
462
469
|
constructor(cb: () => void) {
|
|
463
470
|
this.callback = cb;
|
|
464
471
|
roInstances.push(this);
|
|
465
472
|
}
|
|
466
|
-
observe() {}
|
|
473
|
+
observe(el: Element) { this.target = el; }
|
|
467
474
|
disconnect() {}
|
|
468
475
|
});
|
|
469
476
|
const rig = await mountRig();
|
|
@@ -477,7 +484,7 @@ describe("HkModal rides its chrome and gates the slide window", () => {
|
|
|
477
484
|
rig.announceSwap({ delta: 0, durationMs: 300, phase: "swap" });
|
|
478
485
|
await nextTick();
|
|
479
486
|
content = 560;
|
|
480
|
-
|
|
487
|
+
morphRo().callback();
|
|
481
488
|
await vi.advanceTimersByTimeAsync(400);
|
|
482
489
|
expect(frame.style.height).toBe("596px");
|
|
483
490
|
|
|
@@ -506,14 +513,19 @@ describe("HkModal rides its chrome and gates the slide window", () => {
|
|
|
506
513
|
stubClip();
|
|
507
514
|
stubBox(500, Number.POSITIVE_INFINITY);
|
|
508
515
|
stubPhoneViewport();
|
|
509
|
-
const roInstances: Array<{ callback: () => void }> = [];
|
|
516
|
+
const roInstances: Array<{ callback: () => void; target: Element | null }> = [];
|
|
517
|
+
// Same target-based selection as the safety-timer test: the scrollbar's
|
|
518
|
+
// observer registers before the morph's open-edge arm.
|
|
519
|
+
const morphRo = () =>
|
|
520
|
+
roInstances.find((ro) => ro.target?.classList.contains("hk-modal-body-inner"))!;
|
|
510
521
|
vi.stubGlobal("ResizeObserver", class {
|
|
511
522
|
callback: () => void;
|
|
523
|
+
target: Element | null = null;
|
|
512
524
|
constructor(cb: () => void) {
|
|
513
525
|
this.callback = cb;
|
|
514
526
|
roInstances.push(this);
|
|
515
527
|
}
|
|
516
|
-
observe() {}
|
|
528
|
+
observe(el: Element) { this.target = el; }
|
|
517
529
|
disconnect() {}
|
|
518
530
|
});
|
|
519
531
|
// No rAF stub: happy-dom maps rAF onto its timer queue, so the
|
|
@@ -528,7 +540,7 @@ describe("HkModal rides its chrome and gates the slide window", () => {
|
|
|
528
540
|
rig.announceSwap({ delta: 0, durationMs: 300, phase: "swap" });
|
|
529
541
|
await nextTick();
|
|
530
542
|
content = 560;
|
|
531
|
-
|
|
543
|
+
morphRo().callback();
|
|
532
544
|
await vi.advanceTimersByTimeAsync(700);
|
|
533
545
|
expect(frame.style.height).toBe("596px");
|
|
534
546
|
|
|
@@ -540,7 +552,7 @@ describe("HkModal rides its chrome and gates the slide window", () => {
|
|
|
540
552
|
// Harness validity + unfreeze proof: the SAME observer path now
|
|
541
553
|
// moves the pin without any event.
|
|
542
554
|
content = 700;
|
|
543
|
-
|
|
555
|
+
morphRo().callback();
|
|
544
556
|
await vi.advanceTimersByTimeAsync(700);
|
|
545
557
|
expect(frame.style.height).toBe("796px");
|
|
546
558
|
restoreBox();
|
|
@@ -2,10 +2,6 @@ import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, s
|
|
|
2
2
|
|
|
3
3
|
import { useI18n } from "../i18n/context";
|
|
4
4
|
import "./HkModal.scss";
|
|
5
|
-
// The ride GEOMETRY lives in the family sheet; the modal maps its phases onto
|
|
6
|
-
// it, so the modal must ship it too (deep-import consumers of
|
|
7
|
-
// `./components/HkModal` never touch the barrel). Idempotent with HkRideBox.
|
|
8
|
-
import "./HkRideBox.scss";
|
|
9
5
|
import HkFloatingLayer from "./HkFloatingLayer";
|
|
10
6
|
import { focusFirst, trapFocus } from "../utils/dom";
|
|
11
7
|
import { useOverlay } from "../runtime/useOverlay";
|
|
@@ -288,24 +284,27 @@ export default defineComponent({
|
|
|
288
284
|
const innerRef = ref<HTMLElement>();
|
|
289
285
|
// Content-driven size morphing: the frame follows content growth with
|
|
290
286
|
// the height transition instead of snapping (see useSizeMorph). The
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
287
|
+
// morph arms at the machine's `open` edge only — never during the
|
|
288
|
+
// enter. Under the current whole-window zoom+rise choreography the
|
|
289
|
+
// open animation is opacity+transform on the frame: nothing about it
|
|
290
|
+
// reads the frame's height, so an enter-time pin has nothing left to
|
|
291
|
+
// protect, and pinning during the enter would freeze the frame at the
|
|
292
|
+
// mount-time height while `overflow: hidden` clips whatever content
|
|
293
|
+
// streams in behind it (2026-11-07 chest report — topic windows
|
|
294
|
+
// fetched their detail mid-enter and read as "the bottom of the
|
|
295
|
+
// window is missing" for the whole open animation, then grew). The
|
|
296
|
+
// old arm-and-freeze existed for the retired height-relative unveil
|
|
297
|
+
// (translateY fraction + bottom clip of the frame height), whose
|
|
298
|
+
// geometry a mid-enter resize would have corrupted; that grammar is
|
|
299
|
+
// gone, so the freeze is gone with it.
|
|
300
|
+
// The freeze flag (round 16) stays: while a stepflow SLIDE is in
|
|
301
|
+
// flight the sheet's height must not chase the flow's changing cell —
|
|
302
|
+
// the gate below also returns true then, and the morph window's own
|
|
303
|
+
// event flushes the frozen change with an explicit remeasure.
|
|
302
304
|
let swapFrozen = false;
|
|
303
305
|
let swapFreezeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
304
306
|
const morph = useSizeMorph(contentRef, innerRef, {
|
|
305
|
-
deferRemeasure: () =>
|
|
306
|
-
swapFrozen ||
|
|
307
|
-
machine.phase.value === "openingFrom" ||
|
|
308
|
-
machine.phase.value === "openingTo",
|
|
307
|
+
deferRemeasure: () => swapFrozen,
|
|
309
308
|
// The sheet's chrome and content block ride the fold edge in
|
|
310
309
|
// lockstep with the clip sweep: the title bar glides with the edge
|
|
311
310
|
// instead of being sliced by it mid-sweep, the content block starts
|
|
@@ -597,26 +596,32 @@ export default defineComponent({
|
|
|
597
596
|
backGuard.push();
|
|
598
597
|
}
|
|
599
598
|
surfTrack.run();
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
// the
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
599
|
+
// No morph arm here, on purpose: the enter is opacity+transform
|
|
600
|
+
// (whole-window zoom+rise), which never reads the frame's
|
|
601
|
+
// height — the frame stays at `height: auto` so content that
|
|
602
|
+
// streams in mid-enter reflows it live instead of being clipped
|
|
603
|
+
// behind a stale mount-time pin (see the morph gate above). The
|
|
604
|
+
// size morph arms once, at the `open` edge below.
|
|
605
|
+
// A REOPEN-INTERRUPT (OPEN mid-close) lands here too: the close
|
|
606
|
+
// request held the previous cycle's pin (morph.hold(), so the
|
|
607
|
+
// fold played on a stable box) and nothing else would drop it —
|
|
608
|
+
// the reopen enter would then play pinned at the OLD content's
|
|
609
|
+
// height, clipping new content exactly like the first-open bug
|
|
610
|
+
// this file retired. Release the pin so every enter — first or
|
|
611
|
+
// interrupted — starts at `height: auto`; the hold-kept pin's
|
|
612
|
+
// other purpose (the close completing) is served by the
|
|
613
|
+
// finalize edge's own release.
|
|
614
|
+
morph.release();
|
|
611
615
|
} else if (to === "open") {
|
|
612
616
|
surfTrack.cancel();
|
|
613
617
|
onAfterEnter();
|
|
614
|
-
//
|
|
618
|
+
// The ONLY morph arm (see the openingFrom note): the enter has
|
|
619
|
+
// landed, so calibrating and pinning here measures the frame at
|
|
620
|
+
// rest — with the enter classes no longer in play one tick
|
|
621
|
+
// later, the follow-up remeasure re-checks the natural height
|
|
622
|
+
// under the base height transition (a growth that landed in the
|
|
623
|
+
// last tick of the enter animates instead of snapping).
|
|
615
624
|
morph.start();
|
|
616
|
-
// Flush the growth deferred through the unfold. One tick later
|
|
617
|
-
// the transition classes are off the frame, so the base height
|
|
618
|
-
// transition animates the pin change instead of the enter's
|
|
619
|
-
// opacity/transform-only transition list swallowing it as a snap.
|
|
620
625
|
void nextTick(() => {
|
|
621
626
|
if (machine.phase.value === "open") morph.remeasure();
|
|
622
627
|
});
|
|
@@ -127,15 +127,6 @@ describe("pane-swap contract", () => {
|
|
|
127
127
|
// These are hosts that MUST render their slot keyed by state — they
|
|
128
128
|
// are exactly the pane slots the rule is about, and each is either
|
|
129
129
|
// wrapped by the standard above or justified here.
|
|
130
|
-
// HkRideBox renders its two CHROME slots by name
|
|
131
|
-
// (`slots["chrome-start"]`, `slots["chrome-end"]`). They are not pane
|
|
132
|
-
// slots and never swap: they are the fixed-size bands bracketing the
|
|
133
|
-
// content region, and the whole point of the box is that they stay
|
|
134
|
-
// OUTSIDE the scaled region (2026-11-07 user report: a frame-level
|
|
135
|
-
// scale squashed the title bar and the footer buttons). Routing them
|
|
136
|
-
// through HkTransitionContainer would be wrong — they must render
|
|
137
|
-
// exactly once, in place, in the same paint.
|
|
138
|
-
"HkRideBox.tsx",
|
|
139
130
|
"HkTabs.tsx", // wrapped by HkTransitionContainer (pinned above)
|
|
140
131
|
"HkSettingsDialog.tsx", // wrapped by HkTransitionContainer (pinned above)
|
|
141
132
|
// HkStepFlow is NOT a rogue pane slot — it is a sibling mechanism
|
|
@@ -34,10 +34,13 @@ const REVEAL_WARMUP_FRAMES = 2;
|
|
|
34
34
|
export interface SizeMorph {
|
|
35
35
|
/** Arm the morph: observe the content and pin the frame's natural
|
|
36
36
|
* height on every change. Call once the surface finished its open
|
|
37
|
-
* enter
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* enter — the enter must own the frame's geometry (HkModal and
|
|
38
|
+
* HkSelectPanel both arm at the machine's `open` edge; the enter
|
|
39
|
+
* classes are off the frame by then, so calibration reads the rest
|
|
40
|
+
* state). There is no early-arm protocol anymore: the modal's enter
|
|
41
|
+
* is opacity+transform and never reads the frame's height, so an
|
|
42
|
+
* enter-time pin would only freeze a stale height under
|
|
43
|
+
* `overflow: hidden`. */
|
|
41
44
|
start(): void;
|
|
42
45
|
/** Disarm the morph and release the frame to `height: auto` — call
|
|
43
46
|
* before a surface's leave/close so the exit animation owns the
|
|
@@ -48,12 +51,20 @@ export interface SizeMorph {
|
|
|
48
51
|
* choreography owns the frame's geometry through the whole leave and
|
|
49
52
|
* the pin keeps it stable (a mid-leave content change must not resize
|
|
50
53
|
* the folding frame). The next start() re-arms and, because the pin
|
|
51
|
-
* was kept, a re-pin to the SAME natural height is a no-op (
|
|
52
|
-
*
|
|
54
|
+
* was kept, a re-pin to the SAME natural height is a no-op (a reopen
|
|
55
|
+
* that goes hold → start without releasing — no current HkModal path
|
|
56
|
+
* does this, its reopen edge releases the pin first; held content is unchanged, delta
|
|
53
57
|
* zero; a re-pin to a CHANGED natural while an enter's transition
|
|
54
58
|
* classes own the frame lands without a height animation, i.e. snaps
|
|
55
59
|
* — acceptable, the enter's own choreography owns that moment). */
|
|
56
60
|
hold(): void;
|
|
61
|
+
/** Drop the height pin and hand the frame back to `height: auto`
|
|
62
|
+
* without disarming anything (a hold()ed morph stays disarmed; an
|
|
63
|
+
* armed one keeps observing). In-flight reveals are torn down first,
|
|
64
|
+
* exactly like stop(). This is the reopen-interrupt edge's tool: a
|
|
65
|
+
* reopen enter must not inherit the previous cycle's pin, or new
|
|
66
|
+
* content sits clipped behind the old height for the whole enter. */
|
|
67
|
+
release(): void;
|
|
57
68
|
/** Re-measure and pin now (resize events, open flows). */
|
|
58
69
|
remeasure(): void;
|
|
59
70
|
/** Plain CSS height morph (round 18): transition the frame's pin
|
|
@@ -69,13 +80,14 @@ export interface RideEntry {
|
|
|
69
80
|
|
|
70
81
|
export interface SizeMorphOptions {
|
|
71
82
|
/** While this returns true, resize-driven re-measurements are deferred
|
|
72
|
-
* (the current pin stays). HkModal freezes the frame's height
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
83
|
+
* (the current pin stays). HkModal freezes the frame's height while a
|
|
84
|
+
* stepflow SLIDE is in flight: the sheet must not chase the flow's
|
|
85
|
+
* changing cell mid-slide, and the slide's morph event flushes the
|
|
86
|
+
* frozen change with an explicit remeasure once the content settles.
|
|
87
|
+
* (The gate used to also cover the modal's own enter — that open
|
|
88
|
+
* choreography is opacity+transform today and never reads the frame's
|
|
89
|
+
* height, so the enter arms nothing and defers nothing; see the morph
|
|
90
|
+
* note in HkModal.) */
|
|
79
91
|
deferRemeasure?: () => boolean;
|
|
80
92
|
/** Fold riders (2026-09-23 round-14 chest report): elements collected
|
|
81
93
|
* when a clip sweep is STAGED whose translateY must track the sweep's
|
|
@@ -623,13 +635,12 @@ export function useSizeMorph(
|
|
|
623
635
|
* finished) frame — the only moment guaranteed free of transition-class
|
|
624
636
|
* flex rules. Never calibrate from a remeasure sample: the first
|
|
625
637
|
* remeasure can itself be the contaminated one (a frozen enter leaves
|
|
626
|
-
* the flex rules behind when the surface is mid-repair).
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
* rather than a corrupted one. */
|
|
638
|
+
* the flex rules behind when the surface is mid-repair). Every current
|
|
639
|
+
* consumer arms at the machine's `open` edge, on a resting frame. If a
|
|
640
|
+
* future caller ever arms earlier (mid-enter), this precondition is
|
|
641
|
+
* violated by construction and the contamination guard below degrades
|
|
642
|
+
* the bad sample to a dropped pin rather than a corrupted one — it
|
|
643
|
+
* does not make early arming correct. */
|
|
633
644
|
function calibrate(): void {
|
|
634
645
|
const f = frame.value;
|
|
635
646
|
const c = content.value;
|
|
@@ -834,9 +845,9 @@ export function useSizeMorph(
|
|
|
834
845
|
}
|
|
835
846
|
|
|
836
847
|
function onResize(): void {
|
|
837
|
-
// Frozen window (
|
|
838
|
-
// the
|
|
839
|
-
//
|
|
848
|
+
// Frozen window (HkModal's stepflow SLIDE freeze — the only defer
|
|
849
|
+
// gate left): keep the current pin; the morph event flushes the
|
|
850
|
+
// accumulated change with an explicit remeasure.
|
|
840
851
|
if (options.deferRemeasure?.()) return;
|
|
841
852
|
// Debounce the choreography: content can change in a burst (a list
|
|
842
853
|
// transition shrinking rows over several frames, a textarea growing
|
|
@@ -1076,5 +1087,5 @@ export function useSizeMorph(
|
|
|
1076
1087
|
setTimeout(restore, durationMs + 350);
|
|
1077
1088
|
}
|
|
1078
1089
|
|
|
1079
|
-
return { start, stop, hold, remeasure, heightMorph };
|
|
1090
|
+
return { start, stop, hold, release, remeasure, heightMorph };
|
|
1080
1091
|
}
|
package/src/index.ts
CHANGED
|
@@ -86,7 +86,6 @@ export { default as HProgressBar } from "./components/HkProgressBar";
|
|
|
86
86
|
export { default as HProgressDialog } from "./components/HkProgressDialog";
|
|
87
87
|
export { default as HRadio } from "./components/HkRadio";
|
|
88
88
|
export { default as HScrollContainer } from "./components/HkScrollContainer";
|
|
89
|
-
export { default as HRideBox } from "./components/HkRideBox";
|
|
90
89
|
export { default as HScreenIdentifyCard } from "./components/HkScreenIdentifyCard";
|
|
91
90
|
export { default as HScreenIdentifyHost } from "./components/HkScreenIdentifyHost";
|
|
92
91
|
export { default as HSearchInput } from "./components/HkSearchInput";
|
|
@@ -519,8 +518,6 @@ export type { HkScrollProgressHandle, HkScrollProgressRegistrar } from "./compon
|
|
|
519
518
|
export type { OverlayScrollMapSegment, OverlayScrollbarMarker } from "./composables/useOverlayScrollbar";
|
|
520
519
|
export { useDeferredTransition } from "./composables/useDeferredTransition";
|
|
521
520
|
export { useSurfaceTransition } from "./composables/useSurfaceTransition";
|
|
522
|
-
export { useHeightRide } from "./composables/useHeightRide";
|
|
523
|
-
export type { HeightRide, HeightRideOptions } from "./composables/useHeightRide";
|
|
524
521
|
export { useSizeMorph } from "./composables/useSizeMorph";
|
|
525
522
|
export type { SizeMorph } from "./composables/useSizeMorph";
|
|
526
523
|
export { useImage } from "./composables/useImage";
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
// HkRideBox.scss
|
|
2
|
-
// The family-level height-ride container (see useHeightRide for the WHY).
|
|
3
|
-
//
|
|
4
|
-
// Contract, in one place:
|
|
5
|
-
// .hk-ride the frame whose LAYOUT height rides
|
|
6
|
-
// .hk-ride--from compressed end (chrome + content × factor)
|
|
7
|
-
// .hk-ride--to natural end (chrome + content)
|
|
8
|
-
// .hk-ride-content the ONLY scaled region (origin top, so the
|
|
9
|
-
// compressed paint lands exactly in the shorter slot
|
|
10
|
-
// the frame leaves for it)
|
|
11
|
-
// --hk-ride-chrome px, measured by the composable
|
|
12
|
-
// --hk-ride-content px, measured by the composable
|
|
13
|
-
// --hk-ride-factor the content fraction (default 0.9)
|
|
14
|
-
// [data-hk-riding] present for the whole flight; scroll features that
|
|
15
|
-
// must skip work read it, like [data-hk-morphing] on
|
|
16
|
-
// the mobile sheet
|
|
17
|
-
//
|
|
18
|
-
// At rest neither state class is present, so the frame keeps whatever
|
|
19
|
-
// height its own layout gives it (`auto`, or a consumer's px pin) and the
|
|
20
|
-
// ride degrades to "no ride" rather than to a wrong box if a measurement
|
|
21
|
-
// is missing.
|
|
22
|
-
|
|
23
|
-
// NOTE: the shared class deliberately carries GEOMETRY only. Timing,
|
|
24
|
-
// easing and `overflow` belong to the consuming surface (the modal already
|
|
25
|
-
// runs its own enter/leave transition list, and the phone sheet has a
|
|
26
|
-
// completely different choreography), so this sheet can never clobber one.
|
|
27
|
-
.hk-ride {
|
|
28
|
-
// Only meaningful while a ride class is present (the height resolves
|
|
29
|
-
// from the measured variables); at rest the box keeps its own layout
|
|
30
|
-
// height, or the consumer's px pin.
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// The two ends of the ride. `!important` is deliberate and load-bearing: a
|
|
34
|
-
// consumer's own size-morph pins `height` as an INLINE px value, and the
|
|
35
|
-
// ride — not the pin — owns the box for the duration of the flight.
|
|
36
|
-
.hk-ride-box {
|
|
37
|
-
position: relative;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// The ride's geometry is DESKTOP-only on purpose: the ≤767px sheet has its
|
|
41
|
-
// own clip/translateY choreography, and a shared height override there would
|
|
42
|
-
// fight the sheet's `max-height ... !important` cap.
|
|
43
|
-
@media (min-width: 768px) {
|
|
44
|
-
|
|
45
|
-
.hk-ride[data-hk-ride-ready].hk-ride--from {
|
|
46
|
-
height: calc(
|
|
47
|
-
var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px) * var(--hk-ride-factor, 0.9)
|
|
48
|
-
) !important;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
.hk-ride[data-hk-ride-ready].hk-ride--to {
|
|
52
|
-
height: calc(var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px)) !important;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// The box's OWN root class carries the positioning context: the shared
|
|
56
|
-
// `.hk-ride` class is also worn by surfaces that bring their own `position`
|
|
57
|
-
// (`.hk-modal-content` is `position: fixed`), and a `position: relative`
|
|
58
|
-
// here would tie with it at (0,1,0) and win or lose by bundle order.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
.hk-ride-content {
|
|
62
|
-
transform-origin: 50% 0;
|
|
63
|
-
transition: transform var(--hk-ride-duration, 0.42s) var(--hk-ride-ease, cubic-bezier(0.33, 1, 0.68, 1));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// THE load-bearing pair of the whole primitive: while riding, the content
|
|
67
|
-
// region stops participating in the flex shrink and holds its NATURAL
|
|
68
|
-
// height, so its layout box is stable (scroll geometry, pins and the
|
|
69
|
-
// overlay scrollbar keep reading the same numbers) and the `scaleY` below
|
|
70
|
-
// is the ONLY thing that compresses it. Without this the slot shrink and the
|
|
71
|
-
// transform would compound (0.9 × 0.9 = 0.81) and leave a gap above the
|
|
72
|
-
// pinned chrome-end band (2026-11-07 R3 finding).
|
|
73
|
-
.hk-ride[data-hk-ride-ready].hk-ride--from > .hk-ride-content,
|
|
74
|
-
.hk-ride[data-hk-ride-ready].hk-ride--to > .hk-ride-content {
|
|
75
|
-
flex: 0 0 auto;
|
|
76
|
-
height: var(--hk-ride-content, auto);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
.hk-ride[data-hk-ride-ready].hk-ride--from > .hk-ride-chrome-start,
|
|
80
|
-
.hk-ride[data-hk-ride-ready].hk-ride--to > .hk-ride-chrome-start {
|
|
81
|
-
flex-shrink: 0;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
.hk-ride[data-hk-ride-ready].hk-ride--from > .hk-ride-chrome-end,
|
|
85
|
-
.hk-ride[data-hk-ride-ready].hk-ride--to > .hk-ride-chrome-end {
|
|
86
|
-
position: absolute;
|
|
87
|
-
inset-inline: 0;
|
|
88
|
-
bottom: 0;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
.hk-ride[data-hk-ride-ready].hk-ride--from > .hk-ride-content {
|
|
92
|
-
transform: scaleY(var(--hk-ride-factor, 0.9));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/* Reduced motion: the frame swaps instantly and the content is never
|
|
98
|
-
* compressed. */
|
|
99
|
-
@media (prefers-reduced-motion: reduce) {
|
|
100
|
-
.hk-ride,
|
|
101
|
-
.hk-ride-content {
|
|
102
|
-
transition: none;
|
|
103
|
-
}
|
|
104
|
-
/* Repeated with the readiness gate so this flatten outranks the state
|
|
105
|
-
* rule above (which is (0,4,0)); a bare `.hk-ride.hk-ride--from` here
|
|
106
|
-
* would lose the cascade and the a11y contract would be a lie. */
|
|
107
|
-
.hk-ride[data-hk-ride-ready].hk-ride--from > .hk-ride-content {
|
|
108
|
-
transform: none;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Contract for the family-level height-ride container (`HkRideBox` + the
|
|
3
|
-
* `useHeightRide` measurement primitive).
|
|
4
|
-
*
|
|
5
|
-
* Why these assertions: the motion is a LAYOUT-height animation precisely so
|
|
6
|
-
* that the chrome (title bar, footer action row) keeps its natural size — the
|
|
7
|
-
* 2026-11-07 user report was that a frame-level `transform: scaleY()` squashed
|
|
8
|
-
* the title text and the footer buttons along with everything else. The test
|
|
9
|
-
* that matters is therefore structural, not decorative:
|
|
10
|
-
*
|
|
11
|
-
* 1. the chrome bands are SIBLINGS of the content region, so no transform
|
|
12
|
-
* can ever reach them;
|
|
13
|
-
* 2. the only scaled region is `.hk-ride-content`;
|
|
14
|
-
* 3. the geometry is published as variables (chrome / content / factor) and
|
|
15
|
-
* the two state classes are what the sheet interpolates between;
|
|
16
|
-
* 4. an unmeasurable box (jsdom reports 0) publishes NOTHING, so the sheet
|
|
17
|
-
* falls back to `auto` instead of collapsing the frame;
|
|
18
|
-
* 5. a second ride never shrinks an already-compressed measurement.
|
|
19
|
-
*/
|
|
20
|
-
import { describe, expect, it } from "vitest";
|
|
21
|
-
import { createApp, defineComponent, h, nextTick, ref, type Component } from "vue";
|
|
22
|
-
|
|
23
|
-
import HkRideBox from "./HkRideBox";
|
|
24
|
-
import {
|
|
25
|
-
RIDE_ATTR,
|
|
26
|
-
RIDE_CLASS,
|
|
27
|
-
RIDE_CONTENT_CLASS,
|
|
28
|
-
RIDE_FROM_CLASS,
|
|
29
|
-
RIDE_TO_CLASS,
|
|
30
|
-
} from "../composables/useHeightRide";
|
|
31
|
-
|
|
32
|
-
interface Harness {
|
|
33
|
-
root: () => HTMLElement;
|
|
34
|
-
setRiding: (next: boolean) => Promise<void>;
|
|
35
|
-
unmount: () => void;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function mountBox(
|
|
39
|
-
initial: { riding?: boolean; factor?: number } = {},
|
|
40
|
-
slots?: Record<string, () => unknown>,
|
|
41
|
-
): Harness {
|
|
42
|
-
const host = document.createElement("div");
|
|
43
|
-
document.body.appendChild(host);
|
|
44
|
-
const riding = ref(Boolean(initial.riding));
|
|
45
|
-
const Host = defineComponent({
|
|
46
|
-
setup() {
|
|
47
|
-
return () =>
|
|
48
|
-
h(
|
|
49
|
-
HkRideBox as Component,
|
|
50
|
-
{ riding: riding.value, factor: initial.factor ?? 0.9 },
|
|
51
|
-
slots ?? {
|
|
52
|
-
"chrome-start": () => h("header", { class: "probe-chrome-start" }, "title"),
|
|
53
|
-
default: () => h("div", { class: "probe-content" }, "body"),
|
|
54
|
-
"chrome-end": () => h("footer", { class: "probe-chrome-end" }, "actions"),
|
|
55
|
-
},
|
|
56
|
-
);
|
|
57
|
-
},
|
|
58
|
-
});
|
|
59
|
-
const app = createApp(Host);
|
|
60
|
-
app.mount(host);
|
|
61
|
-
return {
|
|
62
|
-
root: () => host.firstElementChild as HTMLElement,
|
|
63
|
-
setRiding: async (next: boolean) => {
|
|
64
|
-
riding.value = next;
|
|
65
|
-
await nextTick();
|
|
66
|
-
await nextTick();
|
|
67
|
-
},
|
|
68
|
-
unmount: () => {
|
|
69
|
-
app.unmount();
|
|
70
|
-
host.remove();
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** jsdom reports 0 for every layout box. The measurement deliberately never
|
|
76
|
-
* reads the FRAME's height (that is the number the ride computes), so the
|
|
77
|
-
* stub feeds it the two honest sources: the content region's natural scroll
|
|
78
|
-
* extent and the chrome bands' own offsets. */
|
|
79
|
-
function stubBox(root: HTMLElement, content: number, chrome: number): void {
|
|
80
|
-
const contentEl = root.querySelector(`.${RIDE_CONTENT_CLASS}`) as HTMLElement;
|
|
81
|
-
Object.defineProperty(contentEl, "scrollHeight", { configurable: true, value: content });
|
|
82
|
-
Object.defineProperty(contentEl, "offsetHeight", { configurable: true, value: content });
|
|
83
|
-
const chromeEls = Array.from(root.children).filter((el) => el !== contentEl) as HTMLElement[];
|
|
84
|
-
chromeEls.forEach((el, index) => {
|
|
85
|
-
Object.defineProperty(el, "offsetHeight", {
|
|
86
|
-
configurable: true,
|
|
87
|
-
value: index === 0 ? chrome : 0,
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
describe("HkRideBox (family height-ride container)", () => {
|
|
93
|
-
it("renders the ride contract and keeps the chrome outside the scaled region", () => {
|
|
94
|
-
const h1 = mountBox();
|
|
95
|
-
const root = h1.root();
|
|
96
|
-
expect(root.classList.contains(RIDE_CLASS)).toBe(true);
|
|
97
|
-
const content = root.querySelector(`.${RIDE_CONTENT_CLASS}`) as HTMLElement;
|
|
98
|
-
expect(content).not.toBeNull();
|
|
99
|
-
// Structural guarantee: no transform on the box can ever reach the
|
|
100
|
-
// chrome, because the chrome is not inside the content region.
|
|
101
|
-
expect(content.querySelector(".probe-chrome-start")).toBeNull();
|
|
102
|
-
expect(content.querySelector(".probe-chrome-end")).toBeNull();
|
|
103
|
-
expect(content.querySelector(".probe-content")).not.toBeNull();
|
|
104
|
-
expect(root.querySelector(".probe-chrome-start")).not.toBeNull();
|
|
105
|
-
expect(root.querySelector(".probe-chrome-end")).not.toBeNull();
|
|
106
|
-
h1.unmount();
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("stamps the compressed end plus the riding flag while riding", async () => {
|
|
110
|
-
const h2 = mountBox({ riding: true });
|
|
111
|
-
await nextTick();
|
|
112
|
-
const root = h2.root();
|
|
113
|
-
expect(root.classList.contains(RIDE_FROM_CLASS)).toBe(true);
|
|
114
|
-
expect(root.classList.contains(RIDE_TO_CLASS)).toBe(false);
|
|
115
|
-
expect(root.hasAttribute(RIDE_ATTR)).toBe(true);
|
|
116
|
-
h2.unmount();
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("swaps to the natural end and drops the riding flag at rest", async () => {
|
|
120
|
-
const h3 = mountBox({ riding: true });
|
|
121
|
-
await h3.setRiding(false);
|
|
122
|
-
const root = h3.root();
|
|
123
|
-
expect(root.classList.contains(RIDE_FROM_CLASS)).toBe(false);
|
|
124
|
-
expect(root.classList.contains(RIDE_TO_CLASS)).toBe(true);
|
|
125
|
-
expect(root.hasAttribute(RIDE_ATTR)).toBe(false);
|
|
126
|
-
h3.unmount();
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("publishes the measured chrome/content split and the factor", async () => {
|
|
130
|
-
const h4 = mountBox({ riding: false, factor: 0.9 });
|
|
131
|
-
const root = h4.root();
|
|
132
|
-
stubBox(root, 300, 100);
|
|
133
|
-
await h4.setRiding(true);
|
|
134
|
-
|
|
135
|
-
expect(root.style.getPropertyValue("--hk-ride-chrome")).toBe("100px");
|
|
136
|
-
expect(root.style.getPropertyValue("--hk-ride-content")).toBe("300px");
|
|
137
|
-
expect(root.style.getPropertyValue("--hk-ride-factor")).toBe("0.9");
|
|
138
|
-
h4.unmount();
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("publishes nothing when the box cannot be measured", async () => {
|
|
142
|
-
const h5 = mountBox({ riding: true });
|
|
143
|
-
await nextTick();
|
|
144
|
-
const root = h5.root();
|
|
145
|
-
// Not "0px": an unmeasured ride must degrade to `auto` (no ride), never
|
|
146
|
-
// to a collapsed frame.
|
|
147
|
-
expect(root.style.getPropertyValue("--hk-ride-chrome")).toBe("");
|
|
148
|
-
expect(root.style.getPropertyValue("--hk-ride-content")).toBe("");
|
|
149
|
-
h5.unmount();
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("measures the same split whether the frame is at rest or compressed", async () => {
|
|
153
|
-
// The measurement must never read the frame's height (that is the value
|
|
154
|
-
// the ride computes): a compressed frame would otherwise report a short
|
|
155
|
-
// box and every ride would shrink again on top of the last one.
|
|
156
|
-
const h6 = mountBox({ riding: false });
|
|
157
|
-
const root = h6.root();
|
|
158
|
-
stubBox(root, 300, 60);
|
|
159
|
-
await h6.setRiding(true);
|
|
160
|
-
const atRest = {
|
|
161
|
-
chrome: root.style.getPropertyValue("--hk-ride-chrome"),
|
|
162
|
-
content: root.style.getPropertyValue("--hk-ride-content"),
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
// Squeeze the frame as the ride would, and re-arm.
|
|
166
|
-
Object.defineProperty(root, "offsetHeight", { configurable: true, value: 2 });
|
|
167
|
-
await h6.setRiding(false);
|
|
168
|
-
await h6.setRiding(true);
|
|
169
|
-
|
|
170
|
-
expect(root.style.getPropertyValue("--hk-ride-chrome")).toBe(atRest.chrome);
|
|
171
|
-
expect(root.style.getPropertyValue("--hk-ride-content")).toBe(atRest.content);
|
|
172
|
-
h6.unmount();
|
|
173
|
-
});
|
|
174
|
-
});
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HkRideBox — the surface container for the family-level height ride.
|
|
3
|
-
*
|
|
4
|
-
* One job: give a surface a frame whose LAYOUT height rides between the
|
|
5
|
-
* natural height and `chrome + content × factor`, while every chrome band
|
|
6
|
-
* the caller pins keeps its natural size (see `useHeightRide` for why the
|
|
7
|
-
* motion is done on layout height instead of `transform: scaleY()` — the
|
|
8
|
-
* 2026-11-07 user report: a frame-level scale squashed the title bar and
|
|
9
|
-
* the footer buttons).
|
|
10
|
-
*
|
|
11
|
-
* <HkRideBox :riding="isRiding" :factor="0.9">
|
|
12
|
-
* <template #chrome-start><header/></template>
|
|
13
|
-
* <Body/> <!-- the region that gets compressed -->
|
|
14
|
-
* <template #chrome-end><footer/></template>
|
|
15
|
-
* </HkRideBox>
|
|
16
|
-
*
|
|
17
|
-
* The chrome split is MEASURED, not declared (`chrome = boxHeight −
|
|
18
|
-
* contentHeight`), so any number of chrome bands — header, sub-header,
|
|
19
|
-
* breadcrumb strip, footer, a status row — works without the caller
|
|
20
|
-
* listing them. The default slot is the only region ever scaled.
|
|
21
|
-
*
|
|
22
|
-
* Scroll-container compatibility: the content slot keeps its natural
|
|
23
|
-
* layout box for the whole ride, so `HkScrollContainer` / `HkScrollPin` /
|
|
24
|
-
* the overlay scrollbar read stable geometry — no per-frame re-measure, no
|
|
25
|
-
* pins drifting. Surfaces that must skip work while a ride is in flight
|
|
26
|
-
* read the `data-hk-riding` attribute this box stamps (the same contract
|
|
27
|
-
* `data-hk-morphing` gives the mobile sheet).
|
|
28
|
-
*/
|
|
29
|
-
import { defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
30
|
-
|
|
31
|
-
import {
|
|
32
|
-
RIDE_ATTR,
|
|
33
|
-
RIDE_CLASS,
|
|
34
|
-
RIDE_CONTENT_CLASS,
|
|
35
|
-
RIDE_FROM_CLASS,
|
|
36
|
-
RIDE_TO_CLASS,
|
|
37
|
-
useHeightRide,
|
|
38
|
-
} from "../composables/useHeightRide";
|
|
39
|
-
|
|
40
|
-
/** Read the ride duration the sheet is using (ms), with a sane default. */
|
|
41
|
-
function readRideMs(el: HTMLElement): number {
|
|
42
|
-
const raw = getComputedStyle(el).transitionDuration || "";
|
|
43
|
-
const ms = raw
|
|
44
|
-
.split(",")
|
|
45
|
-
.map((part) => part.trim())
|
|
46
|
-
.map((part) => (part.endsWith("ms") ? parseFloat(part) : parseFloat(part) * 1000))
|
|
47
|
-
.filter((n) => Number.isFinite(n) && n > 0);
|
|
48
|
-
return ms.length > 0 ? Math.max(...ms) : 420;
|
|
49
|
-
}
|
|
50
|
-
import "./HkRideBox.scss";
|
|
51
|
-
|
|
52
|
-
export default defineComponent({
|
|
53
|
-
name: "HkRideBox",
|
|
54
|
-
props: {
|
|
55
|
-
/** True for the whole flight: the box is compressed from mount, and
|
|
56
|
-
* drops back to natural when this goes false. */
|
|
57
|
-
riding: { type: Boolean, default: false },
|
|
58
|
-
/** Content height at the compressed extreme, as a fraction of natural. */
|
|
59
|
-
factor: { type: Number, default: 0.9 },
|
|
60
|
-
/** Root element tag. */
|
|
61
|
-
as: { type: String, default: "div" },
|
|
62
|
-
},
|
|
63
|
-
emits: {
|
|
64
|
-
/** The ride landed (its own transitionend, or immediately when there
|
|
65
|
-
* was nothing to animate). */
|
|
66
|
-
settle: () => true,
|
|
67
|
-
},
|
|
68
|
-
setup(props, { slots, emit }) {
|
|
69
|
-
const rootRef = ref<HTMLElement | null>(null);
|
|
70
|
-
const contentRef = ref<HTMLElement | null>(null);
|
|
71
|
-
const ride = useHeightRide(rootRef, contentRef, { factor: props.factor });
|
|
72
|
-
|
|
73
|
-
/** Stamps the state class for the current phase. Called after the
|
|
74
|
-
* measurement lands so the first painted frame is already correct. */
|
|
75
|
-
const apply = (riding: boolean): void => {
|
|
76
|
-
const el = rootRef.value;
|
|
77
|
-
if (!el) return;
|
|
78
|
-
el.classList.toggle(RIDE_CLASS, true);
|
|
79
|
-
if (riding) {
|
|
80
|
-
el.classList.add(RIDE_FROM_CLASS);
|
|
81
|
-
el.classList.remove(RIDE_TO_CLASS);
|
|
82
|
-
el.setAttribute(RIDE_ATTR, "");
|
|
83
|
-
} else if (el.classList.contains(RIDE_FROM_CLASS)) {
|
|
84
|
-
// Coming out of a ride: land through the natural end, then drop the
|
|
85
|
-
// classes (see land()) so the box returns to its own height.
|
|
86
|
-
el.classList.remove(RIDE_FROM_CLASS);
|
|
87
|
-
el.classList.add(RIDE_TO_CLASS);
|
|
88
|
-
el.removeAttribute(RIDE_ATTR);
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
let landTimer: ReturnType<typeof setTimeout> | null = null;
|
|
93
|
-
/** Bumped on every arm: a stale async continuation (rapid
|
|
94
|
-
* riding→rest→riding in one tick) must not land the NEW ride. */
|
|
95
|
-
let generation = 0;
|
|
96
|
-
|
|
97
|
-
const clearLand = (): void => {
|
|
98
|
-
if (landTimer !== null) {
|
|
99
|
-
clearTimeout(landTimer);
|
|
100
|
-
landTimer = null;
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
/** Drop every ride state so the box returns to its own layout height
|
|
105
|
-
* (`auto`, or the consumer's pin) instead of freezing at the measured
|
|
106
|
-
* px value. */
|
|
107
|
-
const land = (): void => {
|
|
108
|
-
const el = rootRef.value;
|
|
109
|
-
if (!el) return;
|
|
110
|
-
el.classList.remove(RIDE_FROM_CLASS, RIDE_TO_CLASS);
|
|
111
|
-
el.removeAttribute(RIDE_ATTR);
|
|
112
|
-
emit("settle");
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
const armRide = async (riding: boolean): Promise<void> => {
|
|
116
|
-
const mine = ++generation;
|
|
117
|
-
clearLand();
|
|
118
|
-
// Measure at REST geometry first (a frame at the compressed end
|
|
119
|
-
// reports a short box, which would make the next ride shrink twice).
|
|
120
|
-
ride.refresh();
|
|
121
|
-
const wasRiding = rootRef.value?.classList.contains(RIDE_FROM_CLASS) ?? false;
|
|
122
|
-
apply(riding);
|
|
123
|
-
await nextTick();
|
|
124
|
-
if (mine !== generation) return;
|
|
125
|
-
if (riding) return;
|
|
126
|
-
if (!wasRiding) {
|
|
127
|
-
emit("settle");
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
// Landing: the natural end is reached once the height transition has
|
|
131
|
-
// run; then the classes come off so the box is `auto` again. A lost
|
|
132
|
-
// transitionend (a WebView that skips it, a frame that never
|
|
133
|
-
// animated) still lands through the watchdog.
|
|
134
|
-
const el = rootRef.value;
|
|
135
|
-
const duration = el ? readRideMs(el) : 420;
|
|
136
|
-
landTimer = setTimeout(() => {
|
|
137
|
-
landTimer = null;
|
|
138
|
-
if (mine !== generation) return;
|
|
139
|
-
land();
|
|
140
|
-
}, duration + 120);
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
onMounted(() => void armRide(props.riding));
|
|
144
|
-
onBeforeUnmount(() => {
|
|
145
|
-
generation += 1;
|
|
146
|
-
clearLand();
|
|
147
|
-
ride.release();
|
|
148
|
-
});
|
|
149
|
-
watch(
|
|
150
|
-
() => props.riding,
|
|
151
|
-
(riding) => void armRide(riding),
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
return () =>
|
|
155
|
-
h(
|
|
156
|
-
props.as,
|
|
157
|
-
{ ref: rootRef, class: [RIDE_CLASS, "hk-ride-box"] },
|
|
158
|
-
[
|
|
159
|
-
h("div", { class: "hk-ride-chrome-start" }, slots["chrome-start"]?.()),
|
|
160
|
-
h("div", { ref: contentRef, class: RIDE_CONTENT_CLASS }, slots.default?.()),
|
|
161
|
-
h("div", { class: "hk-ride-chrome-end" }, slots["chrome-end"]?.()),
|
|
162
|
-
],
|
|
163
|
-
);
|
|
164
|
-
},
|
|
165
|
-
});
|
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* useHeightRide — the family-level "height ride" measurement primitive.
|
|
3
|
-
*
|
|
4
|
-
* Several hikari surfaces want one specific open/close motion: the SURFACE
|
|
5
|
-
* FRAME itself grows from a fraction of its natural height to the full
|
|
6
|
-
* height, while the chrome (title bar, footer action row, any pinned band)
|
|
7
|
-
* keeps its natural size and stays legible. Scaling the whole frame
|
|
8
|
-
* (`transform: scaleY()`) is the wrong tool for that — it squashes the
|
|
9
|
-
* title glyphs, the buttons and the corner radii along with everything
|
|
10
|
-
* else (2026-11-07 user report on the modal ride), and it re-rasters text
|
|
11
|
-
* for the entire ride.
|
|
12
|
-
*
|
|
13
|
-
* The ride is therefore a LAYOUT-height animation:
|
|
14
|
-
*
|
|
15
|
-
* frame height : chrome + content × factor ⇄ chrome + content
|
|
16
|
-
* content region: scaleY(factor) ⇄ scaleY(1) (origin top)
|
|
17
|
-
*
|
|
18
|
-
* The content region keeps its natural layout box (so nothing inside it
|
|
19
|
-
* reflows per frame) and is only visually compressed about its own TOP
|
|
20
|
-
* edge, which makes the compressed paint land exactly in the shorter slot
|
|
21
|
-
* the frame leaves for it: the frame's bottom edge and the content's
|
|
22
|
-
* bottom edge coincide at every t. Chrome is never inside the transform,
|
|
23
|
-
* so its type, icons and padding render at natural size throughout.
|
|
24
|
-
*
|
|
25
|
-
* This composable owns the MEASUREMENT half only: it publishes
|
|
26
|
-
*
|
|
27
|
-
* --hk-ride-chrome px the part of the frame the ride must not squeeze
|
|
28
|
-
* --hk-ride-content px the region that does get compressed
|
|
29
|
-
* --hk-ride-factor the fraction (default 0.9)
|
|
30
|
-
*
|
|
31
|
-
* on the frame, and leaves the interpolation to CSS. That split is what
|
|
32
|
-
* keeps it composable with the surfaces' existing state machines: the
|
|
33
|
-
* consumer keeps flipping its own enter/leave classes (HkModal's surface
|
|
34
|
-
* machine, HkRideBox's `riding` prop) and the sheet resolves
|
|
35
|
-
* `height: calc(chrome + content × factor)` from the published numbers.
|
|
36
|
-
* No timers, no class ownership, no fight with a consumer's own height pin
|
|
37
|
-
* — a state rule carrying `!important` legitimately outranks the inline
|
|
38
|
-
* px the size-morph pins while a ride is in flight.
|
|
39
|
-
*
|
|
40
|
-
* Scroll-container compatibility (the reason this lives upstream rather
|
|
41
|
-
* than inside HkModal): every hikari custom scroller (`HkScrollContainer`,
|
|
42
|
-
* `HkScrollPin`, the overlay scrollbar, the windowed scroller) measures
|
|
43
|
-
* LAYOUT boxes — `offsetHeight` / `clientHeight` / `scrollHeight` — which
|
|
44
|
-
* a transform never touches. Because the ride animates the FRAME's height
|
|
45
|
-
* and leaves the content box natural, those measurements, the scroll
|
|
46
|
-
* offsets, the pins and the fade bands stay coherent for the whole ride;
|
|
47
|
-
* nothing has to be re-measured per frame, and nothing drifts when the
|
|
48
|
-
* ride is interrupted.
|
|
49
|
-
*/
|
|
50
|
-
import { onBeforeUnmount, type Ref } from "vue";
|
|
51
|
-
|
|
52
|
-
/** Class/attribute vocabulary shared by the sheet and the contract tests. */
|
|
53
|
-
export const RIDE_CLASS = "hk-ride";
|
|
54
|
-
export const RIDE_FROM_CLASS = "hk-ride--from";
|
|
55
|
-
export const RIDE_TO_CLASS = "hk-ride--to";
|
|
56
|
-
export const RIDE_CONTENT_CLASS = "hk-ride-content";
|
|
57
|
-
export const RIDE_ATTR = "data-hk-riding";
|
|
58
|
-
/** Present only once the chrome/content split has been measured. */
|
|
59
|
-
export const RIDE_READY_ATTR = "data-hk-ride-ready";
|
|
60
|
-
|
|
61
|
-
export interface HeightRideOptions {
|
|
62
|
-
/** Content height at the ride's extreme, as a fraction of natural.
|
|
63
|
-
* Default 0.9 (a 10% vertical reveal). */
|
|
64
|
-
factor?: number;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface HeightRide {
|
|
68
|
-
/** Measure the frame's chrome/content split and publish the variables.
|
|
69
|
-
* Safe to call at any time OUTSIDE a ride (rest geometry is what the
|
|
70
|
-
* ride needs); calling it mid-ride re-measures the live, compressed
|
|
71
|
-
* box and is therefore a no-op unless `force` is passed. */
|
|
72
|
-
refresh(force?: boolean): boolean;
|
|
73
|
-
/** Inline style vars for the CURRENT cache, or null when nothing has
|
|
74
|
-
* been measured yet. Consumers bind this in their render so the very
|
|
75
|
-
* first painted frame of a remount already carries the ride (a post-
|
|
76
|
-
* mount publish is one paint too late: the enter-from frame would show
|
|
77
|
-
* a full-height frame with unscaled content — read as "clipping"). */
|
|
78
|
-
styleVars(): Record<string, string> | null;
|
|
79
|
-
/** True when a measured split exists (drives the ready attribute). */
|
|
80
|
-
hasCache(): boolean;
|
|
81
|
-
/** Re-apply the LAST measured split without touching the DOM boxes.
|
|
82
|
-
* A surface that unmounts its frame on close loses the inline variables
|
|
83
|
-
* with it; republishing on the next mount is what makes the very first
|
|
84
|
-
* painted frame of the next ride already correct. */
|
|
85
|
-
publish(): boolean;
|
|
86
|
-
/** The chrome height (px) from the last successful measure. */
|
|
87
|
-
chromeHeight(): number;
|
|
88
|
-
/** The content height (px) from the last successful measure. */
|
|
89
|
-
contentHeight(): number;
|
|
90
|
-
/** Drop the published variables (the sheet then falls back to `auto`,
|
|
91
|
-
* i.e. no ride — never to a wrong box). */
|
|
92
|
-
release(): void;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Any ride class means the box is (or is heading) somewhere other than its
|
|
96
|
-
* rest height, so a measurement taken then would poison the next ride. The
|
|
97
|
-
* guard therefore covers BOTH ends; consumers measure at rest. */
|
|
98
|
-
function isInFlight(frame: HTMLElement): boolean {
|
|
99
|
-
return (
|
|
100
|
-
frame.classList.contains(RIDE_FROM_CLASS) || frame.classList.contains(RIDE_TO_CLASS)
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Wire a frame (whose height rides) to the content region it compresses.
|
|
106
|
-
*
|
|
107
|
-
* `frame` must carry `.hk-ride` (directly, or via `HkRideBox`); `content`
|
|
108
|
-
* must carry `.hk-ride-content`. Both are applied by the sheet/component,
|
|
109
|
-
* so consumers normally only supply the refs.
|
|
110
|
-
*/
|
|
111
|
-
export function useHeightRide(
|
|
112
|
-
frame: Ref<HTMLElement | null | undefined>,
|
|
113
|
-
content: Ref<HTMLElement | null | undefined>,
|
|
114
|
-
options: HeightRideOptions = {},
|
|
115
|
-
): HeightRide {
|
|
116
|
-
// Read live: a consumer may change `factor` after mount.
|
|
117
|
-
const factorOf = (): number => options.factor ?? 0.9;
|
|
118
|
-
let chrome = 0;
|
|
119
|
-
let contentH = 0;
|
|
120
|
-
|
|
121
|
-
const release = (): void => {
|
|
122
|
-
const f = frame.value;
|
|
123
|
-
if (!f) return;
|
|
124
|
-
f.style.removeProperty("--hk-ride-chrome");
|
|
125
|
-
f.style.removeProperty("--hk-ride-content");
|
|
126
|
-
f.style.removeProperty("--hk-ride-factor");
|
|
127
|
-
f.removeAttribute(RIDE_READY_ATTR);
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const refresh = (force = false): boolean => {
|
|
131
|
-
const f = frame.value;
|
|
132
|
-
const c = content.value;
|
|
133
|
-
if (!f || !c) return false;
|
|
134
|
-
// Once the split is known, re-measuring DURING a ride would read the
|
|
135
|
-
// compressed boxes and shrink again on the next flight. The first
|
|
136
|
-
// measurement is exempt: the sheet keeps the frame at `auto` until the
|
|
137
|
-
// split is published, so that one is taken on honest natural geometry.
|
|
138
|
-
if (!force && f.hasAttribute(RIDE_READY_ATTR) && isInFlight(f)) return false;
|
|
139
|
-
// Neither side reads the FRAME's height (that is the number the ride
|
|
140
|
-
// computes): the content region's own layout box, plus the frame's OTHER
|
|
141
|
-
// element children (the pinned chrome bands, always `flex-shrink: 0`).
|
|
142
|
-
// Phones/WebViews that report 0 publish nothing at all rather than a
|
|
143
|
-
// wrong box.
|
|
144
|
-
let chromeNatural = 0;
|
|
145
|
-
for (const child of Array.from(f.children)) {
|
|
146
|
-
if (child === c || !(child instanceof HTMLElement)) continue;
|
|
147
|
-
chromeNatural += child.offsetHeight;
|
|
148
|
-
}
|
|
149
|
-
// The content region's own layout box is the ONLY honest source. With
|
|
150
|
-
// no ride class the body is flex-natural; while a ride class is present
|
|
151
|
-
// the box is locked to the published var (stable, no new information —
|
|
152
|
-
// and the frame's height is the ride's own output, so deriving the
|
|
153
|
-
// content from it would feed every early error forward forever).
|
|
154
|
-
const measured = c.offsetHeight;
|
|
155
|
-
if (measured <= 0) return false;
|
|
156
|
-
contentH = measured;
|
|
157
|
-
chrome = chromeNatural;
|
|
158
|
-
f.style.setProperty("--hk-ride-chrome", `${chrome}px`);
|
|
159
|
-
f.style.setProperty("--hk-ride-content", `${contentH}px`);
|
|
160
|
-
f.style.setProperty("--hk-ride-factor", String(factorOf()));
|
|
161
|
-
// Readiness flag: the sheet only overrides the frame's height once the
|
|
162
|
-
// split is real, so an unmeasured ride degrades to a plain fade instead
|
|
163
|
-
// of collapsing to `calc(0px + 0px)`.
|
|
164
|
-
f.setAttribute(RIDE_READY_ATTR, "");
|
|
165
|
-
return true;
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
onBeforeUnmount(() => release());
|
|
169
|
-
|
|
170
|
-
const publish = (): boolean => {
|
|
171
|
-
const f = frame.value;
|
|
172
|
-
if (!f || contentH <= 0) return false;
|
|
173
|
-
f.style.setProperty("--hk-ride-chrome", `${chrome}px`);
|
|
174
|
-
f.style.setProperty("--hk-ride-content", `${contentH}px`);
|
|
175
|
-
f.style.setProperty("--hk-ride-factor", String(factorOf()));
|
|
176
|
-
f.setAttribute(RIDE_READY_ATTR, "");
|
|
177
|
-
return true;
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
const styleVars = (): Record<string, string> | null =>
|
|
181
|
-
contentH <= 0
|
|
182
|
-
? null
|
|
183
|
-
: {
|
|
184
|
-
"--hk-ride-chrome": `${chrome}px`,
|
|
185
|
-
"--hk-ride-content": `${contentH}px`,
|
|
186
|
-
"--hk-ride-factor": String(factorOf()),
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
return {
|
|
190
|
-
refresh,
|
|
191
|
-
styleVars,
|
|
192
|
-
hasCache: () => contentH > 0,
|
|
193
|
-
publish,
|
|
194
|
-
chromeHeight: () => chrome,
|
|
195
|
-
contentHeight: () => contentH,
|
|
196
|
-
release,
|
|
197
|
-
};
|
|
198
|
-
}
|