@jsenv/navi 0.29.24 → 0.29.26

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,7 @@ import { createContext, isValidElement, h, Fragment, toChildArray, render, optio
7
7
  import { useContext, useLayoutEffect, useRef, useCallback, useState, useMemo, useId, useEffect, useErrorBoundary } from "preact/hooks";
8
8
  import { jsx, jsxs, Fragment as Fragment$1 } from "preact/jsx-runtime";
9
9
  import { computed, signal, effect, batch, useSignal } from "@preact/signals";
10
- import { createPubSub, normalizeStyle, mergeOneStyle, getPositionedParent, dispatchInternalCustomEvent, dispatchCustomEvent, findEvent, mergeTwoStyles, normalizeStyles, resolveCSSSize, measureLongestVisualLineWidth, hasCSSSizeUnit, resolveOklchLightness, contrastColor, createIterableWeakSet, getElementSignature, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, createEventGroupLogger, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, getKeyboardEventDefaultAction, chainEvent, activeElementSignal, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, startDragToTravel, scrollRoomTowards, findBefore, findAfter, initFocusGroup, elementIsFocusable, scrollIntoViewScoped, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
10
+ import { createPubSub, normalizeStyle, mergeOneStyle, getPositionedParent, dispatchInternalCustomEvent, dispatchCustomEvent, findEvent, mergeTwoStyles, normalizeStyles, resolveCSSSize, measureLongestVisualLineWidth, hasCSSSizeUnit, resolveOklchLightness, contrastColor, createIterableWeakSet, getElementSignature, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, createEventGroupLogger, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, getKeyboardEventDefaultAction, chainEvent, activeElementSignal, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, startDragToTravel, scrollRoomTowards, findBefore, findAfter, initFocusGroup, elementIsFocusable, scrollIntoViewScoped, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
11
11
  export { contrastColor, startDragToReorder } from "@jsenv/dom";
12
12
  import { createValidity, parseDuration, durationContainsNaN, compareTwoDurations, durationToSeconds, durationToISOString } from "@jsenv/validity";
13
13
  export { compareTwoDurations, durationContainsNaN, durationToHours, durationToISOString, durationToMinutes, durationToNumber, durationToSeconds, durationToString, parseDuration } from "@jsenv/validity";
@@ -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;
@@ -34592,17 +34654,25 @@ const Route = props => {
34592
34654
  });
34593
34655
  };
34594
34656
  /**
34595
- * The routes a tree of <Route> children is made of, in the order they are
34657
+ * The pages a tree of <Route> children is made of, in the order they are
34596
34658
  * written. Reading them is what turns a router into a row one can walk: "one
34597
34659
  * step that way" is a fact about the order the branches were declared in, and
34598
34660
  * nothing in a URL says it.
34599
34661
  *
34662
+ * A page is `{ route, params }`, never the route alone: a section of a page is
34663
+ * as often a PARAM as it is a route of its own — `<Route route={PAGE}
34664
+ * routeParams={{ section: "done" }}>` is how this very file selects a branch on
34665
+ * one — and three branches of the same route are then the same object three
34666
+ * times. Told apart by their params, they are three pages one walks between;
34667
+ * told apart by identity, they are one page and there is nowhere to walk.
34668
+ * `params` is undefined for a branch that is a route on its own.
34669
+ *
34600
34670
  * The same walk the container does to find the active branch (collectBranches),
34601
34671
  * except that it keeps every leaf rather than the one that matches — and reads
34602
34672
  * no signal, so asking does not subscribe the asker to anything.
34603
34673
  */
34604
- const collectRoutes = children => {
34605
- const routes = [];
34674
+ const collectRoutePages = children => {
34675
+ const pages = [];
34606
34676
  const visit = child => {
34607
34677
  if (!child || child === true || child === false) {
34608
34678
  return;
@@ -34618,18 +34688,22 @@ const collectRoutes = children => {
34618
34688
  }
34619
34689
  const {
34620
34690
  children: nodeChildren,
34621
- route
34691
+ route,
34692
+ routeParams
34622
34693
  } = child.props;
34623
34694
  if (nodeChildren) {
34624
34695
  visit(nodeChildren);
34625
34696
  return;
34626
34697
  }
34627
34698
  if (route) {
34628
- routes.push(route);
34699
+ pages.push({
34700
+ route,
34701
+ params: routeParams
34702
+ });
34629
34703
  }
34630
34704
  };
34631
34705
  visit(children);
34632
- return routes;
34706
+ return pages;
34633
34707
  };
34634
34708
 
34635
34709
  // RouteContainer: traverses children statically per render, finds the active branch,
@@ -34835,14 +34909,19 @@ const DRAGGED_ATTRIBUTE = "data-navi-route-travel-dragged";
34835
34909
  // transition carries was measured once, at the start, against a destination
34836
34910
  // this travel is no longer going to.
34837
34911
  const TURNED_ATTRIBUTE = "data-navi-route-travel-turned";
34912
+ // The name the box wears while it travels, and only then (see nameForTravel).
34913
+ const TRAVEL_NAME = "navi-route-travel";
34838
34914
  const css$R = /* css */`
34915
+ /* The name that makes the page inside this box a picture of its own during a
34916
+ transition — rather than part of the one big picture the document takes, so
34917
+ the two pages can move past each other while everything else stays where it
34918
+ is — is not written here: it is worn only for the length of a travel (see
34919
+ nameForTravel). A name belongs to ONE element at a time, and a page can hold
34920
+ several of these boxes at once — a section of the url and a search param of
34921
+ the root route are two rows of tabs, both live, and only one of them is ever
34922
+ travelling. */
34839
34923
  .navi_route_travel {
34840
34924
  position: relative;
34841
- /* Named, so the page inside this box is a picture of its own during a
34842
- transition rather than part of the one big picture the document takes:
34843
- the two pages can then move past each other while everything else stays
34844
- where it is. */
34845
- view-transition-name: navi-route-travel;
34846
34925
  /* The gesture takes the axis the pages travel on and leaves the other one
34847
34926
  to the page, so a list still scrolls under the same finger. */
34848
34927
  touch-action: pan-y;
@@ -34884,7 +34963,14 @@ const css$R = /* css */`
34884
34963
 
34885
34964
  &::view-transition-old(navi-route-travel),
34886
34965
  &::view-transition-new(navi-route-travel) {
34887
- height: 100%;
34966
+ /* Each picture at the size it was taken at: a page is not resized by the
34967
+ page it crosses. Told to fill a box whose height is being animated, a
34968
+ picture is STRETCHED with it — the page leaving is then seen squashing
34969
+ upwards, or zooming, over the length of the travel, when all it is
34970
+ doing is walking off the edge. */
34971
+ height: auto;
34972
+ object-fit: none;
34973
+ object-position: top left;
34888
34974
  /* The default cross-fade, dropped: two pages sliding past each other are
34889
34975
  two solid things, and seeing through one to the other says they are the
34890
34976
  same page changing its mind. */
@@ -34900,7 +34986,21 @@ const css$R = /* css */`
34900
34986
  overflow: clip;
34901
34987
  }
34902
34988
  &::view-transition-group(navi-route-travel) {
34989
+ /* The window the two pictures are seen through, held still for the whole
34990
+ travel at the taller of the two boxes (see holdTravelHeight): the group
34991
+ is what CLIPS, and the browser animates its height from the box being
34992
+ left to the box arriving — so the window shrinks under the pictures and
34993
+ cuts the page leaving from the bottom, progressively. The box does end
34994
+ up at the arriving page's height, and that is right; what must not
34995
+ happen is the user watching it get there.
34996
+
34997
+ The height is held by dropping the group's animation rather than by
34998
+ winning against it with !important — which also drops its position
34999
+ animation, fine while a travel box stands in the same place from one
35000
+ route to the next. */
35001
+ height: var(--navi-route-travel-height);
34903
35002
  animation-duration: var(--navi-route-travel-duration, 300ms);
35003
+ animation-name: none;
34904
35004
  }
34905
35005
  }
34906
35006
 
@@ -35019,21 +35119,30 @@ const css$R = /* css */`
35019
35119
 
35020
35120
  /**
35021
35121
  * @type {import("ignore:preact").FunctionComponent<{
35022
- * routes?: Array<object>,
35122
+ * routes?: Array<object|{route: object, params?: object}>,
35023
35123
  * axis?: "x"|"y",
35024
35124
  * travelByDrag?: boolean,
35025
- * onTravel?: (detail: {route: object, cause: string}) => void|Promise<void>,
35125
+ * onTravel?: (detail: {route: object, params: object|undefined, cause: string}) => void|Promise<void>,
35026
35126
  * }>}
35027
- * @param {Array<object>} [props.routes] - the tabs, in the order they are shown.
35028
- * Read from the <Route> children by default, in the order they are written:
35029
- * the router already holds that list, and asking a caller to write it twice is
35030
- * asking for the two to disagree. Pass it to say another order, or when the
35031
- * pages are not children of this box.
35127
+ * @param {Array<object|{route: object, params?: object}>} [props.routes] - the
35128
+ * tabs, in the order they are shown. Read from the <Route> children by
35129
+ * default, in the order they are written: the router already holds that list,
35130
+ * and asking a caller to write it twice is asking for the two to disagree.
35131
+ * Pass it to say another order, when the pages are not children of this box,
35132
+ * or to name a tab the children cannot — the section a <Route fallback> shows
35133
+ * is a tab like the others, and only its params say which one.
35134
+ *
35135
+ * An entry is a route, or `{ route, params }` when the tabs of the row are a
35136
+ * PARAM of one route rather than routes of their own (the form
35137
+ * `<Route routeParams>` selects a branch on, and the form that lets a link
35138
+ * with no params reopen the section one was looking at). Written as bare
35139
+ * routes, three tabs of one route are the same object three times: there is
35140
+ * then one tab, and nowhere to travel.
35032
35141
  * @param {"x"|"y"} [props.axis="x"] - which way the pages are laid out.
35033
35142
  * @param {boolean} [props.travelByDrag=true] - whether a pointer dragging the
35034
35143
  * page travels. Off where the gesture belongs to the content.
35035
- * @param {(detail: {route: object, cause: "drag"|"wheel"|"revert"}) => void|Promise<void>} [props.onTravel]
35036
- * - how to go to a route. The default REPLACES the current history entry
35144
+ * @param {(detail: {route: object, params: object|undefined, cause: "drag"|"wheel"|"revert"}) => void|Promise<void>} [props.onTravel]
35145
+ * - how to go to a tab. The default REPLACES the current history entry
35037
35146
  * rather than pushing one: a swipe is how one browses a page, not a place one
35038
35147
  * aimed at, and three swipes back and forth must not bury the way out of the
35039
35148
  * page under six entries. A tab pressed is the other case and pushes, which
@@ -35056,8 +35165,9 @@ const RouteTravel = ({
35056
35165
  axis = "x",
35057
35166
  travelByDrag = true,
35058
35167
  onTravel = ({
35059
- route
35060
- }) => route.redirectTo(),
35168
+ route,
35169
+ params
35170
+ }) => route.redirectTo(params),
35061
35171
  className,
35062
35172
  children,
35063
35173
  ...rest
@@ -35069,51 +35179,58 @@ const RouteTravel = ({
35069
35179
  // left, the animations the finger drives, and what to do with them once the
35070
35180
  // browser has them ready. Null when no page is on its way anywhere.
35071
35181
  const travelRef = useRef(null);
35072
- // The route this box has ASKED for and is still waiting to see arrive.
35182
+ // The page this box has ASKED for and is still waiting to see arrive.
35073
35183
  // Routing is asynchronous: a travel's own navigation lands well after the
35074
35184
  // travel decided anything about it — sometimes after the travel was undone —
35075
- // and read back as "the route changed" it would start a second travel nobody
35185
+ // and read back as "the page changed" it would start a second travel nobody
35076
35186
  // asked for, over pictures that are already showing something else.
35077
- const routeAskedForRef = useRef(null);
35187
+ const pageAskedForRef = useRef(null);
35078
35188
  // What a press stopped in flight, until the gesture says what it is about.
35079
35189
  const caughtAtPressRef = useRef(null);
35080
35190
  // The latest way to answer a gesture, for a watcher that outlives every
35081
35191
  // render (see the wheel effect below).
35082
35192
  const travelHandlersRef = useRef(null);
35083
35193
  const pointerDownRef = useRef(null);
35084
- const routesFromChildren = useMemo(() => collectRoutes(children), [children]);
35085
- const routes = routesProp || routesFromChildren;
35086
-
35087
- // Which page is on screen, read from the routes themselves: every one of them
35088
- // is read, so this re-renders when any of them starts or stops matching.
35089
- let currentIndex = -1;
35090
- for (let i = 0; i < routes.length; i++) {
35091
- if (routes[i].matchingSignal.value) {
35092
- currentIndex = i;
35093
- }
35094
- }
35194
+ const pagesFromChildren = useMemo(() => collectRoutePages(children), [children]);
35195
+ const pagesFromProp = useMemo(() => routesProp && routesProp.map(normalizePage), [routesProp]);
35196
+ const pages = pagesFromProp || pagesFromChildren;
35197
+
35198
+ // Which page is on screen, read from the pages themselves: every one of them
35199
+ // is read, so this re-renders when any of them starts or stops matching — and
35200
+ // for a row whose tabs are params of one route, when the params move from one
35201
+ // tab to the next (see pageIsCurrent).
35202
+ const currentIndex = currentPageIndex(pages);
35095
35203
  // The page that was on screen when the change now happening was asked for:
35096
35204
  // a travel is between two of them, and by the time anything renders the first
35097
35205
  // one is already gone. Written after each render (below), so a subscriber
35098
35206
  // reading it — they all run before Preact flushes — reads the one being left.
35099
35207
  const currentIndexRef = useRef(currentIndex);
35100
35208
 
35209
+ // Where a page is asked for, whoever asks: a page is a route AND the params
35210
+ // that say which of its tabs, and a caller told only the route would send the
35211
+ // row back to whichever tab the URL already says (see redirectTo).
35212
+ const travelTo = (page, cause) => onTravel({
35213
+ route: page.route,
35214
+ params: page.params,
35215
+ cause
35216
+ });
35217
+
35101
35218
  // One travel, whoever asked for it: a finger, a tab pressed, the browser's
35102
35219
  // own back button. What differs is only who moves it — the finger drives it
35103
35220
  // frame by frame (`scrub`), everything else lets it play.
35104
35221
  const beginTravel = ({
35105
- route,
35106
- fromRoute,
35222
+ page,
35223
+ fromPage,
35107
35224
  direction,
35108
35225
  scrub,
35109
35226
  change
35110
35227
  }) => {
35111
35228
  const travel = {
35112
- route,
35229
+ page,
35113
35230
  // The page this set off from, kept rather than looked up again: the URL
35114
35231
  // changes at the first pixel, so a moment later nothing on screen
35115
35232
  // remembers where it started.
35116
- fromRoute,
35233
+ fromPage,
35117
35234
  direction,
35118
35235
  scrub,
35119
35236
  ratio: 0,
@@ -35124,24 +35241,36 @@ const RouteTravel = ({
35124
35241
  ended: false
35125
35242
  };
35126
35243
  travelRef.current = travel;
35244
+ // Taken before the picture is: the browser reads the name off the DOM as it
35245
+ // stands when the transition starts, and this box is only a picture of its
35246
+ // own for as long as it is the one travelling.
35247
+ nameForTravel(elementRef.current);
35127
35248
  document.documentElement.setAttribute(TRAVEL_ATTRIBUTE, direction);
35128
35249
  if (scrub) {
35129
35250
  holdPictures(travel);
35130
35251
  document.documentElement.setAttribute(DRAGGED_ATTRIBUTE, "");
35131
35252
  }
35132
- routeAskedForRef.current = route;
35253
+ pageAskedForRef.current = page;
35254
+ // The box as it stands before anything moves: rendering is held, so this is
35255
+ // still the page being left (see holdTravelHeight).
35256
+ const heightBefore = elementRef.current.getBoundingClientRect().height;
35133
35257
  // The hold a navigation already took, if this travel is the answer to one:
35134
35258
  // taking another would be taking a hold on a page that is holding still.
35135
35259
  const releaseRendering = renderingHeldForRouting || holdRendering();
35136
35260
  renderingHeldForRouting = null;
35137
35261
  // The picture the browser is about to take must be of the page that was
35138
35262
  // 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
- }));
35263
+ const viewTransition = startViewTransition(async () => {
35264
+ await whilePageRenders(page, async () => {
35265
+ releaseRendering();
35266
+ if (change) {
35267
+ await change();
35268
+ }
35269
+ });
35270
+ // The page arriving is in the DOM and the transition has not started
35271
+ // playing: the one moment both boxes can be known.
35272
+ holdTravelHeight(elementRef.current, heightBefore);
35273
+ });
35145
35274
  travel.viewTransition = viewTransition;
35146
35275
  if (scrub) {
35147
35276
  // Said only now: the release has to have something to let go of, and the
@@ -35185,21 +35314,41 @@ const RouteTravel = ({
35185
35314
  };
35186
35315
 
35187
35316
  // A page change nobody here asked for: a tab pressed, a key, the back button.
35188
- // The transition is started from the route's own announcement rather than
35189
- // from a render, because a render is one flush too late — by then the DOM
35190
- // holds the new page and the picture of the old one cannot be taken anymore.
35317
+ // The transition is started from what the router SAYS rather than from a
35318
+ // render, because a render is one flush too late — by then the DOM holds the
35319
+ // new page and the picture of the old one cannot be taken anymore.
35320
+ //
35321
+ // Watched as a position in the row rather than route by route. A route
35322
+ // announces its own status, and the row's tabs can all be one route: the
35323
+ // announcement then says a section changed without saying which is on screen,
35324
+ // and it says it about things this row does not move for (a route that has
35325
+ // now been visited, a param of its own that is not a tab of this row). Worse,
35326
+ // a status is published from inside the routing and the params it carries are
35327
+ // the ones known at that instant — a section that lands as a signal settles
35328
+ // is announced late, or not at all. The signals ARE the position, so the
35329
+ // position is read from them: one computed over the whole row, notified once
35330
+ // per move, whichever route moved and whether by matching or by params.
35191
35331
  useLayoutEffect(() => {
35192
- const unsubscribes = routes.map((route, index) => route.subscribeStatus(({
35193
- matching
35194
- }) => {
35195
- if (!matching) {
35332
+ const currentIndexSignal = computed(() => currentPageIndex(pages));
35333
+ const onRowMove = index => {
35334
+ if (index === -1) {
35196
35335
  return;
35197
35336
  }
35337
+ if (index === currentIndexRef.current) {
35338
+ // Where the row already was — the first reading of all, and any move
35339
+ // this box has already taken note of (a render writes it too). What it
35340
+ // was still waiting for is here nonetheless, so the wait is called off.
35341
+ if (samePage(pageAskedForRef.current, pages[index])) {
35342
+ pageAskedForRef.current = null;
35343
+ }
35344
+ return;
35345
+ }
35346
+ const page = pages[index];
35198
35347
  // A page this box asked for itself — a travel's own navigation, or one
35199
35348
  // it had given up waiting on: what arrives here is the answer to a
35200
35349
  // question already answered, not somebody going somewhere.
35201
- if (routeAskedForRef.current === route) {
35202
- routeAskedForRef.current = null;
35350
+ if (samePage(pageAskedForRef.current, page)) {
35351
+ pageAskedForRef.current = null;
35203
35352
  currentIndexRef.current = index;
35204
35353
  return;
35205
35354
  }
@@ -35207,13 +35356,13 @@ const RouteTravel = ({
35207
35356
  // is not coming, or no longer means anything. Forgotten here rather
35208
35357
  // than kept, or the next press on that very tab would be taken for the
35209
35358
  // late answer to a question nobody remembers asking.
35210
- routeAskedForRef.current = null;
35359
+ pageAskedForRef.current = null;
35211
35360
  // Asked for a page while one was already on its way: the travel in
35212
35361
  // flight is the answer, aimed somewhere else. Starting a second one on
35213
35362
  // top would leave this one's pictures to be dropped mid-slide.
35214
35363
  if (travelRef.current) {
35215
35364
  currentIndexRef.current = index;
35216
- retargetTravel(travelRef.current, route);
35365
+ retargetTravel(travelRef.current, page);
35217
35366
  return;
35218
35367
  }
35219
35368
  const fromIndex = currentIndexRef.current;
@@ -35224,18 +35373,18 @@ const RouteTravel = ({
35224
35373
  return;
35225
35374
  }
35226
35375
  beginTravel({
35227
- route,
35228
- fromRoute: routes[fromIndex],
35376
+ page,
35377
+ fromPage: pages[fromIndex],
35229
35378
  direction: index > fromIndex ? "forward" : "back",
35230
35379
  scrub: false
35231
35380
  });
35232
- }));
35233
- return () => {
35234
- for (const unsubscribe of unsubscribes) {
35235
- unsubscribe();
35236
- }
35237
35381
  };
35238
- }, [routes]);
35382
+ // `subscribe` rather than `effect`: it hands the value to a callback that
35383
+ // is NOT being tracked, and what this one does — navigate, ask the router
35384
+ // for another page — reads and writes the very signals the row is watched
35385
+ // through.
35386
+ return currentIndexSignal.subscribe(onRowMove);
35387
+ }, [pages]);
35239
35388
 
35240
35389
  // Rendering is held for the length of a navigation, so that whatever picture
35241
35390
  // this box is about to take is of the page being LEFT (see holdRendering).
@@ -35272,6 +35421,13 @@ const RouteTravel = ({
35272
35421
  // Let go of far enough: the movement carries on from under the finger, at its
35273
35422
  // own pace, to the end.
35274
35423
  const finishTravel = travel => {
35424
+ // Nobody is driving it anymore. `scrub` is who MOVES the pictures, not what
35425
+ // set them off: left standing after the release, this travel would go on
35426
+ // claiming a hand that is no longer there — and everything that asks
35427
+ // "is somebody holding this?" before touching it (a press on the tab one
35428
+ // came from, a wheel push) would be answered yes and do nothing, while the
35429
+ // pages carry on to a page the router has already left.
35430
+ travel.scrub = false;
35275
35431
  releaseHold();
35276
35432
  travel.viewTransition.finished.then(() => endTravel(travel), () => endTravel(travel));
35277
35433
  };
@@ -35324,19 +35480,20 @@ const RouteTravel = ({
35324
35480
  Promise.resolve();
35325
35481
  backAtTheStart.then(async () => {
35326
35482
  try {
35327
- routeAskedForRef.current = travel.fromRoute;
35483
+ pageAskedForRef.current = travel.fromPage;
35328
35484
  // The page that was left is put back UNDER the picture before the
35329
35485
  // picture is dropped, so the two are the same thing at the moment they
35330
35486
  // are swapped: that only holds once the page is really back.
35331
- if (travel.fromRoute.matchingSignal.peek()) {
35487
+ if (pageIsCurrent(travel.fromPage)) {
35332
35488
  // It never left: the press that set this revert off put it back
35333
35489
  // there, and the pages have been held where they were until now.
35334
35490
  // Nothing to ask for, and nothing to wait for — waiting anyway is a
35335
35491
  // render that never comes and a page frozen under its own pictures.
35336
35492
  releaseRendering();
35337
35493
  } else {
35338
- await whileRouteRenders(travel.fromRoute, () => onTravel({
35339
- route: travel.fromRoute,
35494
+ await whilePageRenders(travel.fromPage, () => onTravel({
35495
+ route: travel.fromPage.route,
35496
+ params: travel.fromPage.params,
35340
35497
  cause: "revert"
35341
35498
  }));
35342
35499
  }
@@ -35375,8 +35532,8 @@ const RouteTravel = ({
35375
35532
  // change — only what is being brought in against it, and that one is LIVE:
35376
35533
  // pointing the router elsewhere is all it takes for the picture to show that
35377
35534
  // page instead.
35378
- const redirectTravel = (travel, route, direction) => {
35379
- travel.route = route;
35535
+ const redirectTravel = (travel, page, direction) => {
35536
+ travel.page = page;
35380
35537
  // Everything the transition carries that is NOT the pages was measured
35381
35538
  // against a destination this travel is no longer going to (see the CSS).
35382
35539
  document.documentElement.setAttribute(TURNED_ATTRIBUTE, "");
@@ -35397,28 +35554,28 @@ const RouteTravel = ({
35397
35554
 
35398
35555
  // Somebody asked for a page while one was on its way. Where they asked for
35399
35556
  // decides what that means.
35400
- const retargetTravel = (travel, route) => {
35557
+ const retargetTravel = (travel, page) => {
35401
35558
  if (travel.scrub || travel.reverting || travel.ended || travel.noPicture) {
35402
35559
  // A hand is holding the pages, or they are already on their way back:
35403
35560
  // either way this travel's end is decided by somebody else.
35404
35561
  return;
35405
35562
  }
35406
- if (route === travel.route) {
35563
+ if (samePage(page, travel.page)) {
35407
35564
  // Already on its way there.
35408
35565
  return;
35409
35566
  }
35410
- if (route === travel.fromRoute) {
35567
+ if (samePage(page, travel.fromPage)) {
35411
35568
  // Back where it set off from: that is not another travel, it is this one
35412
35569
  // undone — the same pictures, run backwards.
35413
35570
  revertTravel(travel);
35414
35571
  return;
35415
35572
  }
35416
- const fromIndex = routes.indexOf(travel.fromRoute);
35417
- const toIndex = routes.indexOf(route);
35573
+ const fromIndex = pageIndexOf(pages, travel.fromPage);
35574
+ const toIndex = pageIndexOf(pages, page);
35418
35575
  if (fromIndex === -1 || toIndex === -1) {
35419
35576
  return;
35420
35577
  }
35421
- redirectTravel(travel, route, toIndex > fromIndex ? "forward" : "back");
35578
+ redirectTravel(travel, page, toIndex > fromIndex ? "forward" : "back");
35422
35579
  };
35423
35580
  const endTravel = travel => {
35424
35581
  if (travel.ended) {
@@ -35432,9 +35589,14 @@ const RouteTravel = ({
35432
35589
  releaseHold(travel);
35433
35590
  if (travelRef.current === travel) {
35434
35591
  travelRef.current = null;
35592
+ // Given back, so another box may wear it: kept, two of them on a page
35593
+ // would both answer to it and the browser refuses the whole transition
35594
+ // rather than pick.
35595
+ unnameAfterTravel(elementRef.current);
35435
35596
  document.documentElement.removeAttribute(TRAVEL_ATTRIBUTE);
35436
35597
  document.documentElement.removeAttribute(DRAGGED_ATTRIBUTE);
35437
35598
  document.documentElement.removeAttribute(TURNED_ATTRIBUTE);
35599
+ releaseTravelHeight();
35438
35600
  }
35439
35601
  };
35440
35602
 
@@ -35495,24 +35657,21 @@ const RouteTravel = ({
35495
35657
  }
35496
35658
  // Dragging the page towards the end of the axis brings in what is
35497
35659
  // BEFORE it, the way pushing a sheet to the right reveals its left.
35498
- const route = sign > 0 ? routes[currentIndex - 1] : routes[currentIndex + 1];
35499
- if (!route || !size || scrollRoomTowards(target, elementRef.current, axis, sign)) {
35660
+ const page = sign > 0 ? pages[currentIndex - 1] : pages[currentIndex + 1];
35661
+ if (!page || !size || scrollRoomTowards(target, elementRef.current, axis, sign)) {
35500
35662
  return false;
35501
35663
  }
35502
35664
  if (CAN_KEEP_PICTURE) {
35503
35665
  beginTravel({
35504
- route,
35505
- fromRoute: routes[currentIndex],
35666
+ page,
35667
+ fromPage: pages[currentIndex],
35506
35668
  direction: sign > 0 ? "back" : "forward",
35507
35669
  scrub: true,
35508
- change: () => onTravel({
35509
- route,
35510
- cause: "drag"
35511
- })
35670
+ change: () => travelTo(page, "drag")
35512
35671
  });
35513
35672
  } else {
35514
35673
  travelRef.current = {
35515
- route,
35674
+ page,
35516
35675
  noPicture: true,
35517
35676
  ended: false
35518
35677
  };
@@ -35564,9 +35723,9 @@ const RouteTravel = ({
35564
35723
  // Where we are is what THIS travel was bringing in — the URL changed at
35565
35724
  // the first pixel, so `currentIndex` belongs to a render this gesture
35566
35725
  // is older than.
35567
- const fromIndex = routes.indexOf(travel.route);
35568
- const route = direction === "back" ? routes[fromIndex - 1] : routes[fromIndex + 1];
35569
- if (fromIndex === -1 || !route) {
35726
+ const fromIndex = pageIndexOf(pages, travel.page);
35727
+ const page = direction === "back" ? pages[fromIndex - 1] : pages[fromIndex + 1];
35728
+ if (fromIndex === -1 || !page) {
35570
35729
  return false;
35571
35730
  }
35572
35731
  // At their very end before they are let go of: what ends the travel in
@@ -35576,14 +35735,11 @@ const RouteTravel = ({
35576
35735
  scrubTravel(travel, 1);
35577
35736
  travel.ratio = 1;
35578
35737
  beginTravel({
35579
- route,
35580
- fromRoute: travel.route,
35738
+ page,
35739
+ fromPage: travel.page,
35581
35740
  direction,
35582
35741
  scrub: true,
35583
- change: () => onTravel({
35584
- route,
35585
- cause: "drag"
35586
- })
35742
+ change: () => travelTo(page, "drag")
35587
35743
  });
35588
35744
  return {
35589
35745
  size: boxSizeOnAxis(),
@@ -35598,17 +35754,14 @@ const RouteTravel = ({
35598
35754
  // other neighbour is enough for it to show that one instead. The travel
35599
35755
  // turns around where it stands, on the same transition and under the same
35600
35756
  // hand, and there is no gap at all.
35601
- const fromIndex = routes.indexOf(travel.fromRoute);
35602
- const route = direction === "back" ? routes[fromIndex - 1] : routes[fromIndex + 1];
35603
- if (fromIndex === -1 || !route) {
35757
+ const fromIndex = pageIndexOf(pages, travel.fromPage);
35758
+ const page = direction === "back" ? pages[fromIndex - 1] : pages[fromIndex + 1];
35759
+ if (fromIndex === -1 || !page) {
35604
35760
  return false;
35605
35761
  }
35606
- redirectTravel(travel, route, direction);
35607
- routeAskedForRef.current = route;
35608
- onTravel({
35609
- route,
35610
- cause: "drag"
35611
- });
35762
+ redirectTravel(travel, page, direction);
35763
+ pageAskedForRef.current = page;
35764
+ travelTo(page, "drag");
35612
35765
  return {
35613
35766
  size: boxSizeOnAxis(),
35614
35767
  travelBack: sign > 0,
@@ -35631,10 +35784,7 @@ const RouteTravel = ({
35631
35784
  if (travel.noPicture) {
35632
35785
  travelRef.current = null;
35633
35786
  if (travels) {
35634
- onTravel({
35635
- route: travel.route,
35636
- cause: "drag"
35637
- });
35787
+ travelTo(travel.page, "drag");
35638
35788
  }
35639
35789
  return;
35640
35790
  }
@@ -35666,6 +35816,16 @@ const RouteTravel = ({
35666
35816
  if (currentIndex === -1) {
35667
35817
  return;
35668
35818
  }
35819
+ // A press that never became a gesture: whatever it stopped goes on its way,
35820
+ // from where the finger caught it.
35821
+ const giveUp = () => {
35822
+ gestureRef.current = null;
35823
+ const caught = caughtAtPressRef.current;
35824
+ caughtAtPressRef.current = null;
35825
+ if (caught && !caught.ended) {
35826
+ releaseHold(caught);
35827
+ }
35828
+ };
35669
35829
  const gesture = startDragToTravel(pointerDownEvent, {
35670
35830
  element: elementRef.current,
35671
35831
  axes: axis,
@@ -35673,17 +35833,14 @@ const RouteTravel = ({
35673
35833
  // is answered from its first pixel, on the axis the pages travel.
35674
35834
  immediate: caughtAtPressRef.current ? axis : false,
35675
35835
  ...travelHandlers,
35676
- onGiveUp: () => {
35677
- gestureRef.current = null;
35678
- // A press that never became a gesture: whatever it stopped goes on its
35679
- // way, from where the finger caught it.
35680
- const caught = caughtAtPressRef.current;
35681
- caughtAtPressRef.current = null;
35682
- if (caught && !caught.ended) {
35683
- releaseHold(caught);
35684
- }
35685
- }
35836
+ onGiveUp: giveUp
35686
35837
  });
35838
+ if (!gesture) {
35839
+ // Not a press this box can be about — something that reads the pointer
35840
+ // itself, a box below it that travels the same way.
35841
+ giveUp();
35842
+ return;
35843
+ }
35687
35844
  gestureRef.current = gesture;
35688
35845
  };
35689
35846
 
@@ -35735,13 +35892,13 @@ const RouteTravel = ({
35735
35892
  }
35736
35893
  // Where the box is going, which is not where it is: a step asked for while
35737
35894
  // a travel plays is the page after the one on its way.
35738
- const fromRoute = travelInFlight ? travelInFlight.route : routes[currentIndex];
35739
- const fromIndex = routes.indexOf(fromRoute);
35895
+ const fromPage = travelInFlight ? travelInFlight.page : pages[currentIndex];
35896
+ const fromIndex = pageIndexOf(pages, fromPage);
35740
35897
  if (fromIndex === -1) {
35741
35898
  return;
35742
35899
  }
35743
- const route = sign > 0 ? routes[fromIndex - 1] : routes[fromIndex + 1];
35744
- if (!route) {
35900
+ const page = sign > 0 ? pages[fromIndex - 1] : pages[fromIndex + 1];
35901
+ if (!page) {
35745
35902
  return;
35746
35903
  }
35747
35904
  if (travelInFlight) {
@@ -35752,14 +35909,11 @@ const RouteTravel = ({
35752
35909
  travelInFlight.ratio = 1;
35753
35910
  }
35754
35911
  beginTravel({
35755
- route,
35756
- fromRoute,
35912
+ page,
35913
+ fromPage,
35757
35914
  direction: sign > 0 ? "back" : "forward",
35758
35915
  scrub: false,
35759
- change: () => onTravel({
35760
- route,
35761
- cause: "wheel"
35762
- })
35916
+ change: () => travelTo(page, "wheel")
35763
35917
  });
35764
35918
  };
35765
35919
 
@@ -35790,7 +35944,15 @@ const RouteTravel = ({
35790
35944
  // page (see drag_to_travel.js).
35791
35945
  ,
35792
35946
 
35793
- "data-drag-travel": travelByDrag ? axis : undefined,
35947
+ "data-drag-travel": travelByDrag ? axis : undefined
35948
+ // The same fact said once per gesture, and for the other question the
35949
+ // DOM answers: a box that travels INSIDE this one — a row of slides in a
35950
+ // page — takes the axis it walks, and these are what it reads to know
35951
+ // this box walks it too.
35952
+ ,
35953
+
35954
+ "data-travel-by-drag": travelByDrag ? axis : undefined,
35955
+ "data-travel-by-wheel": travelByDrag ? axis : undefined,
35794
35956
  onPointerDown: onPointerDown,
35795
35957
  children: children
35796
35958
  });
@@ -35816,6 +35978,24 @@ const releaseHold = travel => {
35816
35978
  travelHoldingPictures = null;
35817
35979
  document.documentElement.removeAttribute(HOLD_ATTRIBUTE);
35818
35980
  };
35981
+ const TRAVEL_HEIGHT_PROPERTY = "--navi-route-travel-height";
35982
+ // The height the group is held at for the whole travel: the taller of the two
35983
+ // boxes, so neither picture is ever cut. It cannot be said in CSS — neither box
35984
+ // is knowable there — and it cannot be measured from one side alone: a page
35985
+ // arriving shorter than the one it replaces would cut the one leaving, a page
35986
+ // arriving taller would be cut itself.
35987
+ const holdTravelHeight = (element, heightBefore) => {
35988
+ const heightAfter = element.getBoundingClientRect().height;
35989
+ const height = heightBefore > heightAfter ? heightBefore : heightAfter;
35990
+ document.documentElement.style.setProperty(TRAVEL_HEIGHT_PROPERTY, `${height}px`);
35991
+ };
35992
+ // The live layout takes the box back. A discontinuity by construction — the
35993
+ // group stands at the held height, the box is at the new one — and an invisible
35994
+ // one: the page arriving is fully in place, and the strip below it that the
35995
+ // group still covers shows the page leaving only while it is still on screen.
35996
+ const releaseTravelHeight = () => {
35997
+ document.documentElement.style.removeProperty(TRAVEL_HEIGHT_PROPERTY);
35998
+ };
35819
35999
 
35820
36000
  // The browser does not take the picture of the page being left when a
35821
36001
  // transition is ASKED for — it takes it at the next frame, just before running
@@ -35966,7 +36146,7 @@ const scrubTravel = (travel, ratio) => {
35966
36146
  }
35967
36147
  };
35968
36148
 
35969
- // A route change, carried out and then waited for until the page it selects is
36149
+ // A page change, carried out and then waited for until the page it selects is
35970
36150
  // really on screen. The container doing the swapping is the only one who knows
35971
36151
  // when that is (observeRouteRender): a route matching is a signal changing, and
35972
36152
  // how many passes Preact takes to answer it is its own business.
@@ -35976,7 +36156,7 @@ const scrubTravel = (travel, ratio) => {
35976
36156
  // inside the callback of a view transition: the browser has stopped rendering
35977
36157
  // and is waiting on this very promise to take its picture, so a wait that never
35978
36158
  // ends is a page frozen under a transition that never became ready.
35979
- const whileRouteRenders = async (route, change) => {
36159
+ const whilePageRenders = async (page, change) => {
35980
36160
  let stopListening;
35981
36161
  const rendered = new Promise(resolve => {
35982
36162
  // Listened for before the change, or a render landing while the change is
@@ -35985,7 +36165,7 @@ const whileRouteRenders = async (route, change) => {
35985
36165
  });
35986
36166
  try {
35987
36167
  await change();
35988
- if (route.matchingSignal.peek()) {
36168
+ if (pageIsCurrent(page)) {
35989
36169
  await rendered;
35990
36170
  }
35991
36171
  } finally {
@@ -35993,9 +36173,76 @@ const whileRouteRenders = async (route, change) => {
35993
36173
  }
35994
36174
  };
35995
36175
 
36176
+ // A page of the row: a route, and the params that say which of its tabs when
36177
+ // several of them share it. Written as a bare route by a caller whose tabs are
36178
+ // routes of their own — which is the same page with nothing to tell apart.
36179
+ const normalizePage = page => page.isRoute ? {
36180
+ route: page,
36181
+ params: undefined
36182
+ } : page;
36183
+
36184
+ // Two pages are the same page when they select the same thing, not when they
36185
+ // were written by the same hand: the params of a tab are a literal in JSX, so
36186
+ // every render builds another object for what is plainly the same tab.
36187
+ const samePage = (a, b) => {
36188
+ if (a === b) {
36189
+ return true;
36190
+ }
36191
+ if (!a || !b) {
36192
+ return false;
36193
+ }
36194
+ return a.route === b.route && compareTwoJsValues(a.params, b.params);
36195
+ };
36196
+ const pageIndexOf = (pages, page) => pages.findIndex(candidate => samePage(candidate, page));
36197
+
36198
+ // Whether this page is the one on screen. `matchesParams` reads paramsSignal,
36199
+ // so a caller reading this during a render is subscribed to the param changes
36200
+ // that walk from one tab to the next — matchingSignal alone never moves there,
36201
+ // and a row whose tabs are params of one route would never re-render.
36202
+ //
36203
+ // The params are read only for a route that matches, and that is not a signal
36204
+ // left unread: a reader wakes on anything it read last time, so what matters is
36205
+ // that everything able to make this answer change is among them.
36206
+ // matchingSignal is read whatever happens, and it is a NECESSARY condition —
36207
+ // while it is false no param of that route can put this page on screen, and the
36208
+ // day one could, matchingSignal itself has to turn true to say so, which is the
36209
+ // read that brings the params back in. (Asking anyway would be worse than
36210
+ // useless: the params of a route that does not match are not params.)
36211
+ const pageIsCurrent = ({
36212
+ route,
36213
+ params
36214
+ }) => {
36215
+ if (!route.matchingSignal.value) {
36216
+ return false;
36217
+ }
36218
+ return params ? route.matchesParams(params) : true;
36219
+ };
36220
+ // Every page is read, never only up to the one that answers yes: a page that is
36221
+ // not the current one today is the one that must wake the reader tomorrow.
36222
+ const currentPageIndex = pages => {
36223
+ let currentIndex = -1;
36224
+ for (let i = 0; i < pages.length; i++) {
36225
+ if (pageIsCurrent(pages[i])) {
36226
+ currentIndex = i;
36227
+ }
36228
+ }
36229
+ return currentIndex;
36230
+ };
36231
+
35996
36232
  // A transition skipped by another one starting is an outcome, not a failure.
35997
36233
  const ignoreSkipped = () => {};
35998
36234
 
36235
+ // The name is lent to the box that is travelling and taken back afterwards.
36236
+ // There is one transition in a document at a time, so one box wears it at a
36237
+ // time — and the others, unnamed, are simply not captured: they stay live
36238
+ // under the pictures rather than being frozen with the page.
36239
+ const nameForTravel = element => {
36240
+ element.style.viewTransitionName = TRAVEL_NAME;
36241
+ };
36242
+ const unnameAfterTravel = element => {
36243
+ element.style.viewTransitionName = "";
36244
+ };
36245
+
35999
36246
  const routeAction = (
36000
36247
  routeOrRoutes,
36001
36248
  action,
@@ -37958,8 +38205,10 @@ const BinderItemContext = createContext(null);
37958
38205
 
37959
38206
  /**
37960
38207
  * What a <Link> learns from the <Nav> around it: where to draw the bar that
37961
- * says "you are here", and the name under which the browser is to recognise
37962
- * that bar from one page to the next (see nav.jsx).
38208
+ * says "you are here", the name under which the browser is to recognise that
38209
+ * bar from one page to the next, and — for a row of tabs that are slides — which
38210
+ * <SlideContainer> they are about and which of its slides is on screen (see
38211
+ * nav.jsx).
37963
38212
  */
37964
38213
  const NavContext = createContext(null);
37965
38214
 
@@ -38392,6 +38641,12 @@ Object.assign(PSEUDO_CLASSES, {
38392
38641
  * instead of a raw `href`: the URL is built from the route (see
38393
38642
  * `routeParams`) and "current" is derived from whether the route matches.
38394
38643
  * @param {object} [props.routeParams] - Params passed to `route.buildUrl`.
38644
+ * @param {string} [props.slide] - Makes this a tab for a slide rather than for
38645
+ * a URL: the area of a `<SlideContainer>` it goes to. The container is the one
38646
+ * the surrounding `<Nav slideContainer={id}>` names, and it is also what says
38647
+ * whether this tab is the current one. Nothing is written to the URL — these
38648
+ * are places within one screen, not pages of their own — so there is no href
38649
+ * and the tab behaves like a button.
38395
38650
  * @param {string} [props.target] - Native anchor target; defaults from
38396
38651
  * internal/external detection when omitted.
38397
38652
  * @param {string} [props.rel] - Native anchor rel; defaults to
@@ -38481,6 +38736,7 @@ const LinkPlain = props => {
38481
38736
  target,
38482
38737
  rel,
38483
38738
  anchor,
38739
+ slide,
38484
38740
  value = href,
38485
38741
  // visual
38486
38742
  variant,
@@ -38531,7 +38787,9 @@ const LinkPlain = props => {
38531
38787
  isAnchor,
38532
38788
  isCurrent
38533
38789
  } = getHrefTargetInfo(href);
38534
- const innerCurrent = current || isCurrent;
38790
+ // A tab that is a SLIDE is current when the container is on it — which the
38791
+ // <Nav> around reads off that container, so nothing here has to be told.
38792
+ const innerCurrent = current || (slide ? nav?.currentSlideArea === slide : isCurrent);
38535
38793
  useReportCurrentToBinderItem(innerCurrent);
38536
38794
  controlHostProps.basePseudoState = {
38537
38795
  ...basePseudoState,
@@ -38618,6 +38876,12 @@ const LinkPlain = props => {
38618
38876
  onClick,
38619
38877
  preventDefault
38620
38878
  } = props;
38879
+ // Travelling there is the container's business, said as the command anything
38880
+ // else in the page would say it with: the tab knows the name of a slide and
38881
+ // the id of the box, and nothing more about either.
38882
+ const goToSlide = (element, event) => {
38883
+ triggerNaviCommand(element, `--navi-go-to-slide:${slide}`, event);
38884
+ };
38621
38885
  return jsxs(Text, {
38622
38886
  as: "a",
38623
38887
  color: anchor && !innerChildren ? "inherit" : undefined,
@@ -38630,6 +38894,7 @@ const LinkPlain = props => {
38630
38894
  // was handed.
38631
38895
  preventDefault: undefined,
38632
38896
  anchor: undefined,
38897
+ slide: undefined,
38633
38898
  revealOnInteraction: undefined,
38634
38899
  variant: undefined,
38635
38900
  current: undefined,
@@ -38643,15 +38908,43 @@ const LinkPlain = props => {
38643
38908
  hrefFallback: undefined,
38644
38909
  onClick: e => {
38645
38910
  onClick?.(e);
38911
+ if (slide) {
38912
+ goToSlide(e.currentTarget, e);
38913
+ }
38646
38914
  if (preventDefault) {
38647
38915
  e.preventDefault();
38648
38916
  }
38917
+ }
38918
+ // A tab with no href is not a link the browser knows how to press: it is
38919
+ // focusable because it says so (tabIndex below) and it answers the two
38920
+ // keys a button answers, since that is what it behaves like.
38921
+ ,
38922
+
38923
+ onKeyDown: e => {
38924
+ props.onKeyDown?.(e);
38925
+ if (!slide || e.defaultPrevented) {
38926
+ return;
38927
+ }
38928
+ if (e.key === "Enter" || e.key === " ") {
38929
+ e.preventDefault();
38930
+ goToSlide(e.currentTarget, e);
38931
+ }
38649
38932
  },
38650
38933
  href: href,
38651
38934
  rel: innerRel,
38652
- target: innerTarget === "_self" ? undefined : target,
38935
+ target: innerTarget === "_self" ? undefined : target
38936
+ // Which slide this tab is, and which box to say it to: read by the <Nav>
38937
+ // around it to place the row's own bar, and by the command above to find
38938
+ // the container across the document.
38939
+ ,
38940
+
38941
+ "data-slide-target": slide,
38942
+ commandfor: slide ? nav?.slideContainer : undefined,
38943
+ "aria-controls": slide ? nav?.slideContainer : undefined,
38944
+ tabIndex: slide ? props.tabIndex ?? 0 : props.tabIndex,
38945
+ role: slide ? "tab" : props.role,
38653
38946
  "aria-current": isCurrent ? "page" : undefined,
38654
- "aria-selected": selectionContext ? selected : undefined,
38947
+ "aria-selected": slide ? innerCurrent : selectionContext ? selected : undefined,
38655
38948
  "data-value-event": "navi_value",
38656
38949
  onnavi_value: e => {
38657
38950
  e.detail.setValue(value);
@@ -38714,6 +39007,75 @@ const css$N = /* css */`
38714
39007
  --nav-padding: 0px;
38715
39008
  --nav-border-radius: 0px;
38716
39009
  --nav-background: transparent;
39010
+ --nav-current-indicator-size: 2px;
39011
+ --nav-current-indicator-color: var(--navi-link-current-indicator-color);
39012
+ }
39013
+ }
39014
+
39015
+ /* The bar of a nav whose tabs are SLIDES: one element for the whole row,
39016
+ placed over the current tab and interpolated towards the one the picture
39017
+ leans on (see paintIndicatorGeometry). The two ends are written in pixels
39018
+ as plain numbers, so the whole of the movement is a calc() the browser
39019
+ runs itself — the trait then follows a finger dragging the slides without a
39020
+ render per frame, and rides the same animation as the track when the travel
39021
+ was asked for rather than dragged.
39022
+ No named view transition here, unlike the bar of a nav made of routes:
39023
+ there is no transition to be part of — the slides travel under an animation
39024
+ of their own, which a finger can hold. */
39025
+ .navi_nav[data-nav-indicator] {
39026
+ position: relative;
39027
+
39028
+ > .navi_nav_indicator {
39029
+ --x-nav-indicator-position: calc(
39030
+ var(--nav-indicator-position) + var(--slide-travel-progress) *
39031
+ var(--nav-indicator-position-delta)
39032
+ );
39033
+ --x-nav-indicator-length: calc(
39034
+ var(--nav-indicator-length) + var(--slide-travel-progress) *
39035
+ var(--nav-indicator-length-delta)
39036
+ );
39037
+
39038
+ position: absolute;
39039
+ z-index: 1;
39040
+ background: var(--nav-current-indicator-color);
39041
+ border-radius: 0.1px;
39042
+ pointer-events: none;
39043
+ }
39044
+ /* Nothing to draw until the row has been measured: a tab bar whose current
39045
+ tab is not among its links (a container on a slide no tab names) has no
39046
+ place to put the trait. */
39047
+ &:not([data-nav-indicator-measured]) > .navi_nav_indicator {
39048
+ display: none;
39049
+ }
39050
+
39051
+ &[data-nav-indicator="top"],
39052
+ &[data-nav-indicator="bottom"] {
39053
+ > .navi_nav_indicator {
39054
+ left: calc(var(--x-nav-indicator-position) * 1px);
39055
+ width: calc(var(--x-nav-indicator-length) * 1px);
39056
+ height: var(--nav-current-indicator-size);
39057
+ }
39058
+ }
39059
+ &[data-nav-indicator="top"] > .navi_nav_indicator {
39060
+ top: 0;
39061
+ }
39062
+ &[data-nav-indicator="bottom"] > .navi_nav_indicator {
39063
+ bottom: 0;
39064
+ }
39065
+
39066
+ &[data-nav-indicator="left"],
39067
+ &[data-nav-indicator="right"] {
39068
+ > .navi_nav_indicator {
39069
+ top: calc(var(--x-nav-indicator-position) * 1px);
39070
+ width: var(--nav-current-indicator-size);
39071
+ height: calc(var(--x-nav-indicator-length) * 1px);
39072
+ }
39073
+ }
39074
+ &[data-nav-indicator="left"] > .navi_nav_indicator {
39075
+ left: 0;
39076
+ }
39077
+ &[data-nav-indicator="right"] > .navi_nav_indicator {
39078
+ right: 0;
38717
39079
  }
38718
39080
  }
38719
39081
 
@@ -38869,23 +39231,43 @@ const NavStyleCSSVars = {
38869
39231
  paddingRight: "--nav-padding-right",
38870
39232
  paddingBottom: "--nav-padding-bottom",
38871
39233
  paddingLeft: "--nav-padding-left",
38872
- background: "--nav-background"
39234
+ background: "--nav-background",
39235
+ currentIndicatorColor: "--nav-current-indicator-color",
39236
+ currentIndicatorSize: "--nav-current-indicator-size"
38873
39237
  };
39238
+ const positionOfCurrentIndicator = (currentIndicator, vertical) => {
39239
+ if (currentIndicator === true) {
39240
+ return vertical ? "left" : "bottom";
39241
+ }
39242
+ if (currentIndicator === "top" || currentIndicator === "bottom" || currentIndicator === "left" || currentIndicator === "right") {
39243
+ return currentIndicator;
39244
+ }
39245
+ return null;
39246
+ };
39247
+
38874
39248
  /**
38875
39249
  * @type {import("ignore:preact").FunctionComponent<{
38876
39250
  * currentIndicator?: boolean|"top"|"bottom"|"left"|"right",
38877
39251
  * currentIndicatorSlides?: boolean,
39252
+ * slideContainer?: string,
38878
39253
  * }>}
38879
39254
  * @param {boolean|"top"|"bottom"|"left"|"right"} [props.currentIndicator] - the
38880
39255
  * bar that says which tab one is on, said once here rather than on every
38881
39256
  * `<Link>`. A link may still say otherwise for itself.
38882
39257
  * @param {boolean} [props.currentIndicatorSlides=true] - whether that bar
38883
39258
  * travels from the tab it was under to the tab it is under now, instead of
38884
- * going out on one and coming back on the other. It does so by being NAMED,
38885
- * which is all the browser needs: any change played as a view transition
38886
- * animates it on the same clock as everything else in that transition. Inside
38887
- * a `RouteTravel` that means it follows the pages, and the thumb dragging
38888
- * them, without either of them being told about the other.
39259
+ * going out on one and coming back on the other. For a nav made of routes it
39260
+ * does so by being NAMED, which is all the browser needs: any change played as
39261
+ * a view transition animates it on the same clock as everything else in that
39262
+ * transition. Inside a `RouteTravel` that means it follows the pages, and the
39263
+ * thumb dragging them, without either of them being told about the other. For
39264
+ * a nav made of slides (`slideContainer`) the bar is one element for the whole
39265
+ * row, and it reads the travel the container publishes.
39266
+ * @param {string} [props.slideContainer] - the id of a `<SlideContainer>` these
39267
+ * tabs are about: each one says which slide it is (`<Link slide="…">`), the
39268
+ * container says which one is on screen, and pressing a tab travels there.
39269
+ * Tabs that are places in the same screen rather than pages of their own —
39270
+ * nothing is written to the URL and nothing is a link.
38889
39271
  */
38890
39272
  const Nav = ({
38891
39273
  children,
@@ -38898,21 +39280,132 @@ const Nav = ({
38898
39280
  currentIndicatorSlides = true,
38899
39281
  panelPosition,
38900
39282
  // "before" or "after": which side the panel sits on, turning the nav into folder tabs
39283
+ slideContainer,
38901
39284
  ...props
38902
39285
  }) => {
38903
39286
  import.meta.css = [css$N, "@jsenv/navi/src/nav/link/nav.jsx"];
39287
+ const defaultRef = useRef();
39288
+ props.ref = props.ref || defaultRef;
39289
+ const navRef = props.ref;
38904
39290
  const indicatorNameRef = useRef(null);
38905
39291
  if (indicatorNameRef.current === null) {
38906
39292
  indicatorNameRef.current = `navi-nav-indicator-${++navCount}`;
38907
39293
  }
39294
+ const [currentSlideArea, setCurrentSlideArea] = useState(undefined);
39295
+ const slideContainerElementRef = useRef(null);
39296
+ const indicatorPosition = slideContainer ? positionOfCurrentIndicator(currentIndicator, vertical) : null;
39297
+
39298
+ // Where the trait is and where it is headed, as four numbers of pixels the
39299
+ // CSS above interpolates between (see the .navi_nav_indicator rules). Written
39300
+ // by hand rather than rendered: it is read off the row as it stands, and the
39301
+ // travel it must agree with starts in the same frame the container publishes
39302
+ // it — a render would land after the movement had begun.
39303
+ const paintIndicatorGeometry = () => {
39304
+ const navElement = navRef.current;
39305
+ const containerElement = slideContainerElementRef.current;
39306
+ if (!navElement || !containerElement || !indicatorPosition) {
39307
+ return;
39308
+ }
39309
+ const tabElements = Array.from(navElement.querySelectorAll("[data-slide-target]"));
39310
+ const areaOf = tabElement => tabElement.getAttribute("data-slide-target");
39311
+ const currentArea = containerElement.getAttribute("data-slide-current");
39312
+ const currentIndex = tabElements.findIndex(tabElement => areaOf(tabElement) === currentArea);
39313
+ if (currentIndex === -1) {
39314
+ // On a slide no tab in this row names: there is no tab to sit under.
39315
+ navElement.removeAttribute("data-nav-indicator-measured");
39316
+ return;
39317
+ }
39318
+ const measure = tabElement => vertical ? {
39319
+ position: tabElement.offsetTop,
39320
+ length: tabElement.offsetHeight
39321
+ } : {
39322
+ position: tabElement.offsetLeft,
39323
+ length: tabElement.offsetWidth
39324
+ };
39325
+ const currentMeasure = measure(tabElements[currentIndex]);
39326
+ const towardArea = containerElement.getAttribute("data-slide-travel-toward");
39327
+ const towardIndex = tabElements.findIndex(tabElement => areaOf(tabElement) === towardArea);
39328
+ let positionDelta = 0;
39329
+ let lengthDelta = 0;
39330
+ if (towardIndex !== -1 && towardIndex !== currentIndex) {
39331
+ const towardMeasure = measure(tabElements[towardIndex]);
39332
+ // What one box of travel is worth in pixels of this row, signed so that
39333
+ // the trait is exactly on the other tab when the progress is at its own
39334
+ // end: the container counts +1 when the picture leans on a slide sitting
39335
+ // BEFORE the current one and -1 when it sits after.
39336
+ const sign = towardIndex > currentIndex ? -1 : 1;
39337
+ positionDelta = (towardMeasure.position - currentMeasure.position) * sign;
39338
+ lengthDelta = (towardMeasure.length - currentMeasure.length) * sign;
39339
+ }
39340
+ const {
39341
+ style
39342
+ } = navElement;
39343
+ style.setProperty("--nav-indicator-position", currentMeasure.position);
39344
+ style.setProperty("--nav-indicator-length", currentMeasure.length);
39345
+ style.setProperty("--nav-indicator-position-delta", positionDelta);
39346
+ style.setProperty("--nav-indicator-length-delta", lengthDelta);
39347
+ navElement.setAttribute("data-nav-indicator-measured", "");
39348
+ };
39349
+ // Reached through a ref by everything watching the DOM below: those watchers
39350
+ // outlive a render, and what they must run is the version of this that knows
39351
+ // about the row as it is now.
39352
+ const paintIndicatorGeometryRef = useRef(null);
39353
+ paintIndicatorGeometryRef.current = paintIndicatorGeometry;
39354
+ useLayoutEffect(() => {
39355
+ if (!slideContainer) {
39356
+ return undefined;
39357
+ }
39358
+ const containerElement = document.getElementById(slideContainer);
39359
+ if (!containerElement) {
39360
+ console.warn(`<Nav slideContainer="${slideContainer}"> but no element with that id found`);
39361
+ return undefined;
39362
+ }
39363
+ slideContainerElementRef.current = containerElement;
39364
+ const readContainer = () => {
39365
+ setCurrentSlideArea(containerElement.getAttribute("data-slide-current") ?? undefined);
39366
+ paintIndicatorGeometryRef.current();
39367
+ };
39368
+ readContainer();
39369
+ // The container says where one is and what the picture leans on, and says
39370
+ // it in the DOM: nothing here is told, everything is read — which is what
39371
+ // lets this row sit anywhere on the page (above the box, in a fixed bar)
39372
+ // rather than inside it.
39373
+ const attributeObserver = new MutationObserver(readContainer);
39374
+ attributeObserver.observe(containerElement, {
39375
+ attributes: true,
39376
+ attributeFilter: ["data-slide-current", "data-slide-travel-toward"]
39377
+ });
39378
+ // A row whose tabs changed width — a badge count, a font that just
39379
+ // arrived, a window resized — is measured again: what was written is
39380
+ // pixels, and pixels go stale.
39381
+ const sizeObserver = new ResizeObserver(() => {
39382
+ paintIndicatorGeometryRef.current();
39383
+ });
39384
+ sizeObserver.observe(navRef.current);
39385
+ return () => {
39386
+ attributeObserver.disconnect();
39387
+ sizeObserver.disconnect();
39388
+ slideContainerElementRef.current = null;
39389
+ };
39390
+ }, [slideContainer]);
39391
+
39392
+ // Said after every commit: a tab added, removed or renamed moves the trait,
39393
+ // and no observer above watches this row's own children.
39394
+ useLayoutEffect(() => {
39395
+ paintIndicatorGeometry();
39396
+ });
38908
39397
  const navContextValue = useMemo(() => ({
38909
- currentIndicator,
39398
+ // The bar belongs to the row itself when the tabs are slides, so the
39399
+ // links draw none of their own.
39400
+ currentIndicator: slideContainer ? undefined : currentIndicator,
38910
39401
  // Read by the link that is current, and by it alone: a name belongs to
38911
39402
  // one element at a time, and the bar exists in every tab.
38912
- indicatorName: currentIndicatorSlides ? indicatorNameRef.current : null
38913
- }), [currentIndicator, currentIndicatorSlides]);
39403
+ indicatorName: currentIndicatorSlides ? indicatorNameRef.current : null,
39404
+ slideContainer,
39405
+ currentSlideArea
39406
+ }), [currentIndicator, currentIndicatorSlides, slideContainer, currentSlideArea]);
38914
39407
  children = toChildArray(children);
38915
- return jsx(Box, {
39408
+ return jsxs(Box, {
38916
39409
  as: "nav",
38917
39410
  row: vertical,
38918
39411
  column: !vertical,
@@ -38921,15 +39414,30 @@ const Nav = ({
38921
39414
  "data-expand": expand || expandX ? "" : undefined,
38922
39415
  "data-vertical": vertical ? "" : undefined,
38923
39416
  "data-panel-position": panelPosition,
39417
+ "data-nav-indicator": indicatorPosition ?? undefined
39418
+ // "write your travel here too": a custom property cannot be read across
39419
+ // the DOM, so the container paints its progress onto this element and the
39420
+ // trait follows in CSS alone (see SlideContainer's followerElements).
39421
+ ,
39422
+
39423
+ "data-slide-container-follows": slideContainer
39424
+ // Tabs over one screen, not links to pages: a screen reader is told so,
39425
+ // and told which way the row runs.
39426
+ ,
39427
+
39428
+ role: slideContainer ? "tablist" : undefined,
39429
+ "aria-orientation": slideContainer && vertical ? "vertical" : undefined,
38924
39430
  expand: expand,
38925
39431
  expandX: expandX,
38926
39432
  spacing: spacing,
38927
39433
  ...props,
38928
39434
  styleCSSVars: NavStyleCSSVars,
38929
- children: jsx(NavContext.Provider, {
39435
+ children: [indicatorPosition && jsx("span", {
39436
+ className: "navi_nav_indicator"
39437
+ }), jsx(NavContext.Provider, {
38930
39438
  value: navContextValue,
38931
39439
  children: children
38932
- })
39440
+ })]
38933
39441
  });
38934
39442
  };
38935
39443
 
@@ -45812,7 +46320,16 @@ const SlideContainer = ({
45812
46320
  // picture is from the slide ARRIVING when the travel starts, in boxes. Null
45813
46321
  // for a travel nobody dragged, where a whole box is what is left to close.
45814
46322
  const travelProgressFromRef = useRef(null);
45815
- const progressAnimationRef = useRef(null);
46323
+ // One per element painting the progress: the box, plus everything following
46324
+ // it (see followerElementsRef). All started together and with the same
46325
+ // options, so they are one movement said in several places.
46326
+ const progressAnimationsRef = useRef([]);
46327
+ // Elements outside the box that draw something about this travel — a tab bar
46328
+ // above it, most of all. A custom property cannot be read across the DOM, so
46329
+ // the progress is WRITTEN on each of them: they then interpolate whatever they
46330
+ // draw in CSS alone, at the pace of the travel and under the finger, with
46331
+ // nothing measured per frame.
46332
+ const followerElementsRef = useRef([]);
45816
46333
  const current = rollingArea ?? provisionalArea ?? currentProp ?? currentAreaState;
45817
46334
  const vertical = layout === "column";
45818
46335
  // What the map has, and what each way of asking is allowed to use of it.
@@ -45938,6 +46455,28 @@ const SlideContainer = ({
45938
46455
  ...map
45939
46456
  };
45940
46457
  };
46458
+
46459
+ // Who is drawing something about this box from outside it: a tab bar saying
46460
+ // where one is, a row of dots. They name the box they follow by its id, the
46461
+ // way everything else that talks to it across the document does (commandfor).
46462
+ const readFollowerElements = () => {
46463
+ const containerEl = containerRef.current;
46464
+ const {
46465
+ id
46466
+ } = containerEl;
46467
+ if (!id) {
46468
+ return [];
46469
+ }
46470
+ return Array.from(document.querySelectorAll(`[data-slide-container-follows="${CSS.escape(id)}"]`));
46471
+ };
46472
+
46473
+ // Which slide is on screen, said in the DOM: it is what anything outside the
46474
+ // box reads to know where one is (a tab bar marking its current tab), and
46475
+ // there is nothing else for it to read — a slide the container holds by
46476
+ // itself is known to no one else.
46477
+ const paintCurrentArea = area => {
46478
+ containerRef.current?.setAttribute("data-slide-current", area);
46479
+ };
45941
46480
  const markAnswered = area => {
45942
46481
  const order = readMap().slideElements.map(readArea);
45943
46482
  const rank = order.indexOf(area);
@@ -46020,6 +46559,10 @@ const SlideContainer = ({
46020
46559
  // the children free — their shape says nothing about the arrangement, the map
46021
46560
  // does — and it is also the only place that has to agree with itself.
46022
46561
  useLayoutEffect(() => {
46562
+ // Read on every render rather than subscribed to: a follower says who it
46563
+ // follows in the DOM, and the render that mounted one is the render this
46564
+ // runs after.
46565
+ followerElementsRef.current = readFollowerElements();
46023
46566
  const {
46024
46567
  slideElements,
46025
46568
  placeOf
@@ -46032,6 +46575,7 @@ const SlideContainer = ({
46032
46575
  // shown, the way a stack of pages opens on its first page.
46033
46576
  slideElements[0];
46034
46577
  const currentArea = readArea(currentElement);
46578
+ paintCurrentArea(currentArea);
46035
46579
  const realPlaceOf = area => placeOf.get(area) || {
46036
46580
  x: 0,
46037
46581
  y: 0
@@ -46177,7 +46721,9 @@ const SlideContainer = ({
46177
46721
  // there was one, from a whole box away when the travel was asked for.
46178
46722
  const progressFrom = travelProgressFromRef.current ?? (travelStep ? travelStep.x || travelStep.y : 0);
46179
46723
  travelProgressFromRef.current = null;
46180
- animateTravelProgress(progressFrom, durationMs * travelRatio, easing);
46724
+ // The slide the picture leans on for the length of it is the one being
46725
+ // LEFT: it is the second one in the frame until the travel is over.
46726
+ animateTravelProgress(progressFrom, durationMs * travelRatio, easing, drawnArea);
46181
46727
  // Presses still waiting behind this one: it is already late, so it is
46182
46728
  // sent home at once rather than played out at the pace of someone who
46183
46729
  // has stopped pressing. Someone pressing → four times is asking to be
@@ -46597,6 +47143,30 @@ const SlideContainer = ({
46597
47143
  paintTravelProgress(drag.progress, drag.areaPulled);
46598
47144
  };
46599
47145
 
47146
+ // Everything that draws this travel: the box itself and whoever follows it.
47147
+ const travelPainters = () => {
47148
+ const containerEl = containerRef.current;
47149
+ if (!containerEl) {
47150
+ return [];
47151
+ }
47152
+ return [containerEl, ...followerElementsRef.current];
47153
+ };
47154
+
47155
+ // Which OTHER slide the picture leans on while it is not on the current one:
47156
+ // the slide being pulled in under a finger, the slide being left during a
47157
+ // travel. Two slides are in the frame and the number below says how far
47158
+ // between them one is — this says which the second one is, so a trait can be
47159
+ // drawn between two places rather than merely offset from one.
47160
+ const paintTravelToward = area => {
47161
+ for (const element of travelPainters()) {
47162
+ if (area) {
47163
+ element.setAttribute("data-slide-travel-toward", area);
47164
+ } else {
47165
+ element.removeAttribute("data-slide-travel-toward");
47166
+ }
47167
+ }
47168
+ };
47169
+
46600
47170
  // Where the picture stands relative to the slide that is CURRENT, in boxes:
46601
47171
  // 0 on it, +1 one whole box before it, -1 one box after. Written on the
46602
47172
  // container so an indicator drawn inside the box — a tab bar, a dot row, a
@@ -46605,45 +47175,50 @@ const SlideContainer = ({
46605
47175
  // gesture, so the number stays continuous when the travel commits and the
46606
47176
  // current slide changes under it.
46607
47177
  const paintTravelProgress = (progress, area) => {
46608
- const containerEl = containerRef.current;
46609
- if (!containerEl) {
46610
- return;
46611
- }
46612
- if (!progress) {
46613
- containerEl.style.removeProperty("--slide-travel-progress");
46614
- containerEl.removeAttribute("data-slide-travel-to");
46615
- return;
47178
+ for (const element of travelPainters()) {
47179
+ if (progress) {
47180
+ element.style.setProperty("--slide-travel-progress", progress);
47181
+ } else {
47182
+ element.style.removeProperty("--slide-travel-progress");
47183
+ }
46616
47184
  }
46617
- containerEl.style.setProperty("--slide-travel-progress", progress);
46618
- if (area) {
46619
- containerEl.setAttribute("data-slide-travel-to", area);
46620
- } else {
46621
- containerEl.removeAttribute("data-slide-travel-to");
47185
+ paintTravelToward(progress ? area : null);
47186
+ };
47187
+ const cancelTravelProgressAnimation = () => {
47188
+ for (const animation of progressAnimationsRef.current) {
47189
+ animation.cancel();
46622
47190
  }
47191
+ progressAnimationsRef.current = [];
46623
47192
  };
46624
47193
 
46625
47194
  // The indicator, brought home at the pace of the travel it belongs to: the
46626
47195
  // same duration and the same easing as the track, so the trait and the slides
46627
47196
  // are one movement. The value it lands on is the one nothing writes (0), so
46628
- // the animation is left to fall away on its own.
46629
- const animateTravelProgress = (from, durationMs, easing) => {
46630
- const containerEl = containerRef.current;
46631
- progressAnimationRef.current?.cancel();
46632
- progressAnimationRef.current = null;
47197
+ // the animation is left to fall away on its own — only the name of the slide
47198
+ // being leant on is taken back by hand, at the end.
47199
+ const animateTravelProgress = (from, durationMs, easing, area) => {
47200
+ // Read again at the start of every travel, not only at every render: a
47201
+ // follower appearing does not make this box render, and the travel it must
47202
+ // draw is the one about to start.
47203
+ followerElementsRef.current = readFollowerElements();
47204
+ cancelTravelProgressAnimation();
46633
47205
  paintTravelProgress(0);
46634
- if (!containerEl || !from || !durationMs) {
47206
+ const painters = travelPainters();
47207
+ if (!painters.length || !from || !durationMs) {
46635
47208
  return;
46636
47209
  }
46637
- progressAnimationRef.current = containerEl.animate([{
47210
+ paintTravelToward(area);
47211
+ progressAnimationsRef.current = painters.map(element => element.animate([{
46638
47212
  "--slide-travel-progress": from
46639
47213
  }, {
46640
47214
  "--slide-travel-progress": 0
46641
47215
  }], {
46642
47216
  duration: durationMs,
46643
47217
  easing
46644
- });
46645
- progressAnimationRef.current.finished.then(() => {
46646
- progressAnimationRef.current = null;
47218
+ }));
47219
+ progressAnimationsRef.current[0].finished.then(() => {
47220
+ progressAnimationsRef.current = [];
47221
+ paintTravelToward(null);
46647
47222
  }, () => {
46648
47223
  // cancelled by the next travel — that one says where the trait goes
46649
47224
  });
@@ -46716,7 +47291,7 @@ const SlideContainer = ({
46716
47291
  settleTravel();
46717
47292
  return;
46718
47293
  }
46719
- animateTravelProgress(drag.progress, durationMs * (pulled / size), "ease-out");
47294
+ animateTravelProgress(drag.progress, durationMs * (pulled / size), "ease-out", drag.areaPulled);
46720
47295
  const animation = track.animate([{
46721
47296
  translate: drag.offset
46722
47297
  }, {
@@ -46842,8 +47417,8 @@ const SlideContainer = ({
46842
47417
  caughtTravel = null;
46843
47418
  trackAnimationRef.current?.cancel();
46844
47419
  trackAnimationRef.current = null;
46845
- progressAnimationRef.current?.cancel();
46846
- progressAnimationRef.current = null;
47420
+ cancelTravelProgressAnimation();
47421
+ followerElementsRef.current = readFollowerElements();
46847
47422
  drag.axis = axis;
46848
47423
  drag.areaBack = areaBack;
46849
47424
  drag.areaOn = areaOn;
@@ -46988,6 +47563,10 @@ const SlideContainer = ({
46988
47563
  ...handlers
46989
47564
  });
46990
47565
  if (!gesture) {
47566
+ // Not a press this box can be about — something that reads the pointer
47567
+ // itself, a box below it that travels the same way. Whatever the press
47568
+ // stopped on its way in goes back on its way.
47569
+ handlers.onGiveUp();
46991
47570
  return;
46992
47571
  }
46993
47572
  handlers.drag.gesture = gesture;
@@ -47034,7 +47613,7 @@ const SlideContainer = ({
47034
47613
  return () => {
47035
47614
  dragRef.current?.gesture?.stop();
47036
47615
  dragRef.current = null;
47037
- progressAnimationRef.current?.cancel();
47616
+ cancelTravelProgressAnimation();
47038
47617
  };
47039
47618
  }, []);
47040
47619
 
@@ -47099,10 +47678,16 @@ const SlideContainer = ({
47099
47678
  "data-slide-container": ""
47100
47679
  // Which axes a touch may travel on, said in the DOM: what the browser
47101
47680
  // does with a finger is decided by CSS (touch-action) before any of this
47102
- // has seen the gesture.
47681
+ // has seen the gesture — and it is also what a box HOLDING this one reads
47682
+ // to know the gesture is not its own (see drag_to_travel.js).
47103
47683
  ,
47104
47684
 
47105
47685
  "data-travel-by-drag": dragAxes ?? undefined
47686
+ // The same fact for a wheel, and only for that second reason: this box
47687
+ // takes the push, whatever the box around it also travels on.
47688
+ ,
47689
+
47690
+ "data-travel-by-wheel": scrollAxes ?? undefined
47106
47691
  // The same fact, read by the shared gesture stylesheet: what scrolls
47107
47692
  // inside a box that travels must not spill onto the page behind it (see
47108
47693
  // drag_to_travel.js).
@@ -48329,6 +48914,11 @@ const css$z = /* css */`
48329
48914
  * content something depends on while the popup is still closed: a value read
48330
48915
  * off it, fields a surrounding form collects on submit, a size measured from
48331
48916
  * outside.
48917
+ * @param {boolean} [props.unmountWhenClosed] - Throws `children` away once the
48918
+ * popup has finished closing (see popup_content_mount.js). For content whose
48919
+ * fresh state is its initial state: an uncontrolled field seeded from a
48920
+ * `defaultValue` that changed while the popup was closed. Ignored when
48921
+ * `mountWhenClosed` is set.
48332
48922
  * @param {import("ignore:preact").ComponentChildren} props.children
48333
48923
  */
48334
48924
  const Popup = props => {
@@ -51323,10 +51913,17 @@ const ListUI = props => {
51323
51913
  // search fallback), otherwise an empty list is the "empty" state.
51324
51914
  const searchFallbackShown = (allNoMatch || searching && itemCount === 0) && !searchFallbackDisabled;
51325
51915
  const emptyFallbackShown = !searching && itemCount === 0 && !fallbackDisabled;
51916
+ // A loading state only holds the list on screen when it has something to
51917
+ // draw. A count of 0 (or no loadingFallback at all) says the list is known to
51918
+ // be empty before the response arrives, so the empty state can already be
51919
+ // shown — nothing jumps when the response lands, exactly as three skeletons
51920
+ // become three rows.
51921
+ const loadingPlaceholderShown = Boolean(loading) && Boolean(loadingFallback) && (loadingFallback !== "skeleton" || loadingSkeletonCount > 0);
51326
51922
  // 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;
51923
+ // show: no visible items AND no fallback message. Never while a loading
51924
+ // placeholder or an error message is on screen (they ARE the content to
51925
+ // display).
51926
+ const nothingToDisplay = !loadingPlaceholderShown && !error && noVisibleItems && !searchFallbackShown && !emptyFallbackShown;
51330
51927
 
51331
51928
  // Placeholder content replaces the real children: an error message when the
51332
51929
  // load failed (takes precedence), otherwise — while loading — whatever
@@ -51421,7 +52018,7 @@ const ListUI = props => {
51421
52018
  fallbackShown: emptyFallbackShown,
51422
52019
  searchFallback: searchFallback,
51423
52020
  searchFallbackShown: searchFallbackShown,
51424
- loading: loading,
52021
+ loadingPlaceholderShown: loadingPlaceholderShown,
51425
52022
  error: error,
51426
52023
  searchNoMatchMode: searchNoMatchMode,
51427
52024
  separator: separator,
@@ -51521,6 +52118,10 @@ const ListFirstResolver = props => {
51521
52118
  * displays nothing. A list that knows how many rows it will have has no use
51522
52119
  * for this — see `<List.Items count>`, whose not-yet-loaded rows are drawn
51523
52120
  * as skeletons in place, one per row, virtualized like the rest.
52121
+ * @param {number} [props.loadingSkeletonCount=3]
52122
+ * How many placeholder rows `loadingFallback="skeleton"` draws. `0` says the
52123
+ * list is already known to be empty: the empty `fallback` shows right away
52124
+ * rather than an empty frame, so nothing moves when the response arrives.
51524
52125
  * @param {"start"|"end"|number|{id: string, offset?: number}} [props.defaultScrolled="start"]
51525
52126
  * Where the list opens, after which the user owns the scroll. `"end"` is a
51526
52127
  * thread read backwards — the last rows are the ones to show, and the ones
@@ -51581,7 +52182,7 @@ const ListContent = ({
51581
52182
  fallbackShown,
51582
52183
  searchFallback,
51583
52184
  searchFallbackShown,
51584
- loading,
52185
+ loadingPlaceholderShown,
51585
52186
  error,
51586
52187
  searchNoMatchMode,
51587
52188
  separator,
@@ -51605,7 +52206,7 @@ const ListContent = ({
51605
52206
  fallbackShown: fallbackShown,
51606
52207
  searchFallback: searchFallback,
51607
52208
  searchFallbackShown: searchFallbackShown,
51608
- loading: loading,
52209
+ loadingPlaceholderShown: loadingPlaceholderShown,
51609
52210
  error: error,
51610
52211
  searchNoMatchMode: searchNoMatchMode,
51611
52212
  separator: separator,
@@ -52893,7 +53494,7 @@ const UnorderedList = ({
52893
53494
  fallbackShown,
52894
53495
  searchFallback,
52895
53496
  searchFallbackShown,
52896
- loading,
53497
+ loadingPlaceholderShown,
52897
53498
  error,
52898
53499
  searchNoMatchMode,
52899
53500
  separator,
@@ -52904,9 +53505,11 @@ const UnorderedList = ({
52904
53505
  children,
52905
53506
  ...rest
52906
53507
  }) => {
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);
53508
+ // No empty/no-match message while a loading placeholder or an error message
53509
+ // is on screen — that IS the content, even though no items are tracked yet.
53510
+ // A loading state drawing nothing keeps the message: an announced count of 0
53511
+ // already tells us the list is empty (see ListUI's loadingPlaceholderShown).
53512
+ const suppressFallback = loadingPlaceholderShown || Boolean(error);
52910
53513
  return jsxs(Box, {
52911
53514
  as: "ul",
52912
53515
  flex: columns ? undefined : horizontal ? "x" : "y",
@@ -59362,8 +59965,16 @@ const useWheelInteractions = ({
59362
59965
  document.removeEventListener("wheel", onDocumentWheel, {
59363
59966
  capture: true
59364
59967
  });
59968
+ releaseWheelGesture(vp);
59365
59969
  };
59366
59970
  const keepClaimingGesture = () => {
59971
+ // Said out loud as well as swallowed: preventDefault only settles it with
59972
+ // the browser, and a box that travels with the wheel answers the burst
59973
+ // from a listener of its own — it asks who owns the gesture instead (see
59974
+ // wheel_gesture.js in @jsenv/dom).
59975
+ claimWheelGesture(vp, {
59976
+ delay: WHEEL_GESTURE_MAX_GAP
59977
+ });
59367
59978
  if (!gestureGuardTimer) {
59368
59979
  document.addEventListener("wheel", onDocumentWheel, {
59369
59980
  capture: true,
@@ -59374,6 +59985,12 @@ const useWheelInteractions = ({
59374
59985
  gestureGuardTimer = setTimeout(stopClaimingGesture, WHEEL_GESTURE_MAX_GAP);
59375
59986
  };
59376
59987
  const onWheel = e => {
59988
+ // The burst belongs to something else — a box that travels with the
59989
+ // wheel, another wheel the pointer has just left. It is theirs until the
59990
+ // events stop coming.
59991
+ if (wheelGestureIsTakenFrom(vp)) {
59992
+ return;
59993
+ }
59377
59994
  const raw = isHorizontal ? e.deltaX || e.deltaY : e.deltaY;
59378
59995
  if (!raw) {
59379
59996
  return;
@@ -66817,5 +67434,5 @@ const UserSvg = () => jsx("svg", {
66817
67434
  })
66818
67435
  });
66819
67436
 
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 };
67437
+ 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
67438
  //# sourceMappingURL=jsenv_navi.js.map