@jsenv/navi 0.29.24 → 0.29.25

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/README.md CHANGED
@@ -47,6 +47,8 @@ A capable `Table` component that handles what you'd expect from a spreadsheet-li
47
47
 
48
48
  Dialogs, badges, details/collapsible, separators, keyboard shortcuts, popovers, copy-to-clipboard, and other utilities.
49
49
 
50
+ A `Dialog`/`Popover` owns whether it is open; what varies is the trigger — a button, a gesture, a piece of application state. Which one to use, and what each costs, is in [docs/popup_open.md](./docs/popup_open.md).
51
+
50
52
  ---
51
53
 
52
54
  Named after Navi, the fairy guide from Zelda — it helps you navigate through the complexities of building modern web applications.
@@ -25120,6 +25120,9 @@ const createOpenController = (
25120
25120
  openEffectCleanup = null;
25121
25121
  closeHandlers?.onClose?.(closeEvent);
25122
25122
  closeHandlers = null;
25123
+ // Last: the close effects above are what starts the exit transition the
25124
+ // content must outlive (see popup_content_mount.js).
25125
+ controller.unmountContent?.();
25123
25126
  };
25124
25127
  const controller = {
25125
25128
  opened: false,
@@ -25128,6 +25131,9 @@ const createOpenController = (
25128
25131
  // content is still waiting for a first open to be built. Called below,
25129
25132
  // before openEffect, so the popup measures and positions the real thing.
25130
25133
  mountContent: null,
25134
+ // The counterpart, set only when the popup was told to throw its content
25135
+ // away on close (`unmountWhenClosed`). Called from performClose above.
25136
+ unmountContent: null,
25131
25137
  open: (e, detail) => {
25132
25138
  if (controller.opened || !controller.openEffect) {
25133
25139
  return;
@@ -25407,7 +25413,166 @@ const flushSyncRendering = (fn) => {
25407
25413
  };
25408
25414
 
25409
25415
  /**
25410
- * When a popup builds what it holds.
25416
+ * Small, renderer-agnostic helpers shared by Popover and Dialog's own custom
25417
+ * (non-top-layer) renderers — operate on a plain DOM element, no knowledge
25418
+ * of which of the two owns it.
25419
+ */
25420
+
25421
+
25422
+ /**
25423
+ * Calls `onSettled` once `el`'s current CSS transition is over — via
25424
+ * `transitionend`, with a safety `setTimeout` fallback matching the longest
25425
+ * `transition-duration`, in case nothing actually transitions or an event is
25426
+ * missed.
25427
+ *
25428
+ * Returns a "cancel" function, so a caller whose instance has been superseded
25429
+ * (a fresh open/close about to set its own state) can keep this stale one from
25430
+ * firing later. Cancelling only stops `onSettled`: undoing whatever the caller
25431
+ * did up front is that fresh call's business, not this one's.
25432
+ */
25433
+ const whenTransitionSettles = (el, onSettled) => {
25434
+ let settled = false;
25435
+ const onTransitionEnd = (transitionEvent) => {
25436
+ if (transitionEvent.target === el) {
25437
+ finish();
25438
+ }
25439
+ };
25440
+ const stopWatching = () => {
25441
+ settled = true;
25442
+ el.removeEventListener("transitionend", onTransitionEnd);
25443
+ clearTimeout(safetyTimeoutId);
25444
+ };
25445
+ const finish = () => {
25446
+ if (settled) {
25447
+ return;
25448
+ }
25449
+ stopWatching();
25450
+ onSettled();
25451
+ };
25452
+ el.addEventListener("transitionend", onTransitionEnd);
25453
+ const durationsInSeconds = getComputedStyle(el)
25454
+ .transitionDuration.split(",")
25455
+ .map((value) => parseFloat(value) || 0);
25456
+ const longestDurationMs = Math.max(0, ...durationsInSeconds) * 1000;
25457
+ const safetyTimeoutId = setTimeout(finish, longestDurationMs + 50);
25458
+ return () => {
25459
+ if (settled) {
25460
+ return;
25461
+ }
25462
+ stopWatching();
25463
+ };
25464
+ };
25465
+
25466
+ /**
25467
+ * Disables pointer-events on `el` until its current CSS transition settles —
25468
+ * avoids the cursor changing/something becoming clickable while the popup is
25469
+ * still visually moving into or out of place.
25470
+ *
25471
+ * Returns whenTransitionSettles' own "cancel" function: it doesn't restore
25472
+ * pointer-events, since a fresh call for the next open/close is about to set
25473
+ * its own state.
25474
+ */
25475
+ const suppressPointerEventsDuringTransition = (el) => {
25476
+ el.style.pointerEvents = "none";
25477
+ return whenTransitionSettles(el, () => {
25478
+ el.style.pointerEvents = "";
25479
+ });
25480
+ };
25481
+
25482
+ /**
25483
+ * Hides the backdrop, deferring until the browser's matching "click" fires
25484
+ * when `closeEvent` was triggered by a mousedown (see popover.jsx's top
25485
+ * comment for why) — same capture-phase-on-document pattern as
25486
+ * armSuppressNextOpenRequest in open_controller.js, which a plain timeout
25487
+ * can't safely replace: mouseup (and the click that follows it) can land an
25488
+ * arbitrarily long time after mousedown (the user is still holding the
25489
+ * button down), so a short timeout can fire first and hide the backdrop
25490
+ * before its own click ever arrives. A capture-phase listener on document
25491
+ * fires for every click regardless of what any bubble-phase handler does
25492
+ * downstream, so no fallback timer is needed.
25493
+ *
25494
+ * `hide` is the caller's own way to actually hide the backdrop
25495
+ * (`hidePopover()` for a top-layer backdrop, a plain `style.display = "none"`
25496
+ * for a plain div) — this helper only owns the mousedown/click timing.
25497
+ *
25498
+ * Returns a disarm function (or undefined if hidden immediately), so a
25499
+ * fresh open can cancel a pending hide it's about to make redundant.
25500
+ */
25501
+ const armPointerDownOutsideClose = (closeEvent, hide) => {
25502
+ const mousedownEvent = findEvent(closeEvent, "mousedown");
25503
+ if (!mousedownEvent) {
25504
+ hide();
25505
+ return undefined;
25506
+ }
25507
+ const onClick = () => {
25508
+ document.removeEventListener("click", onClick, { capture: true });
25509
+ hide();
25510
+ };
25511
+ document.addEventListener("click", onClick, { capture: true });
25512
+ return () => {
25513
+ document.removeEventListener("click", onClick, { capture: true });
25514
+ };
25515
+ };
25516
+
25517
+ /**
25518
+ * Maps a positionArea y/x pair to a concrete `navi-animation` value (a
25519
+ * `prefix` plus a direction word), or `null` if both axes overlap the anchor
25520
+ * (no direction at all — that's `resolvedAnimationKind === "scaling"`
25521
+ * territory instead, see resolveAutoAnimationKind below).
25522
+ *
25523
+ * `prefix: "slide-from"` (used with no real anchor — Dialog always, Popover
25524
+ * when docked) keeps the word as the compass direction the popup comes
25525
+ * from: placed "top" (a point/corner), it slides in from the top.
25526
+ * `prefix: "expand"` (a real anchor, Popover-only) uses the motion/growth
25527
+ * direction instead, the opposite compass point: placed "top" of the
25528
+ * anchor, it moves/grows up, away from the anchor (which sits below it).
25529
+ *
25530
+ * "inset-*"/"center" contribute no direction on their axis either way.
25531
+ */
25532
+ const resolveDirectionValue = (y, x, { prefix }) => {
25533
+ const yWord =
25534
+ y === "top"
25535
+ ? prefix === "expand"
25536
+ ? "up"
25537
+ : "top"
25538
+ : y === "bottom"
25539
+ ? prefix === "expand"
25540
+ ? "down"
25541
+ : "bottom"
25542
+ : null;
25543
+ const xWord = x === "left" ? "left" : x === "right" ? "right" : null;
25544
+ if (!yWord && !xWord) {
25545
+ return null;
25546
+ }
25547
+ return yWord && xWord
25548
+ ? `${prefix}-${yWord}-${xWord}`
25549
+ : `${prefix}-${yWord || xWord}`;
25550
+ };
25551
+
25552
+ /**
25553
+ * Shared `animation="auto"`/`true` resolution: "scaling" reads best overall
25554
+ * — picked for any real anchor, or for a point/corner placed dead-center
25555
+ * (both positionArea axes overlapping — there's no sensible direction to
25556
+ * slide from in that case). "sliding" otherwise. `anchor` is `undefined`
25557
+ * for any no-anchor/docked case (Dialog always, Popover's own custom
25558
+ * renderer when there's no real anchor), so this collapses to "scaling"
25559
+ * there only for the dead-center case, "sliding" otherwise. The two
25560
+ * "overlapping" booleans below describe the *positionArea* itself (a bare
25561
+ * word vs. "inset-"/"center"), not anything about the anchor — they'd
25562
+ * mean exactly the same thing even with no anchor at all, since it's the
25563
+ * position strategy, not the anchor, that decides whether there's a
25564
+ * direction to slide from.
25565
+ */
25566
+ const resolveAutoAnimationKind = (anchor, parsedPositionArea) => {
25567
+ const yIsOverlapping =
25568
+ parsedPositionArea.y !== "top" && parsedPositionArea.y !== "bottom";
25569
+ const xIsOverlapping =
25570
+ parsedPositionArea.x !== "left" && parsedPositionArea.x !== "right";
25571
+ return anchor || (yIsOverlapping && xIsOverlapping) ? "scaling" : "sliding";
25572
+ };
25573
+
25574
+ /**
25575
+ * When a popup builds what it holds, and when it throws it away.
25411
25576
  *
25412
25577
  * A closed popup shows nothing, focuses nothing, and answers nothing: what it
25413
25578
  * holds is out of reach until it opens. Building that content at mount time
@@ -25430,12 +25595,18 @@ const flushSyncRendering = (fn) => {
25430
25595
  * `mountWhenClosed` is for content something else depends on before any of
25431
25596
  * this: a value the popup's owner reads off its own children, fields a form
25432
25597
  * around it collects on submit, a size measured from outside.
25598
+ *
25599
+ * `unmountWhenClosed` is the opposite end: content that must be rebuilt from
25600
+ * scratch every time, because what it shows is read once at build time and can
25601
+ * change while the popup is closed — an uncontrolled field seeded from a
25602
+ * `defaultValue`, a form whose fresh state is its initial state.
25433
25603
  */
25434
25604
 
25435
25605
 
25436
25606
  const usePopupContentMount = (
25437
25607
  openController,
25438
- { children, mountWhenClosed },
25608
+ ref,
25609
+ { children, mountWhenClosed, unmountWhenClosed },
25439
25610
  ) => {
25440
25611
  const [contentMounted, setContentMounted] = useState(
25441
25612
  () => Boolean(mountWhenClosed) || openController.opened,
@@ -25447,6 +25618,27 @@ const usePopupContentMount = (
25447
25618
  setContentMounted(true);
25448
25619
  });
25449
25620
  };
25621
+ openController.unmountContent =
25622
+ unmountWhenClosed && !mountWhenClosed
25623
+ ? () => {
25624
+ const element = ref?.current;
25625
+ if (!element) {
25626
+ setContentMounted(false);
25627
+ return;
25628
+ }
25629
+ // The popup is still on screen while it plays its exit transition;
25630
+ // emptying it right away would show that transition running on a
25631
+ // blank surface.
25632
+ whenTransitionSettles(element, () => {
25633
+ if (openController.opened) {
25634
+ // reopened while it was leaving — the content it holds is the
25635
+ // one that open just asked for
25636
+ return;
25637
+ }
25638
+ setContentMounted(false);
25639
+ });
25640
+ }
25641
+ : null;
25450
25642
  useLayoutEffect(() => {
25451
25643
  if (mountWhenClosed) {
25452
25644
  setContentMounted(true);
@@ -25760,150 +25952,6 @@ const unfreezeSize = (el) => {
25760
25952
  el.style.height = "";
25761
25953
  };
25762
25954
 
25763
- /**
25764
- * Small, renderer-agnostic helpers shared by Popover and Dialog's own custom
25765
- * (non-top-layer) renderers — operate on a plain DOM element, no knowledge
25766
- * of which of the two owns it.
25767
- */
25768
-
25769
-
25770
- /**
25771
- * Disables pointer-events on `el` until its current CSS transition settles
25772
- * (via `transitionend`, with a safety `setTimeout` fallback matching the
25773
- * longest `transition-duration` in case nothing actually transitions or an
25774
- * event is missed) — avoids the cursor changing/something becoming
25775
- * clickable while the popup is still visually moving into or out of place.
25776
- *
25777
- * Returns a "cancel" function: doesn't restore pointer-events (a fresh call
25778
- * for the next open/close is about to set its own state) — only prevents
25779
- * this stale instance's `transitionend` listener/timeout from firing later
25780
- * and clobbering that fresh state.
25781
- */
25782
- const suppressPointerEventsDuringTransition = (el) => {
25783
- el.style.pointerEvents = "none";
25784
- let settled = false;
25785
- const onTransitionEnd = (transitionEvent) => {
25786
- if (transitionEvent.target === el) {
25787
- finish();
25788
- }
25789
- };
25790
- const finish = () => {
25791
- if (settled) {
25792
- return;
25793
- }
25794
- settled = true;
25795
- el.style.pointerEvents = "";
25796
- el.removeEventListener("transitionend", onTransitionEnd);
25797
- clearTimeout(safetyTimeoutId);
25798
- };
25799
- el.addEventListener("transitionend", onTransitionEnd);
25800
- const durationsInSeconds = getComputedStyle(el)
25801
- .transitionDuration.split(",")
25802
- .map((value) => parseFloat(value) || 0);
25803
- const longestDurationMs = Math.max(0, ...durationsInSeconds) * 1000;
25804
- const safetyTimeoutId = setTimeout(finish, longestDurationMs + 50);
25805
- return () => {
25806
- if (settled) {
25807
- return;
25808
- }
25809
- settled = true;
25810
- el.removeEventListener("transitionend", onTransitionEnd);
25811
- clearTimeout(safetyTimeoutId);
25812
- };
25813
- };
25814
-
25815
- /**
25816
- * Hides the backdrop, deferring until the browser's matching "click" fires
25817
- * when `closeEvent` was triggered by a mousedown (see popover.jsx's top
25818
- * comment for why) — same capture-phase-on-document pattern as
25819
- * armSuppressNextOpenRequest in open_controller.js, which a plain timeout
25820
- * can't safely replace: mouseup (and the click that follows it) can land an
25821
- * arbitrarily long time after mousedown (the user is still holding the
25822
- * button down), so a short timeout can fire first and hide the backdrop
25823
- * before its own click ever arrives. A capture-phase listener on document
25824
- * fires for every click regardless of what any bubble-phase handler does
25825
- * downstream, so no fallback timer is needed.
25826
- *
25827
- * `hide` is the caller's own way to actually hide the backdrop
25828
- * (`hidePopover()` for a top-layer backdrop, a plain `style.display = "none"`
25829
- * for a plain div) — this helper only owns the mousedown/click timing.
25830
- *
25831
- * Returns a disarm function (or undefined if hidden immediately), so a
25832
- * fresh open can cancel a pending hide it's about to make redundant.
25833
- */
25834
- const armPointerDownOutsideClose = (closeEvent, hide) => {
25835
- const mousedownEvent = findEvent(closeEvent, "mousedown");
25836
- if (!mousedownEvent) {
25837
- hide();
25838
- return undefined;
25839
- }
25840
- const onClick = () => {
25841
- document.removeEventListener("click", onClick, { capture: true });
25842
- hide();
25843
- };
25844
- document.addEventListener("click", onClick, { capture: true });
25845
- return () => {
25846
- document.removeEventListener("click", onClick, { capture: true });
25847
- };
25848
- };
25849
-
25850
- /**
25851
- * Maps a positionArea y/x pair to a concrete `navi-animation` value (a
25852
- * `prefix` plus a direction word), or `null` if both axes overlap the anchor
25853
- * (no direction at all — that's `resolvedAnimationKind === "scaling"`
25854
- * territory instead, see resolveAutoAnimationKind below).
25855
- *
25856
- * `prefix: "slide-from"` (used with no real anchor — Dialog always, Popover
25857
- * when docked) keeps the word as the compass direction the popup comes
25858
- * from: placed "top" (a point/corner), it slides in from the top.
25859
- * `prefix: "expand"` (a real anchor, Popover-only) uses the motion/growth
25860
- * direction instead, the opposite compass point: placed "top" of the
25861
- * anchor, it moves/grows up, away from the anchor (which sits below it).
25862
- *
25863
- * "inset-*"/"center" contribute no direction on their axis either way.
25864
- */
25865
- const resolveDirectionValue = (y, x, { prefix }) => {
25866
- const yWord =
25867
- y === "top"
25868
- ? prefix === "expand"
25869
- ? "up"
25870
- : "top"
25871
- : y === "bottom"
25872
- ? prefix === "expand"
25873
- ? "down"
25874
- : "bottom"
25875
- : null;
25876
- const xWord = x === "left" ? "left" : x === "right" ? "right" : null;
25877
- if (!yWord && !xWord) {
25878
- return null;
25879
- }
25880
- return yWord && xWord
25881
- ? `${prefix}-${yWord}-${xWord}`
25882
- : `${prefix}-${yWord || xWord}`;
25883
- };
25884
-
25885
- /**
25886
- * Shared `animation="auto"`/`true` resolution: "scaling" reads best overall
25887
- * — picked for any real anchor, or for a point/corner placed dead-center
25888
- * (both positionArea axes overlapping — there's no sensible direction to
25889
- * slide from in that case). "sliding" otherwise. `anchor` is `undefined`
25890
- * for any no-anchor/docked case (Dialog always, Popover's own custom
25891
- * renderer when there's no real anchor), so this collapses to "scaling"
25892
- * there only for the dead-center case, "sliding" otherwise. The two
25893
- * "overlapping" booleans below describe the *positionArea* itself (a bare
25894
- * word vs. "inset-"/"center"), not anything about the anchor — they'd
25895
- * mean exactly the same thing even with no anchor at all, since it's the
25896
- * position strategy, not the anchor, that decides whether there's a
25897
- * direction to slide from.
25898
- */
25899
- const resolveAutoAnimationKind = (anchor, parsedPositionArea) => {
25900
- const yIsOverlapping =
25901
- parsedPositionArea.y !== "top" && parsedPositionArea.y !== "bottom";
25902
- const xIsOverlapping =
25903
- parsedPositionArea.x !== "left" && parsedPositionArea.x !== "right";
25904
- return anchor || (yIsOverlapping && xIsOverlapping) ? "scaling" : "sliding";
25905
- };
25906
-
25907
25955
  installImportMetaCssBuild(import.meta);/**
25908
25956
  * A dialog is a surface, not a control: it holds no value, has no action and
25909
25957
  * aggregates nothing. Fields and a submit go in a `<Form>` inside it, exactly
@@ -26384,6 +26432,11 @@ const css$V = /* css */`
26384
26432
  * content something depends on while the popup is still closed: a value read
26385
26433
  * off it, fields a surrounding form collects on submit, a size measured from
26386
26434
  * outside.
26435
+ * @param {boolean} [props.unmountWhenClosed] - Throws `children` away once the
26436
+ * popup has finished closing (see popup_content_mount.js). For content whose
26437
+ * fresh state is its initial state: an uncontrolled field seeded from a
26438
+ * `defaultValue` that changed while the popup was closed. Ignored when
26439
+ * `mountWhenClosed` is set.
26387
26440
  * @param {import("ignore:preact").ComponentChildren} props.children
26388
26441
  */
26389
26442
  const Dialog = props => {
@@ -26580,11 +26633,13 @@ const useDialogProps = props => {
26580
26633
  onKeyDown,
26581
26634
  children: childrenProp,
26582
26635
  mountWhenClosed,
26636
+ unmountWhenClosed,
26583
26637
  ...rest
26584
26638
  } = props;
26585
- const children = usePopupContentMount(openController, {
26639
+ const children = usePopupContentMount(openController, props.ref, {
26586
26640
  children: childrenProp,
26587
- mountWhenClosed
26641
+ mountWhenClosed,
26642
+ unmountWhenClosed
26588
26643
  });
26589
26644
  const isModal = layer === "top";
26590
26645
  const ref = props.ref;
@@ -27632,6 +27687,11 @@ const css$U = /* css */`
27632
27687
  * content something depends on while the popup is still closed: a value read
27633
27688
  * off it, fields a surrounding form collects on submit, a size measured from
27634
27689
  * outside.
27690
+ * @param {boolean} [props.unmountWhenClosed] - Throws `children` away once the
27691
+ * popup has finished closing (see popup_content_mount.js). For content whose
27692
+ * fresh state is its initial state: an uncontrolled field seeded from a
27693
+ * `defaultValue` that changed while the popup was closed. Ignored when
27694
+ * `mountWhenClosed` is set.
27635
27695
  * @param {import("ignore:preact").ComponentChildren} props.children
27636
27696
  */
27637
27697
  const Popover = props => {
@@ -27821,11 +27881,13 @@ const usePopoverProps = props => {
27821
27881
  onKeyDown,
27822
27882
  children: childrenProp,
27823
27883
  mountWhenClosed,
27884
+ unmountWhenClosed,
27824
27885
  ...rest
27825
27886
  } = props;
27826
- const children = usePopupContentMount(openController, {
27887
+ const children = usePopupContentMount(openController, props.ref, {
27827
27888
  children: childrenProp,
27828
- mountWhenClosed
27889
+ mountWhenClosed,
27890
+ unmountWhenClosed
27829
27891
  });
27830
27892
  const isTopLayer = layer === "top";
27831
27893
  const ref = props.ref;
@@ -34884,7 +34946,14 @@ const css$R = /* css */`
34884
34946
 
34885
34947
  &::view-transition-old(navi-route-travel),
34886
34948
  &::view-transition-new(navi-route-travel) {
34887
- height: 100%;
34949
+ /* Each picture at the size it was taken at: a page is not resized by the
34950
+ page it crosses. Told to fill a box whose height is being animated, a
34951
+ picture is STRETCHED with it — the page leaving is then seen squashing
34952
+ upwards, or zooming, over the length of the travel, when all it is
34953
+ doing is walking off the edge. */
34954
+ height: auto;
34955
+ object-fit: none;
34956
+ object-position: top left;
34888
34957
  /* The default cross-fade, dropped: two pages sliding past each other are
34889
34958
  two solid things, and seeing through one to the other says they are the
34890
34959
  same page changing its mind. */
@@ -34900,7 +34969,21 @@ const css$R = /* css */`
34900
34969
  overflow: clip;
34901
34970
  }
34902
34971
  &::view-transition-group(navi-route-travel) {
34972
+ /* The window the two pictures are seen through, held still for the whole
34973
+ travel at the taller of the two boxes (see holdTravelHeight): the group
34974
+ is what CLIPS, and the browser animates its height from the box being
34975
+ left to the box arriving — so the window shrinks under the pictures and
34976
+ cuts the page leaving from the bottom, progressively. The box does end
34977
+ up at the arriving page's height, and that is right; what must not
34978
+ happen is the user watching it get there.
34979
+
34980
+ The height is held by dropping the group's animation rather than by
34981
+ winning against it with !important — which also drops its position
34982
+ animation, fine while a travel box stands in the same place from one
34983
+ route to the next. */
34984
+ height: var(--navi-route-travel-height);
34903
34985
  animation-duration: var(--navi-route-travel-duration, 300ms);
34986
+ animation-name: none;
34904
34987
  }
34905
34988
  }
34906
34989
 
@@ -35130,18 +35213,26 @@ const RouteTravel = ({
35130
35213
  document.documentElement.setAttribute(DRAGGED_ATTRIBUTE, "");
35131
35214
  }
35132
35215
  routeAskedForRef.current = route;
35216
+ // The box as it stands before anything moves: rendering is held, so this is
35217
+ // still the page being left (see holdTravelHeight).
35218
+ const heightBefore = elementRef.current.getBoundingClientRect().height;
35133
35219
  // The hold a navigation already took, if this travel is the answer to one:
35134
35220
  // taking another would be taking a hold on a page that is holding still.
35135
35221
  const releaseRendering = renderingHeldForRouting || holdRendering();
35136
35222
  renderingHeldForRouting = null;
35137
35223
  // The picture the browser is about to take must be of the page that was
35138
35224
  // asked for, and a route matching is not yet a page rendered.
35139
- const viewTransition = startViewTransition(() => whileRouteRenders(route, async () => {
35140
- releaseRendering();
35141
- if (change) {
35142
- await change();
35143
- }
35144
- }));
35225
+ const viewTransition = startViewTransition(async () => {
35226
+ await whileRouteRenders(route, async () => {
35227
+ releaseRendering();
35228
+ if (change) {
35229
+ await change();
35230
+ }
35231
+ });
35232
+ // The page arriving is in the DOM and the transition has not started
35233
+ // playing: the one moment both boxes can be known.
35234
+ holdTravelHeight(elementRef.current, heightBefore);
35235
+ });
35145
35236
  travel.viewTransition = viewTransition;
35146
35237
  if (scrub) {
35147
35238
  // Said only now: the release has to have something to let go of, and the
@@ -35435,6 +35526,7 @@ const RouteTravel = ({
35435
35526
  document.documentElement.removeAttribute(TRAVEL_ATTRIBUTE);
35436
35527
  document.documentElement.removeAttribute(DRAGGED_ATTRIBUTE);
35437
35528
  document.documentElement.removeAttribute(TURNED_ATTRIBUTE);
35529
+ releaseTravelHeight();
35438
35530
  }
35439
35531
  };
35440
35532
 
@@ -35816,6 +35908,24 @@ const releaseHold = travel => {
35816
35908
  travelHoldingPictures = null;
35817
35909
  document.documentElement.removeAttribute(HOLD_ATTRIBUTE);
35818
35910
  };
35911
+ const TRAVEL_HEIGHT_PROPERTY = "--navi-route-travel-height";
35912
+ // The height the group is held at for the whole travel: the taller of the two
35913
+ // boxes, so neither picture is ever cut. It cannot be said in CSS — neither box
35914
+ // is knowable there — and it cannot be measured from one side alone: a page
35915
+ // arriving shorter than the one it replaces would cut the one leaving, a page
35916
+ // arriving taller would be cut itself.
35917
+ const holdTravelHeight = (element, heightBefore) => {
35918
+ const heightAfter = element.getBoundingClientRect().height;
35919
+ const height = heightBefore > heightAfter ? heightBefore : heightAfter;
35920
+ document.documentElement.style.setProperty(TRAVEL_HEIGHT_PROPERTY, `${height}px`);
35921
+ };
35922
+ // The live layout takes the box back. A discontinuity by construction — the
35923
+ // group stands at the held height, the box is at the new one — and an invisible
35924
+ // one: the page arriving is fully in place, and the strip below it that the
35925
+ // group still covers shows the page leaving only while it is still on screen.
35926
+ const releaseTravelHeight = () => {
35927
+ document.documentElement.style.removeProperty(TRAVEL_HEIGHT_PROPERTY);
35928
+ };
35819
35929
 
35820
35930
  // The browser does not take the picture of the page being left when a
35821
35931
  // transition is ASKED for — it takes it at the next frame, just before running
@@ -48329,6 +48439,11 @@ const css$z = /* css */`
48329
48439
  * content something depends on while the popup is still closed: a value read
48330
48440
  * off it, fields a surrounding form collects on submit, a size measured from
48331
48441
  * outside.
48442
+ * @param {boolean} [props.unmountWhenClosed] - Throws `children` away once the
48443
+ * popup has finished closing (see popup_content_mount.js). For content whose
48444
+ * fresh state is its initial state: an uncontrolled field seeded from a
48445
+ * `defaultValue` that changed while the popup was closed. Ignored when
48446
+ * `mountWhenClosed` is set.
48332
48447
  * @param {import("ignore:preact").ComponentChildren} props.children
48333
48448
  */
48334
48449
  const Popup = props => {
@@ -51323,10 +51438,17 @@ const ListUI = props => {
51323
51438
  // search fallback), otherwise an empty list is the "empty" state.
51324
51439
  const searchFallbackShown = (allNoMatch || searching && itemCount === 0) && !searchFallbackDisabled;
51325
51440
  const emptyFallbackShown = !searching && itemCount === 0 && !fallbackDisabled;
51441
+ // A loading state only holds the list on screen when it has something to
51442
+ // draw. A count of 0 (or no loadingFallback at all) says the list is known to
51443
+ // be empty before the response arrives, so the empty state can already be
51444
+ // shown — nothing jumps when the response lands, exactly as three skeletons
51445
+ // become three rows.
51446
+ const loadingPlaceholderShown = Boolean(loading) && Boolean(loadingFallback) && (loadingFallback !== "skeleton" || loadingSkeletonCount > 0);
51326
51447
  // Hide the whole list — border included — when there is genuinely nothing to
51327
- // show: no visible items AND no fallback message. Never while loading or in
51328
- // error (the placeholder / error message ARE the content to display).
51329
- const nothingToDisplay = !loading && !error && noVisibleItems && !searchFallbackShown && !emptyFallbackShown;
51448
+ // show: no visible items AND no fallback message. Never while a loading
51449
+ // placeholder or an error message is on screen (they ARE the content to
51450
+ // display).
51451
+ const nothingToDisplay = !loadingPlaceholderShown && !error && noVisibleItems && !searchFallbackShown && !emptyFallbackShown;
51330
51452
 
51331
51453
  // Placeholder content replaces the real children: an error message when the
51332
51454
  // load failed (takes precedence), otherwise — while loading — whatever
@@ -51421,7 +51543,7 @@ const ListUI = props => {
51421
51543
  fallbackShown: emptyFallbackShown,
51422
51544
  searchFallback: searchFallback,
51423
51545
  searchFallbackShown: searchFallbackShown,
51424
- loading: loading,
51546
+ loadingPlaceholderShown: loadingPlaceholderShown,
51425
51547
  error: error,
51426
51548
  searchNoMatchMode: searchNoMatchMode,
51427
51549
  separator: separator,
@@ -51521,6 +51643,10 @@ const ListFirstResolver = props => {
51521
51643
  * displays nothing. A list that knows how many rows it will have has no use
51522
51644
  * for this — see `<List.Items count>`, whose not-yet-loaded rows are drawn
51523
51645
  * as skeletons in place, one per row, virtualized like the rest.
51646
+ * @param {number} [props.loadingSkeletonCount=3]
51647
+ * How many placeholder rows `loadingFallback="skeleton"` draws. `0` says the
51648
+ * list is already known to be empty: the empty `fallback` shows right away
51649
+ * rather than an empty frame, so nothing moves when the response arrives.
51524
51650
  * @param {"start"|"end"|number|{id: string, offset?: number}} [props.defaultScrolled="start"]
51525
51651
  * Where the list opens, after which the user owns the scroll. `"end"` is a
51526
51652
  * thread read backwards — the last rows are the ones to show, and the ones
@@ -51581,7 +51707,7 @@ const ListContent = ({
51581
51707
  fallbackShown,
51582
51708
  searchFallback,
51583
51709
  searchFallbackShown,
51584
- loading,
51710
+ loadingPlaceholderShown,
51585
51711
  error,
51586
51712
  searchNoMatchMode,
51587
51713
  separator,
@@ -51605,7 +51731,7 @@ const ListContent = ({
51605
51731
  fallbackShown: fallbackShown,
51606
51732
  searchFallback: searchFallback,
51607
51733
  searchFallbackShown: searchFallbackShown,
51608
- loading: loading,
51734
+ loadingPlaceholderShown: loadingPlaceholderShown,
51609
51735
  error: error,
51610
51736
  searchNoMatchMode: searchNoMatchMode,
51611
51737
  separator: separator,
@@ -52893,7 +53019,7 @@ const UnorderedList = ({
52893
53019
  fallbackShown,
52894
53020
  searchFallback,
52895
53021
  searchFallbackShown,
52896
- loading,
53022
+ loadingPlaceholderShown,
52897
53023
  error,
52898
53024
  searchNoMatchMode,
52899
53025
  separator,
@@ -52904,9 +53030,11 @@ const UnorderedList = ({
52904
53030
  children,
52905
53031
  ...rest
52906
53032
  }) => {
52907
- // No empty/no-match message while loading or in error — the placeholder /
52908
- // error message is the content, even though no items are tracked yet.
52909
- const suppressFallback = loading || Boolean(error);
53033
+ // No empty/no-match message while a loading placeholder or an error message
53034
+ // is on screen — that IS the content, even though no items are tracked yet.
53035
+ // A loading state drawing nothing keeps the message: an announced count of 0
53036
+ // already tells us the list is empty (see ListUI's loadingPlaceholderShown).
53037
+ const suppressFallback = loadingPlaceholderShown || Boolean(error);
52910
53038
  return jsxs(Box, {
52911
53039
  as: "ul",
52912
53040
  flex: columns ? undefined : horizontal ? "x" : "y",
@@ -66817,5 +66945,5 @@ const UserSvg = () => jsx("svg", {
66817
66945
  })
66818
66946
  });
66819
66947
 
66820
- export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineNaviConfirmPopupOptions, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isToday, languagesSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePopupMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideValue, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage, windowWidthSignal };
66948
+ export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineNaviConfirmPopupOptions, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isToday, languagesSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePopupMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideValue, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage, windowWidthSignal };
66821
66949
  //# sourceMappingURL=jsenv_navi.js.map