@celestia-island/hikari 0.55.306 → 0.55.308
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.enter-watchdog.test.tsx +2 -2
- package/src/components/HkModal.open-close-scale.test.ts +98 -72
- package/src/components/HkModal.scss +85 -60
- package/src/components/HkModal.tsx +73 -3
- package/src/components/HkRideBox.scss +110 -0
- package/src/components/HkRideBox.test.tsx +174 -0
- package/src/components/HkRideBox.tsx +165 -0
- package/src/components/paneSwap.contract.test.ts +9 -0
- package/src/composables/useHeightRide.ts +198 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -81,7 +81,7 @@ describe("HkModal enter-class watchdog", () => {
|
|
|
81
81
|
await vi.advanceTimersByTimeAsync(700);
|
|
82
82
|
// Both layers snapped to their resting (class-less) state.
|
|
83
83
|
expect(overlay!.className).toBe("hk-modal-overlay");
|
|
84
|
-
expect(content!.className).toBe("hk-modal-content");
|
|
84
|
+
expect(content!.className).toBe("hk-modal-content hk-ride");
|
|
85
85
|
// …and the surface is still open and intact.
|
|
86
86
|
expect(document.querySelector(".hk-modal-content")).not.toBeNull();
|
|
87
87
|
});
|
|
@@ -170,7 +170,7 @@ describe("HkModal enter-class watchdog across an interrupted leave", () => {
|
|
|
170
170
|
// (fade from visible) instead of the full-opacity pop.
|
|
171
171
|
await vi.advanceTimersByTimeAsync(700);
|
|
172
172
|
expect(overlay.className).toBe("hk-modal-overlay");
|
|
173
|
-
expect(content.className).toBe("hk-modal-content");
|
|
173
|
+
expect(content.className).toBe("hk-modal-content hk-ride");
|
|
174
174
|
// The surface is still open, and closing it afterwards runs a NORMAL
|
|
175
175
|
// leave (fade from visible) instead of the full-opacity pop.
|
|
176
176
|
open.value = false;
|
|
@@ -2,27 +2,23 @@
|
|
|
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 (supersedes the 2026-11-05
|
|
6
|
-
* and the 2026-11-06 body scales): ONLY THE HEIGHT
|
|
7
|
-
*
|
|
8
|
-
* translate
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `.hk-modal-body` carries NO transform rule of its own: its vertical ride
|
|
13
|
-
* is entirely the frame's scaleY, so its width stays exactly 100% while its
|
|
14
|
-
* height follows the 0.9 → 1 compression. The retired grammars, oldest
|
|
15
|
-
* first: the 2026-11-04 whole-frame `scale(1, 0.9)`, the 2026-11-05
|
|
16
|
-
* body-only uniform `scale(0.9)`, the 2026-11-06 `scale(1.1, 1)` overshoot
|
|
17
|
-
* and its same-day `scale(0.95, 1)` pinch.
|
|
5
|
+
* User direction 2026-11-07 (third revision, supersedes the 2026-11-05
|
|
6
|
+
* content-only ride and the 2026-11-06 body scales): ONLY THE HEIGHT MOVES,
|
|
7
|
+
* and the CHROME KEEPS ITS NATURAL SIZE. The frame's transform is the plain
|
|
8
|
+
* centering translate in EVERY state; the open/close is carried by LAYOUT
|
|
9
|
+
* HEIGHT through the family primitive (`useHeightRide` publishes
|
|
10
|
+
* `--hk-ride-chrome` / `--hk-ride-content` / `--hk-ride-factor`, and the
|
|
11
|
+
* frame maps its surface phases onto `hk-ride--from` / `hk-ride--to`):
|
|
18
12
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
13
|
+
* frame height : chrome + content × 0.9 ⇄ chrome + content
|
|
14
|
+
* content region: scaleY(0.9) ⇄ scaleY(1) (origin top)
|
|
15
|
+
*
|
|
16
|
+
* The retired grammars, oldest first: the 2026-11-04 whole-frame
|
|
17
|
+
* `scale(1, 0.9)`, the 2026-11-05 body-only uniform `scale(0.9)`, the
|
|
18
|
+
* 2026-11-06 `scale(1.1, 1)` overshoot, its same-day `scale(0.95, 1)`
|
|
19
|
+
* pinch, and the 2026-11-06/07 frame-level `scaleY(0.9)` ride (retired
|
|
20
|
+
* because a frame scale squashed the title glyphs and the footer buttons —
|
|
21
|
+
* the user's 2026-11-07 report on 0.55.305).
|
|
26
22
|
*/
|
|
27
23
|
import { describe, expect, it } from "vitest";
|
|
28
24
|
import { dirname, join } from "node:path";
|
|
@@ -337,10 +333,17 @@ const FRAME_TO = `${CENTERING} scaleY(1)`;
|
|
|
337
333
|
* R2 finding 2026-11-06 (S1): prefix `toContain` let both survive. */
|
|
338
334
|
const ENTER_TRANSITION =
|
|
339
335
|
"opacity var(--hk-modal-duration, 0.42s) var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)), " +
|
|
340
|
-
"
|
|
336
|
+
"height var(--hk-modal-duration, 0.42s) var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1))";
|
|
341
337
|
const LEAVE_TRANSITION =
|
|
342
338
|
"opacity var(--hk-modal-duration, 0.32s) var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)), " +
|
|
343
|
-
"
|
|
339
|
+
"height var(--hk-modal-duration, 0.32s) var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1))";
|
|
340
|
+
/** The ride's own geometry lives in the shared container sheet; the modal
|
|
341
|
+
* frame maps its phases onto these two ends. */
|
|
342
|
+
const RIDE_SHEET = (() => {
|
|
343
|
+
const path = join(here, "HkRideBox.scss");
|
|
344
|
+
return compile(path, { loadPaths: [here, join(here, "..", "styles")], style: "expanded" }).css;
|
|
345
|
+
})();
|
|
346
|
+
const RIDE_RULES = parseRules(stripComments(RIDE_SHEET));
|
|
344
347
|
|
|
345
348
|
describe("HkModal desktop open/close contract (compiled cascade, user direction 2026-11-07)", () => {
|
|
346
349
|
it("compiles into a stylesheet with the ride rules present (known-positive)", () => {
|
|
@@ -352,30 +355,22 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
352
355
|
expect(selectors.some((s) => s.includes("hk-modal-content-enter-from"))).toBe(true);
|
|
353
356
|
});
|
|
354
357
|
|
|
355
|
-
it("
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
// the
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const expected = state.name === "enter-from" || state.name === "leave-to" ? FRAME_FROM : FRAME_TO;
|
|
371
|
-
expect(
|
|
372
|
-
winner(draggableFrameWith(...state.classes), "transform", DESKTOP),
|
|
373
|
-
`draggable frame ${state.name}`,
|
|
374
|
-
).toBe(expected);
|
|
358
|
+
it("keeps the frame's transform at the plain centering translate in every state", () => {
|
|
359
|
+
// The ride is layout height, not a frame transform — that is the whole
|
|
360
|
+
// point (a frame scale squashed the chrome). So the transform must be
|
|
361
|
+
// the SAME `translate(...)` in rest and at both ride ends, and adding a
|
|
362
|
+
// scale to it in any state is the regression this pins.
|
|
363
|
+
for (const state of [...RIDE, { name: "rest", classes: [] as string[] }]) {
|
|
364
|
+
for (const [label, frame] of [
|
|
365
|
+
["", frameWith(...state.classes)],
|
|
366
|
+
["draggable ", draggableFrameWith(...state.classes)],
|
|
367
|
+
] as const) {
|
|
368
|
+
expect(winner(frame, "transform", DESKTOP), `${label}${state.name}`).toBe(CENTERING);
|
|
369
|
+
for (const prop of ["scale", "zoom", "translate", "rotate", "transform-origin"]) {
|
|
370
|
+
expect(winner(frame, prop, DESKTOP), `${label}${state.name} ${prop}`).toBeUndefined();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
375
373
|
}
|
|
376
|
-
// …and at rest, too: the settled frame carries the translate only.
|
|
377
|
-
expect(winner(frameWith(), "transform", DESKTOP)).toBe(CENTERING);
|
|
378
|
-
expect(winner(draggableFrameWith(), "transform", DESKTOP)).toBe(CENTERING);
|
|
379
374
|
});
|
|
380
375
|
|
|
381
376
|
it("never lets a transform longhand creep onto the frame", () => {
|
|
@@ -421,37 +416,58 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
421
416
|
"will-change",
|
|
422
417
|
DESKTOP,
|
|
423
418
|
);
|
|
424
|
-
expect(enterWill).toBe("opacity,
|
|
419
|
+
expect(enterWill).toBe("opacity, height");
|
|
425
420
|
// The close direction promotes the same pair of layers.
|
|
426
421
|
const leaveWill = winner(
|
|
427
422
|
frameWith("hk-modal-content-leave-to", "hk-modal-content-leave-active"),
|
|
428
423
|
"will-change",
|
|
429
424
|
DESKTOP,
|
|
430
425
|
);
|
|
431
|
-
expect(leaveWill).toBe("opacity,
|
|
426
|
+
expect(leaveWill).toBe("opacity, height");
|
|
432
427
|
});
|
|
433
428
|
|
|
434
|
-
it("
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
429
|
+
it("rides the HEIGHT through the shared container contract, chrome untouched", () => {
|
|
430
|
+
// The two ends of the ride are expressed as `height: calc(chrome +
|
|
431
|
+
// content × factor)` on the frame (`!important` beats the size morph's
|
|
432
|
+
// inline pin for the duration of the flight) and as a top-origin
|
|
433
|
+
// `scaleY` on the CONTENT REGION only — so the title bar and the footer
|
|
434
|
+
// keep their natural size, which is the 2026-11-07 user requirement.
|
|
435
|
+
// A selector may be declared by more than one rule (the ride's geometry
|
|
436
|
+
// pairs: the compressed height, the content's natural-height lock and the
|
|
437
|
+
// content's scale). Look the declaration up by property, not by "the
|
|
438
|
+
// first rule with this selector".
|
|
439
|
+
const rideRule = (selector: string, prop = "height"): CssRule | undefined =>
|
|
440
|
+
RIDE_RULES.find((r) => r.selector === selector && r.declarations.has(prop));
|
|
441
|
+
|
|
442
|
+
const from = rideRule(".hk-ride[data-hk-ride-ready].hk-ride--from");
|
|
443
|
+
expect(from, "compressed end rule").toBeTruthy();
|
|
444
|
+
expect(from!.declarations.get("height")).toBe(
|
|
445
|
+
"calc(var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px) * var(--hk-ride-factor, 0.9))",
|
|
446
|
+
);
|
|
447
|
+
expect(from!.important.has("height"), "the ride outranks the morph's inline pin").toBe(true);
|
|
448
|
+
|
|
449
|
+
const to = rideRule(".hk-ride[data-hk-ride-ready].hk-ride--to");
|
|
450
|
+
expect(to, "natural end rule").toBeTruthy();
|
|
451
|
+
expect(to!.declarations.get("height")).toBe(
|
|
452
|
+
"calc(var(--hk-ride-chrome, 0px) + var(--hk-ride-content, 0px))",
|
|
453
|
+
);
|
|
454
|
+
expect(to!.important.has("height")).toBe(true);
|
|
455
|
+
|
|
456
|
+
// Only the content region is ever scaled — top origin, so the compressed
|
|
457
|
+
// paint lands exactly in the shorter slot the frame leaves for it.
|
|
458
|
+
const scaled = rideRule(".hk-ride[data-hk-ride-ready].hk-ride--from > .hk-ride-content", "transform");
|
|
459
|
+
expect(scaled, "content compress rule").toBeTruthy();
|
|
460
|
+
expect(scaled!.declarations.get("transform")).toBe(
|
|
461
|
+
"scaleY(var(--hk-ride-factor, 0.9))",
|
|
462
|
+
);
|
|
463
|
+
const origin = rideRule(".hk-ride-content", "transform-origin");
|
|
464
|
+
expect(origin!.declarations.get("transform-origin")).toBe("50% 0");
|
|
465
|
+
|
|
466
|
+
// …and the CHROME is never named by any ride rule: the modal's header,
|
|
467
|
+
// sub-header and footer must not be reachable from the ride sheet.
|
|
468
|
+
const CHROME = /hk-modal-(header|subheader|footer|title|close)\b/;
|
|
469
|
+
const offenders = RIDE_RULES.filter((r) => CHROME.test(r.selector)).map((r) => r.selector);
|
|
470
|
+
expect(offenders, "the ride container must not target modal chrome").toEqual([]);
|
|
455
471
|
});
|
|
456
472
|
|
|
457
473
|
it("never transforms the chrome: title bar, sub-header, footer, close button, title glyph", () => {
|
|
@@ -534,6 +550,16 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
534
550
|
).toBeUndefined();
|
|
535
551
|
});
|
|
536
552
|
|
|
553
|
+
it("maps the surface states onto the ride's two ends", () => {
|
|
554
|
+
// The modal's TSX adds `hk-ride--from` for openingFrom/closingTo and
|
|
555
|
+
// `hk-ride--to` for openingTo/closingFrom; the container sheet keys off
|
|
556
|
+
// exactly those class names. A rename on either side has to fail here.
|
|
557
|
+
const names = RIDE_RULES.map((r) => r.selector).join("\n");
|
|
558
|
+
expect(names).toContain("hk-ride--from");
|
|
559
|
+
expect(names).toContain("hk-ride--to");
|
|
560
|
+
expect(names).toContain("hk-ride-content");
|
|
561
|
+
});
|
|
562
|
+
|
|
537
563
|
it("never lets ANY rule put a ride transform on the content region", () => {
|
|
538
564
|
// Fail-closed sweep, selector-level and media-independent (R2 finding
|
|
539
565
|
// 2026-11-07 (S2): the cascade mock cannot match `:not()`, a type
|
|
@@ -662,11 +688,11 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
662
688
|
expect(winner(body, "translate", DESKTOP), `body ${state.name}`).toBeUndefined();
|
|
663
689
|
expect(winner(body, "zoom", DESKTOP), `body ${state.name}`).toBeUndefined();
|
|
664
690
|
|
|
691
|
+
// The FRAME never carries a scale any more — the ride is height, and
|
|
692
|
+
// the only `scaleY` in the choreography lives on the content region's
|
|
693
|
+
// shared contract (pinned by the ride test above).
|
|
665
694
|
const frameTransform = winner(frame, "transform", DESKTOP) ?? "";
|
|
666
|
-
|
|
667
|
-
expect(frameTransform, `frame ${state.name}`).toMatch(
|
|
668
|
-
new RegExp(`^${centeringRe} scaleY\\((0\\.9|1)\\)$`),
|
|
669
|
-
);
|
|
695
|
+
expect(frameTransform, `frame ${state.name}`).toBe(CENTERING);
|
|
670
696
|
expect(frameTransform, `frame ${state.name}`).not.toMatch(FRAME_ESCAPES);
|
|
671
697
|
expect(winner(frame, "scale", DESKTOP), `frame ${state.name}`).toBeUndefined();
|
|
672
698
|
expect(winner(frame, "zoom", DESKTOP), `frame ${state.name}`).toBeUndefined();
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
z-index: 1;
|
|
58
58
|
top: 50%;
|
|
59
59
|
left: 50%;
|
|
60
|
-
// The centering translate is the frame's
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
60
|
+
// The centering translate is the frame's WHOLE transform, in every state
|
|
61
|
+
// (rest, enter-from/to, leave-from/to). The open/close ride is carried by
|
|
62
|
+
// the height instead (see the choreography block below), so the frame can
|
|
63
|
+
// never re-seat mid-flight, dragged windows keep their shift, and the
|
|
64
|
+
// chrome is never scaled.
|
|
65
65
|
transform: translate(
|
|
66
66
|
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
67
67
|
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
@@ -99,43 +99,41 @@
|
|
|
99
99
|
// ------
|
|
100
100
|
// Content enter/leave transitions (desktop ≥768px)
|
|
101
101
|
//
|
|
102
|
-
// Default window open/close (user direction 2026-11-
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
// center (the default 50% 50% origin, composed AFTER the permanent
|
|
106
|
-
// centering translate, which is byte-identical in every state — so the
|
|
107
|
-
// box grows symmetrically up and down from its rest center and nothing
|
|
108
|
-
// re-seats the frame mid-flight; dragged windows keep --hk-modal-shift-*
|
|
109
|
-
// through the whole ride). Because a uniform map about the frame center
|
|
110
|
-
// preserves relative geometry, anything sitting centered in the frame —
|
|
111
|
-
// the content region above all — stays horizontally AND vertically
|
|
112
|
-
// centered at every t. The chrome (title bar, sub-header, footer) has no
|
|
113
|
-
// transform rule of its own; it inherits this single frame scale, which
|
|
114
|
-
// IS the window-height motion the user asked for.
|
|
102
|
+
// Default window open/close (user direction 2026-11-07, third revision):
|
|
103
|
+
// the WINDOW's own height rides, the CHROME keeps its natural size, and
|
|
104
|
+
// only the content region is compressed. Concretely:
|
|
115
105
|
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
// is never scaled, only height is. `.hk-modal-body` therefore carries NO
|
|
119
|
-
// transform rule in any ride state — its vertical ride is entirely the
|
|
120
|
-
// frame's `scaleY` above, so during the open/close its width stays exactly
|
|
121
|
-
// 100% of the window while its height follows the 0.9 → 1 compression. Two
|
|
122
|
-
// retired grammars before this one: the two-arg `scale(0.95, 1)` pinch and
|
|
123
|
-
// the `scale(1.1, 1)` overshoot.
|
|
106
|
+
// frame height : chrome + content × 0.9 ⇄ chrome + content
|
|
107
|
+
// content region: scaleY(0.9) ⇄ scaleY(1) (origin top)
|
|
124
108
|
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
109
|
+
// Why not `transform: scaleY()` on the frame (the 2026-11-05/06 grammar):
|
|
110
|
+
// a frame-level scale squashes the title glyphs, the footer buttons and the
|
|
111
|
+
// corner radii along with everything else — the user reported exactly that
|
|
112
|
+
// ("顶部标题和底部按钮组也跟着压缩") on 0.55.305. The ride is therefore a
|
|
113
|
+
// LAYOUT-height animation driven by the family primitive `useHeightRide`,
|
|
114
|
+
// which publishes the measured chrome/content split as CSS variables; this
|
|
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.
|
|
131
120
|
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
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.
|
|
139
137
|
// ------
|
|
140
138
|
|
|
141
139
|
@media (min-width: 768px) {
|
|
@@ -145,9 +143,9 @@
|
|
|
145
143
|
transition:
|
|
146
144
|
opacity var(--hk-modal-duration, 0.42s)
|
|
147
145
|
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1)),
|
|
148
|
-
|
|
146
|
+
height var(--hk-modal-duration, 0.42s)
|
|
149
147
|
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1));
|
|
150
|
-
will-change: opacity,
|
|
148
|
+
will-change: opacity, height;
|
|
151
149
|
}
|
|
152
150
|
|
|
153
151
|
// Close: the reveal's TRUE reverse — same chrome fade, while the window
|
|
@@ -156,32 +154,58 @@
|
|
|
156
154
|
transition:
|
|
157
155
|
opacity var(--hk-modal-duration, 0.32s)
|
|
158
156
|
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1)),
|
|
159
|
-
|
|
157
|
+
height var(--hk-modal-duration, 0.32s)
|
|
160
158
|
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
|
|
161
|
-
will-change: opacity,
|
|
159
|
+
will-change: opacity, height;
|
|
162
160
|
}
|
|
163
161
|
|
|
164
|
-
// The
|
|
165
|
-
//
|
|
166
|
-
//
|
|
162
|
+
// The frame's transform is the plain centering translate in EVERY state —
|
|
163
|
+
// the ride is carried by the shared `.hk-ride--*` contract (geometry) plus
|
|
164
|
+
// the modal's own phase mapping (the machine's states are translated into
|
|
165
|
+
// `hk-ride--from` / `hk-ride--to` on the frame), which is what keeps the
|
|
166
|
+
// chrome crisp.
|
|
167
167
|
.hk-modal-content-enter-from,
|
|
168
168
|
.hk-modal-content-leave-to {
|
|
169
169
|
opacity: 0;
|
|
170
|
-
transform: translate(
|
|
171
|
-
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
172
|
-
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
173
|
-
)
|
|
174
|
-
scaleY(0.9);
|
|
175
170
|
}
|
|
176
171
|
|
|
177
172
|
.hk-modal-content-enter-to,
|
|
178
173
|
.hk-modal-content-leave-from {
|
|
179
174
|
opacity: 1;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The content region keeps its NATURAL layout height for the whole ride: it
|
|
178
|
+
// must not join the frame's flex shrink, or the slot compression and the
|
|
179
|
+
// `.hk-ride-content` scale would compound to 0.81 and leave a gap above the
|
|
180
|
+
// footer (2026-11-07 R3 finding). The footer band pins to the frame's
|
|
181
|
+
// bottom edge instead, so it stays visible while the content's natural box
|
|
182
|
+
// overflows underneath it.
|
|
183
|
+
|
|
184
|
+
// The content's compression has to ride the SAME per-direction token pair
|
|
185
|
+
// as the frame's height: the family sheet's default (0.42s) would leave a
|
|
186
|
+
// 0.1s tail on close (0.32s height) — a visible gap above the pinned
|
|
187
|
+
// footer — and would ignore a host's `--hk-modal-duration` retune.
|
|
188
|
+
.hk-modal-content-enter-active > .hk-modal-body {
|
|
189
|
+
transition: transform var(--hk-modal-duration, 0.42s)
|
|
190
|
+
var(--hk-modal-ease-out, cubic-bezier(0.33, 1, 0.68, 1));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.hk-modal-content-leave-active > .hk-modal-body {
|
|
194
|
+
transition: transform var(--hk-modal-duration, 0.32s)
|
|
195
|
+
var(--hk-modal-ease-in, cubic-bezier(0.45, 0, 0.55, 1));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.hk-modal-content[data-hk-ride-ready].hk-ride--from > .hk-modal-body,
|
|
199
|
+
.hk-modal-content[data-hk-ride-ready].hk-ride--to > .hk-modal-body {
|
|
200
|
+
flex: 0 0 auto;
|
|
201
|
+
height: var(--hk-ride-content, auto);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.hk-modal-content[data-hk-ride-ready].hk-ride--from > .hk-modal-footer,
|
|
205
|
+
.hk-modal-content[data-hk-ride-ready].hk-ride--to > .hk-modal-footer {
|
|
206
|
+
position: absolute;
|
|
207
|
+
inset-inline: 0;
|
|
208
|
+
bottom: 0;
|
|
185
209
|
}
|
|
186
210
|
}
|
|
187
211
|
|
|
@@ -671,10 +695,11 @@
|
|
|
671
695
|
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
672
696
|
);
|
|
673
697
|
}
|
|
674
|
-
// The content region carries no transform of its own any more (
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
698
|
+
// The content region carries no transform of its own any more (the ride is
|
|
699
|
+
// height-only — 2026-11-07). These lines are a belt-and-braces guard only:
|
|
700
|
+
// at (0,2,0) they would NOT outrank a future ride rule at (0,4,0), so the
|
|
701
|
+
// family sheet's own reduced-motion block (which repeats the full
|
|
702
|
+
// selector) is the one that actually flattens the content scale.
|
|
678
703
|
.hk-modal-content .hk-modal-body {
|
|
679
704
|
transform: none;
|
|
680
705
|
transition: none;
|
|
@@ -2,6 +2,10 @@ 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";
|
|
5
9
|
import HkFloatingLayer from "./HkFloatingLayer";
|
|
6
10
|
import { focusFirst, trapFocus } from "../utils/dom";
|
|
7
11
|
import { useOverlay } from "../runtime/useOverlay";
|
|
@@ -14,6 +18,7 @@ import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
|
14
18
|
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
15
19
|
import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
|
|
16
20
|
import { useSizeMorph, type RideEntry } from "../composables/useSizeMorph";
|
|
21
|
+
import { useHeightRide } from "../composables/useHeightRide";
|
|
17
22
|
|
|
18
23
|
import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
|
|
19
24
|
|
|
@@ -327,7 +332,14 @@ export default defineComponent({
|
|
|
327
332
|
// finding). Riding the block keeps the scrollport's content
|
|
328
333
|
// untransformed; the block's own poke below the frame lands
|
|
329
334
|
// off-screen on a bottom-docked sheet.
|
|
330
|
-
|
|
335
|
+
// While the desktop height ride owns the body's paint (its scaleY
|
|
336
|
+
// IS the choreography), the morph must not write an inline transform
|
|
337
|
+
// on the same element: an inline style outranks the ride's class
|
|
338
|
+
// rule, and the close would stop compressing (2026-11-07 user
|
|
339
|
+
// report: the window folded "together with the chrome"). The body
|
|
340
|
+
// ride matters for the ≤767px sheet sweep, where the ride geometry
|
|
341
|
+
// is disabled — so the two never legitimately overlap.
|
|
342
|
+
if (bodyRef.value && rideStateClass() === null) out.push({ el: bodyRef.value });
|
|
331
343
|
return out;
|
|
332
344
|
},
|
|
333
345
|
// Republish the morph's real sweep span and its landing on the body
|
|
@@ -348,6 +360,51 @@ export default defineComponent({
|
|
|
348
360
|
);
|
|
349
361
|
},
|
|
350
362
|
});
|
|
363
|
+
|
|
364
|
+
// Desktop open/close: the WINDOW's own height rides, the chrome
|
|
365
|
+
// (title bar, sub-header, footer) keeps its natural size, and only the
|
|
366
|
+
// content region is visually compressed. The measurement half lives in
|
|
367
|
+
// the family primitive (useHeightRide); this sheet maps its surface
|
|
368
|
+
// states onto the ride's two ends (see the choreography block below).
|
|
369
|
+
const ride = useHeightRide(contentRef, bodyRef, { factor: 0.9 });
|
|
370
|
+
|
|
371
|
+
/** The window ride's own state, translated from the surface phases:
|
|
372
|
+
* the open/close endpoints are the compressed end (`--from`) and the
|
|
373
|
+
* natural end (`--to`). Between and after them no ride class is
|
|
374
|
+
* present, so the frame keeps its laid-out height (or the morph's
|
|
375
|
+
* px pin) and the geometry contract never leaks into the rest state. */
|
|
376
|
+
// The body element mounts with the frame on every open, and the inline
|
|
377
|
+
// variables die with the previous frame. Republish the split cached from
|
|
378
|
+
// the last rest measurement FIRST (so the first painted frame is already
|
|
379
|
+
// correct), then — if this is the very first open — measure as soon as
|
|
380
|
+
// the element lands (the sheet keeps the frame at `auto` until then, so
|
|
381
|
+
// that measurement sees honest natural geometry).
|
|
382
|
+
watch(
|
|
383
|
+
bodyRef,
|
|
384
|
+
(el) => {
|
|
385
|
+
if (!el) return;
|
|
386
|
+
// ALWAYS measure: the component instance survives a close, so a
|
|
387
|
+
// cached split may belong to different content (this modal is reused
|
|
388
|
+
// across tabs/entries). Publishing the cache first made the second
|
|
389
|
+
// and later opens ride the PREVIOUS geometry, cropping new content
|
|
390
|
+
// and then snapping when the cache expired (R5 finding).
|
|
391
|
+
void nextTick(() => ride.refresh());
|
|
392
|
+
},
|
|
393
|
+
{ flush: "post" },
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const rideStateClass = (): string | null => {
|
|
397
|
+
switch (machine.phase.value) {
|
|
398
|
+
case "openingFrom":
|
|
399
|
+
case "closingTo":
|
|
400
|
+
return "hk-ride--from";
|
|
401
|
+
case "openingTo":
|
|
402
|
+
case "closingFrom":
|
|
403
|
+
return "hk-ride--to";
|
|
404
|
+
default:
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
};
|
|
351
408
|
let previouslyFocused: HTMLElement | null = null;
|
|
352
409
|
let unmounted = false;
|
|
353
410
|
|
|
@@ -566,6 +623,10 @@ export default defineComponent({
|
|
|
566
623
|
],
|
|
567
624
|
onPhase: (from, to, event) => {
|
|
568
625
|
if (to === "openingFrom") {
|
|
626
|
+
// Measure BEFORE the enter classes land: the ride's `height:
|
|
627
|
+
// calc()` needs the rest chrome/content split, and a frame that is
|
|
628
|
+
// already compressed reports the compressed box.
|
|
629
|
+
ride.refresh();
|
|
569
630
|
// Open-request bookkeeping (was the modelValue watcher's open
|
|
570
631
|
// arm): registration, overlay registry, back-guard, focus
|
|
571
632
|
// capture.
|
|
@@ -598,6 +659,9 @@ export default defineComponent({
|
|
|
598
659
|
});
|
|
599
660
|
} else if (to === "open") {
|
|
600
661
|
surfTrack.cancel();
|
|
662
|
+
// Re-measure at the landed rest geometry so the NEXT ride starts
|
|
663
|
+
// from this box (content may have changed while the surface opened).
|
|
664
|
+
void nextTick(() => ride.refresh());
|
|
601
665
|
onAfterEnter();
|
|
602
666
|
// Idempotent re-arm for the fast paths (see openingFrom); the
|
|
603
667
|
// morph is usually armed already by the enter-edge tick.
|
|
@@ -610,6 +674,9 @@ export default defineComponent({
|
|
|
610
674
|
if (machine.phase.value === "open") morph.remeasure();
|
|
611
675
|
});
|
|
612
676
|
} else if (to === "closingFrom") {
|
|
677
|
+
// Same rule on the way out: the frame is at its open (natural)
|
|
678
|
+
// height right now, so this is the last honest measurement.
|
|
679
|
+
ride.refresh();
|
|
613
680
|
// Close-request bookkeeping (was the watcher's close arm): the
|
|
614
681
|
// registries forget the surface at request time; the finalize
|
|
615
682
|
// edge below completes the teardown at leave end.
|
|
@@ -1065,7 +1132,8 @@ export default defineComponent({
|
|
|
1065
1132
|
/>
|
|
1066
1133
|
<div
|
|
1067
1134
|
ref={contentRef}
|
|
1068
|
-
class={["hk-modal-content", props.contentClass, ...machine.classesFor("hk-modal-content")]}
|
|
1135
|
+
class={["hk-modal-content", "hk-ride", rideStateClass(), props.contentClass, ...machine.classesFor("hk-modal-content")]}
|
|
1136
|
+
data-hk-ride-ready={ride.hasCache() ? "" : undefined}
|
|
1069
1137
|
role="dialog"
|
|
1070
1138
|
aria-modal="true"
|
|
1071
1139
|
aria-label={resolvedSurfaceName.value}
|
|
@@ -1073,6 +1141,8 @@ export default defineComponent({
|
|
|
1073
1141
|
style={{
|
|
1074
1142
|
maxWidth: resolvedWidth.value,
|
|
1075
1143
|
zIndex: contentZ.value,
|
|
1144
|
+
...ride.styleVars(),
|
|
1145
|
+
|
|
1076
1146
|
}}
|
|
1077
1147
|
onClick={onContentClick}
|
|
1078
1148
|
tabindex={-1}
|
|
@@ -1131,7 +1201,7 @@ export default defineComponent({
|
|
|
1131
1201
|
)}
|
|
1132
1202
|
</>
|
|
1133
1203
|
) : null,
|
|
1134
|
-
<div ref={bodyRef} class="hk-modal-body">
|
|
1204
|
+
<div ref={bodyRef} class="hk-modal-body hk-ride-content">
|
|
1135
1205
|
<div
|
|
1136
1206
|
ref={scrollContainerRef}
|
|
1137
1207
|
class="hk-modal-body-scroll hk-scroll-pin-host"
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
});
|
|
@@ -127,6 +127,15 @@ 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",
|
|
130
139
|
"HkTabs.tsx", // wrapped by HkTransitionContainer (pinned above)
|
|
131
140
|
"HkSettingsDialog.tsx", // wrapped by HkTransitionContainer (pinned above)
|
|
132
141
|
// HkStepFlow is NOT a rogue pane slot — it is a sibling mechanism
|
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -86,6 +86,7 @@ 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";
|
|
89
90
|
export { default as HScreenIdentifyCard } from "./components/HkScreenIdentifyCard";
|
|
90
91
|
export { default as HScreenIdentifyHost } from "./components/HkScreenIdentifyHost";
|
|
91
92
|
export { default as HSearchInput } from "./components/HkSearchInput";
|
|
@@ -518,6 +519,8 @@ export type { HkScrollProgressHandle, HkScrollProgressRegistrar } from "./compon
|
|
|
518
519
|
export type { OverlayScrollMapSegment, OverlayScrollbarMarker } from "./composables/useOverlayScrollbar";
|
|
519
520
|
export { useDeferredTransition } from "./composables/useDeferredTransition";
|
|
520
521
|
export { useSurfaceTransition } from "./composables/useSurfaceTransition";
|
|
522
|
+
export { useHeightRide } from "./composables/useHeightRide";
|
|
523
|
+
export type { HeightRide, HeightRideOptions } from "./composables/useHeightRide";
|
|
521
524
|
export { useSizeMorph } from "./composables/useSizeMorph";
|
|
522
525
|
export type { SizeMorph } from "./composables/useSizeMorph";
|
|
523
526
|
export { useImage } from "./composables/useImage";
|