@celestia-island/hikari 0.55.310 → 0.55.312
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.open-close-scale.test.ts +30 -111
- package/src/components/HkModal.scss +55 -146
- package/src/components/HkModal.tsx +5 -119
- package/src/components/paneSwap.contract.test.ts +0 -9
- 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
|
@@ -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";
|
|
@@ -18,7 +14,6 @@ import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
|
18
14
|
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
19
15
|
import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
|
|
20
16
|
import { useSizeMorph, type RideEntry } from "../composables/useSizeMorph";
|
|
21
|
-
import { useHeightRide } from "../composables/useHeightRide";
|
|
22
17
|
|
|
23
18
|
import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
|
|
24
19
|
|
|
@@ -332,14 +327,8 @@ export default defineComponent({
|
|
|
332
327
|
// finding). Riding the block keeps the scrollport's content
|
|
333
328
|
// untransformed; the block's own poke below the frame lands
|
|
334
329
|
// off-screen on a bottom-docked sheet.
|
|
335
|
-
|
|
336
|
-
|
|
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 && !desktopRide()) out.push({ el: bodyRef.value });
|
|
330
|
+
if (bodyRef.value) out.push({ el: bodyRef.value });
|
|
331
|
+
if (bodyRef.value) out.push({ el: bodyRef.value });
|
|
343
332
|
return out;
|
|
344
333
|
},
|
|
345
334
|
// Republish the morph's real sweep span and its landing on the body
|
|
@@ -361,47 +350,11 @@ export default defineComponent({
|
|
|
361
350
|
},
|
|
362
351
|
});
|
|
363
352
|
|
|
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
353
|
/** The window ride's own state, translated from the surface phases:
|
|
372
354
|
* the open/close endpoints are the compressed end (`--from`) and the
|
|
373
355
|
* natural end (`--to`). Between and after them no ride class is
|
|
374
356
|
* present, so the frame keeps its laid-out height (or the morph's
|
|
375
357
|
* 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
|
-
// The ride's geometry lives behind `@media (min-width: 768px)`, so the
|
|
397
|
-
// phase->class mapping alone cannot tell whether the ride actually owns
|
|
398
|
-
// the frame (on a phone the classes land but nothing happens). This
|
|
399
|
-
// predicate gates every "is the ride flying" decision below.
|
|
400
|
-
const desktopRide = (): boolean =>
|
|
401
|
-
typeof window !== "undefined" &&
|
|
402
|
-
window.matchMedia("(min-width: 768px)").matches;
|
|
403
|
-
|
|
404
|
-
|
|
405
358
|
let previouslyFocused: HTMLElement | null = null;
|
|
406
359
|
let unmounted = false;
|
|
407
360
|
|
|
@@ -620,10 +573,6 @@ export default defineComponent({
|
|
|
620
573
|
],
|
|
621
574
|
onPhase: (from, to, event) => {
|
|
622
575
|
if (to === "openingFrom") {
|
|
623
|
-
// Measure BEFORE the enter classes land: the ride's `height:
|
|
624
|
-
// calc()` needs the rest chrome/content split, and a frame that is
|
|
625
|
-
// already compressed reports the compressed box.
|
|
626
|
-
ride.refresh();
|
|
627
576
|
// Open-request bookkeeping (was the modelValue watcher's open
|
|
628
577
|
// arm): registration, overlay registry, back-guard, focus
|
|
629
578
|
// capture.
|
|
@@ -653,71 +602,13 @@ export default defineComponent({
|
|
|
653
602
|
void nextTick(() => {
|
|
654
603
|
const p = machine.phase.value;
|
|
655
604
|
if (p !== "openingFrom" && p !== "openingTo") return;
|
|
656
|
-
|
|
657
|
-
// frame, the morph must not write anything to it: arming installs
|
|
658
|
-
// a ResizeObserver on the body, and the ride's own class change
|
|
659
|
-
// (locking the body to its measured height) fires it — the
|
|
660
|
-
// remeasure then lands `transition: none` mid-flight, which kills
|
|
661
|
-
// the ride's running height/scaleY transitions and pins the
|
|
662
|
-
// COMPRESSED used height as if it were natural (2026-11-07 user
|
|
663
|
-
// report: the open "replays" — ride animates part-way, snaps to
|
|
664
|
-
// the bad pin, then re-animates to full height). The morph arms at
|
|
665
|
-
// the open edge instead, where the ride classes are already off
|
|
666
|
-
// and the pin equals the used height (no visible change). The
|
|
667
|
-
// phone keeps the enter-edge arm: its sheet choreography rides
|
|
668
|
-
// the morph's clip sweep, and the ride geometry is gated off.
|
|
669
|
-
if (!desktopRide()) morph.start();
|
|
605
|
+
if (p === "openingFrom" || p === "openingTo") morph.start();
|
|
670
606
|
});
|
|
671
607
|
} else if (to === "open") {
|
|
672
608
|
surfTrack.cancel();
|
|
673
|
-
// Re-measure at the landed rest geometry so the NEXT ride starts
|
|
674
|
-
// from this box (content may have changed while the surface opened).
|
|
675
|
-
void nextTick(() => ride.refresh());
|
|
676
609
|
onAfterEnter();
|
|
677
|
-
// Idempotent re-arm for the fast paths (see openingFrom)
|
|
678
|
-
|
|
679
|
-
// `open` (its deadline is advisory): arming the morph right now
|
|
680
|
-
// would land `transition: none` + a px pin on a frame whose height
|
|
681
|
-
// is mid-transition, snapping it to the end value (2026-11-07
|
|
682
|
-
// user report: the window "jumps" the last third of its growth).
|
|
683
|
-
// Wait for the height leg to actually land (watchdog: the budget
|
|
684
|
-
// plus slack), then arm — the pin then equals the used height and
|
|
685
|
-
// the handoff is invisible. The phone has no ride; arm at once.
|
|
686
|
-
if (desktopRide()) {
|
|
687
|
-
const f = contentRef.value;
|
|
688
|
-
const armNow = (): void => {
|
|
689
|
-
if (machine.phase.value !== "open" || unmounted) return;
|
|
690
|
-
morph.start();
|
|
691
|
-
};
|
|
692
|
-
// No live CSS duration (test DOMs, reduced motion, the global
|
|
693
|
-
// animation switch): nothing is animating, arm at once — the
|
|
694
|
-
// same zero-duration semantics the surface machine uses.
|
|
695
|
-
const rawDuration = f ? getComputedStyle(f).transitionDuration : "";
|
|
696
|
-
const durMs = rawDuration
|
|
697
|
-
.split(",")
|
|
698
|
-
.map((part) => parseFloat(part))
|
|
699
|
-
.filter((v) => Number.isFinite(v))
|
|
700
|
-
.reduce((a, b) => Math.max(a, b), 0);
|
|
701
|
-
if (!f || durMs === 0) {
|
|
702
|
-
armNow();
|
|
703
|
-
} else {
|
|
704
|
-
let done = false;
|
|
705
|
-
const finish = (): void => {
|
|
706
|
-
if (done) return;
|
|
707
|
-
done = true;
|
|
708
|
-
f.removeEventListener("transitionend", onHeightEnd);
|
|
709
|
-
clearTimeout(guard);
|
|
710
|
-
armNow();
|
|
711
|
-
};
|
|
712
|
-
const onHeightEnd = (e: TransitionEvent): void => {
|
|
713
|
-
if (e.target === f && e.propertyName === "height") finish();
|
|
714
|
-
};
|
|
715
|
-
const guard = setTimeout(finish, 600);
|
|
716
|
-
f.addEventListener("transitionend", onHeightEnd);
|
|
717
|
-
}
|
|
718
|
-
} else {
|
|
719
|
-
morph.start();
|
|
720
|
-
}
|
|
610
|
+
// Idempotent re-arm for the fast paths (see openingFrom).
|
|
611
|
+
morph.start();
|
|
721
612
|
// Flush the growth deferred through the unfold. One tick later
|
|
722
613
|
// the transition classes are off the frame, so the base height
|
|
723
614
|
// transition animates the pin change instead of the enter's
|
|
@@ -726,9 +617,6 @@ export default defineComponent({
|
|
|
726
617
|
if (machine.phase.value === "open") morph.remeasure();
|
|
727
618
|
});
|
|
728
619
|
} else if (to === "closingFrom") {
|
|
729
|
-
// Same rule on the way out: the frame is at its open (natural)
|
|
730
|
-
// height right now, so this is the last honest measurement.
|
|
731
|
-
ride.refresh();
|
|
732
620
|
// Close-request bookkeeping (was the watcher's close arm): the
|
|
733
621
|
// registries forget the surface at request time; the finalize
|
|
734
622
|
// edge below completes the teardown at leave end.
|
|
@@ -1185,7 +1073,6 @@ export default defineComponent({
|
|
|
1185
1073
|
<div
|
|
1186
1074
|
ref={contentRef}
|
|
1187
1075
|
class={["hk-modal-content", props.contentClass, ...machine.classesFor("hk-modal-content")]}
|
|
1188
|
-
data-hk-ride-ready={ride.hasCache() ? "" : undefined}
|
|
1189
1076
|
role="dialog"
|
|
1190
1077
|
aria-modal="true"
|
|
1191
1078
|
aria-label={resolvedSurfaceName.value}
|
|
@@ -1193,7 +1080,6 @@ export default defineComponent({
|
|
|
1193
1080
|
style={{
|
|
1194
1081
|
maxWidth: resolvedWidth.value,
|
|
1195
1082
|
zIndex: contentZ.value,
|
|
1196
|
-
...ride.styleVars(),
|
|
1197
1083
|
|
|
1198
1084
|
}}
|
|
1199
1085
|
onClick={onContentClick}
|
|
@@ -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
|
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
|
-
});
|