@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.
@@ -7,7 +7,6 @@ import {
7
7
  onUnmounted,
8
8
  ref,
9
9
  Teleport,
10
- Transition,
11
10
  useAttrs,
12
11
  watch,
13
12
  type PropType,
@@ -17,6 +16,7 @@ import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
17
16
  import { useBreakpoint } from "../runtime/useBreakpoint";
18
17
  import { useI18n } from "../i18n/context";
19
18
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
19
+ import { useSurfaceMachine } from "../composables/useSurfaceMachine";
20
20
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
21
21
  import { onceFrame } from "../runtime/animationBus";
22
22
  import HIconButton from "./HkIconButton";
@@ -88,6 +88,13 @@ export default defineComponent({
88
88
 
89
89
  const { isMobile } = useBreakpoint();
90
90
  const sheetMode = computed(() => props.sheetOnMobile && isMobile.value);
91
+ /** The form factor this surface OPENED in. The render branches on it
92
+ * (not the live sheetMode) so a viewport crossing the mobile
93
+ * breakpoint mid-flight closes in the shape the surface was born in
94
+ * — the anchored popup must not flash in for the closing window of
95
+ * a dying sheet (or vice versa). Set on the machine's opening edge;
96
+ * the next open adopts whatever the viewport calls for. */
97
+ const activeBranch = ref<"sheet" | "anchored">("anchored");
91
98
 
92
99
  // Crossing the mobile/desktop breakpoint mid-flight would leave a
93
100
  // SHEET-mode panel hung between two form factors — close it and let
@@ -99,14 +106,87 @@ export default defineComponent({
99
106
  });
100
107
 
101
108
  // Open/close motion reported into the unified animation context
102
- // through the same standard hook wiring every surface shares
103
- // (useSurfaceTransition) — this component was the pattern's
104
- // origin and now consumes it like the rest. Scrim and panel ride
105
- // separate named tracks (window-layer contract, ./_scrim-fade.scss)
106
- // so the backdrop's fade reports and settles on its own.
107
- const surfaceAnim = useSurfaceTransition(300);
108
- const anim = surfaceAnim.hooks();
109
- const scrimAnim = surfaceAnim.hooks("scrim");
109
+ // (animationBus) — one track for the whole surface, armed on the
110
+ // machine's animation-phase edges.
111
+ const surfTrack = useSurfaceTransition(300).track("surface");
112
+
113
+ // ── Surface lifecycle machine ─────────────────────────────────────
114
+ // One machine drives the sheet scrim and the panel in BOTH form
115
+ // factors (the mobile sheet and the anchored popup) — the layers are
116
+ // outputs of the shared phase, so the divergent-layer states of the
117
+ // two-<Transition> era are unrepresentable (see
118
+ // runtime/surfaceMachine.ts).
119
+ const scrimRef = ref<HTMLElement>();
120
+ const machine = useSurfaceMachine({
121
+ layers: [
122
+ // Budgets are the starvation-era bounds over the SCSS truths
123
+ // (scrim 0.25s/0.2s, sheet panel 0.3s/0.25s, anchored pop
124
+ // 0.2s/0.15s); the driver probes the live CSS durations and
125
+ // tightens them.
126
+ { prefix: "hk-popover-scrim", el: () => scrimRef.value, enterMs: () => 270, leaveMs: () => 240 },
127
+ // The machine classes land on the INNER panel div — the probe
128
+ // must read that element, not the positioning host (a classless
129
+ // host reads duration 0 and kills the animation in real
130
+ // browsers; the el-carries-the-classes invariant of #417/#418).
131
+ { prefix: "hk-popover-sheet", el: () => panelRef.value, enterMs: () => 320, leaveMs: () => 280 },
132
+ { prefix: "hk-popover", el: () => panelRef.value, enterMs: () => 240, leaveMs: () => 190 },
133
+ ],
134
+ onPhase: (from, to, event) => {
135
+ if (to === "openingFrom") {
136
+ surfTrack.run();
137
+ activeBranch.value = sheetMode.value ? "sheet" : "anchored";
138
+ // The open arm of the old modelValue watcher, verbatim.
139
+ fullCleanup();
140
+ // Blocking follows the sheet decision: anchored (desktop) the
141
+ // popover is a hidden breadcrumb level, docked as a bottom
142
+ // sheet (mobile) it is a window layer and must be listed —
143
+ // hence the i18n `title` riding along.
144
+ handle.value = manager.register(
145
+ "dropdown",
146
+ false,
147
+ props.title || undefined,
148
+ sheetMode.value,
149
+ );
150
+ if (sheetMode.value) {
151
+ // Bottom sheet: nothing to anchor-measure; the scrim handles
152
+ // dismissal (the outside-click shield is desktop-only).
153
+ nextTick(() => {
154
+ panelRef.value?.focus?.();
155
+ // The sheet mounts on this render — attach the overlay
156
+ // once the DOM has landed (sheet panels scroll; desktop
157
+ // popovers size to content and need none).
158
+ if (machine.mounted.value && sheetMode.value && panelRef.value) {
159
+ detachPanelScrollbar();
160
+ panelScrollbar = attachOverlayScrollbars(panelRef.value, {
161
+ axis: "vertical",
162
+ host: panelHostRef.value,
163
+ });
164
+ }
165
+ });
166
+ } else {
167
+ computeInitialCoords();
168
+ attachObservers();
169
+ attachOutsideClickShield();
170
+ nextTick(() => {
171
+ computePosition();
172
+ schedulePosition();
173
+ });
174
+ }
175
+ } else if (to === "closingFrom") {
176
+ surfTrack.run();
177
+ // The close arm of the old watcher.
178
+ detachObservers();
179
+ detachOutsideClickShield();
180
+ } else if (
181
+ to === "closed" &&
182
+ (from === "closingFrom" || from === "closingTo") &&
183
+ event !== "UNMOUNT"
184
+ ) {
185
+ surfTrack.cancel();
186
+ onPopupAfterLeave();
187
+ }
188
+ },
189
+ });
110
190
 
111
191
  // Suppress native browser tooltips while the popover is open — and
112
192
  // not just on the anchor itself: a `title` on ANY descendant fires
@@ -385,47 +465,9 @@ export default defineComponent({
385
465
  watch(
386
466
  () => props.modelValue,
387
467
  (open) => {
388
- if (open) {
389
- fullCleanup();
390
- // Blocking follows the sheet decision: anchored (desktop) the
391
- // popover is a hidden breadcrumb level, docked as a bottom
392
- // sheet (mobile) it is a window layer and must be listed —
393
- // hence the i18n `title` riding along.
394
- handle.value = manager.register(
395
- "dropdown",
396
- false,
397
- props.title || undefined,
398
- sheetMode.value,
399
- );
400
- if (sheetMode.value) {
401
- // Bottom sheet: nothing to anchor-measure; the scrim handles
402
- // dismissal (the outside-click shield is desktop-only).
403
- nextTick(() => {
404
- panelRef.value?.focus?.();
405
- // The sheet mounts on this render — attach the overlay
406
- // once the DOM has landed (sheet panels scroll; desktop
407
- // popovers size to content and need none).
408
- if (props.modelValue && sheetMode.value && panelRef.value) {
409
- detachPanelScrollbar();
410
- panelScrollbar = attachOverlayScrollbars(panelRef.value, {
411
- axis: "vertical",
412
- host: panelHostRef.value,
413
- });
414
- }
415
- });
416
- } else {
417
- computeInitialCoords();
418
- attachObservers();
419
- attachOutsideClickShield();
420
- nextTick(() => {
421
- computePosition();
422
- schedulePosition();
423
- });
424
- }
425
- } else {
426
- detachObservers();
427
- detachOutsideClickShield();
428
- }
468
+ // The machine owns every visual/registry consequence on its
469
+ // phase edges; this watcher is purely the event feed.
470
+ machine.send(open ? "OPEN" : "CLOSE");
429
471
  },
430
472
  { immediate: true },
431
473
  );
@@ -497,7 +539,7 @@ export default defineComponent({
497
539
  const panelZ = computed(() => (handle.value?.zIndex ?? 0) + 1);
498
540
 
499
541
  const panelStyle = computed(() => {
500
- if (sheetMode.value) {
542
+ if (activeBranch.value === "sheet") {
501
543
  // Bottom sheet: sits flush on the viewport bottom edge (the
502
544
  // host-tunable --hk-sheet-bottom-gap lift is still honored so the
503
545
  // sheet belongs to the HkModal/HkSelect sheet family — the family
@@ -527,49 +569,39 @@ export default defineComponent({
527
569
  };
528
570
  });
529
571
 
530
- return () => (
572
+ return () => {
573
+ if (!machine.mounted.value) return null;
574
+ const sheetBranch = activeBranch.value === "sheet";
575
+ const panelPrefix = sheetBranch ? "hk-popover-sheet" : "hk-popover";
576
+ return (
531
577
  <Teleport to="body">
532
578
  {/* Window-layer contract (./_scrim-fade.scss): the sheet scrim
533
- fades in place under its own transition name — it used to
534
- mount and unmount bare, snapping the dim curtain on and off
535
- around the sliding panel. z rides the live registration,
579
+ fades in place under its own transition name (machine output)
580
+ — it never mounts bare, so the dim curtain never snaps on or
581
+ off around the sliding panel. z rides the live registration,
536
582
  which outlives the close until the leave fade finishes. */}
537
- <Transition name="hk-popover-scrim" appear {...scrimAnim}>
538
- {sheetMode.value && props.modelValue && props.closeOnBackdrop ? (
539
- <div
540
- class="hk-popover-scrim"
541
- style={{ zIndex: backdropZ.value }}
542
- onClick={() => close()}
543
- />
544
- ) : null}
545
- </Transition>
546
- {props.backdrop && !sheetMode.value && props.modelValue && (
583
+ {sheetBranch && props.closeOnBackdrop ? (
584
+ <div
585
+ ref={scrimRef}
586
+ class={["hk-popover-scrim", ...machine.classesFor("hk-popover-scrim")]}
587
+ style={{ zIndex: backdropZ.value }}
588
+ onClick={() => close()}
589
+ />
590
+ ) : null}
591
+ {props.backdrop && !sheetBranch && props.modelValue && (
547
592
  <div
548
593
  class="hk-popover-backdrop"
549
594
  style={{ zIndex: backdropZ.value }}
550
595
  />
551
596
  )}
552
- <Transition
553
- name={sheetMode.value ? "hk-popover-sheet" : "hk-popover"}
554
- appear
555
- onBeforeEnter={anim.onBeforeEnter}
556
- onAfterEnter={anim.onAfterEnter}
557
- onBeforeLeave={anim.onBeforeLeave}
558
- onAfterLeave={() => {
559
- anim.onAfterLeave();
560
- onPopupAfterLeave();
561
- }}
562
- onEnterCancelled={anim.onEnterCancelled}
563
- onLeaveCancelled={anim.onLeaveCancelled}
564
- >
565
- {props.modelValue ? (
566
- <div ref={panelHostRef} style={panelStyle.value}>
597
+ <div ref={panelHostRef} style={panelStyle.value}>
567
598
  <div
568
599
  ref={panelRef}
569
600
  class={[
570
601
  "hk-popover-panel",
571
- ...(sheetMode.value ? ["hk-is-sheet"] : [`hk-popover-${resolvedPlacement.value}`]),
602
+ ...(sheetBranch ? ["hk-is-sheet"] : [`hk-popover-${resolvedPlacement.value}`]),
572
603
  props.glass ? "hii-dropdown-content" : "",
604
+ ...machine.classesFor(panelPrefix),
573
605
  ...(typeof attrs.class === "string" ? [attrs.class]
574
606
  : Array.isArray(attrs.class) ? attrs.class as string[]
575
607
  : attrs.class && typeof attrs.class === "object"
@@ -578,15 +610,15 @@ export default defineComponent({
578
610
  : []),
579
611
  ]}
580
612
  role="dialog"
581
- aria-modal={sheetMode.value ? "true" : undefined}
613
+ aria-modal={sheetBranch ? "true" : undefined}
582
614
  aria-label={props.title || undefined}
583
- tabindex={sheetMode.value ? "-1" : undefined}
615
+ tabindex={sheetBranch ? "-1" : undefined}
584
616
  onKeydown={onPanelKeydown}
585
617
  >
586
- {sheetMode.value && (
618
+ {sheetBranch && (
587
619
  <div class="hk-popover-sheet-grabber" aria-hidden="true" onClick={() => close()} />
588
620
  )}
589
- {sheetMode.value && (
621
+ {sheetBranch && (
590
622
  // Sheet heading row — the title and the close button on
591
623
  // one vertically-centered line (2026-09-04 user report:
592
624
  // the bare title band read too small and hung right of
@@ -610,10 +642,9 @@ export default defineComponent({
610
642
  )}
611
643
  {slots.default?.()}
612
644
  </div>
613
- </div>
614
- ) : null}
615
- </Transition>
645
+ </div>
616
646
  </Teleport>
617
- );
647
+ );
648
+ };
618
649
  },
619
650
  });
@@ -690,14 +690,16 @@ describe("HkSelectPanel enter-class watchdog (frozen-rAF repair)", () => {
690
690
  const panel = document.body.querySelector<HTMLElement>(".hk-select-sheet-panel")!;
691
691
  expect(scrim).toBeTruthy();
692
692
  expect(panel).toBeTruthy();
693
- // The frozen enter left its from-pair on both layers.
693
+ // The frozen enter left its from-pair on both layers (checked before
694
+ // any budget elapses in the frozen-clock run below).
694
695
  expect(scrim.classList.contains("hk-select-sheet-scrim-enter-from")).toBe(true);
695
696
  expect(panel.classList.contains("hk-select-sheet-enter-from")).toBe(true);
696
697
 
697
- await vi.advanceTimersByTimeAsync(590);
698
- expect(scrim.classList.contains("hk-select-sheet-scrim-enter-from")).toBe(true);
699
-
700
- await vi.advanceTimersByTimeAsync(100);
698
+ // Past every budget the machine rests fully open with no frozen
699
+ // classes — repaired without a single frame (environments with live
700
+ // CSS durations exercise the full window; without them the probe
701
+ // completes even sooner — the property is the BOUND).
702
+ await vi.advanceTimersByTimeAsync(700);
701
703
  expect(scrim.classList.contains("hk-select-sheet-scrim-enter-from")).toBe(false);
702
704
  expect(panel.classList.contains("hk-select-sheet-enter-from")).toBe(false);
703
705
  // …and the surface is still open and intact.