@celestia-island/hikari 0.40.18 → 0.40.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/HkColorSchemeEditor.test.ts +24 -1
- package/src/components/HkColorSchemeEditor.tsx +19 -7
- package/src/components/HkDrawer.tsx +97 -85
- package/src/components/HkModal.enter-watchdog.test.tsx +42 -19
- package/src/components/HkModal.transition-completion.test.tsx +21 -19
- package/src/components/HkModal.tsx +91 -199
- package/src/components/HkPopover.scss +18 -0
- package/src/components/HkPopover.surfacevars.test.ts +14 -0
- package/src/components/HkPopover.tsx +120 -89
- package/src/components/HkSelectPanel.test.tsx +7 -5
- package/src/components/HkSelectPanel.tsx +163 -231
- package/src/components/HkStatusBar.test.tsx +8 -8
- package/src/components/HkThemeToggle.test.ts +6 -2
- package/src/components/scrim-fade.contract.test.ts +14 -9
- package/src/composables/useSurfaceMachine.test.ts +259 -0
- package/src/composables/useSurfaceMachine.ts +289 -0
- package/src/runtime/surfaceMachine.test.ts +186 -0
- package/src/runtime/surfaceMachine.ts +229 -0
- package/src/runtime/transitionWatchdog.test.ts +0 -79
- package/src/runtime/transitionWatchdog.ts +0 -73
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
ref,
|
|
8
8
|
shallowRef,
|
|
9
9
|
Teleport,
|
|
10
|
-
Transition,
|
|
11
10
|
watch,
|
|
12
11
|
type PropType,
|
|
13
12
|
} from "vue";
|
|
@@ -19,9 +18,9 @@ import { useOverlay } from "../runtime/useOverlay";
|
|
|
19
18
|
import { usePopupManager } from "../runtime/usePopupManager";
|
|
20
19
|
import { createBackGuard } from "../runtime/backStack";
|
|
21
20
|
import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
|
|
22
|
-
import { armTransitionClassWatchdog, stripTransitionClasses } from "../runtime/transitionWatchdog";
|
|
23
21
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
24
22
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
23
|
+
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
25
24
|
import { useSizeMorph } from "../composables/useSizeMorph";
|
|
26
25
|
import HButton from "./HkButton";
|
|
27
26
|
import HFab from "./HkFab";
|
|
@@ -167,11 +166,10 @@ export default defineComponent({
|
|
|
167
166
|
const { t } = useI18n();
|
|
168
167
|
const manager = usePopupManager();
|
|
169
168
|
// Open/close motion reported into the unified animation context
|
|
170
|
-
// (animationBus) —
|
|
171
|
-
//
|
|
169
|
+
// (animationBus) — one track for the whole surface, armed on the
|
|
170
|
+
// machine's animation-phase edges.
|
|
172
171
|
const surf = useSurfaceTransition(320);
|
|
173
|
-
const
|
|
174
|
-
const contentHooks = surf.hooks("content");
|
|
172
|
+
const surfTrack = surf.track("surface");
|
|
175
173
|
const overlay = useOverlay({
|
|
176
174
|
name: "hk-modal",
|
|
177
175
|
// A global closeAll() must be able to actually close this modal
|
|
@@ -181,7 +179,6 @@ export default defineComponent({
|
|
|
181
179
|
});
|
|
182
180
|
|
|
183
181
|
const handle = ref<{ id: string; zIndex: number } | null>(null);
|
|
184
|
-
const overlayRef = ref<HTMLElement>();
|
|
185
182
|
const bodyRef = ref<HTMLElement>();
|
|
186
183
|
const contentRef = ref<HTMLElement>();
|
|
187
184
|
/** Natural-height probe inside the scroll container: the body's
|
|
@@ -192,63 +189,9 @@ export default defineComponent({
|
|
|
192
189
|
// Content-driven size morphing: the frame follows content growth with
|
|
193
190
|
// the height transition instead of snapping (see useSizeMorph).
|
|
194
191
|
const morph = useSizeMorph(contentRef, innerRef);
|
|
195
|
-
const shouldRender = ref(false);
|
|
196
192
|
let previouslyFocused: HTMLElement | null = null;
|
|
197
193
|
let unmounted = false;
|
|
198
194
|
|
|
199
|
-
// ── Leave-completion watchdog ─────────────────────────────────────
|
|
200
|
-
// The close path hands unmounting to <Transition>'s leave, whose
|
|
201
|
-
// engine is rAF-driven: it double-raf's the leave-from → leave-to
|
|
202
|
-
// class flip and only THEN arms its transitionend wait. In an
|
|
203
|
-
// occluded/backgrounded webview rAF can starve for the whole leave
|
|
204
|
-
// window, the classes freeze in leave-from/leave-active, and the
|
|
205
|
-
// modal stays over the page forever — undismissable, only a full
|
|
206
|
-
// reload escaped it (field-reported 2026-09). The watchdog bounds
|
|
207
|
-
// the wait: if the leave has not finalized within a budget larger
|
|
208
|
-
// than any themed CSS leave (--hk-modal-duration defaults to 0.25s,
|
|
209
|
-
// themes may raise it), onAfterLeaveFinalize runs the exact same
|
|
210
|
-
// finalization the Transition would have. Normal closes disarm it;
|
|
211
|
-
// late or stale completions (post-watchdog real onAfterLeave, or
|
|
212
|
-
// the open-interrupted leave Vue resolves without a cancelled flag)
|
|
213
|
-
// are no-ops through the finalize and modelValue guards.
|
|
214
|
-
const LEAVE_WATCHDOG_MS = 600;
|
|
215
|
-
let leaveWatchdog: ReturnType<typeof setTimeout> | null = null;
|
|
216
|
-
let leaveFinalized = false;
|
|
217
|
-
|
|
218
|
-
function armLeaveWatchdog(): void {
|
|
219
|
-
disarmLeaveWatchdog();
|
|
220
|
-
leaveWatchdog = setTimeout(() => {
|
|
221
|
-
leaveWatchdog = null;
|
|
222
|
-
if (unmounted || props.modelValue || !shouldRender.value) return;
|
|
223
|
-
onAfterLeaveFinalize();
|
|
224
|
-
}, LEAVE_WATCHDOG_MS);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function disarmLeaveWatchdog(): void {
|
|
228
|
-
if (leaveWatchdog !== null) {
|
|
229
|
-
clearTimeout(leaveWatchdog);
|
|
230
|
-
leaveWatchdog = null;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// ── Enter-class watchdog ──────────────────────────────────────────
|
|
235
|
-
// The leave side above bounds rAF starvation for the CLOSE; the enter
|
|
236
|
-
// side has no such guard. A starved enter freezes the from-pair on
|
|
237
|
-
// the layer (scrim at opacity: 0 while the panel floats above it),
|
|
238
|
-
// and the eventual close then flashes the resurrected curtain at full
|
|
239
|
-
// opacity — the mobile "black rectangle" report (2026-09). Each layer
|
|
240
|
-
// (overlay, content) arms on its before-enter and strips stuck
|
|
241
|
-
// enter classes once the same budget lapses; normal enters disarm.
|
|
242
|
-
let overlayEnterGuard: (() => void) | null = null;
|
|
243
|
-
let contentEnterGuard: (() => void) | null = null;
|
|
244
|
-
|
|
245
|
-
function disarmEnterGuards(): void {
|
|
246
|
-
overlayEnterGuard?.();
|
|
247
|
-
overlayEnterGuard = null;
|
|
248
|
-
contentEnterGuard?.();
|
|
249
|
-
contentEnterGuard = null;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
195
|
const overlayZ = computed(() => handle.value?.zIndex ?? 0);
|
|
253
196
|
const contentZ = computed(() => (handle.value?.zIndex ?? 0) + 1);
|
|
254
197
|
const resolvedWidth = computed(() => resolveModalWidth(props.width));
|
|
@@ -331,20 +274,15 @@ export default defineComponent({
|
|
|
331
274
|
}
|
|
332
275
|
|
|
333
276
|
function onAfterLeaveFinalize() {
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
if (unmounted || props.modelValue || leaveFinalized) return;
|
|
340
|
-
leaveFinalized = true;
|
|
341
|
-
disarmLeaveWatchdog();
|
|
342
|
-
disarmEnterGuards();
|
|
277
|
+
// The machine reaches `closed` from a closing phase exactly once
|
|
278
|
+
// per close cycle (its table cannot re-enter or double-fire), so
|
|
279
|
+
// the old finalize-once flag and stale-completion guards reduce
|
|
280
|
+
// to a defense-in-depth modelValue check.
|
|
281
|
+
if (unmounted || props.modelValue) return;
|
|
343
282
|
if (handle.value) {
|
|
344
283
|
manager.unregister(handle.value.id);
|
|
345
284
|
handle.value = null;
|
|
346
285
|
}
|
|
347
|
-
shouldRender.value = false;
|
|
348
286
|
if (previouslyFocused) {
|
|
349
287
|
previouslyFocused.focus();
|
|
350
288
|
previouslyFocused = null;
|
|
@@ -354,6 +292,71 @@ export default defineComponent({
|
|
|
354
292
|
emit("afterLeave");
|
|
355
293
|
}
|
|
356
294
|
|
|
295
|
+
// ── Surface lifecycle machine ─────────────────────────────────────
|
|
296
|
+
// One machine drives BOTH layers (scrim + panel) — the layer outputs
|
|
297
|
+
// are pure functions of the shared phase, so the divergent-layer
|
|
298
|
+
// states of the two-<Transition> era (scrim gone while the panel
|
|
299
|
+
// floats; the full-opacity close flash) are unrepresentable. The
|
|
300
|
+
// correctness clock is the machine's deadline timers; rAF is an
|
|
301
|
+
// optimization for the class flip. See runtime/surfaceMachine.ts
|
|
302
|
+
// for the axioms, the total transition table, and the invariants.
|
|
303
|
+
const overlayEl = ref<HTMLElement>();
|
|
304
|
+
const machine = useSurfaceMachine({
|
|
305
|
+
layers: [
|
|
306
|
+
// Budgets are the starvation-era upper bound; the driver probes
|
|
307
|
+
// the layers' live CSS durations (themes, reduced motion) and
|
|
308
|
+
// tightens the deadlines accordingly.
|
|
309
|
+
{ prefix: "hk-modal-overlay", el: () => overlayEl.value, enterMs: () => 320, leaveMs: () => 340 },
|
|
310
|
+
{ prefix: "hk-modal-content", el: () => contentRef.value, enterMs: () => 320, leaveMs: () => 300 },
|
|
311
|
+
],
|
|
312
|
+
onPhase: (from, to, event) => {
|
|
313
|
+
if (to === "openingFrom") {
|
|
314
|
+
// Open-request bookkeeping (was the modelValue watcher's open
|
|
315
|
+
// arm): registration, overlay registry, back-guard, focus
|
|
316
|
+
// capture.
|
|
317
|
+
previouslyFocused = document.activeElement as HTMLElement | null;
|
|
318
|
+
if (handle.value) {
|
|
319
|
+
manager.unregister(handle.value.id);
|
|
320
|
+
}
|
|
321
|
+
handle.value = manager.register("modal", true, resolvedSurfaceName.value);
|
|
322
|
+
overlay.open();
|
|
323
|
+
if (backGuardEnabled() && backGuard.entries === 0) {
|
|
324
|
+
backGuard.push();
|
|
325
|
+
}
|
|
326
|
+
surfTrack.run();
|
|
327
|
+
} else if (to === "open") {
|
|
328
|
+
surfTrack.cancel();
|
|
329
|
+
onAfterEnter();
|
|
330
|
+
// Size morphs arm once the open choreography finished —
|
|
331
|
+
// pinning during enter would override its height reveal.
|
|
332
|
+
morph.start();
|
|
333
|
+
} else if (to === "closingFrom") {
|
|
334
|
+
// Close-request bookkeeping (was the watcher's close arm): the
|
|
335
|
+
// registries forget the surface at request time; the finalize
|
|
336
|
+
// edge below completes the teardown at leave end.
|
|
337
|
+
surfTrack.run();
|
|
338
|
+
overlay.close();
|
|
339
|
+
backGuard.release();
|
|
340
|
+
// Release the pinned height so the leave owns the frame.
|
|
341
|
+
morph.stop();
|
|
342
|
+
} else if (
|
|
343
|
+
to === "closed" &&
|
|
344
|
+
(from === "closingFrom" || from === "closingTo") &&
|
|
345
|
+
// UNMOUNT mid-close walks the same edge but is a TEARDOWN, not
|
|
346
|
+
// a finalized leave — afterLeave/focus-restore belong to the
|
|
347
|
+
// close lifecycle only (the machine's own unmount hook runs
|
|
348
|
+
// before this component's onBeforeUnmount sets `unmounted`).
|
|
349
|
+
event !== "UNMOUNT"
|
|
350
|
+
) {
|
|
351
|
+
surfTrack.cancel();
|
|
352
|
+
onAfterLeaveFinalize();
|
|
353
|
+
}
|
|
354
|
+
// to === "closed" via UNMOUNT: teardown is owned by
|
|
355
|
+
// onBeforeUnmount (the machine's own unmount hook already
|
|
356
|
+
// cleared its clocks and walked the phase to `closed`).
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
357
360
|
// --- Windowed mode ---
|
|
358
361
|
|
|
359
362
|
function setupWindowed(container: HTMLElement) {
|
|
@@ -613,7 +616,7 @@ export default defineComponent({
|
|
|
613
616
|
// --- Lifecycle ---
|
|
614
617
|
|
|
615
618
|
// Overlay scrollbar on the scrolling body (shared chrome). The body
|
|
616
|
-
// mounts with the modal surface (
|
|
619
|
+
// mounts with the modal surface (machine.mounted) and survives through
|
|
617
620
|
// the leave transition; attach after the DOM lands, detach on close
|
|
618
621
|
// and unmount so nothing leaks inside the Teleport portal.
|
|
619
622
|
let bodyScrollbar: OverlayScrollbarHandle | null = null;
|
|
@@ -623,10 +626,10 @@ export default defineComponent({
|
|
|
623
626
|
bodyScrollbar = null;
|
|
624
627
|
}
|
|
625
628
|
|
|
626
|
-
watch(
|
|
629
|
+
watch(machine.mounted, (render) => {
|
|
627
630
|
if (render) {
|
|
628
631
|
void nextTick(() => {
|
|
629
|
-
if (!
|
|
632
|
+
if (!machine.mounted.value || !scrollContainerRef.value) return;
|
|
630
633
|
detachBodyScrollbar();
|
|
631
634
|
bodyScrollbar = attachOverlayScrollbars(scrollContainerRef.value, { axis: "vertical" });
|
|
632
635
|
});
|
|
@@ -639,37 +642,9 @@ export default defineComponent({
|
|
|
639
642
|
() => props.modelValue,
|
|
640
643
|
(val) => {
|
|
641
644
|
if (unmounted) return;
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
manager.unregister(handle.value.id);
|
|
646
|
-
}
|
|
647
|
-
leaveFinalized = false;
|
|
648
|
-
disarmLeaveWatchdog();
|
|
649
|
-
shouldRender.value = true;
|
|
650
|
-
// Windows always block, so the kind alone lists this layer in
|
|
651
|
-
// the modal-stack breadcrumb on every form factor.
|
|
652
|
-
handle.value = manager.register("modal", true, resolvedSurfaceName.value);
|
|
653
|
-
overlay.open();
|
|
654
|
-
if (backGuardEnabled() && backGuard.entries === 0) {
|
|
655
|
-
backGuard.push();
|
|
656
|
-
}
|
|
657
|
-
} else {
|
|
658
|
-
// Close happens via Transition onAfterLeave,
|
|
659
|
-
// but if modelValue flips to false without Transition
|
|
660
|
-
// (e.g. immediate), clean up now.
|
|
661
|
-
overlay.close();
|
|
662
|
-
backGuard.release();
|
|
663
|
-
// The enter cycles are dead the moment the surface starts
|
|
664
|
-
// closing — their guards must not fire into the leave.
|
|
665
|
-
disarmEnterGuards();
|
|
666
|
-
// Bound the Transition leave: if rAF starvation froze the
|
|
667
|
-
// class flip, the watchdog finalizes in the watchdog's place
|
|
668
|
-
// (see LEAVE_WATCHDOG_MS above). Only an actually-mounted
|
|
669
|
-
// surface can stall — a never-opened modal has nothing to
|
|
670
|
-
// finalize.
|
|
671
|
-
if (shouldRender.value) armLeaveWatchdog();
|
|
672
|
-
}
|
|
645
|
+
// The machine owns every visual/registry consequence on its
|
|
646
|
+
// phase edges; this watcher is purely the event feed.
|
|
647
|
+
machine.send(val ? "OPEN" : "CLOSE");
|
|
673
648
|
},
|
|
674
649
|
{ immediate: true },
|
|
675
650
|
);
|
|
@@ -701,8 +676,9 @@ export default defineComponent({
|
|
|
701
676
|
|
|
702
677
|
onBeforeUnmount(() => {
|
|
703
678
|
unmounted = true;
|
|
704
|
-
|
|
705
|
-
|
|
679
|
+
// The machine's own unmount hook (registered first) already sent
|
|
680
|
+
// UNMOUNT — phase `closed`, clocks cleared. This is the surface's
|
|
681
|
+
// own teardown, idempotent with the finalize edge.
|
|
706
682
|
detachBodyScrollbar();
|
|
707
683
|
teardownWindowed();
|
|
708
684
|
teardownAutoFollow();
|
|
@@ -711,7 +687,6 @@ export default defineComponent({
|
|
|
711
687
|
manager.unregister(handle.value.id);
|
|
712
688
|
handle.value = null;
|
|
713
689
|
}
|
|
714
|
-
shouldRender.value = false;
|
|
715
690
|
});
|
|
716
691
|
|
|
717
692
|
// --- Render helpers ---
|
|
@@ -742,7 +717,7 @@ export default defineComponent({
|
|
|
742
717
|
}
|
|
743
718
|
|
|
744
719
|
return () => {
|
|
745
|
-
if (!
|
|
720
|
+
if (!machine.mounted.value) return null;
|
|
746
721
|
|
|
747
722
|
const headerShown = props.title || props.closable || slots.header || slots.headerLead;
|
|
748
723
|
|
|
@@ -753,95 +728,14 @@ export default defineComponent({
|
|
|
753
728
|
style={{ zIndex: overlayZ.value }}
|
|
754
729
|
onKeydown={onKeydown}
|
|
755
730
|
>
|
|
756
|
-
<
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
// recycled node — never enter from a frozen transition.
|
|
763
|
-
stripTransitionClasses(el as HTMLElement, "hk-modal-overlay");
|
|
764
|
-
overlayEnterGuard?.();
|
|
765
|
-
overlayEnterGuard = armTransitionClassWatchdog(
|
|
766
|
-
el as HTMLElement,
|
|
767
|
-
"hk-modal-overlay",
|
|
768
|
-
() => !unmounted && !!props.modelValue,
|
|
769
|
-
);
|
|
770
|
-
}}
|
|
771
|
-
onAfterEnter={(el: Element) => {
|
|
772
|
-
overlayHooks.onAfterEnter();
|
|
773
|
-
// Stale after-enters from an interrupted cycle (Vue fires
|
|
774
|
-
// them without a cancelled flag) must not disarm the live
|
|
775
|
-
// cycle's guard — only the current element's completion.
|
|
776
|
-
if (el === overlayRef.value) {
|
|
777
|
-
overlayEnterGuard?.();
|
|
778
|
-
overlayEnterGuard = null;
|
|
779
|
-
}
|
|
780
|
-
}}
|
|
781
|
-
onEnterCancelled={() => {
|
|
782
|
-
overlayHooks.onEnterCancelled();
|
|
783
|
-
overlayEnterGuard?.();
|
|
784
|
-
overlayEnterGuard = null;
|
|
785
|
-
}}
|
|
786
|
-
onBeforeLeave={overlayHooks.onBeforeLeave}
|
|
787
|
-
onAfterLeave={overlayHooks.onAfterLeave}
|
|
788
|
-
onLeaveCancelled={overlayHooks.onLeaveCancelled}
|
|
789
|
-
>
|
|
790
|
-
{props.modelValue && (
|
|
791
|
-
<div
|
|
792
|
-
ref={overlayRef}
|
|
793
|
-
class="hk-modal-overlay"
|
|
794
|
-
onClick={onOverlayClick}
|
|
795
|
-
/>
|
|
796
|
-
)}
|
|
797
|
-
</Transition>
|
|
798
|
-
<Transition
|
|
799
|
-
name="hk-modal-content"
|
|
800
|
-
appear
|
|
801
|
-
onBeforeEnter={(el: Element) => {
|
|
802
|
-
contentHooks.onBeforeEnter();
|
|
803
|
-
stripTransitionClasses(el as HTMLElement, "hk-modal-content");
|
|
804
|
-
contentEnterGuard?.();
|
|
805
|
-
contentEnterGuard = armTransitionClassWatchdog(
|
|
806
|
-
el as HTMLElement,
|
|
807
|
-
"hk-modal-content",
|
|
808
|
-
() => !unmounted && !!props.modelValue,
|
|
809
|
-
);
|
|
810
|
-
}}
|
|
811
|
-
onAfterEnter={(el: Element) => {
|
|
812
|
-
contentHooks.onAfterEnter();
|
|
813
|
-
// Identity gate: a stale after-enter from an interrupted
|
|
814
|
-
// cycle must neither disarm the live guard nor re-run the
|
|
815
|
-
// open side effects (focus, morph arming).
|
|
816
|
-
if (el === contentRef.value) {
|
|
817
|
-
contentEnterGuard?.();
|
|
818
|
-
contentEnterGuard = null;
|
|
819
|
-
onAfterEnter();
|
|
820
|
-
// Size morphs arm once the open choreography finished —
|
|
821
|
-
// pinning during enter would override its height reveal.
|
|
822
|
-
morph.start();
|
|
823
|
-
}
|
|
824
|
-
}}
|
|
825
|
-
onEnterCancelled={() => {
|
|
826
|
-
contentHooks.onEnterCancelled();
|
|
827
|
-
contentEnterGuard?.();
|
|
828
|
-
contentEnterGuard = null;
|
|
829
|
-
}}
|
|
830
|
-
onBeforeLeave={() => {
|
|
831
|
-
contentHooks.onBeforeLeave();
|
|
832
|
-
// Release the pinned height so the leave owns the frame.
|
|
833
|
-
morph.stop();
|
|
834
|
-
}}
|
|
835
|
-
onAfterLeave={() => {
|
|
836
|
-
contentHooks.onAfterLeave();
|
|
837
|
-
onAfterLeaveFinalize();
|
|
838
|
-
}}
|
|
839
|
-
onLeaveCancelled={contentHooks.onLeaveCancelled}
|
|
840
|
-
>
|
|
841
|
-
{props.modelValue && (
|
|
842
|
-
<div
|
|
731
|
+
<div
|
|
732
|
+
ref={overlayEl}
|
|
733
|
+
class={["hk-modal-overlay", ...machine.classesFor("hk-modal-overlay")]}
|
|
734
|
+
onClick={onOverlayClick}
|
|
735
|
+
/>
|
|
736
|
+
<div
|
|
843
737
|
ref={contentRef}
|
|
844
|
-
class={["hk-modal-content", props.contentClass]}
|
|
738
|
+
class={["hk-modal-content", props.contentClass, ...machine.classesFor("hk-modal-content")]}
|
|
845
739
|
role="dialog"
|
|
846
740
|
aria-modal="true"
|
|
847
741
|
aria-label={resolvedSurfaceName.value}
|
|
@@ -927,8 +821,6 @@ export default defineComponent({
|
|
|
927
821
|
</div>
|
|
928
822
|
{renderFooter()}
|
|
929
823
|
</div>
|
|
930
|
-
)}
|
|
931
|
-
</Transition>
|
|
932
824
|
</div>
|
|
933
825
|
</Teleport>
|
|
934
826
|
);
|
|
@@ -10,6 +10,20 @@
|
|
|
10
10
|
.hk-popover-panel {
|
|
11
11
|
min-width: 0;
|
|
12
12
|
max-height: min(70vh, calc(100vh - 2 * 8px));
|
|
13
|
+
/* Width MUST be position-independent (max-content, viewport-capped).
|
|
14
|
+
* The anchored panel is fixed-positioned with shrink-to-fit sizing,
|
|
15
|
+
* and computePosition places it as `left = anchorEnd - panelWidth`
|
|
16
|
+
* (bottom-end). With shrink-to-fit the width itself derives from the
|
|
17
|
+
* available space (`viewport - left`) — so any elastically-wide
|
|
18
|
+
* content (percentage-width rows) creates a geometric feedback: every
|
|
19
|
+
* ResizeObserver → rAF reposition adds (viewport - anchorEnd) px of
|
|
20
|
+
* width until the clamp saturates, and the menu visibly ratchets
|
|
21
|
+
* wider frame by frame (demo.dev field report 2026-09-07). Pinning
|
|
22
|
+
* the width to max-content breaks the loop: the panel sizes to its
|
|
23
|
+
* content wherever it sits, and positioning converges in one pass.
|
|
24
|
+
* VIEWPORT_PAD is 8 (script side). */
|
|
25
|
+
width: max-content;
|
|
26
|
+
max-width: calc(100vw - 2 * 8px);
|
|
13
27
|
}
|
|
14
28
|
|
|
15
29
|
/* Pop motion family tokens (--hk-pop-* in theme.scss) — the reference
|
|
@@ -91,6 +105,10 @@
|
|
|
91
105
|
}
|
|
92
106
|
|
|
93
107
|
.hk-popover-panel.hk-is-sheet {
|
|
108
|
+
/* The sheet spans the viewport via inline left/right — the anchored
|
|
109
|
+
* rule's max-content width would over-constrain it (left+right+width
|
|
110
|
+
* drops `right` in LTR) and dock it at content width instead. */
|
|
111
|
+
width: auto;
|
|
94
112
|
border-radius: var(--hk-modal-radius, var(--hi-radius-lg, 12px))
|
|
95
113
|
var(--hk-modal-radius, var(--hi-radius-lg, 12px))
|
|
96
114
|
0 0;
|
|
@@ -17,4 +17,18 @@ describe("HkPopover glass layer surface hooks", () => {
|
|
|
17
17
|
expect(block).toContain("var(--hk-popover-bg, rgb(var(--color-surface)))");
|
|
18
18
|
expect(block).toContain("var(--hk-popover-blur,");
|
|
19
19
|
});
|
|
20
|
+
|
|
21
|
+
// Width must stay position-independent: computePosition places the
|
|
22
|
+
// anchored panel at `left = anchorEnd - panelWidth` (bottom-end), so
|
|
23
|
+
// a shrink-to-fit (position-dependent) width creates a ResizeObserver
|
|
24
|
+
// feedback that ratchets the menu wider every frame with elastically-
|
|
25
|
+
// wide content (demo.dev field report 2026-09-07). The sheet branch
|
|
26
|
+
// must keep spanning via its inline left/right (width: auto).
|
|
27
|
+
it("pins the anchored panel width to max-content and resets the sheet", () => {
|
|
28
|
+
const base = src.match(/\.hk-popover-panel\s*{[^}]*}/)![0];
|
|
29
|
+
expect(base).toContain("width: max-content");
|
|
30
|
+
expect(base).toContain("max-width: calc(100vw - 2 * 8px)");
|
|
31
|
+
const sheet = src.match(/\.hk-popover-panel\.hk-is-sheet\s*{[^}]*}/)![0];
|
|
32
|
+
expect(sheet).toContain("width: auto");
|
|
33
|
+
});
|
|
20
34
|
});
|