@jsenv/navi 0.29.523 → 0.30.1

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.
@@ -3,11 +3,11 @@
3
3
  * using @jsenv/navi as intended.
4
4
  */
5
5
  import { installImportMetaCssBuild, coarsePointerSignal, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
6
- import { createPubSub, createIterableWeakSet, elementIsFocusable, getScrollContainer, dispatchInternalCustomEvent, dispatchCustomEvent, createEventGroupLogger, findEvent, mergeOneStyle, findFocusable, getElementSignature, chainEvent, isTouchDrivenEvent, getKeyboardEventDefaultAction, isPressDrivenClick, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, createValueEffect, findFocusDelegateTarget, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, getScrollContainerSet, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, keepTouchRefusable, waitForTap, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, dragSourceThatStoodDown, markDragSource, refuseDragTo, startDragTo, installPanZoom, createInternalCustomEvent, activeElementSignal, normalizeStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, trapScrollInside, scrollIntoViewScoped, isPressDisputedByDrag, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, initFocusGroup, stringifyStyle as stringifyStyle$1, isScrollable, resolveOklchLightness, contrastColor, overflowGoesToViewport, parsePositionArea, snapToPixel, trapFocusInside, getVirtualKeyboardOverlayHeight, onAncestorReopen, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, setVirtualKeyboardOverlaysContent, subscribeVirtualKeyboardGeometryChange } from "@jsenv/dom";
6
+ import { createPubSub, createIterableWeakSet, elementIsFocusable, getScrollContainer, dispatchInternalCustomEvent, dispatchCustomEvent, createEventGroupLogger, findEvent, mergeOneStyle, findFocusable, getElementSignature, chainEvent, isTouchDrivenEvent, getKeyboardEventDefaultAction, isPressDrivenClick, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, createValueEffect, findFocusDelegateTarget, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, getScrollContainerSet, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, keepTouchRefusable, waitForTap, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, dragSourceThatStoodDown, markDragSource, refuseDragTo, startDragTo, installPanZoom, createInternalCustomEvent, activeElementSignal, normalizeStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, trapScrollInside, scrollIntoViewScoped, isPressDisputedByDrag, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, initFocusGroup, stringifyStyle as stringifyStyle$1, isScrollable, resolveOklchLightness, contrastColor, scrollRoomTowards, overflowGoesToViewport, getVirtualKeyboardOverlayHeight, subscribeVisualViewportResizeSettled, subscribeVirtualKeyboardGeometryChange, parsePositionArea, snapToPixel, trapFocusInside, onAncestorReopen, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, setVirtualKeyboardOverlaysContent } from "@jsenv/dom";
7
7
  export { chainEvent, clickIsSuppressed, contrastColor, createDragGestureController, dragAfterIntent, findEvent, markDragSource, startDragTo } from "@jsenv/dom";
8
8
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
9
9
  import { useState, useLayoutEffect, useErrorBoundary, useContext, useMemo, useRef, useEffect, useReducer, useCallback } from "preact/hooks";
10
- import { options, isValidElement, createContext, render as render$1, h, toChildArray, createRef, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
10
+ import { options, isValidElement, createContext, render as render$1, h, toChildArray, cloneElement, createRef, createElement, Fragment as Fragment$1 } from "preact";
11
11
  import { humanizeI18n, prefixFirstAndIndentRemainingLines, setRuntimeLangSource, formatDuration, formatMonth, formatDay, resolveTimeRangePrecision, formatDatePlaceholder, toDate, getRelativeDay, formatDayRelative, formatMonthPlaceholder, formatWeekPlaceholder, formatDatetimePlaceholder, formatDatetime, toTimeOfDay, formatTimeOfDay, formatMinuteDuration, formatTime, formatSecondDuration, formatHourDuration, formatTimeRelative, pluralCategory, formatNumber, interpolateText, installInterpolateJsx } from "@jsenv/humanize";
12
12
  export { createI18n, formatByteSize, formatDatetime, formatDay, formatDayRelative, formatDuration, formatHourDuration, formatList, formatMinuteDuration, formatMonth, formatNumber, formatSecondDuration, formatTime, formatTimeOfDay, formatTimeRange, formatTimeRelative, interpolateText, pluralCategory } from "@jsenv/humanize";
13
13
  import { createValidity, CHAR_CLASS_PRESETS, durationContainsNaN, compareTwoDurations, TYPE_RULE, durationToSeconds, resolveCharClass, getCharClassMessageKey, compileCharClassAnchored, compileCharClass, DISPLAYABLE_RULE, MAX_LINE_BREAKS_RULE, NO_EMOJI_RULE, SINGLE_SPACE_RULE, parseDuration, durationToISOString } from "@jsenv/validity";
@@ -444,8 +444,7 @@ const flushSyncRendering = (fn) => {
444
444
  * it was left, and the bottom arrives under a page that has already jumped. So
445
445
  * whoever writes an arrival's offset builds first the boxes that offset shows
446
446
  * (buildDeferredBoxesAbove), in the same task — synchronously, since what comes
447
- * right after it (the offset, a route travel measuring its window) reads the
448
- * layout.
447
+ * right after it (the offset) reads the layout.
449
448
  */
450
449
 
451
450
  const deferredBoxes = new Set();
@@ -546,9 +545,8 @@ const readTopInWindow = element => {
546
545
  * around Preact's `options.debounceRendering`, and two of them installed
547
546
  * independently restore each other in the wrong order when they let go — every
548
547
  * render queued in between is then handed to a wrapper nobody will ever
549
- * release. Everything that photographs a navigation (RouteTravel's box, a
550
- * route transition) must therefore hold through this module, never through a
551
- * wrapper of its own.
548
+ * release. Everything that photographs a navigation must therefore hold through
549
+ * this module, never through a wrapper of its own.
552
550
  */
553
551
 
554
552
 
@@ -10234,8 +10232,8 @@ const haveSameMembers = (setA, setB) => {
10234
10232
  * A route matching is a signal changing, and the page it selects reaches the
10235
10233
  * DOM only once Preact has rendered — an unknown number of passes later, in an
10236
10234
  * unknown number of microtasks. Anyone who needs the page as it IS rather than
10237
- * as it has been decided (a travel about to have its picture taken by the
10238
- * browser, see route_travel.jsx) waits for this instead of counting.
10235
+ * as it has been decided (a route transition about to have its picture taken by
10236
+ * the browser) waits for this instead of counting.
10239
10237
  *
10240
10238
  * A page waiting on data is announced too, by the boundary showing its loading
10241
10239
  * state (see Loading in use_async_data.jsx): what the container could put on
@@ -24514,10 +24512,10 @@ const readNumberFromDom = (element, attribute, defaultValue) => {
24514
24512
  *
24515
24513
  * A popup is painted OVER the page and stays a DOM descendant of whatever it
24516
24514
  * was written in — a dialog inside the card that opens it, a callout beside the
24517
- * button it belongs to. Its pointer events therefore bubble through boxes it
24518
- * covers, and a box reading a gesture off one of them answers for something
24519
- * nobody aimed at it: a finger held in a sheet opening the card underneath the
24520
- * sheet, a card the sheet is drawn from and that the finger cannot even reach.
24515
+ * button it belongs to. Its pointer and key events therefore bubble through
24516
+ * boxes it covers, and a box reading a gesture off one of them answers for
24517
+ * something nobody aimed at it: a finger held in a sheet opening the card
24518
+ * underneath the sheet, an arrow pressed in a sheet walking the slides behind it.
24521
24519
  *
24522
24520
  * The same list, read for the same reason, as the drag sources in @jsenv/dom
24523
24521
  * (see DRAG_IGNORED_SELECTOR in drag_to.js) and as the selection rule in
@@ -25274,26 +25272,7 @@ const ensureDocumentStartViewTransition = () => {
25274
25272
  startViewTransitionPolyfill.isPolyfill = true;
25275
25273
  document.startViewTransition = startViewTransitionPolyfill;
25276
25274
  }
25277
- return startViewTransition$2;
25278
- };
25279
-
25280
- // A transition a finger is holding still (see route_travel.jsx): it has to be
25281
- // let go of before any other one starts, and this is the only place that knows
25282
- // a new one is about to. There is a single transition per document — starting
25283
- // one SKIPS the one in flight — and the hold that keeps pictures under a finger
25284
- // is written against whatever transition is running, because everything the
25285
- // gesture must carry along (a trait under a tab row) belongs to that same
25286
- // transition and has no name of its own here. Left on, it takes hold of the
25287
- // transition that has just replaced ours: born paused, held by nobody, it never
25288
- // finishes, and its pictures stand over a page that cannot be touched anymore.
25289
- let releaseHeldViewTransition = null;
25290
- const holdViewTransition = (release) => {
25291
- releaseHeldViewTransition = release;
25292
- return () => {
25293
- if (releaseHeldViewTransition === release) {
25294
- releaseHeldViewTransition = null;
25295
- }
25296
- };
25275
+ return startViewTransition$1;
25297
25276
  };
25298
25277
 
25299
25278
  // The last transition started here, for code that runs a callback of the
@@ -25303,12 +25282,7 @@ const holdViewTransition = (release) => {
25303
25282
  let lastViewTransition = null;
25304
25283
  const peekLastViewTransition = () => lastViewTransition;
25305
25284
 
25306
- const startViewTransition$2 = (updateCallback) => {
25307
- if (releaseHeldViewTransition) {
25308
- const release = releaseHeldViewTransition;
25309
- releaseHeldViewTransition = null;
25310
- release();
25311
- }
25285
+ const startViewTransition$1 = (updateCallback) => {
25312
25286
  const viewTransition = document.startViewTransition(updateCallback);
25313
25287
  viewTransition.updateCallbackDone.catch(ignoreSkip);
25314
25288
  viewTransition.ready.catch(ignoreSkip);
@@ -27301,6 +27275,9 @@ const createOnKeyDownForShortcutArray = (
27301
27275
  };
27302
27276
  };
27303
27277
  /**
27278
+ * A key pressed in a popup nested in the listening element is not offered to
27279
+ * these shortcuts: see applyKeyboardShortcuts.
27280
+ *
27304
27281
  * @param {object} shortcuts
27305
27282
  * @param {object} [options]
27306
27283
  * @param {(target: Element) => boolean} [options.ownsTarget] Whether a
@@ -27326,6 +27303,13 @@ const createOnKeyDownForShortcuts = (shortcuts, { ownsTarget } = {}) => {
27326
27303
  const applyKeyboardShortcuts = (shortcuts, keyboardEvent, { ownsTarget }) => {
27327
27304
  const currentTarget = keyboardEvent.currentTarget;
27328
27305
  const target = keyboardEvent.target;
27306
+ // A popup stays a DOM descendant of what it was written in, so a key pressed
27307
+ // in it bubbles through boxes the reader cannot reach while it is open. Those
27308
+ // boxes have no shortcut to answer it with: an arrow in a sheet does not walk
27309
+ // the slides behind it, and Escape closes the popup it was pressed in.
27310
+ if (isPressOnLayerOver(target, currentTarget)) {
27311
+ return null;
27312
+ }
27329
27313
  let canIntercept;
27330
27314
  if (target === currentTarget) {
27331
27315
  // the event occurs on the element itself so we are sure we won't interfere with native behaviors of child elements
@@ -27561,6 +27545,9 @@ const isSameKey = (browserEventKey, key) => {
27561
27545
  * well, whether the gate lets the interaction through or refuses it and says
27562
27546
  * why — the same as a number field's arrows.
27563
27547
  *
27548
+ * A key pressed in a popup nested in the element is not this element's, like a
27549
+ * press there (see isPressOnLayerOver).
27550
+ *
27564
27551
  * The interaction is dispatched under its full name, colon included, so
27565
27552
  * `findEvent(event, "keyboard:ctrl+backspace")` gets back to it — the shortcut
27566
27553
  * that asked is what the action wants to know, not that a key went down.
@@ -33139,8 +33126,7 @@ const LoadingFallback = ({
33139
33126
  // A page that suspends never gets to say it is on screen — its own effects
33140
33127
  // are held with it — so this says it for it: what the document shows of the
33141
33128
  // page arriving is this. Anyone waiting for the page to be there before
33142
- // moving (a travel about to have its picture taken, see route_travel.jsx)
33143
- // would otherwise wait for a render that cannot happen until the data does.
33129
+ // moving (a route transition about to have its picture taken) would otherwise wait for a render that cannot happen until the data does.
33144
33130
  useLayoutEffect(() => {
33145
33131
  publishRouteRender();
33146
33132
  });
@@ -37628,29 +37614,6 @@ const samePages = (pages, otherPages) => {
37628
37614
 
37629
37615
  installImportMetaCssBuild(import.meta);
37630
37616
 
37631
- /**
37632
- * Keep every container showing the page it is showing, whatever the routes say.
37633
- *
37634
- * For a caller whose picture of a page is LIVE and must not follow the router:
37635
- * a travel being undone shows the page it is going back to on both sides at
37636
- * once if the router is allowed to swap under it (see route_travel.jsx). Only
37637
- * the pages are held still — the rest of the document goes on rendering, which
37638
- * is the whole reason this lives here rather than in Preact's own scheduler.
37639
- */
37640
- let routeRenderFrozen = false;
37641
- const routeRenderFrozenSignal = signal(0);
37642
- const freezeRouteRender = () => {
37643
- routeRenderFrozen = true;
37644
- return () => {
37645
- if (!routeRenderFrozen) {
37646
- return;
37647
- }
37648
- routeRenderFrozen = false;
37649
- // Read during every container's render, so letting go brings them all back.
37650
- routeRenderFrozenSignal.value++;
37651
- };
37652
- };
37653
-
37654
37617
  /**
37655
37618
  * Keep the page a container leaves, hidden, until its route transition is over.
37656
37619
  *
@@ -37721,8 +37684,11 @@ const debug$1 = (...args) => {
37721
37684
  * collectBranches).
37722
37685
  */
37723
37686
  const Route = props => {
37687
+ const inRow = useContext(RouteRowContext);
37724
37688
  if (props.children) {
37725
- return jsx(RouteContainer, {
37689
+ return inRow ? jsx(RouteRowContainer, {
37690
+ ...props
37691
+ }) : jsx(RouteContainer, {
37726
37692
  ...props
37727
37693
  });
37728
37694
  }
@@ -37786,6 +37752,105 @@ const collectRoutePages = children => {
37786
37752
  return pages;
37787
37753
  };
37788
37754
 
37755
+ /**
37756
+ * Whether the next container down is a row a hand scrolls: every page has a
37757
+ * place on it from the start, and the browser moves between them (see
37758
+ * RouteTravel's `nativeScroll`). Told to one container, not to the pages it holds.
37759
+ */
37760
+ const RouteRowContext = createContext(false);
37761
+
37762
+ // A container as a row: one pane per leaf, in the order they are written. A page
37763
+ // still builds when its route first matches, since a route's actions only ask
37764
+ // while it does: before that its pane is empty. Once built it stays, with what
37765
+ // it holds and where it was scrolled to, so a hand brings back the page itself
37766
+ // and the address coming back to it finds it there.
37767
+ //
37768
+ // A page not on screen is treated as one kept while leaving, except that this
37769
+ // can be undone: it does not re-render for what it reads (isShown), and it steps
37770
+ // out of whatever it fills outside its pane (leavingSignal).
37771
+ const RouteRowContainer = ({
37772
+ children
37773
+ }) => {
37774
+ const {
37775
+ activeBranch,
37776
+ leaves
37777
+ } = collectBranches(children);
37778
+ const childrenRef = useRef(null);
37779
+ childrenRef.current = children;
37780
+ const readShownBranchRef = useRef(null);
37781
+ if (!readShownBranchRef.current) {
37782
+ readShownBranchRef.current = () => collectBranches(childrenRef.current).activeBranch;
37783
+ }
37784
+ const parentPage = useContext(RoutePageContext);
37785
+ const pagesRef = useRef(new Map());
37786
+ const pageOf = branch => {
37787
+ let page = pagesRef.current.get(branch.id);
37788
+ if (!page) {
37789
+ page = createRoutePage(readShownBranchRef.current, parentPage);
37790
+ pagesRef.current.set(branch.id, page);
37791
+ }
37792
+ page.branchId = branch.id;
37793
+ // Never answering for another pane: two leaves rendering the same element
37794
+ // are two pages here, each in its pane, where a container carries one over.
37795
+ page.element = null;
37796
+ return page;
37797
+ };
37798
+ const shownPage = activeBranch ? pageOf(activeBranch) : null;
37799
+ const containerKey = useRef({}).current;
37800
+ useLayoutEffect(() => {
37801
+ publishRouteRender();
37802
+ reportContainerShows(containerKey, {
37803
+ parentPage,
37804
+ page: shownPage,
37805
+ branchType: activeBranch ? activeBranch.type : null,
37806
+ routePage: branchRoutePage(activeBranch)
37807
+ });
37808
+ });
37809
+ useLayoutEffect(() => {
37810
+ return () => {
37811
+ forgetContainer(containerKey);
37812
+ };
37813
+ }, []);
37814
+ if (activeBranch && activeBranch.type === "fallback") {
37815
+ // An address the row has no place for: nothing to scroll between.
37816
+ return jsx(RouteRowPane, {
37817
+ branch: activeBranch,
37818
+ page: shownPage,
37819
+ shown: true
37820
+ }, "fallback");
37821
+ }
37822
+ return leaves.map(leaf => jsx(RouteRowPane, {
37823
+ branch: leaf,
37824
+ page: pageOf(leaf),
37825
+ shown: activeBranch !== null && leaf.id === activeBranch.id
37826
+ }, leaf.id));
37827
+ };
37828
+ const RouteRowPane = ({
37829
+ branch,
37830
+ page,
37831
+ shown
37832
+ }) => {
37833
+ // The very vnode rendered last, while not on screen: Preact does not look into
37834
+ // it, and a page nobody can see costs nothing when the address moves.
37835
+ const contentRef = useRef(null);
37836
+ if (shown) {
37837
+ contentRef.current = h(RouteRowContext.Provider, {
37838
+ value: false
37839
+ }, h(RoutePageContext.Provider, {
37840
+ value: page
37841
+ }, branch.node));
37842
+ }
37843
+ useLayoutEffect(() => {
37844
+ page.ownLeavingSignal.value = !shown;
37845
+ }, [shown]);
37846
+ return jsx("div", {
37847
+ "data-route-row-pane": "",
37848
+ "data-built": contentRef.current ? "" : undefined,
37849
+ inert: !shown,
37850
+ children: contentRef.current
37851
+ });
37852
+ };
37853
+
37789
37854
  // RouteContainer: traverses children statically per render, finds the active branch,
37790
37855
  // and renders only that branch — or the fallback if nothing matches.
37791
37856
  // No contexts, no state of its own: it reads the route signals directly, and
@@ -37802,16 +37867,7 @@ const RouteContainer = ({
37802
37867
  pages
37803
37868
  } = collectBranches(children);
37804
37869
  const fallbackPage = fallbackBranch ? fallbackBranch.page : null;
37805
-
37806
- // Told to hold still: what is on screen stays on screen. Kept as the very
37807
- // vnode that was rendered last time, which is how Preact is told there is
37808
- // nothing to look at in that subtree.
37809
- const frozen = routeRenderFrozenSignal.value >= 0 && routeRenderFrozen;
37810
- const shownBranchRef = useRef(null);
37811
- if (!frozen) {
37812
- shownBranchRef.current = activeBranch;
37813
- }
37814
- const branch = shownBranchRef.current || activeBranch;
37870
+ const branch = activeBranch;
37815
37871
  // What this container would show if it rendered now, by the same rule as
37816
37872
  // the render above — asked from outside any render (see isShown below).
37817
37873
  const childrenRef = useRef(null);
@@ -37819,9 +37875,6 @@ const RouteContainer = ({
37819
37875
  const readShownBranchRef = useRef(null);
37820
37876
  if (!readShownBranchRef.current) {
37821
37877
  readShownBranchRef.current = () => {
37822
- if (routeRenderFrozen) {
37823
- return shownBranchRef.current;
37824
- }
37825
37878
  return collectBranches(childrenRef.current).activeBranch;
37826
37879
  };
37827
37880
  }
@@ -38116,7 +38169,7 @@ const branchRouteNode = branch => {
38116
38169
  // Walk JSX children vnodes (without rendering) to build a branch list and
38117
38170
  // find the active one in the same pass. Anything that is not a <Route> is read
38118
38171
  // through and kept around the branch it holds (see below).
38119
- // Returns { matchingBranch, fallbackBranch, activeBranch, pages }.
38172
+ // Returns { matchingBranch, fallbackBranch, activeBranch, pages, leaves }.
38120
38173
  //
38121
38174
  // Each branch is given an id from where it is written among the children,
38122
38175
  // which is what tells a container it changed page: the vnodes themselves are
@@ -38131,6 +38184,8 @@ const collectBranches = (children, path = "") => {
38131
38184
  // matching or a leaf matching under it is enough to select it, and the
38132
38185
  // fallback then stands down.
38133
38186
  const pages = [];
38187
+ // Every leaf, wrappers kept, whichever matches: what a row has a pane for.
38188
+ const leaves = [];
38134
38189
  const visit = child => {
38135
38190
  if (!child || child === true || child === false) {
38136
38191
  return;
@@ -38160,11 +38215,15 @@ const collectBranches = (children, path = "") => {
38160
38215
  const {
38161
38216
  matchingBranch: matchingInside,
38162
38217
  fallbackBranch: fallbackInside,
38163
- pages: pagesInside
38218
+ pages: pagesInside,
38219
+ leaves: leavesInside
38164
38220
  } = collectBranches(wrapperChildren, `${id}.`);
38165
38221
  for (const pageInside of pagesInside) {
38166
38222
  pages.push(pageInside);
38167
38223
  }
38224
+ for (const leafInside of leavesInside) {
38225
+ leaves.push(wrapBranch(leafInside, child));
38226
+ }
38168
38227
  if (matchingInside && !matchingBranch) {
38169
38228
  matchingBranch = wrapBranch(matchingInside, child);
38170
38229
  }
@@ -38237,6 +38296,7 @@ const collectBranches = (children, path = "") => {
38237
38296
  id,
38238
38297
  node: child
38239
38298
  };
38299
+ leaves.push(branch);
38240
38300
  pages.push({
38241
38301
  route,
38242
38302
  params: routeParams
@@ -38265,7 +38325,8 @@ const collectBranches = (children, path = "") => {
38265
38325
  matchingBranch,
38266
38326
  fallbackBranch,
38267
38327
  activeBranch,
38268
- pages
38328
+ pages,
38329
+ leaves
38269
38330
  };
38270
38331
  };
38271
38332
  const describeContainer = ({
@@ -38435,14 +38496,7 @@ const releaseTransitionDestination = (owner) => {
38435
38496
  *
38436
38497
  * A control therefore may ask to keep answering, and while a route transition
38437
38498
  * plays the press is caught at the document and handed to it when it fell
38438
- * inside its rectangle — which is where the hand thinks it pressed. The same
38439
- * move route_travel.jsx makes for the box a finger reaches for mid-flight.
38440
- *
38441
- * A `RouteTravel` holds this too, for the bars it leaves live. A control there
38442
- * gets its click on its own except in the first frames of the travel, when a
38443
- * press falls to the document root like any other; the part of a bar photographed
38444
- * for the travel (`data-navi-fixed-bar-overhang`, fixed_bar.jsx) is deaf
38445
- * throughout.
38499
+ * inside its rectangle — which is where the hand thinks it pressed.
38446
38500
  *
38447
38501
  * ASKED FOR, never derived, and that is the whole safety of it: only the
38448
38502
  * application knows the control does not move. One that travels with the pages
@@ -39369,7 +39423,7 @@ const holdTransitionWindow = (
39369
39423
  ) => {
39370
39424
  const bandBefore = stateBefore.band;
39371
39425
  const rectAfter = element.getBoundingClientRect();
39372
- const bandAfter = readBand();
39426
+ readBand();
39373
39427
  const outerScroll = readOuterScroll(element);
39374
39428
  // The state being left, at where its box stands under the offset the box
39375
39429
  // is measured under NOW: what scrolled outside the box between the two
@@ -39403,9 +39457,7 @@ const holdTransitionWindow = (
39403
39457
  setTransitionValue(WINDOW_OLD_LEFT_PROPERTY, `${rectBefore.left}px`);
39404
39458
  setTransitionValue(WINDOW_NEW_TOP_PROPERTY, `${rectAfter.top}px`);
39405
39459
  setTransitionValue(WINDOW_NEW_LEFT_PROPERTY, `${rectAfter.left}px`);
39406
- const oldBand = furnitureLive
39407
- ? bandTheCutMayBeRelaxedTo(bandBefore, bandAfter, rectAfter)
39408
- : bandBefore;
39460
+ const oldBand = bandBefore;
39409
39461
  setTransitionValue(OLD_BAND_TOP_PROPERTY, `${oldBand.top}px`);
39410
39462
  setTransitionValue(OLD_BAND_RIGHT_PROPERTY, `${oldBand.right}px`);
39411
39463
  setTransitionValue(OLD_BAND_BOTTOM_PROPERTY, `${oldBand.bottom}px`);
@@ -39446,21 +39498,6 @@ const placeTransitionWindow = (owner, groupName) => {
39446
39498
  document.documentElement.setAttribute(WINDOW_PLACED_ATTRIBUTE, "");
39447
39499
  };
39448
39500
 
39449
- /**
39450
- * Whether a point of the window lies in a band the furniture keeps: the pictures
39451
- * are cut at those bands, so what is seen there is never a page, however far the
39452
- * box runs under it.
39453
- */
39454
- const isPointInTransitionBand = (clientX, clientY) => {
39455
- const band = readBand();
39456
- return (
39457
- clientX < band.left ||
39458
- clientX > window.innerWidth - band.right ||
39459
- clientY < band.top ||
39460
- clientY > window.innerHeight - band.bottom
39461
- );
39462
- };
39463
-
39464
39501
  // The live layout takes the box back. A discontinuity by construction — the
39465
39502
  // window stands at the held height, the box is at its own — and an invisible
39466
39503
  // one: the page arriving is fully in place, and the strip below it that the
@@ -39543,8 +39580,7 @@ const shiftRect = (rect, byTop, byLeft) => {
39543
39580
  // What has scrolled OUTSIDE the box: the offsets of every scroller above the
39544
39581
  // one the pages scroll in, the viewport's included. That nearest scroller is
39545
39582
  // the pages' own — an offset there is a page scrolled, and it is not counted;
39546
- // a hidden overflow counts as one so that the box asked about is the same the
39547
- // travel asks about (see pagesScrollTheDocument in route_travel.jsx). What is
39583
+ // a hidden overflow counts as one. What is
39548
39584
  // pinned to the viewport (fixed, in the top layer) is not carried by the
39549
39585
  // document's offset, and the walk stops under it.
39550
39586
  const readOuterScroll = (element) => {
@@ -39612,40 +39648,6 @@ const readBand = () => {
39612
39648
  };
39613
39649
  };
39614
39650
 
39615
- // How far the cut may be opened for the picture being left when the furniture
39616
- // is LIVE under the pictures — the band its own state kept free, which is the
39617
- // whole point of photographing it: furniture present in only one of the two
39618
- // states is not the frame, it is part of what changes, and cutting the page
39619
- // being left at a bar it never had shows its header being sliced instead of
39620
- // leaving.
39621
- //
39622
- // Never past what the picture ARRIVING needs, though: the cut is one line for
39623
- // both pictures, so opening it for one opens it for the other. A box that runs
39624
- // under the live furniture it arrives beside — a page scrolled below a top bar
39625
- // — would then be watched painting over that bar for the length of the
39626
- // movement, which is what the cut exists to prevent. On such an edge the
39627
- // arriving band stands, and the page being left is cut as it always was.
39628
- //
39629
- // With the furniture photographed (a route transition) none of this holds: a
39630
- // bar only the arriving state has is a picture ordered over the page it
39631
- // belongs to, and a page arriving under it paints nothing over it. The band
39632
- // of the state being left is then published as it was, so the page being
39633
- // left is never cut at a bar it never had.
39634
- const bandTheCutMayBeRelaxedTo = (bandBefore, bandAfter, rectAfter) => {
39635
- return {
39636
- top: rectAfter.top < bandAfter.top ? bandAfter.top : bandBefore.top,
39637
- right:
39638
- rectAfter.right > window.innerWidth - bandAfter.right
39639
- ? bandAfter.right
39640
- : bandBefore.right,
39641
- bottom:
39642
- rectAfter.bottom > window.innerHeight - bandAfter.bottom
39643
- ? bandAfter.bottom
39644
- : bandBefore.bottom,
39645
- left: rectAfter.left < bandAfter.left ? bandAfter.left : bandBefore.left,
39646
- };
39647
- };
39648
-
39649
39651
  /**
39650
39652
  * A view transition already playing, walked back to where it started.
39651
39653
  *
@@ -39821,7 +39823,7 @@ const easedProgress = (x, [x1, y1, x2, y2]) => {
39821
39823
  };
39822
39824
 
39823
39825
  installImportMetaCssBuild(import.meta);
39824
- const startViewTransition$1 = ensureDocumentStartViewTransition();
39826
+ const startViewTransition = ensureDocumentStartViewTransition();
39825
39827
  const TRANSITION_ATTRIBUTE = "data-navi-route-transition";
39826
39828
  const TRANSITION_TYPE_ATTRIBUTE = "data-navi-route-transition-type";
39827
39829
  const TRANSITION_DURATION_PROPERTY = "--navi-route-transition-duration";
@@ -39853,12 +39855,6 @@ const AREA_NAME = "navi-route-transition";
39853
39855
  // the pages', or the document's own when the pages ARE the document (see
39854
39856
  // findLeadAnimation).
39855
39857
  const LEAD_NAMES = [AREA_NAME, "root"];
39856
- // route_travel.jsx wears this on the root for the length of one of its
39857
- // travels (its TRAVEL_ATTRIBUTE — a comment there mirrors this one). Read by
39858
- // name rather than imported: importing route_travel.jsx would pull the whole
39859
- // travel machinery into an application that only defines transitions.
39860
- const ROUTE_TRAVEL_ATTRIBUTE = "data-navi-route-travel";
39861
-
39862
39858
  // The same movements, written once and played on either target: the document,
39863
39859
  // or the marked area. The guard keeps the two exclusive — with an area marked,
39864
39860
  // the root pictures must NOT move (they carry the whole viewport, blank bands
@@ -40670,10 +40666,10 @@ const rebuildWatcher = () => {
40670
40666
  from,
40671
40667
  to
40672
40668
  } of relations) {
40673
- if (from && pageIndexOf$1(pages, from) === -1) {
40669
+ if (from && pageIndexOf(pages, from) === -1) {
40674
40670
  pages.push(from);
40675
40671
  }
40676
- if (pageIndexOf$1(pages, to) === -1) {
40672
+ if (pageIndexOf(pages, to) === -1) {
40677
40673
  pages.push(to);
40678
40674
  }
40679
40675
  }
@@ -40761,7 +40757,7 @@ const rebuildWatcher = () => {
40761
40757
  // What plays when no relation matched (see defineRouteDefaultTransition), what
40762
40758
  // the navigation now landing asked for on its own (see readNavigationRequest),
40763
40759
  // where it goes, and whether it found an answer already — a relation's
40764
- // transition, a "none", a RouteTravel travel. The last three are read at the
40760
+ // transition, a "none". The last three are read at the
40765
40761
  // start of every navigation, so they are always about the latest one.
40766
40762
  let defaultTransition = null;
40767
40763
  let navigationRequest = null;
@@ -41012,14 +41008,6 @@ const beginTransition = ({
41012
41008
  }) => {
41013
41009
  navigationAnimated = true;
41014
41010
  const documentElement = document.documentElement;
41015
- // One navigation, one animator. A RouteTravel box already travelling this
41016
- // change owns the document's transition — and possibly a finger; starting
41017
- // one here on top would skip its pictures mid-slide. A pair of routes must
41018
- // be animated by RouteTravel or by a route transition, never both.
41019
- if (documentElement.hasAttribute(ROUTE_TRAVEL_ATTRIBUTE)) {
41020
- console.warn("A RouteTravel is animating this navigation; the route transition defined between these routes is skipped. Animate a pair of routes with RouteTravel or defineRouteTransition, not both.");
41021
- return;
41022
- }
41023
41011
  navigationDecision = {
41024
41012
  type,
41025
41013
  direction,
@@ -41104,7 +41092,7 @@ const beginTransition = ({
41104
41092
  // calls that callback a frame later, and a navigation that has already
41105
41093
  // been decided renders its page in between — a wait armed then waits for
41106
41094
  // something that has already happened.
41107
- const renderWait = armRouteRenderWait$1();
41095
+ const renderWait = armRouteRenderWait();
41108
41096
  // What the browser ACTUALLY captured, read once the pictures exist: it is
41109
41097
  // the only place the silent misconfigurations show. They are all about the
41110
41098
  // same thing — a movement playing on pictures that are not the pages.
@@ -41133,7 +41121,7 @@ const beginTransition = ({
41133
41121
  break;
41134
41122
  }
41135
41123
  };
41136
- const viewTransition = startViewTransition$1(async () => {
41124
+ const viewTransition = startViewTransition(async () => {
41137
41125
  // The picture the browser is about to take must be of the page that was
41138
41126
  // asked for, and a route matching is not yet a page rendered. Whatever
41139
41127
  // is awaited here must be able to resolve without a frame: the document
@@ -41213,7 +41201,7 @@ const beginTransition = ({
41213
41201
  }
41214
41202
  };
41215
41203
  transition.viewTransition = viewTransition;
41216
- viewTransition.ready.then(viewTransitionReady, ignoreSkipped$1);
41204
+ viewTransition.ready.then(viewTransitionReady, ignoreSkipped);
41217
41205
  viewTransition.finished.then(end, end);
41218
41206
  };
41219
41207
 
@@ -41321,7 +41309,7 @@ const turnRunningTransitionRound = (fromUrl, url) => {
41321
41309
  const absoluteUrl = url => url ? new URL(url, window.location.href).href : null;
41322
41310
 
41323
41311
  // A transition skipped by another one starting is an outcome, not a failure.
41324
- const ignoreSkipped$1 = () => {};
41312
+ const ignoreSkipped = () => {};
41325
41313
 
41326
41314
  // The names the browser captured, read off the pictures themselves: what was
41327
41315
  // asked for in CSS and what was taken are not the same question (see
@@ -41390,7 +41378,7 @@ const warnAboutNamesEscapingArea = (areaElement, capturedNames) => {
41390
41378
  continue;
41391
41379
  }
41392
41380
  const unclipped = NESTED_GROUPS_SUPPORTED ? "" : " This browser has no nested groups (view-transition-group: contain), so it is not even cut at the pages' edge.";
41393
- warnOnce("names-escaping-area", `"${viewTransitionName}" is a view-transition-name written inside the element marked ${TRANSITION_AREA_ATTRIBUTE}, and the page on the other side of the navigation has nothing of that name. A name lifts its element out of the pages' pictures, in every browser: with nothing to morph into, it stands where it was captured and fades on its own while the pages move.${unclipped} Give that name only for the length of the gesture it serves, or drop it while the pages move: :root:is([${TRANSITION_ATTRIBUTE}], [${ROUTE_TRAVEL_ATTRIBUTE}]) <that element> { view-transition-name: none } (with !important over an inline name).`);
41381
+ warnOnce("names-escaping-area", `"${viewTransitionName}" is a view-transition-name written inside the element marked ${TRANSITION_AREA_ATTRIBUTE}, and the page on the other side of the navigation has nothing of that name. A name lifts its element out of the pages' pictures, in every browser: with nothing to morph into, it stands where it was captured and fades on its own while the pages move.${unclipped} Give that name only for the length of the gesture it serves, or drop it while the pages move: :root[${TRANSITION_ATTRIBUTE}] <that element> { view-transition-name: none } (with !important over an inline name).`);
41394
41382
  return;
41395
41383
  }
41396
41384
  };
@@ -41411,7 +41399,7 @@ const warnOnce = (id, message) => {
41411
41399
  // answer it is its own business, and the render is the moment the picture can
41412
41400
  // be taken. Listening starts before the change, or a render landing while the
41413
41401
  // change settles is a render nobody heard.
41414
- const armRouteRenderWait$1 = () => {
41402
+ const armRouteRenderWait = () => {
41415
41403
  let stopListening;
41416
41404
  const rendered = new Promise(resolve => {
41417
41405
  stopListening = observeRouteRender(resolve);
@@ -41451,7 +41439,7 @@ const samePage$1 = (a, b) => {
41451
41439
  }
41452
41440
  return a.route === b.route && compareTwoJsValues(a.params, b.params);
41453
41441
  };
41454
- const pageIndexOf$1 = (pages, page) => pages.findIndex(candidate => samePage$1(candidate, page));
41442
+ const pageIndexOf = (pages, page) => pages.findIndex(candidate => samePage$1(candidate, page));
41455
41443
 
41456
41444
  // The FIRST page that answers, and every page read all the same: a page that
41457
41445
  // is not the current one today is the one that must wake the reader tomorrow.
@@ -41534,1454 +41522,720 @@ const installRouteTransitionCss = () => {
41534
41522
  installTransitionFurnitureCss();
41535
41523
  };
41536
41524
 
41537
- installImportMetaCssBuild(import.meta);
41538
- // The pictures carrying a travel, among everything else the movement takes
41539
- // along (see findLeadAnimation).
41540
- const TRAVEL_LEAD_NAMES = ["navi-route-travel"];
41541
-
41542
- // A browser with no view transitions of its own has no picture of the page
41543
- // being left, so there is nothing to drag: the gesture is still read (it is how
41544
- // one asks to change tab with a thumb), and the change happens on release, as a
41545
- // press on the tab would have done.
41546
- const CAN_KEEP_PICTURE = Boolean(document.startViewTransition && !document.startViewTransition.isPolyfill);
41547
- const startViewTransition = ensureDocumentStartViewTransition();
41548
-
41549
- // route_transition.js reads this attribute by name (not imported, see its
41550
- // ROUTE_TRAVEL_ATTRIBUTE) to refuse animating a navigation a travel already
41551
- // animates — keep the two in sync.
41552
- const TRAVEL_ATTRIBUTE = "data-navi-route-travel";
41553
- // Which way the pages move, said on the document: the pictures of a transition
41554
- // hang off the root, not off the box that travels, so the box's own `axis` has
41555
- // to be lent to the document for the length of the travel.
41556
- const TRAVEL_AXIS_ATTRIBUTE = "data-navi-route-travel-axis";
41557
- // While a finger holds the travel: the pictures stand still and go exactly
41558
- // where it says (see the CSS, and scrubTravel).
41559
- const HOLD_ATTRIBUTE = "data-navi-route-travel-held";
41560
- // A travel a finger set off, for its whole life — including what plays out
41561
- // after the finger is gone. It moves by another law than one asked for by a
41562
- // press (see the CSS).
41563
- const DRAGGED_ATTRIBUTE = "data-navi-route-travel-dragged";
41564
- // A travel that changed its mind about where it was going (see
41565
- // redirectTravel). Only the pages can be aimed somewhere else: everything the
41566
- // transition carries was measured once, at the start, against a destination
41567
- // this travel is no longer going to.
41568
- const TURNED_ATTRIBUTE = "data-navi-route-travel-turned";
41569
- // The name the box wears while it travels, and only then (see nameForTravel).
41570
- const TRAVEL_NAME = "navi-route-travel";
41571
- const css$16 = /* css */`.navi_route_travel {
41572
- position: relative;
41525
+ /**
41526
+ * Where a row of routes scrolled by the browser stands, for what is drawn about
41527
+ * it elsewhere: the bar under the tabs, in a <Nav> that sits outside the box and
41528
+ * knows nothing of it. A tab says which page it is through its address, so a
41529
+ * nav follows any row it holds tabs for, with nothing to wire.
41530
+ *
41531
+ * Said per scroll and not through a signal: a number written on one element is
41532
+ * all it takes, and a signal would make that a render.
41533
+ */
41534
+ const followers = new Set();
41573
41535
 
41574
- &[data-travel-by-drag="x"] {
41575
- touch-action: pan-y;
41576
- }
41536
+ const followRouteRows = (onMove) => {
41537
+ followers.add(onMove);
41538
+ return () => {
41539
+ followers.delete(onMove);
41540
+ };
41541
+ };
41577
41542
 
41578
- &[data-travel-by-drag="y"] {
41579
- touch-action: pan-x;
41543
+ /**
41544
+ * @param {{basePath: string, towardPath: string|null, progress: number}|null} move
41545
+ * - the two pages the row stands between, by the pathname of their address,
41546
+ * and how far past the first in the terms of --slide-travel-progress. `null`
41547
+ * once it has stopped.
41548
+ */
41549
+ const publishRouteRowMove = (move) => {
41550
+ for (const follower of followers) {
41551
+ follower(move);
41580
41552
  }
41581
- }
41582
-
41583
- :root[data-navi-route-travel] {
41584
- view-transition-name: none;
41553
+ };
41585
41554
 
41586
- &::view-transition, &::view-transition-group(*), &::view-transition-image-pair(*), &::view-transition-old(*), &::view-transition-new(*) {
41587
- pointer-events: none;
41588
- }
41555
+ /**
41556
+ * A row of pages the browser scrolls itself, and what is left to read from it.
41557
+ *
41558
+ * The hand is not interpreted here: the browser follows it, on its compositor,
41559
+ * and it alone knows what the page cannot be told (a notch from a trackpad, the
41560
+ * end of a stroke from its momentum, which scroller a stream is latched on). What
41561
+ * remains is reading where the row stands and when it has come to rest.
41562
+ *
41563
+ * Rows are measured off their pages rather than by rank and width: a row that
41564
+ * ends early because a page is held leaves what lies beyond out (`data-off-row`),
41565
+ * and ranks would then lie about where the others are. An `offsetLeft` is rounded,
41566
+ * but by half a pixel at most whatever the rank, which a rank multiplied by a
41567
+ * rounded width is not. It also ignores a transform: a row inside a popup still
41568
+ * growing into place measures as it will stand.
41569
+ *
41570
+ * Each scroller must be the offsetParent of its pages.
41571
+ */
41589
41572
 
41590
- &::view-transition-old(navi-route-travel), &::view-transition-new(navi-route-travel) {
41591
- object-fit: none;
41592
- object-position: top left;
41593
- mix-blend-mode: normal;
41594
- height: auto;
41595
- }
41573
+ // What the rest of a scroll is worth while a page sits a rounding away from it.
41574
+ const AT_REST_DISTANCE = 1;
41575
+ // How long without a scroll event means the row stopped. The browser's word is
41576
+ // not enough: Firefox says `scrollend` once, when the wheel goes quiet, some 200ms
41577
+ // before its snap has finished and never again, and Safari before 26.2 does not
41578
+ // say it at all.
41579
+ const STOPPED_SILENCE = 150;
41596
41580
 
41597
- &::view-transition-old(navi-route-travel) {
41598
- top: calc(var(--navi-transition-window-old-top) - var(--navi-transition-window-top));
41599
- left: calc(var(--navi-transition-window-old-left) - var(--navi-transition-window-left));
41600
- width: auto;
41601
- }
41581
+ const isOnRow = (page) =>
41582
+ page.offsetWidth > 0 && !page.hasAttribute("data-off-row");
41602
41583
 
41603
- &::view-transition-new(navi-route-travel) {
41604
- top: calc(var(--navi-transition-window-new-top) - var(--navi-transition-window-top));
41605
- left: calc(var(--navi-transition-window-new-left) - var(--navi-transition-window-left));
41606
- width: auto;
41607
- }
41584
+ const restsOn = (scroller, page) =>
41585
+ Math.abs(page.offsetLeft - scroller.scrollLeft) <= AT_REST_DISTANCE &&
41586
+ Math.abs(page.offsetTop - scroller.scrollTop) <= AT_REST_DISTANCE;
41608
41587
 
41609
- &::view-transition-group(navi-route-travel), &::view-transition-image-pair(navi-route-travel) {
41610
- overflow: clip;
41611
- }
41588
+ /**
41589
+ * @returns {Element|undefined} the page the row rests on, nothing while it stands
41590
+ * between two: a hand can pause midway, and an engine may call that the end of
41591
+ * a scroll.
41592
+ */
41593
+ const pageAtRest = (scroller, pages) =>
41594
+ pages.find((page) => isOnRow(page) && restsOn(scroller, page));
41612
41595
 
41613
- &::view-transition-group(navi-route-travel) {
41614
- top: calc(var(--navi-transition-window-top) - var(--navi-transition-window-new-top));
41615
- left: calc(var(--navi-transition-window-left) - var(--navi-transition-window-new-left));
41616
- width: var(--navi-transition-window-width);
41617
- height: var(--navi-transition-window-height);
41618
- --navi-route-travel-clip-top: max(0px,
41619
- min(var(--navi-safe-area-inset-top) + var(--navi-transition-cover-top),
41620
- var(--navi-transition-old-band-top)) - var(--navi-transition-window-top));
41621
- --navi-route-travel-clip-left: max(0px,
41622
- min(var(--navi-safe-area-inset-left) + var(--navi-transition-cover-left),
41623
- var(--navi-transition-old-band-left)) - var(--navi-transition-window-left));
41624
- --navi-route-travel-clip-bottom: max(0px,
41625
- var(--navi-transition-window-top) +
41626
- var(--navi-transition-window-height) +
41627
- min(var(--navi-safe-area-inset-bottom) +
41628
- var(--navi-transition-cover-bottom),
41629
- var(--navi-transition-old-band-bottom)) -
41630
- 100dvh);
41631
- --navi-route-travel-clip-right: max(0px,
41632
- var(--navi-transition-window-left) +
41633
- var(--navi-transition-window-width) +
41634
- min(var(--navi-safe-area-inset-right) +
41635
- var(--navi-transition-cover-right),
41636
- var(--navi-transition-old-band-right)) -
41637
- 100dvw);
41638
- clip-path: inset(var(--navi-route-travel-clip-top) var(--navi-route-travel-clip-right)
41639
- var(--navi-route-travel-clip-bottom)
41640
- var(--navi-route-travel-clip-left));
41641
- animation-duration: var(--navi-route-travel-duration, .3s);
41642
- animation-name: none;
41596
+ /**
41597
+ * Where the row stands between two neighbouring pages, on the side it has moved
41598
+ * to from `page`: a hand is not limited to the next one, so what it has passed
41599
+ * since is walked over. A map moves along one axis at a time, the one it has
41600
+ * moved furthest on.
41601
+ *
41602
+ * @returns {{base: Element, toward: Element|undefined, fraction: number, forward: boolean}}
41603
+ * the last page the row has passed (`page` itself until then), the one after,
41604
+ * and how far between the two from 0 to 1. Nothing `toward` past the last.
41605
+ */
41606
+ const readRowStretch = (scroller, pages, page) => {
41607
+ const alongX =
41608
+ Math.abs(scroller.scrollLeft - page.offsetLeft) >=
41609
+ Math.abs(scroller.scrollTop - page.offsetTop);
41610
+ const position = alongX ? "offsetLeft" : "offsetTop";
41611
+ const cross = alongX ? "offsetTop" : "offsetLeft";
41612
+ const scrolled = alongX ? scroller.scrollLeft : scroller.scrollTop;
41613
+ const forward = scrolled > page[position];
41614
+ const step = forward ? 1 : -1;
41615
+ const line = pages
41616
+ .filter(
41617
+ (candidate) => isOnRow(candidate) && candidate[cross] === page[cross],
41618
+ )
41619
+ .sort((pageA, pageB) => pageA[position] - pageB[position]);
41620
+ let index = line.indexOf(page);
41621
+ let base = page;
41622
+ let toward = line[index + step];
41623
+ while (
41624
+ toward &&
41625
+ (forward ? toward[position] <= scrolled : toward[position] >= scrolled)
41626
+ ) {
41627
+ index += step;
41628
+ base = toward;
41629
+ toward = line[index + step];
41643
41630
  }
41631
+ const span = toward ? Math.abs(toward[position] - base[position]) : 0;
41632
+ const fraction = span ? Math.abs(scrolled - base[position]) / span : 0;
41633
+ return { base, toward, fraction, forward };
41634
+ };
41644
41635
 
41645
- &[data-navi-transition-window-placed]::view-transition-group(navi-route-travel) {
41646
- top: calc(var(--navi-transition-window-top) + var(--navi-transition-viewport-top));
41647
- left: calc(var(--navi-transition-window-left) +
41648
- var(--navi-transition-viewport-left));
41649
- transform: none;
41650
- }
41651
- }
41636
+ /**
41637
+ * @param {Element} scroller
41638
+ * @param {object} options
41639
+ * @param {() => void} options.onScroll - the row moved.
41640
+ * @param {(event: Event|null) => void} options.onLand - the row may have stopped.
41641
+ * Read `pageAtRest` to know whether it stopped on a page. Heard more than once
41642
+ * for one landing, so what it does must find nothing to do the second time.
41643
+ * @returns {() => void} stop listening.
41644
+ */
41645
+ const watchRowScroll = (scroller, { onScroll, onLand }) => {
41646
+ let stoppedTimeout = null;
41647
+ let touching = false;
41648
+ const landOnSilence = () => {
41649
+ // A finger holding the row still: nothing has stopped.
41650
+ if (!touching) {
41651
+ onLand(null);
41652
+ }
41653
+ };
41654
+ const armSilence = () => {
41655
+ clearTimeout(stoppedTimeout);
41656
+ stoppedTimeout = setTimeout(landOnSilence, STOPPED_SILENCE);
41657
+ };
41658
+ const onScrollEvent = () => {
41659
+ onScroll();
41660
+ armSilence();
41661
+ };
41662
+ const onTouchStart = () => {
41663
+ touching = true;
41664
+ clearTimeout(stoppedTimeout);
41665
+ };
41666
+ const onTouchEnd = (touchEvent) => {
41667
+ touching = touchEvent.touches.length > 0;
41668
+ if (!touching) {
41669
+ armSilence();
41670
+ }
41671
+ };
41672
+ scroller.addEventListener("scroll", onScrollEvent, { passive: true });
41673
+ scroller.addEventListener("scrollend", onLand);
41674
+ scroller.addEventListener("touchstart", onTouchStart, { passive: true });
41675
+ scroller.addEventListener("touchend", onTouchEnd, { passive: true });
41676
+ scroller.addEventListener("touchcancel", onTouchEnd, { passive: true });
41677
+ return () => {
41678
+ clearTimeout(stoppedTimeout);
41679
+ scroller.removeEventListener("scroll", onScrollEvent);
41680
+ scroller.removeEventListener("scrollend", onLand);
41681
+ scroller.removeEventListener("touchstart", onTouchStart);
41682
+ scroller.removeEventListener("touchend", onTouchEnd);
41683
+ scroller.removeEventListener("touchcancel", onTouchEnd);
41684
+ };
41685
+ };
41652
41686
 
41653
- :root[data-navi-route-travel-held] {
41654
- &::view-transition-group(*), &::view-transition-old(*), &::view-transition-new(*) {
41655
- animation-play-state: paused;
41656
- }
41687
+ installImportMetaCssBuild(import.meta);
41688
+ const css$16 = /* css */`.navi_route_travel {
41689
+ position: relative;
41657
41690
  }
41658
41691
 
41659
- :root[data-navi-route-travel] {
41660
- &::view-transition-old(navi-route-travel), &::view-transition-new(navi-route-travel) {
41661
- animation-duration: var(--navi-route-travel-duration, .3s);
41662
- animation-timing-function: ease;
41663
- animation-fill-mode: both;
41664
- }
41665
- }
41692
+ [data-route-travel-row] {
41693
+ overflow-anchor: none;
41694
+ overscroll-behavior-x: contain;
41695
+ scroll-snap-type: x mandatory;
41696
+ scrollbar-width: none;
41697
+ flex: auto;
41698
+ grid-template-rows: minmax(0, 1fr);
41699
+ grid-auto-columns: 100%;
41700
+ grid-auto-flow: column;
41701
+ min-width: 0;
41702
+ min-height: 0;
41703
+ display: grid;
41704
+ position: relative;
41705
+ overflow: auto hidden;
41666
41706
 
41667
- :root[data-navi-route-travel-turned] {
41668
- &::view-transition-group(*) {
41707
+ &::-webkit-scrollbar {
41669
41708
  display: none;
41670
41709
  }
41671
41710
 
41672
- &::view-transition-group(navi-route-travel) {
41673
- display: block;
41674
- }
41675
- }
41676
-
41677
- :root[data-navi-route-travel-dragged] {
41678
- &::view-transition-group(navi-route-travel), &::view-transition-old(navi-route-travel), &::view-transition-new(navi-route-travel) {
41679
- animation-timing-function: linear;
41680
- }
41681
- }
41682
-
41683
- :root[data-navi-route-travel="forward"] {
41684
- &::view-transition-old(navi-route-travel) {
41685
- animation-name: navi-route-travel-leave-towards-start;
41686
- }
41687
-
41688
- &::view-transition-new(navi-route-travel) {
41689
- animation-name: navi-route-travel-enter-from-end;
41690
- }
41691
- }
41692
-
41693
- :root[data-navi-route-travel="back"] {
41694
- &::view-transition-old(navi-route-travel) {
41695
- animation-name: navi-route-travel-leave-towards-end;
41696
- }
41697
-
41698
- &::view-transition-new(navi-route-travel) {
41699
- animation-name: navi-route-travel-enter-from-start;
41700
- }
41701
- }
41702
-
41703
- :root[data-navi-route-travel-axis="y"] {
41704
- &[data-navi-route-travel="forward"] {
41705
- &::view-transition-old(navi-route-travel) {
41706
- animation-name: navi-route-travel-leave-towards-top;
41707
- }
41708
-
41709
- &::view-transition-new(navi-route-travel) {
41710
- animation-name: navi-route-travel-enter-from-bottom;
41711
- }
41712
- }
41713
-
41714
- &[data-navi-route-travel="back"] {
41715
- &::view-transition-old(navi-route-travel) {
41716
- animation-name: navi-route-travel-leave-towards-bottom;
41717
- }
41718
-
41719
- &::view-transition-new(navi-route-travel) {
41720
- animation-name: navi-route-travel-enter-from-top;
41721
- }
41722
- }
41723
- }
41724
-
41725
- @keyframes navi-route-travel-leave-towards-start {
41726
- from {
41727
- translate: 0;
41728
- }
41729
-
41730
- to {
41731
- translate: -100%;
41732
- }
41733
- }
41734
-
41735
- @keyframes navi-route-travel-enter-from-end {
41736
- from {
41737
- translate: 100%;
41738
- }
41739
-
41740
- to {
41741
- translate: 0;
41742
- }
41743
- }
41744
-
41745
- @keyframes navi-route-travel-leave-towards-end {
41746
- from {
41747
- translate: 0;
41748
- }
41749
-
41750
- to {
41751
- translate: 100%;
41752
- }
41753
- }
41754
-
41755
- @keyframes navi-route-travel-enter-from-start {
41756
- from {
41757
- translate: -100%;
41758
- }
41759
-
41760
- to {
41761
- translate: 0;
41711
+ &[data-route-travel-row="code"] {
41712
+ overflow-x: hidden;
41762
41713
  }
41763
- }
41764
41714
 
41765
- @keyframes navi-route-travel-leave-towards-top {
41766
- from {
41767
- translate: 0;
41768
- }
41769
-
41770
- to {
41771
- translate: 0 -100%;
41715
+ &[data-route-travel-row="still"] {
41716
+ overflow: visible;
41772
41717
  }
41773
- }
41774
41718
 
41775
- @keyframes navi-route-travel-enter-from-bottom {
41776
- from {
41777
- translate: 0 100%;
41719
+ & > [data-route-row-pane] {
41720
+ scroll-snap-align: start;
41721
+ scroll-snap-stop: always;
41722
+ flex-direction: column;
41723
+ grid-row: 1;
41724
+ min-width: 0;
41725
+ min-height: 0;
41726
+ display: flex;
41778
41727
  }
41779
41728
 
41780
- to {
41781
- translate: 0;
41729
+ &:not([data-moving]) > [data-route-row-pane][inert] {
41730
+ visibility: hidden;
41782
41731
  }
41783
- }
41784
41732
 
41785
- @keyframes navi-route-travel-leave-towards-bottom {
41786
- from {
41787
- translate: 0;
41733
+ & > [data-route-row-pane][inert] {
41734
+ top: var(--navi-route-row-pin, 0);
41735
+ max-height: 100dvh;
41736
+ position: relative;
41737
+ overflow: clip;
41788
41738
  }
41789
41739
 
41790
- to {
41791
- translate: 0 100%;
41740
+ & > [data-route-row-live] {
41741
+ flex-direction: column;
41742
+ grid-row: 1;
41743
+ min-width: 0;
41744
+ min-height: 0;
41745
+ display: flex;
41746
+ position: relative;
41792
41747
  }
41793
- }
41794
41748
 
41795
- @keyframes navi-route-travel-enter-from-top {
41796
- from {
41797
- translate: 0 -100%;
41749
+ & > [data-route-row-pictures] {
41750
+ display: contents;
41798
41751
  }
41799
41752
 
41800
- to {
41801
- translate: 0;
41753
+ & > [data-route-row-pictures] > * {
41754
+ contain: layout paint;
41755
+ pointer-events: none;
41756
+ grid-row: 1;
41757
+ min-width: 0;
41802
41758
  }
41803
41759
  }
41804
41760
  `;
41805
41761
 
41762
+ // How far a hand must have pulled before the address moves for a page that is not
41763
+ // there yet: past what a hand that only shook covers, so that a shake costs
41764
+ // neither a navigation nor the requests of a page.
41765
+ const SET_OFF_PULL = 16;
41766
+
41806
41767
  /**
41807
- * The page a reader is still on while a hand travels: `{ route, params }`, the
41808
- * page the travel set off from, or `null` when the URL is the truth.
41809
- *
41810
- * A travel changes the URL before it arrives, so what reads the URL reads where
41811
- * the hand is heading. A counter, a title or a breadcrumb around the box must
41812
- * not move for a pull that is let go of: it reads this first. It holds for as
41813
- * long as the pictures move, whichever way they end up going, and is back to
41814
- * `null` when they stop, which is when `scrollsnapchange` fires and a pager's
41815
- * `settledPage` changes. A page walked whole under the hand has arrived too. A
41816
- * travel no hand set off (a tab pressed, a wheel) holds nothing: it goes where
41817
- * it goes.
41768
+ * What the URL says while a hand holds the row is where it is heading, for a page
41769
+ * not built yet: it builds by the address moving. A counter, a title or a
41770
+ * breadcrumb around the box must not move for a pull that is let go of: it reads
41771
+ * this first. The page the reader is still on while that lasts, and `null` when
41772
+ * the row has come to rest, which is when `scrollsnapchange` fires and a pager's
41773
+ * `settledPage` changes. A travel no hand set off holds nothing: it goes where it
41774
+ * goes.
41818
41775
  *
41819
- * One for the document, as there is one view transition at a time. `route` says
41820
- * which row, for a page holding several boxes.
41776
+ * One for the document. `route` says which row, for a page holding several boxes.
41821
41777
  */
41822
41778
  const routeTravelSettledPageSignal = signal(null);
41823
- // Which travel wrote it, the only one that may clear it: a travel ending after
41824
- // another has taken over leaves that one's answer alone.
41825
- let travelHoldingSettledPage = null;
41826
41779
 
41827
41780
  /**
41828
41781
  * @type {import("preact").FunctionComponent<{
41829
41782
  * routes?: Array<object|{route: object, params?: object}>,
41830
- * axis?: "x"|"y",
41831
41783
  * travelByDrag?: boolean,
41832
41784
  * onTravel?: (detail: {route: object, params: object|undefined, cause: string}) => void|Promise<void>,
41833
41785
  * }>}
41834
- * @param {Array<object|{route: object, params?: object}>} [props.routes] - the
41835
- * tabs, in the order they are shown. Read from the <Route> children by
41836
- * default, in the order they are written: the router already holds that list,
41837
- * and asking a caller to write it twice is asking for the two to disagree.
41838
- * Pass it to say another order, when the pages are not children of this box,
41839
- * or to name a tab the children cannot — the section a <Route fallback> shows
41840
- * is a tab like the others, and only its params say which one.
41841
- *
41842
- * An entry is a route, or `{ route, params }` when the tabs of the row are a
41843
- * PARAM of one route rather than routes of their own (the form
41844
- * `<Route routeParams>` selects a branch on, and the form that lets a link
41845
- * with no params reopen the section one was looking at). Written as bare
41846
- * routes, three tabs of one route are the same object three times: there is
41847
- * then one tab, and nowhere to travel.
41848
- * @param {"x"|"y"} [props.axis="x"] - which way the pages are laid out.
41849
- * @param {boolean} [props.travelByDrag=true] - whether a pointer dragging the
41850
- * page travels. Off where the gesture belongs to the content.
41851
- * @param {(detail: {route: object, params: object|undefined, cause: "drag"|"wheel"|"revert"}) => void|Promise<void>} [props.onTravel]
41852
- * - how to go to a tab. The default REPLACES the current history entry
41853
- * rather than pushing one: a swipe is how one browses a page, not a place one
41854
- * aimed at, and three swipes back and forth must not bury the way out of the
41855
- * page under six entries. A tab pressed goes the way its <Link> says —
41856
- * `<Link replace>` for a row of tabs, so a press and a swipe towards the
41857
- * same neighbour write the same history.
41858
- *
41859
- * Every other prop lands on the box itself (`id`, `data-testid`, `className`,
41860
- * …), which is how one of these is named: a page can hold several — a row of
41861
- * sections inside the box the whole application travels in — and the class they
41862
- * share plus their axis are not enough to tell them apart from the outside.
41863
- *
41864
- * The pages are cut at the edge of this box while they travel, and at the app's
41865
- * safe area the box runs under, which is written on the transition's own
41866
- * pseudo-elements — no overflow of the document reaches pictures drawn in the
41867
- * top layer. It needs nothing of the browser beyond view transitions
41868
- * themselves: a browser without them navigates without the movement, and the
41869
- * gesture applies its change on release instead of dragging a picture that does
41870
- * not exist.
41871
- *
41872
- * While a travel plays, the rest of the page is NOT taken as a picture: the
41873
- * root's name is dropped for that time (`view-transition-name: none`), so what
41874
- * stands around the box — a tab row beside it — stays live and answers a press
41875
- * mid-travel. The application's own rule for the root applies again the moment
41876
- * the travel is over. What stands out of a `FixedBar` is the exception, when it
41877
- * asks to stay over the pages (see `data-navi-fixed-bar-overhang` there).
41786
+ * @param {Array<object|{route: object, params?: object}>} [props.routes] - a window
41787
+ * over something longer, in order. Read from the <Route> children by default,
41788
+ * in the order they are written. Given here, the children are one page rendered
41789
+ * once, whatever the address, and may be anything: use it when the pages are one
41790
+ * route with other params, or not children of this box. An entry is a route, or
41791
+ * `{ route, params }`.
41792
+ * @param {boolean} [props.travelByDrag=true] - whether a hand travels. Off where the
41793
+ * gesture belongs to the content; a tab pressed still does.
41794
+ * @param {(detail: {route: object, params: object|undefined, cause: "drag"}) => void|Promise<void>} [props.onTravel]
41795
+ * - how to go to a page a hand has brought in. The default REPLACES the current
41796
+ * history entry rather than pushing one: a swipe is how one browses a page, not a
41797
+ * place one aimed at, and three swipes back and forth must not bury the way out
41798
+ * of the page under six entries. A tab pressed goes the way its <Link> says —
41799
+ * `<Link replace>` for a row of tabs, so a press and a swipe towards the same
41800
+ * neighbour write the same history.
41801
+ *
41802
+ * Every other prop lands on the box itself (`id`, `data-testid`, `className`, …).
41803
+ * The box has to outlive every page of its row: around those <Route>s, or around
41804
+ * one <Route> whose params are the tabs, never inside one of the pages.
41878
41805
  */
41879
41806
  const RouteTravel = ({
41880
41807
  routes: routesProp,
41881
- axis = "x",
41882
41808
  travelByDrag = true,
41883
41809
  onTravel = ({
41884
41810
  route,
41885
41811
  params
41886
- }) => route.redirectTo(params),
41812
+ }) => route.navTo(params, {
41813
+ replace: true,
41814
+ routeTransition: "none"
41815
+ }),
41887
41816
  className,
41888
41817
  children,
41889
41818
  ...rest
41890
41819
  }) => {
41891
41820
  import.meta.css = [css$16, "@jsenv/navi/src/nav/route_travel.jsx"];
41892
- installTransitionWindowCss();
41893
- const elementRef = useRef();
41894
- const gestureRef = useRef(null);
41895
- // The travel in hand: the transition keeping the picture of the page being
41896
- // left, the animations the finger drives, and what to do with them once the
41897
- // browser has them ready. Null when no page is on its way anywhere.
41898
- const travelRef = useRef(null);
41899
- // The page this box has ASKED for and is still waiting to see arrive.
41900
- // Routing is asynchronous: a travel's own navigation lands well after the
41901
- // travel decided anything about it — sometimes after the travel was undone —
41902
- // and read back as "the page changed" it would start a second travel nobody
41903
- // asked for, over pictures that are already showing something else.
41904
- const pageAskedForRef = useRef(null);
41905
- // The latest way to answer a gesture, for a watcher that outlives every
41906
- // render (see the wheel effect below).
41907
- const travelHandlersRef = useRef(null);
41908
- const pointerDownRef = useRef(null);
41821
+ const rowRef = useRef();
41822
+ const liveRef = useRef();
41823
+ const picturesRef = useRef();
41824
+ const windowed = Boolean(routesProp);
41825
+ const [, render] = useReducer(count => count + 1, 0);
41909
41826
  const pagesFromChildren = useMemo(() => collectRoutePages(children), [children]);
41910
41827
  const pagesFromProp = useMemo(() => routesProp && routesProp.map(normalizePage), [routesProp]);
41911
- const pages = pagesFromProp || pagesFromChildren;
41912
-
41913
- // Which page is on screen, read from the pages themselves: every one of them
41914
- // is read, so this re-renders when any of them starts or stops matching — and
41915
- // for a row whose tabs are params of one route, when the params move from one
41916
- // tab to the next (see pageIsCurrent).
41828
+ // Held while the row moves: a window recentred under a hand would move the very
41829
+ // panes it is pushing. It is taken as soon as the row has stopped.
41830
+ const pagesAsked = pagesFromProp || pagesFromChildren;
41831
+ const movementRef = useRef(null);
41832
+ const pagesRef = useRef(pagesAsked);
41833
+ if (!movementRef.current) {
41834
+ pagesRef.current = pagesAsked;
41835
+ }
41836
+ const pages = pagesRef.current;
41917
41837
  const currentIndex = currentPageIndex(pages);
41918
- // The page that was on screen when the change now happening was asked for:
41919
- // a travel is between two of them, and by the time anything renders the first
41920
- // one is already gone. Written after each render (below), so a subscriber
41921
- // reading it — they all run before Preact flushes — reads the one being left.
41922
- // The page and not its place in the row: a row given again around the page
41923
- // on screen (a window over a list, read anew around it) puts that page at
41924
- // another index without anything having moved.
41925
- const currentPage = currentIndex === -1 ? null : pages[currentIndex];
41926
- const currentPageRef = useRef(currentPage);
41927
-
41928
- // Where a page is asked for, whoever asks: a page is a route AND the params
41929
- // that say which of its tabs, and a caller told only the route would send the
41930
- // row back to whichever tab the URL already says (see redirectTo).
41931
- const travelTo = (page, cause) => onTravel({
41932
- route: page.route,
41933
- params: page.params,
41934
- cause
41935
- });
41936
-
41937
- // One travel, whoever asked for it: a finger, a tab pressed, the browser's
41938
- // own back button. What differs is only who moves it — the finger drives it
41939
- // frame by frame (`scrub`), everything else lets it play.
41940
- const beginTravel = ({
41941
- page,
41942
- fromPage,
41943
- direction,
41944
- scrub,
41945
- change
41946
- }) => {
41947
- const travel = {
41948
- page,
41949
- // The page this set off from, kept rather than looked up again: the URL
41950
- // changes as it sets off, so a moment later nothing on screen remembers
41951
- // where it started.
41952
- fromPage,
41953
- direction,
41954
- scrub,
41955
- ratio: 0,
41956
- // The animations of the pictures, once the browser has them. Held still
41957
- // by CSS until then, so an empty hand here costs nothing: there is
41958
- // nothing to stop, only nowhere to put them yet.
41959
- animations: null,
41960
- ended: false,
41961
- // The box as it set off, kept rather than read from the ref: the
41962
- // navigation a travel asks for can take the box down (see the update
41963
- // callback), and the name it wears is still given back to it.
41964
- element: elementRef.current
41838
+ // Where the page of a window stands: the address, or where it last was for an
41839
+ // address the row has no place for.
41840
+ const liveIndexRef = useRef(0);
41841
+ if (currentIndex !== -1) {
41842
+ liveIndexRef.current = currentIndex;
41843
+ }
41844
+ const liveIndex = liveIndexRef.current;
41845
+ const latestRef = useRef(null);
41846
+ latestRef.current = {
41847
+ pages,
41848
+ currentIndex,
41849
+ liveIndex,
41850
+ onTravel,
41851
+ travelByDrag
41852
+ };
41853
+
41854
+ // The page this box asked for: routing lands later, and read back as somebody
41855
+ // changing the address it would send the row where the hand already is.
41856
+ const askedPageRef = useRef(null);
41857
+ const panesOf = row => Array.from(row.children).filter(child => child.hasAttribute("data-route-row-pane"));
41858
+ const pathOf = ({
41859
+ route,
41860
+ params
41861
+ }) => new URL(route.buildUrl(params), window.location.href).pathname;
41862
+
41863
+ // One movement, whoever set it off, from the first thing that moves to the row
41864
+ // at rest. Null when nothing moves.
41865
+ const beginMovement = fields => {
41866
+ const row = rowRef.current;
41867
+ if (movementRef.current) {
41868
+ Object.assign(movementRef.current, fields);
41869
+ return movementRef.current;
41870
+ }
41871
+ row.setAttribute("data-moving", "");
41872
+ const movement = {
41873
+ travelling: false,
41874
+ pictures: new Map(),
41875
+ resumeScrollRecording: null,
41876
+ scrollsDocument: getScrollContainer(row.parentElement, {
41877
+ includeHidden: true
41878
+ }) === document.documentElement,
41879
+ ...fields
41965
41880
  };
41966
- travelRef.current = travel;
41967
- travelHoldingSettledPage = travel;
41968
- routeTravelSettledPageSignal.value = scrub ? fromPage : null;
41969
- // A press landing on a playing travel lands on the document root: the box
41970
- // is captured by the transition and cannot be pointed at, so neither its
41971
- // touch-action nor its own touchmove listener is on the touch's path. Left
41972
- // there, the browser takes the finger's first vertical pixels for a scroll
41973
- // and cancels the pointer under a travel that was just caught. Refusable
41974
- // from the root for the length of the travel, the gesture that catches it
41975
- // refuses every touchmove of that press (it is started from the grab), and
41976
- // the finger's only answer is the travel it landed on.
41977
- document.documentElement.addEventListener("touchmove", keepTouchRefusable, {
41978
- passive: false
41881
+ movementRef.current = movement;
41882
+ if (movement.scrollsDocument) {
41883
+ // Whatever the browser does with the offset meanwhile (clamping it to a page
41884
+ // shorter than the trip) is not the reader scrolling, and the address it
41885
+ // would be written against may already be another page's.
41886
+ movement.resumeScrollRecording = suspendScrollRecording();
41887
+ row.style.setProperty("--navi-route-row-pin", `${pinOf(row)}px`);
41888
+ }
41889
+ return movement;
41890
+ };
41891
+
41892
+ // A clone of the page in the pane it stands in, before anything changes it. In
41893
+ // that pane and the same height: the row keeps its size, so the document its
41894
+ // offset, and the page one looked at is where it was.
41895
+ const leavePictureOfLive = movement => {
41896
+ const live = liveRef.current;
41897
+ const index = latestRef.current.liveIndex;
41898
+ const picture = live.cloneNode(true);
41899
+ picture.removeAttribute("data-route-row-live");
41900
+ picture.setAttribute("inert", "");
41901
+ picture.setAttribute("aria-hidden", "true");
41902
+ picture.style.gridColumn = String(index + 1);
41903
+ picture.style.top = "";
41904
+ movement.pictures.get(index)?.remove();
41905
+ movement.pictures.set(index, picture);
41906
+ // After the live page in the document: an id it holds still finds the page.
41907
+ picturesRef.current.append(picture);
41908
+ copyScrollPositions(live, picture);
41909
+ if (movement.scrollsDocument) {
41910
+ live.style.top = `${pinOf(rowRef.current)}px`;
41911
+ }
41912
+ };
41913
+ const askFor = page => {
41914
+ const {
41915
+ onTravel
41916
+ } = latestRef.current;
41917
+ askedPageRef.current = page;
41918
+ notePageChange();
41919
+ return onTravel({
41920
+ route: page.route,
41921
+ params: page.params,
41922
+ cause: "drag"
41979
41923
  });
41980
- // Whether the document's offset is this row's business at all (see
41981
- // pagesScrollTheDocument), asked once and before anything is swapped.
41982
- travel.scrollsDocument = pagesScrollTheDocument(travel.element);
41983
- if (travel.scrollsDocument) {
41984
- // One of the tabs is about to be swapped out from under that scrollport,
41985
- // and whatever the browser does with the offset meanwhile — clamping it
41986
- // to a page shorter than the trip — is not the reader scrolling. The url
41987
- // it would be written against is already the arriving tab's, so recorded
41988
- // it is that tab's own position that the tab being left destroys. Deaf
41989
- // for the whole travel, which is exactly as long as the offset belongs
41990
- // to nobody (see endTravel).
41991
- travel.resumeScrollRecording = suspendScrollRecording();
41992
- }
41993
- // Taken before the picture is: the browser reads the name off the DOM as it
41994
- // stands when the transition starts, and this box is only a picture of its
41995
- // own for as long as it is the one travelling. Where it travels to is said
41996
- // at the same moment and for the same reason: whoever names something for
41997
- // a movement between two pages decides on it now (see
41998
- // transition_destination.js).
41999
- nameForTravel(travel.element);
42000
- holdTransitionDestination(travel, page.route.buildUrl(page.params));
42001
- // The bars stay live, except what one of them has photographed on its own
42002
- // (see fixed_bar.jsx), which is deaf like any picture and may have asked to
42003
- // keep answering.
42004
- holdTransitionPress(travel, travel.element);
42005
- document.documentElement.setAttribute(TRAVEL_ATTRIBUTE, direction);
42006
- document.documentElement.setAttribute(TRAVEL_AXIS_ATTRIBUTE, axis);
42007
- if (scrub) {
42008
- holdPictures(travel);
42009
- document.documentElement.setAttribute(DRAGGED_ATTRIBUTE, "");
42010
- }
42011
- pageAskedForRef.current = page;
42012
- // The box as it stands before anything moves, and the band the furniture
42013
- // around it leaves free: rendering is held, so both are still the page
42014
- // being left (see transition_window.js).
42015
- const stateBefore = measureTransitionWindowState(travel.element);
42016
- // The hold a navigation already took, if this travel is the answer to one:
42017
- // taking another would be taking a hold on a page that is holding still.
42018
- const releaseRendering = takeoverRoutingRenderingHold();
42019
- // The picture the browser is about to take must be of the page that was
42020
- // asked for, and a route matching is not yet a page rendered. Watched from
42021
- // here rather than from inside the callback below: the browser calls that
42022
- // callback a frame later, and a navigation that has already been decided
42023
- // (what follows a send, a command) renders its page in between. A wait
42024
- // armed then waits for something that has already happened — until the
42025
- // browser gives up on the transition, leaving the page it was leaving on
42026
- // screen and an error nobody asked for.
42027
- const renderWait = armRouteRenderWait();
42028
- const viewTransition = startViewTransition(async () => {
42029
- // Let go of too early before the browser got here, a frame or several on
42030
- // a slow phone. Skipping a transition does not cancel this callback, and
42031
- // the router asked from here would take the page anyway, with nobody left
42032
- // to bring it back.
42033
- if (travel.reverting) {
42034
- renderWait.stop();
42035
- releaseRendering();
42036
- return;
42037
- }
42038
- travel.calledBack = true;
42039
- // Whatever is awaited here must be able to resolve without the page being
42040
- // rendered: the document is frozen for the whole of this callback, and a
42041
- // frame never comes — waiting for one waits until the browser gives up on
42042
- // the transition. And it stays frozen exactly this long, so this is also
42043
- // the shortest thing there is to keep short.
42044
- await whilePageRenders(page, async () => {
42045
- releaseRendering();
42046
- if (change) {
42047
- await change();
42048
- }
42049
- }, renderWait);
42050
- // Where the arriving tab was left, or its top when it has never been
42051
- // read. A travel is an arrival — the tab pressed is another route, and
42052
- // the replace it navigates by is the only thing about it that says
42053
- // otherwise — and this is the one place that knows it. Read from the
42054
- // travel rather than from the page it set off for: a travel aimed
42055
- // somewhere else while it waited lands where it is aimed now.
42056
- if (travel.scrollsDocument) {
41924
+ };
41925
+ const sayRowMoves = () => {
41926
+ const row = rowRef.current;
41927
+ const panes = panesOf(row);
41928
+ if (panes.length < 2) {
41929
+ return;
41930
+ }
41931
+ const {
41932
+ pages
41933
+ } = latestRef.current;
41934
+ const {
41935
+ base,
41936
+ toward,
41937
+ fraction
41938
+ } = readRowStretch(row, panes, panes[0]);
41939
+ publishRouteRowMove({
41940
+ basePath: pathOf(pages[panes.indexOf(base)]),
41941
+ towardPath: toward ? pathOf(pages[panes.indexOf(toward)]) : null,
41942
+ progress: -fraction
41943
+ });
41944
+ };
41945
+
41946
+ // The row has stopped, on the page of the address: everything held for the
41947
+ // movement is let go of in one go, before anything paints.
41948
+ const endMovement = () => {
41949
+ const row = rowRef.current;
41950
+ const movement = movementRef.current;
41951
+ if (!movement) {
41952
+ return;
41953
+ }
41954
+ movementRef.current = null;
41955
+ row.removeAttribute("data-moving");
41956
+ publishRouteRowMove(null);
41957
+ routeTravelSettledPageSignal.value = null;
41958
+ if (liveRef.current) {
41959
+ liveRef.current.style.top = "";
41960
+ }
41961
+ for (const picture of movement.pictures.values()) {
41962
+ picture.remove();
41963
+ }
41964
+ if (movement.scrollsDocument) {
41965
+ row.style.removeProperty("--navi-route-row-pin");
41966
+ const {
41967
+ pages,
41968
+ currentIndex
41969
+ } = latestRef.current;
41970
+ if (currentIndex !== -1) {
42057
41971
  const {
42058
41972
  route,
42059
41973
  params
42060
- } = travel.page;
41974
+ } = pages[currentIndex];
42061
41975
  arriveAtScrollPosition(route.buildUrl(params));
42062
41976
  }
42063
- // The box itself may be gone: rendered inside one of the pages of its
42064
- // row rather than around them, it went down with the page being left,
42065
- // and there is no box left for the page arriving to be photographed in.
42066
- // The navigation stands — it is what was asked for — and the movement
42067
- // is dropped.
42068
- if (!travel.element.isConnected) {
42069
- {
42070
- warnBoxTakenDown(travel.page);
42071
- }
42072
- travel.viewTransition.skipTransition();
42073
- endTravel(travel);
42074
- return;
42075
- }
42076
- // The page arriving is in the DOM and the transition has not started
42077
- // playing: the one moment both boxes can be known. After the scroll,
42078
- // which is what the box is measured through: the two states are at the
42079
- // same place in the layout without being at the same place in the window
42080
- // (see transition_window.js).
42081
- // The bars stay live under the pictures for a travel: only the box is
42082
- // photographed (see the TRAVEL_ATTRIBUTE css).
42083
- holdTransitionWindow(travel, travel.element, stateBefore, {
42084
- furnitureLive: true
42085
- });
42086
- });
42087
- travel.viewTransition = viewTransition;
42088
- // Subscribed first, so it reads before the scrub below writes anything.
42089
- viewTransition.ready.then(() => {
42090
- placeTransitionWindow(travel, TRAVEL_NAME);
42091
- }, ignoreSkipped);
42092
- if (scrub) {
42093
- // Said only now: the release has to have something to let go of, and the
42094
- // transition did not exist a line above.
42095
- travel.dropHold = holdViewTransition(() => {
42096
- viewTransition.skipTransition();
42097
- endTravel(travel);
42098
- });
41977
+ movement.resumeScrollRecording();
42099
41978
  }
42100
- // Another transition starting SKIPS this one — there is only ever one in a
42101
- // document. That must end the travel here and now, and above all lift the
42102
- // hold: the hold is written in CSS against whatever transition is running
42103
- // (it has to be, so that everything named — a trait under a tab row —
42104
- // follows the same finger), so left on it would take hold of the transition
42105
- // that has just replaced ours. Born paused, with nobody holding it, that
42106
- // one never finishes: its pictures stand over a page that cannot be
42107
- // touched anymore.
42108
- viewTransition.finished.catch(() => {
42109
- // A transition that fails before it ever calls back leaves the page held:
42110
- // whoever asked for the hold gives it back, here as everywhere else.
42111
- renderWait.stop();
42112
- releaseRendering();
42113
- endTravel(travel);
42114
- });
42115
- if (!scrub) {
42116
- // Nobody is holding it: it plays as any transition does, and is over when
42117
- // the browser says so.
42118
- viewTransition.finished.then(() => {
42119
- endTravel(travel);
42120
- }, ignoreSkipped);
42121
- return travel;
42122
- }
42123
- // The pictures exist from `ready` on, and the finger may have moved a long
42124
- // way by then — held at their start meanwhile, so this is where they catch
42125
- // up with it rather than where they set off.
42126
- viewTransition.ready.then(() => {
42127
- if (travel === travelRef.current) {
42128
- scrubTravel(travel, travel.ratio);
42129
- }
42130
- }, ignoreSkipped);
42131
- return travel;
41979
+ // A window recentred meanwhile is taken now.
41980
+ render();
42132
41981
  };
42133
41982
 
42134
- // A page change nobody here asked for: a tab pressed, a key, the back button.
42135
- // The transition is started from what the router SAYS rather than from a
42136
- // render, because a render is one flush too late — by then the DOM holds the
42137
- // new page and the picture of the old one cannot be taken anymore.
42138
- //
42139
- // Watched as a position in the row rather than route by route. A route
42140
- // announces its own status, and the row's tabs can all be one route: the
42141
- // announcement then says a section changed without saying which is on screen,
42142
- // and it says it about things this row does not move for (a route that has
42143
- // now been visited, a param of its own that is not a tab of this row). Worse,
42144
- // a status is published from inside the routing and the params it carries are
42145
- // the ones known at that instant — a section that lands as a signal settles
42146
- // is announced late, or not at all. The signals ARE the position, so the
42147
- // position is read from them: one computed over the whole row, notified once
42148
- // per move, whichever route moved and whether by matching or by params.
41983
+ // The address and the row, in step. The opening and a row given again around the
41984
+ // page on screen have nothing to show for it; somebody else moving the address
41985
+ // (a tab pressed, the back button) is a travel.
41986
+ const answeredPageRef = useRef(null);
42149
41987
  useLayoutEffect(() => {
42150
- const currentIndexSignal = computed(() => currentPageIndex(pages));
42151
- const onRowMove = index => {
42152
- if (index === -1) {
42153
- return;
42154
- }
42155
- const page = pages[index];
42156
- if (samePage(page, currentPageRef.current)) {
42157
- // Where the row already was — the first reading of all, a row given
42158
- // again around the page on screen, and any move this box has already
42159
- // taken note of (a render writes it too). What it was still waiting
42160
- // for is here nonetheless, so the wait is called off.
42161
- if (samePage(pageAskedForRef.current, page)) {
42162
- pageAskedForRef.current = null;
42163
- }
42164
- return;
42165
- }
42166
- // Whoever asked for it: a tab of this row can be a param, and a move
42167
- // between two of them is a page change the path does not show (see
42168
- // page_change.js).
42169
- notePageChange();
42170
- // A page this box asked for itself — a travel's own navigation, or one
42171
- // it had given up waiting on: what arrives here is the answer to a
42172
- // question already answered, not somebody going somewhere.
42173
- if (samePage(pageAskedForRef.current, page)) {
42174
- pageAskedForRef.current = null;
42175
- currentPageRef.current = page;
42176
- return;
42177
- }
42178
- // Somewhere else arrived first: whatever this box was still waiting for
42179
- // is not coming, or no longer means anything. Forgotten here rather
42180
- // than kept, or the next press on that very tab would be taken for the
42181
- // late answer to a question nobody remembers asking.
42182
- pageAskedForRef.current = null;
42183
- // Asked for a page while one was already on its way: the travel in
42184
- // flight is the answer, aimed somewhere else. Starting a second one on
42185
- // top would leave this one's pictures to be dropped mid-slide.
42186
- if (travelRef.current) {
42187
- currentPageRef.current = page;
42188
- retargetTravel(travelRef.current, page);
42189
- return;
42190
- }
42191
- const fromIndex = pageIndexOf(pages, currentPageRef.current);
42192
- currentPageRef.current = page;
42193
- if (fromIndex === -1) {
42194
- // Arriving from outside this row: there is no pair of pages to show,
42195
- // so there is nothing to travel between.
42196
- return;
42197
- }
42198
- beginTravel({
42199
- page,
42200
- fromPage: pages[fromIndex],
42201
- direction: index > fromIndex ? "forward" : "back",
42202
- scrub: false
42203
- });
42204
- };
42205
- // `subscribe` rather than `effect`: it hands the value to a callback that
42206
- // is NOT being tracked, and what this one does — navigate, ask the router
42207
- // for another page — reads and writes the very signals the row is watched
42208
- // through.
42209
- return currentIndexSignal.subscribe(onRowMove);
42210
- }, [pages]);
42211
-
42212
- // Rendering is held for the length of a navigation, so that whatever picture
42213
- // this box is about to take is of the page being LEFT (see
42214
- // rendering_hold.js).
42215
- useLayoutEffect(() => {
42216
- const stopWatchingStart = observeBeforeRouting(holdRenderingForRouting);
42217
- // Nobody may have had a picture to take: this navigation is not always one
42218
- // this box travels. Whoever wanted the hold took it over (beginTravel)
42219
- // while the change was being applied, and left nothing here.
42220
- const stopWatchingEnd = observeAfterRouting(releaseRoutingRenderingHold);
42221
- return () => {
42222
- stopWatchingStart();
42223
- stopWatchingEnd();
42224
- };
42225
- }, []);
42226
-
42227
- // What the next announcement will compare itself against: written after the
42228
- // render that shows it, so it is always the page one is looking at.
42229
- useLayoutEffect(() => {
42230
- currentPageRef.current = currentPage;
42231
- }, [currentPage]);
42232
-
42233
- // Let go of far enough: the movement carries on from under the finger, at its
42234
- // own pace, to the end.
42235
- const finishTravel = travel => {
42236
- // Nobody is driving it anymore. `scrub` is who MOVES the pictures, not what
42237
- // set them off: left standing after the release, this travel would go on
42238
- // claiming a hand that is no longer there — and everything that asks
42239
- // "is somebody holding this?" before touching it (a press on the tab one
42240
- // came from, a wheel push) would be answered yes and do nothing, while the
42241
- // pages carry on to a page the router has already left.
42242
- travel.scrub = false;
42243
- releaseHold();
42244
- travel.viewTransition.finished.then(() => endTravel(travel), () => endTravel(travel));
42245
- };
42246
-
42247
- // Let go of too early: the pages go back the way they came, and the URL with
42248
- // them. The way back is not a travel of its own — the same animations are run
42249
- // backwards, and the page that was left is put back under the picture BEFORE
42250
- // the picture is dropped, so the two are the same thing at the moment they
42251
- // are swapped and nothing is seen changing.
42252
- const revertTravel = travel => {
42253
- travel.reverting = true;
42254
- // A revert somebody ASKED for — a press on the tab this travel came from —
42255
- // has a problem the others do not: that page is already back, and the
42256
- // picture being brought in is LIVE, so it shows the page one is going back
42257
- // to. Both sides then show the same thing and the way back is invisible:
42258
- // one presses, and one is simply there.
42259
- //
42260
- // So the pages are held where they are until the pictures have finished
42261
- // going back. Only the pages: unlike the hold a navigation takes to have
42262
- // its picture taken, nothing is being photographed here — everything else
42263
- // may go on rendering, and a tab row beside the box keeps answering.
42264
- const releaseRendering = freezeRouteRender();
42265
- const animations = travelAnimations(travel);
42266
- walkPicturesHome(animations, TRAVEL_LEAD_NAMES);
42267
- releaseHold(travel);
42268
- whenPicturesArrived(animations).then(async () => {
42269
- try {
42270
- pageAskedForRef.current = travel.fromPage;
42271
- // The page that was left is put back UNDER the picture before the
42272
- // picture is dropped, so the two are the same thing at the moment they
42273
- // are swapped: that only holds once the page is really back.
42274
- if (pageIsCurrent(travel.fromPage)) {
42275
- // It never left: the press that set this revert off put it back
42276
- // there, and the pages have been held where they were until now.
42277
- // Nothing to ask for, and nothing to wait for — waiting anyway is a
42278
- // render that never comes and a page frozen under its own pictures.
42279
- releaseRendering();
42280
- } else {
42281
- await whilePageRenders(travel.fromPage, () => onTravel({
42282
- route: travel.fromPage.route,
42283
- params: travel.fromPage.params,
42284
- cause: "revert"
42285
- }));
42286
- }
42287
- travel.viewTransition.skipTransition();
42288
- } finally {
42289
- // Refused before the browser called back, nothing was swapped: the
42290
- // offset is still the page's own, and no render is on its way to say so.
42291
- if (travel.scrollsDocument && travel.calledBack) {
42292
- // The page coming back was read somewhere else, and the offset
42293
- // currently on the document is the one the page that came in put
42294
- // there. Given back once the page is really back — the render is
42295
- // what makes the document tall enough to hold that offset again —
42296
- // and the recording stays deaf until then, over the clamp the swap
42297
- // back makes on the way. Its own deafness rather than the travel's:
42298
- // the travel ends here, and this outlives it by a render. Written
42299
- // once the commit that says so has ended, as arriveAtScrollPosition
42300
- // asks.
42301
- const resumeScrollRecording = suspendScrollRecording();
42302
- const stopWatchingRender = observeRouteRender(() => {
42303
- stopWatchingRender();
42304
- queueMicrotask(() => {
42305
- const {
42306
- route,
42307
- params
42308
- } = travel.fromPage;
42309
- arriveAtScrollPosition(route.buildUrl(params));
42310
- resumeScrollRecording();
42311
- });
42312
- });
42313
- }
42314
- releaseRendering();
42315
- // A travel ENDS, whatever happened on the way back: put the state back,
42316
- // fail to drop the picture, be interrupted by something else — the one
42317
- // thing that must not happen is a travel that stays "in flight"
42318
- // forever. Nothing would lift the hold, the pictures would stand where
42319
- // they are over a page that cannot be touched, and every gesture after
42320
- // this one would find the box busy.
42321
- endTravel(travel);
41988
+ const row = rowRef.current;
41989
+ const page = currentIndex === -1 ? null : pages[currentIndex];
41990
+ const pane = panesOf(row)[currentIndex];
41991
+ const answered = answeredPageRef.current;
41992
+ if (samePage(answered, page)) {
41993
+ if (pane && !movementRef.current && !restsOn(row, pane)) {
41994
+ row.scrollTo({
41995
+ left: pane.offsetLeft,
41996
+ behavior: "instant"
41997
+ });
42322
41998
  }
42323
- });
42324
- };
42325
-
42326
- // Taken back in hand: the pictures stop where they are and answer the finger
42327
- // again (see the CSS hold).
42328
- const holdTravel = travel => {
42329
- if (travel.ended || travel.reverting) {
42330
41999
  return;
42331
42000
  }
42332
- travel.scrub = true;
42333
- holdPictures(travel);
42334
- // Whatever asks for a transition next takes ours away (there is one per
42335
- // document): it must find this one already let go of, or it inherits a hold
42336
- // nobody is holding.
42337
- travel.dropHold = holdViewTransition(() => {
42338
- travel.viewTransition?.skipTransition();
42339
- endTravel(travel);
42340
- });
42341
- };
42342
-
42343
- // The same travel, aimed at another page. The still it starts from does not
42344
- // change — only what is being brought in against it, and that one is LIVE:
42345
- // pointing the router elsewhere is all it takes for the picture to show that
42346
- // page instead.
42347
- const redirectTravel = (travel, page, direction) => {
42348
- travel.page = page;
42349
- // Everything the transition carries that is NOT the pages was measured
42350
- // against a destination this travel is no longer going to (see the CSS).
42351
- document.documentElement.setAttribute(TURNED_ATTRIBUTE, "");
42352
- if (direction === travel.direction) {
42353
- // Same way, another page: the pictures in hand are already the right
42354
- // pair, and nothing has to move.
42001
+ answeredPageRef.current = page;
42002
+ if (!page || !pane) {
42355
42003
  return;
42356
42004
  }
42357
- travel.direction = direction;
42358
- travel.ratio = 0;
42359
- // The other way round is another pair of keyframes, and naming another
42360
- // animation builds another Animation: whatever was collected answers to
42361
- // nobody now. They start again from the beginning, which is where a travel
42362
- // that turns around is.
42363
- travel.animations = null;
42364
- document.documentElement.setAttribute(TRAVEL_ATTRIBUTE, direction);
42365
- };
42366
-
42367
- // Somebody asked for a page while one was on its way. Where they asked for
42368
- // decides what that means.
42369
- const retargetTravel = (travel, page) => {
42370
- if (travel.scrub || travel.reverting || travel.ended || travel.noPicture) {
42371
- // A hand is holding the pages, or they are already on their way back:
42372
- // either way this travel's end is decided by somebody else.
42373
- return;
42374
- }
42375
- if (samePage(page, travel.page)) {
42376
- // Already on its way there.
42377
- return;
42378
- }
42379
- if (samePage(page, travel.fromPage)) {
42380
- // Back where it set off from: that is not another travel, it is this one
42381
- // undone — the same pictures, run backwards.
42382
- revertTravel(travel);
42005
+ const asked = askedPageRef.current;
42006
+ if (asked && samePage(asked, page)) {
42007
+ askedPageRef.current = null;
42008
+ // The hand landed before the address did.
42009
+ if (movementRef.current?.landed && restsOn(row, pane)) {
42010
+ Promise.resolve().then(endMovement);
42011
+ }
42383
42012
  return;
42384
42013
  }
42385
- const fromIndex = pageIndexOf(pages, travel.fromPage);
42386
- const toIndex = pageIndexOf(pages, page);
42387
- if (fromIndex === -1 || toIndex === -1) {
42014
+ if (!answered || restsOn(row, pane)) {
42015
+ if (!restsOn(row, pane)) {
42016
+ row.scrollTo({
42017
+ left: pane.offsetLeft,
42018
+ behavior: "instant"
42019
+ });
42020
+ }
42388
42021
  return;
42389
42022
  }
42390
- redirectTravel(travel, page, toIndex > fromIndex ? "forward" : "back");
42391
- };
42023
+ beginMovement({
42024
+ travelling: true
42025
+ });
42026
+ row.scrollTo({
42027
+ left: pane.offsetLeft,
42028
+ behavior: "smooth"
42029
+ });
42030
+ // In this very commit: the tab pressed already wears its bar, and the first
42031
+ // scroll event is a frame away.
42032
+ sayRowMoves();
42033
+ });
42392
42034
 
42393
- // One page further than the travel in flight is going, the same way: what
42394
- // a second swipe asks for over a page still arriving. Reached the way a tab
42395
- // pressed while it plays reaches it — the same pictures, the router pointed
42396
- // one page further (see redirectTravel) — so nothing restarts and nothing
42397
- // stops. Past the last page there is nothing to aim at, and the travel
42398
- // simply arrives where it was going.
42399
- const aimOnePageFurther = (travel, sign) => {
42400
- const fromIndex = pageIndexOf(pages, travel.page);
42401
- const page = sign > 0 ? pages[fromIndex - 1] : pages[fromIndex + 1];
42402
- if (fromIndex === -1 || !page) {
42403
- return;
42404
- }
42405
- redirectTravel(travel, page, travel.direction);
42406
- pageAskedForRef.current = page;
42407
- travelTo(page, "drag");
42408
- };
42409
- const endTravel = travel => {
42410
- if (travel.ended) {
42411
- return;
42035
+ // Somebody else is about to move the address to another page of a window: its
42036
+ // picture has to be taken now, while it still shows the page being left.
42037
+ useLayoutEffect(() => {
42038
+ if (!windowed) {
42039
+ return undefined;
42412
42040
  }
42413
- travel.ended = true;
42414
- travel.dropHold?.();
42415
- travel.dropHold = null;
42416
- if (travelHoldingSettledPage === travel) {
42417
- travelHoldingSettledPage = null;
42418
- routeTravelSettledPageSignal.value = null;
42419
- }
42420
- if (travelRef.current === travel) {
42421
- document.documentElement.removeEventListener("touchmove", keepTouchRefusable);
42422
- }
42423
- // The offset on the scrollport belongs to a page again — the one that
42424
- // arrived, or the one put back. A travel that never got as far as saying
42425
- // which still has to give the recording back; the gesture's own
42426
- // pseudo-travel never took it (see noPicture).
42427
- travel.resumeScrollRecording?.();
42428
- // Its own hold, always — whether or not this travel is still the current
42429
- // one. Nobody else will lift it.
42430
- releaseHold(travel);
42431
- if (travelRef.current === travel) {
42432
- travelRef.current = null;
42433
- // Given back, so another box may wear it: kept, two of them on a page
42434
- // would both answer to it and the browser refuses the whole transition
42435
- // rather than pick.
42436
- unnameAfterTravel(travel.element);
42437
- document.documentElement.removeAttribute(TRAVEL_ATTRIBUTE);
42438
- document.documentElement.removeAttribute(TRAVEL_AXIS_ATTRIBUTE);
42439
- document.documentElement.removeAttribute(DRAGGED_ATTRIBUTE);
42440
- document.documentElement.removeAttribute(TURNED_ATTRIBUTE);
42441
- releaseTransitionWindow(travel);
42442
- releaseTransitionDestination(travel);
42443
- releaseTransitionPress(travel);
42444
- }
42445
- };
42446
-
42447
- // What a gesture is about, whichever hand made it: a thumb dragging the page
42448
- // and two fingers pushing it sideways on a trackpad ask for the same travel,
42449
- // so they are answered by the same three callbacks and only the reading of
42450
- // the input differs (see drag_to_travel.js).
42451
- const boxSizeOnAxis = () => {
42452
- const box = elementRef.current.getBoundingClientRect();
42453
- return axis === "x" ? box.width : box.height;
42454
- };
42455
- const travelHandlers = {
42456
- onStart: ({
42457
- sign,
42458
- target
42459
- }) => {
42460
- const size = boxSizeOnAxis();
42461
- const travelInFlight = travelRef.current;
42462
- if (travelInFlight) {
42463
- // A travel is already playing, and a second one cannot be started on
42464
- // top of it: there is one picture of the page being left, and it is
42465
- // taken. So the gesture takes over THIS travel instead of asking for
42466
- // another — which is what a hand reaching for a page still sliding is
42467
- // asking for anyway. It is refused only when there is nothing to take
42468
- // over (no picture at all).
42469
- //
42470
- // Never given up, even then: a gesture handed back to the browser is a
42471
- // page that rocks under a travel that is already moving.
42472
- // A travel being undone is not up for grabs either: it is already on
42473
- // its way back and its end is decided. Held again mid-revert, its
42474
- // animations would never finish — and the wait for them never resolves,
42475
- // so the pictures stay where they are, over a page that cannot be
42476
- // touched anymore.
42477
- if (travelInFlight.noPicture || travelInFlight.ended || travelInFlight.reverting) {
42478
- return {
42479
- size,
42480
- travelBack: false,
42481
- travelOn: false
42482
- };
42483
- }
42484
- // Stopped here, where it stands now: left playing under the press
42485
- // until the hand said it holds it (see inFlight in onPointerDown).
42486
- holdTravel(travelInFlight);
42487
- // Where the pictures stand right now, said as a pull: what the finger
42488
- // continues from, so nothing jumps when it takes them over.
42489
- const ratio = ratioOfTravel(travelInFlight);
42490
- travelInFlight.ratio = ratio;
42491
- const pulledSign = travelInFlight.direction === "back" ? 1 : -1;
42492
- return {
42493
- size,
42494
- slack: ratio * size * pulledSign,
42495
- // One box, the one being travelled. Either of its ends can be walked
42496
- // out of and the hand carries on into the next page, but that is a
42497
- // travel of its own and the gesture asks for it when it gets there
42498
- // (see onEdge) — from here there is one page on its way.
42499
- travelBack: travelInFlight.direction === "back",
42500
- travelOn: travelInFlight.direction === "forward"
42501
- };
42502
- }
42503
- // Dragging the page towards the end of the axis brings in what is
42504
- // BEFORE it, the way pushing a sheet to the right reveals its left.
42505
- const page = sign > 0 ? pages[currentIndex - 1] : pages[currentIndex + 1];
42506
- if (!page || !size || scrollRoomTowards(target, elementRef.current, axis, sign)) {
42507
- return false;
42508
- }
42509
- if (!CAN_KEEP_PICTURE) {
42510
- travelRef.current = {
42511
- page,
42512
- noPicture: true,
42513
- ended: false
42514
- };
42515
- return {
42516
- size,
42517
- travelBack: sign > 0,
42518
- travelOn: sign < 0
42519
- };
42520
- }
42521
- return {
42522
- size,
42523
- travelBack: sign > 0,
42524
- travelOn: sign < 0,
42525
- // A page change costs what a slide does not, whatever the hand does
42526
- // next: a navigation, a render, the requests of the page arriving, and
42527
- // those of the page coming back. So nothing is asked of the router
42528
- // until the hand has really pulled, and the neighbour is read then:
42529
- // turned around before that, there is nothing to undo.
42530
- setOff: ({
42531
- sign: signNow
42532
- }) => {
42533
- const pageThatWay = signNow > 0 ? pages[currentIndex - 1] : pages[currentIndex + 1];
42534
- if (!pageThatWay || travelRef.current) {
42535
- return false;
42536
- }
42537
- beginTravel({
42538
- page: pageThatWay,
42539
- fromPage: pages[currentIndex],
42540
- direction: signNow > 0 ? "back" : "forward",
42541
- scrub: true,
42542
- change: () => travelTo(pageThatWay, "drag")
42543
- });
42544
- return {
42545
- travelBack: signNow > 0,
42546
- travelOn: signNow < 0
42547
- };
42548
- }
42549
- };
42550
- },
42551
- onPull: ({
42552
- progress
42041
+ return observeBeforeRouting(({
42042
+ url
42553
42043
  }) => {
42554
- const travel = travelRef.current;
42555
- if (!travel || travel.noPicture) {
42044
+ const {
42045
+ pages,
42046
+ currentIndex
42047
+ } = latestRef.current;
42048
+ const asked = askedPageRef.current;
42049
+ if (asked || currentIndex === -1) {
42556
42050
  return;
42557
42051
  }
42558
- // Only what goes the way the gesture set off: a hand turning around
42559
- // mid-drag is putting the page back, and there is no second picture to
42560
- // show it anything else.
42561
- const ratio = travel.direction === "back" ? progress : -progress;
42562
- travel.ratio = ratio > 0 ? ratio : 0;
42563
- scrubTravel(travel, travel.ratio);
42564
- },
42565
- // A page walked all the way to one of its ends and the finger still going:
42566
- // what it asks for is the page past that end, and it has said so by not
42567
- // stopping. Which end decides how it is answered, and the two are not the
42568
- // same amount of work.
42569
- onEdge: ({
42570
- sign,
42571
- thrown
42572
- }) => {
42573
- const travel = travelRef.current;
42574
- if (thrown) {
42575
- // Caught, then thrown on before its end: the page after is reached by
42576
- // aiming the travel in hand further, and let go of it arrives there.
42577
- // There is no box to hand over — a new one would mean jumping these
42578
- // pictures the rest of the way first, a page seen leaping — so none
42579
- // is answered, and the gesture lets the travel go as it is.
42580
- if (travel && !travel.noPicture && !travel.ended && !travel.reverting) {
42581
- aimOnePageFurther(travel, sign);
42582
- }
42583
- return false;
42052
+ const path = new URL(url, window.location.href).pathname;
42053
+ if (pathOf(pages[currentIndex]) === path || !pages.some(page => pathOf(page) === path)) {
42054
+ return;
42584
42055
  }
42585
- if (!travel || travel.noPicture || travel.ended || travel.reverting || !CAN_KEEP_PICTURE) {
42586
- return false;
42056
+ const movement = beginMovement({
42057
+ travelling: true
42058
+ });
42059
+ leavePictureOfLive(movement);
42060
+ });
42061
+ }, [windowed]);
42062
+ useLayoutEffect(() => {
42063
+ const row = rowRef.current;
42064
+ const onScroll = () => {
42065
+ const {
42066
+ pages,
42067
+ currentIndex,
42068
+ travelByDrag
42069
+ } = latestRef.current;
42070
+ const panes = panesOf(row);
42071
+ if (panes.length < 2) {
42072
+ return;
42587
42073
  }
42588
- // Where the page in hand is coming from, said the way the gesture says
42589
- // it: dragging towards the end of the axis brings in what comes BEFORE.
42590
- const pulledSign = travel.direction === "back" ? 1 : -1;
42591
- const direction = sign > 0 ? "back" : "forward";
42592
- if (sign === pulledSign) {
42593
- // Walked whole. What the pictures show is the page that has arrived,
42594
- // and the one leaving is gone: there is no pair left to travel with, so
42595
- // the next travel needs pictures of its own — a navigation, a render, a
42596
- // snapshot, and for those few frames the page does not follow the
42597
- // finger before catching up with it (see `ready` in beginTravel). At
42598
- // the start of a gesture that gap is invisible, the hand has barely
42599
- // moved; here the hand is at full speed, and this is what it costs.
42600
- //
42601
- // Where we are is what THIS travel was bringing in — the URL changed as
42602
- // it set off, so `currentIndex` belongs to a render this gesture is
42603
- // older than.
42604
- const fromIndex = pageIndexOf(pages, travel.page);
42605
- const page = direction === "back" ? pages[fromIndex - 1] : pages[fromIndex + 1];
42606
- if (fromIndex === -1 || !page) {
42607
- return false;
42608
- }
42609
- // At their very end before they are let go of: what ends the travel in
42610
- // hand is the next transition starting (there is one per document, and
42611
- // the funnel skips ours), and a picture skipped short of its end is a
42612
- // page seen jumping the last few pixels.
42613
- scrubTravel(travel, 1);
42614
- travel.ratio = 1;
42615
- beginTravel({
42616
- page,
42617
- fromPage: travel.page,
42618
- direction,
42619
- scrub: true,
42620
- change: () => travelTo(page, "drag")
42621
- });
42622
- return {
42623
- size: boxSizeOnAxis(),
42624
- travelBack: sign > 0,
42625
- travelOn: sign < 0
42626
- };
42074
+ const moving = movementRef.current;
42075
+ const pane = panes[currentIndex];
42076
+ if (!moving && pane && restsOn(row, pane)) {
42077
+ // Written by this box, at rest: nobody is moving anything.
42078
+ return;
42627
42079
  }
42628
- // Walked back to where it began, and out the other side. Nothing has to
42629
- // be built here: the pictures in hand are ALREADY the pair this new
42630
- // travel needs — the still of the page it starts from is the same one,
42631
- // and the picture being brought in is live, so pointing the router at the
42632
- // other neighbour is enough for it to show that one instead. The travel
42633
- // turns around where it stands, on the same transition and under the same
42634
- // hand, and there is no gap at all.
42635
- const fromIndex = pageIndexOf(pages, travel.fromPage);
42636
- const page = direction === "back" ? pages[fromIndex - 1] : pages[fromIndex + 1];
42637
- if (fromIndex === -1 || !page) {
42638
- return false;
42080
+ const movement = moving || beginMovement({});
42081
+ sayRowMoves();
42082
+ if (movement.travelling || !travelByDrag || !pane) {
42083
+ return;
42639
42084
  }
42640
- redirectTravel(travel, page, direction);
42641
- pageAskedForRef.current = page;
42642
- travelTo(page, "drag");
42643
- return {
42644
- size: boxSizeOnAxis(),
42645
- travelBack: sign > 0,
42646
- travelOn: sign < 0
42647
- };
42648
- },
42649
- // The next swipe over a page still arriving: the page after it, and the
42650
- // travel in flight goes there — the same pictures, the router pointed one
42651
- // page further, exactly what a tab pressed while it plays does (see
42652
- // retargetTravel). Nothing is held and nothing restarts.
42653
- onPushOn: ({
42654
- sign
42655
- }) => {
42656
- const travel = travelRef.current;
42657
- if (!travel || travel.noPicture || travel.ended || travel.reverting || travel.scrub) {
42658
- return false;
42085
+ const pulled = row.scrollLeft - pane.offsetLeft;
42086
+ if (Math.abs(pulled) < SET_OFF_PULL) {
42087
+ return;
42659
42088
  }
42660
- aimOnePageFurther(travel, sign);
42661
- return true;
42662
- },
42663
- onEnd: ({
42664
- travels
42665
- }) => {
42666
- gestureRef.current = null;
42667
- const travel = travelRef.current;
42668
- if (!travel) {
42669
- // The travel this gesture was holding ended under it. Nothing left to
42670
- // decide, but the hold is this gesture's own doing and nobody else will
42671
- // take it off — a hold nobody lifts is a page nobody can touch.
42672
- releaseHold();
42089
+ const index = pulled > 0 ? currentIndex + 1 : currentIndex - 1;
42090
+ const paneTowards = panes[index];
42091
+ const page = pages[index];
42092
+ if (!paneTowards || samePage(askedPageRef.current, page)) {
42673
42093
  return;
42674
42094
  }
42675
- if (travel.noPicture) {
42676
- travelRef.current = null;
42677
- if (travels) {
42678
- travelTo(travel.page, "drag");
42095
+ if (windowed) {
42096
+ if (movement.pictures.has(index)) {
42097
+ return;
42679
42098
  }
42099
+ leavePictureOfLive(movement);
42100
+ } else if (paneTowards.hasAttribute("data-built")) {
42680
42101
  return;
42681
42102
  }
42682
- if (travels) {
42683
- finishTravel(travel);
42684
- return;
42103
+ if (routeTravelSettledPageSignal.peek() === null) {
42104
+ routeTravelSettledPageSignal.value = pages[currentIndex];
42685
42105
  }
42686
- revertTravel(travel);
42687
- }
42688
- };
42689
- const onPointerDown = pointerDownEvent => {
42690
- if (!travelByDrag || gestureRef.current) {
42691
- return;
42692
- }
42693
- // A travel playing under the press is left playing: a hand landing on a
42694
- // page still sliding is far more often the next swipe than a catch, and
42695
- // stopping the pages at the press is what that swipe feels as a stall. The
42696
- // gesture listens to the hand first and says which it was — the next page
42697
- // (onPushOn) or this one, held (onStart) — see inFlight in
42698
- // drag_to_travel.js. Either way the press is not refused: a gesture given
42699
- // back to the browser is a page that rocks under a travel already moving.
42700
- const travelInFlight = travelRef.current;
42701
- const leftPlaying = travelInFlight && !travelInFlight.noPicture && !travelInFlight.ended && !travelInFlight.reverting && !travelInFlight.scrub;
42702
- if (currentIndex === -1) {
42703
- return;
42704
- }
42705
- const giveUp = () => {
42706
- gestureRef.current = null;
42106
+ askFor(page);
42707
42107
  };
42708
- const gesture = startDragToTravel(pointerDownEvent, {
42709
- element: elementRef.current,
42710
- axes: axis,
42711
- inFlight: leftPlaying ? {
42712
- axis,
42713
- // The way a pull bringing in the page on its way would go:
42714
- // dragging towards the end of the axis brings in what is BEFORE.
42715
- way: travelInFlight.direction === "back" ? 1 : -1
42716
- } : false,
42717
- ...travelHandlers,
42718
- onGiveUp: giveUp
42719
- });
42720
- if (!gesture) {
42721
- // Not a press this box can be about — something that reads the pointer
42722
- // itself, a box below it that travels the same way.
42723
- giveUp();
42724
- return;
42725
- }
42726
- gestureRef.current = gesture;
42727
- };
42728
-
42729
- // A press aimed at a page while a travel is playing does not always reach the
42730
- // box: the browser's transition covers the page and the press can be delivered
42731
- // to the document root instead, whatever the pictures are told about pointer
42732
- // events. So while a travel plays, the press is caught at the document and
42733
- // handed to the box when it fell where a page is SEEN, which is where the hand
42734
- // thinks it pressed.
42735
- //
42736
- // Decided by the position and never by the target: in the first frames of a
42737
- // travel a press on a live bar falls to the root like any other, and what lies
42738
- // under a picture is not what the hand is aiming at.
42739
- pointerDownRef.current = onPointerDown;
42740
- useLayoutEffect(() => {
42741
- const onDocumentPointerDown = pointerDownEvent => {
42742
- if (!travelRef.current) {
42108
+ const onLand = () => {
42109
+ const movement = movementRef.current;
42110
+ if (!movement) {
42743
42111
  return;
42744
42112
  }
42745
- const boxElement = elementRef.current;
42746
- if (!boxElement || boxElement.contains(pointerDownEvent.target)) {
42747
- // It got there on its own.
42113
+ const panes = panesOf(row);
42114
+ const pane = pageAtRest(row, panes);
42115
+ if (!pane) {
42748
42116
  return;
42749
42117
  }
42118
+ movement.landed = true;
42750
42119
  const {
42751
- left,
42752
- right,
42753
- top,
42754
- bottom
42755
- } = boxElement.getBoundingClientRect();
42756
- const {
42757
- clientX,
42758
- clientY
42759
- } = pointerDownEvent;
42760
- if (clientX < left || clientX > right || clientY < top || clientY > bottom) {
42120
+ pages
42121
+ } = latestRef.current;
42122
+ const page = pages[panes.indexOf(pane)];
42123
+ if (!page || pageIsCurrent(page)) {
42124
+ if (!askedPageRef.current || samePage(askedPageRef.current, page)) {
42125
+ endMovement();
42126
+ }
42761
42127
  return;
42762
42128
  }
42763
- // A box scrolled under a bar still has the press in its rectangle, but
42764
- // the pictures are cut at that bar: the press is aimed at the bar.
42765
- if (isPointInTransitionBand(clientX, clientY)) {
42129
+ if (samePage(askedPageRef.current, page)) {
42766
42130
  return;
42767
42131
  }
42768
- pointerDownRef.current(pointerDownEvent);
42132
+ if (windowed && !movement.pictures.has(panes.indexOf(pane))) {
42133
+ leavePictureOfLive(movement);
42134
+ }
42135
+ Promise.resolve(askFor(page)).then(() => {
42136
+ if (pageIsCurrent(page)) {
42137
+ return;
42138
+ }
42139
+ // Refused, or sent elsewhere: the row says where the address is.
42140
+ askedPageRef.current = null;
42141
+ const {
42142
+ currentIndex
42143
+ } = latestRef.current;
42144
+ const paneCurrent = panesOf(row)[currentIndex];
42145
+ if (paneCurrent) {
42146
+ movement.travelling = true;
42147
+ row.scrollTo({
42148
+ left: paneCurrent.offsetLeft,
42149
+ behavior: "smooth"
42150
+ });
42151
+ }
42152
+ });
42769
42153
  };
42770
- document.addEventListener("pointerdown", onDocumentPointerDown, true);
42154
+ const stopWatching = watchRowScroll(row, {
42155
+ onScroll,
42156
+ onLand
42157
+ });
42771
42158
  return () => {
42772
- document.removeEventListener("pointerdown", onDocumentPointerDown, true);
42159
+ stopWatching();
42160
+ movementRef.current?.resumeScrollRecording?.();
42161
+ movementRef.current = null;
42162
+ publishRouteRowMove(null);
42773
42163
  };
42774
- }, []);
42164
+ }, [windowed]);
42775
42165
 
42776
- // A wheel pushing the box sideways asks for a PAGE, not for a place between
42777
- // two: one push, one neighbour — the same thing a tab pressed asks for, and
42778
- // it plays at its own pace rather than under a hand (see watchWheelTravel).
42779
- const travelOneStep = sign => {
42780
- const travelInFlight = travelRef.current;
42781
- if (travelInFlight?.scrub) {
42782
- // A hand is holding the pages. They are its until it lets go.
42783
- return;
42784
- }
42785
- // Where the box is going, which is not where it is: a step asked for while
42786
- // a travel plays is the page after the one on its way.
42787
- const fromPage = travelInFlight ? travelInFlight.page : pages[currentIndex];
42788
- const fromIndex = pageIndexOf(pages, fromPage);
42789
- if (fromIndex === -1) {
42790
- return;
42791
- }
42792
- const page = sign > 0 ? pages[fromIndex - 1] : pages[fromIndex + 1];
42793
- if (!page) {
42166
+ // A window's page lands on a pane as soon as the address does. The row has come
42167
+ // to rest on it or will: when it already has, nothing else will say so.
42168
+ useLayoutEffect(() => {
42169
+ const movement = movementRef.current;
42170
+ if (!movement?.landed || currentIndex === -1) {
42794
42171
  return;
42795
42172
  }
42796
- if (travelInFlight) {
42797
- // Finished where it stands before the next one sets off: what ends it is
42798
- // that transition starting, and a picture dropped short of its end is a
42799
- // page seen jumping the last few pixels.
42800
- scrubTravel(travelInFlight, 1);
42801
- travelInFlight.ratio = 1;
42173
+ const pane = panesOf(rowRef.current)[currentIndex];
42174
+ if (pane && restsOn(rowRef.current, pane)) {
42175
+ Promise.resolve().then(endMovement);
42802
42176
  }
42803
- beginTravel({
42804
- page,
42805
- fromPage,
42806
- direction: sign > 0 ? "back" : "forward",
42807
- scrub: false,
42808
- change: () => travelTo(page, "wheel")
42809
- });
42810
- };
42811
-
42812
- // Reached through a ref, and the watcher is never rebuilt for it: a travel
42813
- // CHANGES the current page, so anything listening on `currentIndex` would be
42814
- // torn down halfway through the very gesture that is moving it.
42815
- travelHandlersRef.current = travelHandlers;
42816
- const travelOneStepRef = useRef(null);
42817
- travelOneStepRef.current = travelOneStep;
42818
- useLayoutEffect(() => {
42819
- if (!travelByDrag) {
42820
- return undefined;
42821
- }
42822
- return watchWheelTravel(elementRef.current, {
42823
- axes: axis,
42824
- onStep: ({
42825
- sign
42826
- }) => travelOneStepRef.current(sign)
42827
- });
42828
- }, [travelByDrag, axis]);
42177
+ }, [currentIndex]);
42178
+ const rowKind = pages.length < 2 && windowed ? "still" : travelByDrag ? "" : "code";
42829
42179
  return jsx("div", {
42830
42180
  ...rest,
42831
- ref: elementRef,
42832
42181
  className: className ? `navi_route_travel ${className}` : "navi_route_travel",
42833
- "data-axis": axis
42834
- // What travels here, and on which axis: read by the shared gesture
42835
- // stylesheet, which keeps this box's scrolling from spilling onto the
42836
- // page (see drag_to_travel.js).
42837
- ,
42838
- "data-drag-travel": travelByDrag ? axis : undefined
42839
- // The same fact said once per gesture, and for the other question the
42840
- // DOM answers: a box that travels INSIDE this one — a row of slides in a
42841
- // page — takes the axis it walks, and these are what it reads to know
42842
- // this box walks it too.
42843
- ,
42844
- "data-travel-by-drag": travelByDrag ? axis : undefined,
42845
- "data-travel-by-wheel": travelByDrag ? axis : undefined
42846
- // A touch this box may take has to be refusable before the finger lands,
42847
- // or the browser can cancel an established travel mid-swipe by scrolling
42848
- // the axis touch-action leaves it — see keepTouchRefusable for why a JSX
42849
- // prop is enough (an element-level touchmove listener is non-passive).
42850
- ,
42851
- onTouchMove: travelByDrag ? keepTouchRefusable : undefined,
42852
- onPointerDown: onPointerDown,
42853
- children: children
42182
+ children: jsx("div", {
42183
+ ref: rowRef,
42184
+ "data-route-travel-row": rowKind
42185
+ // What a box around this one and a control acting on the press read: this
42186
+ // axis is taken here (see drag_to_travel.js).
42187
+ ,
42188
+ "data-travel-by-drag": travelByDrag && rowKind !== "still" ? "x" : undefined,
42189
+ children: windowed ? jsxs(Fragment, {
42190
+ children: [pages.map((page, index) => jsx("div", {
42191
+ "data-route-row-pane": "",
42192
+ style: {
42193
+ gridColumn: index + 1
42194
+ }
42195
+ }, index)), jsx("div", {
42196
+ ref: liveRef,
42197
+ "data-route-row-live": "",
42198
+ style: {
42199
+ gridColumn: liveIndex + 1
42200
+ },
42201
+ children: children
42202
+ }), jsx("div", {
42203
+ ref: picturesRef,
42204
+ "data-route-row-pictures": ""
42205
+ })]
42206
+ }) : jsx(RouteRowContext.Provider, {
42207
+ value: true,
42208
+ children: children
42209
+ })
42210
+ })
42854
42211
  });
42855
42212
  };
42856
42213
 
42857
- // Nobody holds the pictures anymore: whatever they were told (a time to stand
42858
- // at, a direction to run in) is what they carry on from.
42859
- // Which travel is keeping the pictures still, if any. The hold belongs to the
42860
- // travel that took it and only that one may give it back: a travel ending after
42861
- // another has taken over must not lift a hold it no longer owns, and — the way
42862
- // this went wrong — a travel whose end comes once something else has replaced
42863
- // it must still lift its OWN. A hold left behind is a page frozen under
42864
- // pictures nobody is holding.
42865
- let travelHoldingPictures = null;
42866
- const holdPictures = travel => {
42867
- travelHoldingPictures = travel;
42868
- document.documentElement.setAttribute(HOLD_ATTRIBUTE, "");
42869
- };
42870
- const releaseHold = travel => {
42871
- if (travel && travelHoldingPictures !== travel) {
42872
- return;
42873
- }
42874
- travelHoldingPictures = null;
42875
- document.documentElement.removeAttribute(HOLD_ATTRIBUTE);
42876
- };
42877
-
42878
- // The animations of the pictures, asked for again until there are some: they
42879
- // come into existence with the transition, several frames after it was asked
42880
- // for, and the gesture has already begun by then. Kept once found — the set
42881
- // does not change for the length of one travel.
42882
- const travelAnimations = travel => {
42883
- if (travel.animations) {
42884
- return travel.animations;
42885
- }
42886
- const animations = viewTransitionAnimations();
42887
- if (animations.length) {
42888
- travel.animations = animations;
42889
- }
42890
- return animations;
42891
- };
42892
-
42893
- // How far a travel has come, read off the pictures themselves rather than off
42894
- // what the last gesture wrote: one let go of is still moving, and a hand
42895
- // reaching for it must find it where it IS.
42896
- const ratioOfTravel = travel => {
42897
- const animations = travelAnimations(travel);
42898
- const animation = findLeadAnimation(animations, TRAVEL_LEAD_NAMES) || animations[0];
42899
- if (!animation) {
42900
- return travel.ratio;
42901
- }
42902
- const timing = animation.effect.getComputedTiming();
42903
- const duration = timing.delay + timing.activeDuration;
42904
- if (!duration) {
42905
- return travel.ratio;
42906
- }
42907
- return animation.currentTime / duration;
42908
- };
42909
-
42910
- // Where the two pictures stand, said as a moment in the movement they would
42911
- // have played on their own: the browser knows how they move (it is written in
42912
- // CSS), so the finger only has to say how far in. They are held still by CSS
42913
- // while this lasts, so a time written here is a place they stay at.
42914
- const scrubTravel = (travel, ratio) => {
42915
- for (const animation of travelAnimations(travel)) {
42916
- const timing = animation.effect.getComputedTiming();
42917
- const duration = timing.delay + timing.activeDuration;
42918
- animation.currentTime = ratio * duration;
42919
- }
42214
+ // How far down the row the reader is looking, for a page brought in: a page on
42215
+ // screen scrolls with the document, and one arriving has to start from there.
42216
+ const pinOf = row => {
42217
+ const {
42218
+ top
42219
+ } = row.getBoundingClientRect();
42220
+ return top < 0 ? -top : 0;
42920
42221
  };
42921
42222
 
42922
- // A page change, carried out and then waited for until the page it selects is
42923
- // really on screen. The container doing the swapping is the only one who knows
42924
- // when that is (observeRouteRender): a route matching is a signal changing, and
42925
- // how many passes Preact takes to answer it is its own business.
42926
- //
42927
- // Nothing is waited for when the change did not take — a route refused, a
42928
- // redirect somewhere else. There is no page on its way then, and this runs
42929
- // inside the callback of a view transition: the browser has stopped rendering
42930
- // and is waiting on this very promise to take its picture, so a wait that never
42931
- // ends is a page frozen under a transition that never became ready.
42932
- // Listening starts before the change, or a render landing while the change is
42933
- // being awaited is a render nobody heard. Armed apart from the wait itself
42934
- // because the two do not always happen at the same moment: a view transition
42935
- // calls its update callback a frame after it is started, and the render can
42936
- // land in that gap — see beginTravel, which arms this the moment the travel is
42937
- // decided and hands it over.
42938
- const armRouteRenderWait = () => {
42939
- let stopListening;
42940
- const rendered = new Promise(resolve => {
42941
- stopListening = observeRouteRender(resolve);
42942
- });
42943
- return {
42944
- rendered,
42945
- stop: () => stopListening()
42946
- };
42947
- };
42948
- const whilePageRenders = async (page, change, wait = armRouteRenderWait()) => {
42949
- try {
42950
- await change();
42951
- if (pageIsCurrent(page)) {
42952
- await wait.rendered;
42223
+ // What a clone does not take: where what scrolls inside was left.
42224
+ const copyScrollPositions = (from, to) => {
42225
+ const fromElements = from.querySelectorAll("*");
42226
+ const toElements = to.querySelectorAll("*");
42227
+ for (let i = 0; i < fromElements.length; i++) {
42228
+ const {
42229
+ scrollTop,
42230
+ scrollLeft
42231
+ } = fromElements[i];
42232
+ if (scrollTop || scrollLeft) {
42233
+ toElements[i].scrollTop = scrollTop;
42234
+ toElements[i].scrollLeft = scrollLeft;
42953
42235
  }
42954
- } finally {
42955
- wait.stop();
42956
42236
  }
42957
42237
  };
42958
42238
 
42959
- // Whether the document's offset belongs to the tabs. The pages of a row scroll
42960
- // the document when nothing between the box and the viewport is a scroll
42961
- // container: the tab on screen is then what makes the document tall, and the
42962
- // offset on it is that tab's — it has to be given back with the tab, and the
42963
- // browser's clamping of it while pages are swapped has to be ignored.
42964
- //
42965
- // A box that lives inside a scroller of its own — a frame in an article, a
42966
- // panel beside other content — shares nothing with the document: the offset
42967
- // there is the surrounding page's, the reader never left it, and a travel has
42968
- // no business moving it. Each of its pages brings its own scrollport, which
42969
- // goes away with the page and has nothing to restore.
42970
- //
42971
- // includeHidden, because an `overflow: hidden` ancestor is one of those
42972
- // scrollers: it shows no scrollbar and still holds an offset of its own, and
42973
- // what is under it never reaches the document. A wrapper that merely clips
42974
- // (`overflow: clip`) holds nothing and is walked straight through — see
42975
- // is_scrollable.js.
42976
- const pagesScrollTheDocument = element => {
42977
- // What window.scrollTo moves. A body whose overflow is the viewport's is
42978
- // walked through to it (see overflowGoesToViewport in @jsenv/dom); a body
42979
- // the walk stops on scrolls itself, out of the viewport's reach.
42980
- return getScrollContainer(element, {
42981
- includeHidden: true
42982
- }) === document.documentElement;
42983
- };
42984
-
42985
42239
  // A page of the row: a route, and the params that say which of its tabs when
42986
42240
  // several of them share it. Written as a bare route by a caller whose tabs are
42987
42241
  // routes of their own — which is the same page with nothing to tell apart.
@@ -43002,8 +42256,6 @@ const samePage = (a, b) => {
43002
42256
  }
43003
42257
  return a.route === b.route && compareTwoJsValues(a.params, b.params);
43004
42258
  };
43005
- const pageIndexOf = (pages, page) => pages.findIndex(candidate => samePage(candidate, page));
43006
-
43007
42259
  // The FIRST page that answers, as with the branches of a <Route>: several
43008
42260
  // routes match at once — a literal one and the parameterized one it is a case of
43009
42261
  // ("/games/new" is also a "/games/:gameId"), a section and the page inside it —
@@ -43024,31 +42276,6 @@ const currentPageIndex = pages => {
43024
42276
  return currentIndex;
43025
42277
  };
43026
42278
 
43027
- // A transition skipped by another one starting is an outcome, not a failure.
43028
- const ignoreSkipped = () => {};
43029
-
43030
- // Said once, whatever the number of swipes: a box in the wrong place is one
43031
- // fact about the application, and repeating it every time would bury it.
43032
- let boxTakenDownWarned = false;
43033
- const warnBoxTakenDown = page => {
43034
- if (boxTakenDownWarned) {
43035
- return;
43036
- }
43037
- boxTakenDownWarned = true;
43038
- console.warn(`[navi] RouteTravel: the page ${page.route.buildUrl(page.params)} is not rendered under this box — the navigation took the box down with the page it was in, so the travel is dropped. The box must outlive every page of its row: around those <Route>s (a layout route), or around one <Route> whose params are the tabs, never inside one of the pages.`);
43039
- };
43040
-
43041
- // The name is lent to the box that is travelling and taken back afterwards.
43042
- // There is one transition in a document at a time, so one box wears it at a
43043
- // time — and the others, unnamed, are simply not captured: they stay live
43044
- // under the pictures rather than being frozen with the page.
43045
- const nameForTravel = element => {
43046
- element.style.viewTransitionName = TRAVEL_NAME;
43047
- };
43048
- const unnameAfterTravel = element => {
43049
- element.style.viewTransitionName = "";
43050
- };
43051
-
43052
42279
  /**
43053
42280
  * What covers the box a route movement plays in, from INSIDE the document: a
43054
42281
  * sticky row of tabs above a `<RouteTravel>`, a header pinned to the top of a
@@ -58206,9 +57433,7 @@ const LinkPseudoElements = ["::-navi-loader", "::-navi-indicator"];
58206
57433
  * press on it is aimed at what the reader can see. Say it there, and only
58207
57434
  * there: a control that TRAVELS with the pages has a rectangle where it will
58208
57435
  * be, not where it is seen, and a press handed to it fires something nobody
58209
- * aimed at. A `RouteTravel` honours it as well, for a control in a `FixedBar`:
58210
- * the part it photographs (`data-navi-fixed-bar-overhang`) is deaf for the
58211
- * whole travel, and the rest for its first frames.
57436
+ * aimed at.
58212
57437
  * @param {boolean} [props.replace] - Go to the destination by TAKING THE PLACE
58213
57438
  * of the current history entry instead of stacking onto it: the link stays a
58214
57439
  * link (an address, a middle click, the keyboard, `aria-current`), only the
@@ -58713,6 +57938,10 @@ const SlideContainerContext = createContext(null);
58713
57938
  // ([data-slide-ways~="right"]).
58714
57939
  const SLIDE_CURRENT_ATTRIBUTE = "data-slide-current";
58715
57940
  const SLIDE_TOWARD_ATTRIBUTE = "data-slide-travel-toward";
57941
+ // The slide --slide-travel-progress counts from, when that is not the current
57942
+ // one: a hand on a row the browser scrolls can carry it past several slides, and
57943
+ // what follows it goes on between the two it is standing between.
57944
+ const SLIDE_BASE_ATTRIBUTE = "data-slide-travel-base";
58716
57945
  // Every way out that would DO something right now, in the words the commands
58717
57946
  // use: a direction ("left", "right", "up", "down") when there is a slide that
58718
57947
  // way, plus "first" and "last" when one is not already standing at that end of
@@ -58950,11 +58179,15 @@ const css$_ = /* css */`@layer navi {
58950
58179
  }
58951
58180
  }
58952
58181
 
58182
+ .navi_nav[data-nav-following-row] .navi_current_indicator {
58183
+ visibility: hidden;
58184
+ }
58185
+
58953
58186
  .navi_nav .navi_link[data-href-current] .navi_current_indicator {
58954
58187
  view-transition-name: var(--nav-indicator-name);
58955
58188
  }
58956
58189
 
58957
- :root:is([data-navi-route-transition], [data-navi-route-travel]) .navi_nav:not([data-nav-between-tabs]) .navi_current_indicator {
58190
+ :root[data-navi-route-transition] .navi_nav:not([data-nav-between-tabs]) .navi_current_indicator {
58958
58191
  view-transition-name: none;
58959
58192
  }
58960
58193
 
@@ -59085,6 +58318,64 @@ const NavStyleCSSVars = {
59085
58318
  currentIndicatorColor: "--nav-current-indicator-color",
59086
58319
  currentIndicatorSize: ["--nav-current-indicator-size", "height"]
59087
58320
  };
58321
+
58322
+ // Where the bar is, where it is headed and in which colour at each end: the
58323
+ // numbers the CSS above interpolates between. Measured off the DOM (offsets force
58324
+ // a layout, the colours a style resolve), so asked only when the two tabs change.
58325
+ const writeIndicatorGeometry = (navElement, {
58326
+ baseTabElement,
58327
+ towardTabElement,
58328
+ towardIsAfter,
58329
+ vertical
58330
+ }) => {
58331
+ const measure = tabElement => vertical ? {
58332
+ position: tabElement.offsetTop,
58333
+ length: tabElement.offsetHeight
58334
+ } : {
58335
+ position: tabElement.offsetLeft,
58336
+ length: tabElement.offsetWidth
58337
+ };
58338
+ // The colour a tab asks the bar to be under it. Read from the tab and not
58339
+ // from the row, so a tab that says nothing gives the row's value back —
58340
+ // it inherits it (see the css above) — and one that says something gives
58341
+ // its own. Empty when neither resolves to anything: the css then falls
58342
+ // back on its own, rather than being handed a colour that is not one.
58343
+ const colorOf = tabElement => getComputedStyle(tabElement).getPropertyValue("--link-current-indicator-color").trim();
58344
+ const writeColor = (property, color) => {
58345
+ if (color) {
58346
+ navElement.style.setProperty(property, color);
58347
+ } else {
58348
+ navElement.style.removeProperty(property);
58349
+ }
58350
+ };
58351
+ const baseMeasure = measure(baseTabElement);
58352
+ let positionDelta = 0;
58353
+ let lengthDelta = 0;
58354
+ let towardSign = 0;
58355
+ let towardColor = "";
58356
+ if (towardTabElement) {
58357
+ const towardMeasure = measure(towardTabElement);
58358
+ // What one box of travel is worth in pixels of this row, signed so that
58359
+ // the trait is exactly on the other tab when the progress is at its own
58360
+ // end: the container counts +1 when the picture leans on a slide sitting
58361
+ // BEFORE the current one and -1 when it sits after.
58362
+ const sign = towardIsAfter ? -1 : 1;
58363
+ positionDelta = (towardMeasure.position - baseMeasure.position) * sign;
58364
+ lengthDelta = (towardMeasure.length - baseMeasure.length) * sign;
58365
+ towardSign = sign;
58366
+ towardColor = colorOf(towardTabElement);
58367
+ }
58368
+ const {
58369
+ style
58370
+ } = navElement;
58371
+ style.setProperty("--nav-indicator-position", baseMeasure.position);
58372
+ style.setProperty("--nav-indicator-length", baseMeasure.length);
58373
+ style.setProperty("--nav-indicator-position-delta", positionDelta);
58374
+ style.setProperty("--nav-indicator-length-delta", lengthDelta);
58375
+ style.setProperty("--nav-indicator-toward-sign", towardSign);
58376
+ writeColor("--nav-indicator-color", colorOf(baseTabElement));
58377
+ writeColor("--nav-indicator-color-toward", towardColor);
58378
+ };
59088
58379
  const positionOfCurrentIndicator = (currentIndicator, vertical) => {
59089
58380
  if (currentIndicator === true) {
59090
58381
  return vertical ? "left" : "bottom";
@@ -59157,6 +58448,8 @@ const Nav = ({
59157
58448
  const [currentSlideArea, setCurrentSlideArea] = useState(undefined);
59158
58449
  const slideContainerElementRef = useRef(null);
59159
58450
  const indicatorPosition = slideContainer ? positionOfCurrentIndicator(currentIndicator, vertical) : null;
58451
+ // A row of routes draws that same bar only while it follows a row scrolling.
58452
+ const rowIndicatorPosition = slideContainer ? null : positionOfCurrentIndicator(currentIndicator, vertical);
59160
58453
 
59161
58454
  // Which tab of this row is the one being shown: the slide the container says
59162
58455
  // it is on, or, for a row of routes, the link the browser is on.
@@ -59169,7 +58462,9 @@ const Nav = ({
59169
58462
  if (!containerElement) {
59170
58463
  return navElement.querySelector("[data-href-current]");
59171
58464
  }
59172
- const currentArea = containerElement.getAttribute(SLIDE_CURRENT_ATTRIBUTE);
58465
+ // The tab the trait counts from: the current one, unless a hand has carried
58466
+ // the row past it.
58467
+ const currentArea = containerElement.getAttribute(SLIDE_BASE_ATTRIBUTE) ?? containerElement.getAttribute(SLIDE_CURRENT_ATTRIBUTE);
59173
58468
  if (currentArea === null) {
59174
58469
  return null;
59175
58470
  }
@@ -59229,56 +58524,13 @@ const Nav = ({
59229
58524
  tabCount: tabElements.length,
59230
58525
  vertical
59231
58526
  };
59232
- const measure = tabElement => vertical ? {
59233
- position: tabElement.offsetTop,
59234
- length: tabElement.offsetHeight
59235
- } : {
59236
- position: tabElement.offsetLeft,
59237
- length: tabElement.offsetWidth
59238
- };
59239
- // The colour a tab asks the bar to be under it. Read from the tab and not
59240
- // from the row, so a tab that says nothing gives the row's value back —
59241
- // it inherits it (see the css above) — and one that says something gives
59242
- // its own. Empty when neither resolves to anything: the css then falls
59243
- // back on its own, rather than being handed a colour that is not one.
59244
- const colorOf = tabElement => getComputedStyle(tabElement).getPropertyValue("--link-current-indicator-color").trim();
59245
- const writeColor = (property, color) => {
59246
- if (color) {
59247
- navElement.style.setProperty(property, color);
59248
- } else {
59249
- navElement.style.removeProperty(property);
59250
- }
59251
- };
59252
- const currentMeasure = measure(tabElements[currentIndex]);
59253
- const towardArea = towardAreaNow;
59254
- const towardIndex = tabElements.findIndex(tabElement => areaOf(tabElement) === towardArea);
59255
- let positionDelta = 0;
59256
- let lengthDelta = 0;
59257
- let towardSign = 0;
59258
- let towardColor = "";
59259
- if (towardIndex !== -1 && towardIndex !== currentIndex) {
59260
- const towardTabElement = tabElements[towardIndex];
59261
- const towardMeasure = measure(towardTabElement);
59262
- // What one box of travel is worth in pixels of this row, signed so that
59263
- // the trait is exactly on the other tab when the progress is at its own
59264
- // end: the container counts +1 when the picture leans on a slide sitting
59265
- // BEFORE the current one and -1 when it sits after.
59266
- const sign = towardIndex > currentIndex ? -1 : 1;
59267
- positionDelta = (towardMeasure.position - currentMeasure.position) * sign;
59268
- lengthDelta = (towardMeasure.length - currentMeasure.length) * sign;
59269
- towardSign = sign;
59270
- towardColor = colorOf(towardTabElement);
59271
- }
59272
- const {
59273
- style
59274
- } = navElement;
59275
- style.setProperty("--nav-indicator-position", currentMeasure.position);
59276
- style.setProperty("--nav-indicator-length", currentMeasure.length);
59277
- style.setProperty("--nav-indicator-position-delta", positionDelta);
59278
- style.setProperty("--nav-indicator-length-delta", lengthDelta);
59279
- style.setProperty("--nav-indicator-toward-sign", towardSign);
59280
- writeColor("--nav-indicator-color", colorOf(tabElements[currentIndex]));
59281
- writeColor("--nav-indicator-color-toward", towardColor);
58527
+ const towardIndex = tabElements.findIndex(tabElement => areaOf(tabElement) === towardAreaNow);
58528
+ writeIndicatorGeometry(navElement, {
58529
+ baseTabElement: tabElements[currentIndex],
58530
+ towardTabElement: towardIndex !== -1 && towardIndex !== currentIndex ? tabElements[towardIndex] : null,
58531
+ towardIsAfter: towardIndex > currentIndex,
58532
+ vertical
58533
+ });
59282
58534
  navElement.setAttribute("data-nav-indicator-measured", "");
59283
58535
  };
59284
58536
  // Reached through a ref by everything watching the DOM below: those watchers
@@ -59286,6 +58538,60 @@ const Nav = ({
59286
58538
  // about the row as it is now.
59287
58539
  const paintIndicatorGeometryRef = useRef(null);
59288
58540
  paintIndicatorGeometryRef.current = paintIndicatorGeometry;
58541
+
58542
+ // The bar following a row of routes the browser scrolls, between the two tabs
58543
+ // of the pages it stands between. A row this nav holds no tab for is another
58544
+ // nav's to draw.
58545
+ useLayoutEffect(() => {
58546
+ if (!rowIndicatorPosition) {
58547
+ return undefined;
58548
+ }
58549
+ let paintedBase = null;
58550
+ let paintedToward = null;
58551
+ const letGo = () => {
58552
+ const navElement = navRef.current;
58553
+ paintedBase = null;
58554
+ paintedToward = null;
58555
+ navElement?.removeAttribute("data-nav-following-row");
58556
+ navElement?.removeAttribute("data-nav-indicator-measured");
58557
+ navElement?.style.removeProperty("--slide-travel-progress");
58558
+ };
58559
+ const stopFollowing = followRouteRows(move => {
58560
+ const navElement = navRef.current;
58561
+ if (!navElement) {
58562
+ return;
58563
+ }
58564
+ if (!move) {
58565
+ letGo();
58566
+ return;
58567
+ }
58568
+ const tabElements = Array.from(navElement.querySelectorAll(".navi_link"));
58569
+ const tabOf = path => tabElements.find(tabElement => tabElement.pathname === path);
58570
+ const baseTabElement = tabOf(move.basePath);
58571
+ if (!baseTabElement) {
58572
+ letGo();
58573
+ return;
58574
+ }
58575
+ const towardTabElement = move.towardPath ? tabOf(move.towardPath) : null;
58576
+ if (baseTabElement !== paintedBase || towardTabElement !== paintedToward) {
58577
+ paintedBase = baseTabElement;
58578
+ paintedToward = towardTabElement;
58579
+ writeIndicatorGeometry(navElement, {
58580
+ baseTabElement,
58581
+ towardTabElement,
58582
+ towardIsAfter: tabElements.indexOf(towardTabElement) > tabElements.indexOf(baseTabElement),
58583
+ vertical
58584
+ });
58585
+ navElement.setAttribute("data-nav-indicator-measured", "");
58586
+ navElement.setAttribute("data-nav-following-row", "");
58587
+ }
58588
+ navElement.style.setProperty("--slide-travel-progress", move.progress);
58589
+ });
58590
+ return () => {
58591
+ stopFollowing();
58592
+ letGo();
58593
+ };
58594
+ }, [rowIndicatorPosition, vertical]);
59289
58595
  // What the last paint measured for — see the memo at the top of
59290
58596
  // paintIndicatorGeometry.
59291
58597
  const indicatorPaintedForRef = useRef(null);
@@ -59644,7 +58950,7 @@ const Nav = ({
59644
58950
  "data-expand-y": expand || expandY ? "" : undefined,
59645
58951
  "data-vertical": vertical ? "" : undefined,
59646
58952
  "data-panel-position": panelPosition,
59647
- "data-nav-indicator": indicatorPosition ?? undefined
58953
+ "data-nav-indicator": indicatorPosition ?? rowIndicatorPosition ?? undefined
59648
58954
  // "write your travel here too": a custom property cannot be read across
59649
58955
  // the DOM, so the container paints its progress onto this element and the
59650
58956
  // trait follows in CSS alone (see SlideContainer's followerElements).
@@ -59668,7 +58974,7 @@ const Nav = ({
59668
58974
  "--nav-indicator-name": indicatorNameRef.current
59669
58975
  } : props.style,
59670
58976
  styleCSSVars: NavStyleCSSVars,
59671
- children: [indicatorPosition && jsx("span", {
58977
+ children: [(indicatorPosition || rowIndicatorPosition) && jsx("span", {
59672
58978
  className: "navi_nav_indicator"
59673
58979
  }), jsx(NavContext.Provider, {
59674
58980
  value: navContextValue,
@@ -60862,17 +60168,6 @@ const css$Y = /* css */`@layer navi {
60862
60168
  left: calc(-1 * var(--navi-fixed-bar-border-width));
60863
60169
  clip-path: inset(0 0 0 -100vw);
60864
60170
  }
60865
-
60866
- @supports (view-transition-name: match-element) {
60867
- :root[data-navi-route-travel] .navi_fixed_bar_shadow, :root[data-navi-route-travel] [data-navi-fixed-bar-overhang] {
60868
- view-transition-name: match-element;
60869
- view-transition-class: navi_fixed_bar_over_pages;
60870
- }
60871
- }
60872
-
60873
- :root[data-navi-route-travel-turned]::view-transition-group(.navi_fixed_bar_over_pages) {
60874
- display: block;
60875
- }
60876
60171
  `;
60877
60172
  const FixedBarStyleCSSVars = {
60878
60173
  // width/height go through the vars the size calc reads: written as plain
@@ -60924,16 +60219,7 @@ const FixedBarStyleCSSVars = {
60924
60219
  * `height`/`width`.
60925
60220
  * @param {string} [props.shadow] - What the bar casts over the content, in
60926
60221
  * `box-shadow` syntax, cut at the bar's box on every side but the content's.
60927
- * Takes no room. Prefer it to a `boxShadow` or a `filter` on the bar: those
60928
- * are covered by the pages while a `RouteTravel` plays, this one stays over
60929
- * them.
60930
- *
60931
- * A child standing out of the bar's box (a raised button, the notch around it)
60932
- * wears `data-navi-fixed-bar-overhang`: unmarked, the pages of a `RouteTravel`
60933
- * cover it wherever they have paint behind it. Marked, it stays over them, and
60934
- * like anything photographed it is deaf for the length of the travel —
60935
- * `pressableDuringRouteTransition` on a control inside it keeps that control
60936
- * answering. The rest of the bar stays live.
60222
+ * Takes no room.
60937
60223
  */
60938
60224
  const FixedBar = ({
60939
60225
  children,
@@ -64201,9 +63487,7 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
64201
63487
  * button in a bar the two states share, standing exactly where its picture is
64202
63488
  * drawn — must still close it. What `<Link pressableDuringRouteTransition>`
64203
63489
  * says, for the other half of a toggle. Only for a control that does not
64204
- * travel with the pages. A `RouteTravel` honours it as well, for a control in
64205
- * a `FixedBar`: the part it photographs (`data-navi-fixed-bar-overhang`) is
64206
- * deaf for the whole travel, and the rest for its first frames.
63490
+ * travel with the pages.
64207
63491
  * @param {Function} [action] What the press runs: a button holds no value, so
64208
63492
  * every press is one call. Inside a `<Form>` or a group it carries the value
64209
63493
  * around it, merged over the action's bound params — a value that is not an
@@ -66360,6 +65644,16 @@ const css$M = /* css */`@layer navi {
66360
65644
  }
66361
65645
  }
66362
65646
 
65647
+ @media (pointer: coarse) {
65648
+ & .navi_control_input {
65649
+ overflow: clip;
65650
+
65651
+ &::-webkit-slider-thumb {
65652
+ transform: scaleX(3);
65653
+ }
65654
+ }
65655
+ }
65656
+
66363
65657
  & .navi_input_range_accent_probe {
66364
65658
  background-color: var(--accent-color);
66365
65659
  visibility: hidden;
@@ -68127,7 +67421,9 @@ const InputPseudoElements = ["::-navi-loader"];
68127
67421
  * only: nothing moves and nothing is drawn. It overflows the range, centered
68128
67422
  * on its line, over whatever is next to it and takes the presses aimed there,
68129
67423
  * so leave it the room it needs (`marginY`). Different for a mouse and a
68130
- * finger: `--press-height` in a `@media (pointer: coarse)` rule.
67424
+ * finger: `--press-height` in a `@media (pointer: coarse)` rule. On Safari for
67425
+ * iOS a drag only starts close to the thumb, within three times its width; a
67426
+ * tap further along the line moves the value.
68131
67427
  */
68132
67428
  const Input = props => {
68133
67429
  resolveInputProps(props);
@@ -69722,26 +69018,47 @@ const css$I = /* css */`@property --slide-travel-progress {
69722
69018
 
69723
69019
  .navi_slide_container {
69724
69020
  border-radius: inherit;
69021
+ overflow-anchor: none;
69022
+ scroll-snap-type: both mandatory;
69023
+ scrollbar-width: none;
69725
69024
  flex: 0 auto;
69726
69025
  min-width: 0;
69727
69026
  min-height: 0;
69728
69027
  display: grid;
69729
69028
  position: relative;
69730
- overflow: clip;
69029
+ overflow: hidden;
69030
+
69031
+ &::-webkit-scrollbar {
69032
+ display: none;
69033
+ }
69034
+
69035
+ &[data-slide-travelling] {
69036
+ scroll-snap-type: none;
69037
+ }
69038
+
69039
+ &[data-slide-hand*="x"] {
69040
+ overscroll-behavior-x: contain;
69041
+ overflow-x: auto;
69042
+ }
69043
+
69044
+ &[data-slide-hand*="y"] {
69045
+ overscroll-behavior-y: contain;
69046
+ overflow-y: auto;
69047
+ }
69731
69048
 
69732
69049
  &:focus {
69733
69050
  outline: none;
69734
69051
  }
69735
69052
 
69736
- &[data-travel-by-drag="x"] {
69053
+ &[data-slide-no-touch="x"] {
69737
69054
  touch-action: pan-y;
69738
69055
  }
69739
69056
 
69740
- &[data-travel-by-drag="y"] {
69057
+ &[data-slide-no-touch="y"] {
69741
69058
  touch-action: pan-x;
69742
69059
  }
69743
69060
 
69744
- &[data-travel-by-drag="xy"] {
69061
+ &[data-slide-no-touch="xy"] {
69745
69062
  touch-action: none;
69746
69063
  }
69747
69064
 
@@ -69761,15 +69078,19 @@ const css$I = /* css */`@property --slide-travel-progress {
69761
69078
  display: grid;
69762
69079
 
69763
69080
  & > [data-slide] {
69764
- min-width: 0;
69765
- min-height: 0;
69766
- translate: var(--slide-offset, 0);
69081
+ top: var(--slide-top, 0);
69082
+ left: var(--slide-left, 0);
69083
+ scroll-snap-align: start;
69084
+ scroll-snap-stop: always;
69767
69085
  border-radius: 0;
69768
69086
  outline: none;
69769
69087
  grid-area: 1 / 1;
69088
+ min-width: 0;
69089
+ min-height: 0;
69090
+ position: relative;
69770
69091
  }
69771
69092
 
69772
- & > [data-slide][data-slide-offstage] {
69093
+ & > [data-slide][data-slide-offstage], & > [data-slide][data-off-row] {
69773
69094
  visibility: hidden;
69774
69095
  }
69775
69096
  }
@@ -69799,22 +69120,15 @@ const ratioOfOneTravel = (track, from, to, targetBefore) => {
69799
69120
  // Where the track stands right now, in pixels, read off the box it draws in
69800
69121
  // rather than off the value that moves it: mid-animation the browser reports
69801
69122
  // that value as a calc() of a percentage and a length ("calc(-43% - 119px)"),
69802
- // which no simple parse survives — read as 0, it puts the gesture a whole
69803
- // travel away from what the eye is looking at.
69123
+ // which no simple parse survives. Against the box the track rests in, past the
69124
+ // border (clientLeft/clientTop), and plus what the box has scrolled, which the
69125
+ // track's rectangle carries and its translate does not.
69804
69126
  const trackOffsetPx = (track, containerElement) => {
69805
69127
  const trackRect = track.getBoundingClientRect();
69806
69128
  const containerRect = containerElement.getBoundingClientRect();
69807
- // Against the box the track RESTS in — the content box, past the border
69808
- // (clientLeft/clientTop are the border widths; the box never has a
69809
- // scrollbar nor padding, see the top-of-file comment on padding). Measured
69810
- // against the border box, a border reads as that many pixels of translate
69811
- // nothing ever asked for — and it COMPOUNDS: every travel interrupted
69812
- // mid-flight re-measures the track and carries the error into its "from",
69813
- // so rapid back-and-forth gestures stack a 1px border into a visible
69814
- // vertical drift of the whole track.
69815
69129
  return {
69816
- x: trackRect.x - containerRect.x - containerElement.clientLeft,
69817
- y: trackRect.y - containerRect.y - containerElement.clientTop
69130
+ x: trackRect.x - containerRect.x - containerElement.clientLeft + containerElement.scrollLeft,
69131
+ y: trackRect.y - containerRect.y - containerElement.clientTop + containerElement.scrollTop
69818
69132
  };
69819
69133
  };
69820
69134
  const offsetToPx = (offset, box) => {
@@ -69991,7 +69305,7 @@ const causeOfEvent = event => {
69991
69305
  const {
69992
69306
  type
69993
69307
  } = event;
69994
- if (type === "pointerup" || type === "pointercancel") {
69308
+ if (type === "scrollend") {
69995
69309
  return "drag";
69996
69310
  }
69997
69311
  if (type === "keydown" || type === "keyup") {
@@ -70216,19 +69530,19 @@ const areaHeldOn = slideElement => {
70216
69530
  * go in it), so the surface says `data-slide-container-follows={id}` and the
70217
69531
  * arrows keep walking wherever the keyboard is in it. See the paragraph above
70218
69532
  * about what is drawn AROUND the travel.
70219
- * @param {boolean|"x"|"y"|"xy"} [props.travelByDrag=true] - whether a pointer
70220
- * dragging the slides travels. On by default: slides side by side are
70221
- * something one expects to push around with a thumb. Off where the gesture
70222
- * belongs to the content (a canvas one draws on, a map one pans), or where
70223
- * the slides are steps of a form rather than a row one browses.
70224
- * @param {boolean|"x"|"y"|"xy"} [props.travelByScroll="x"] - whether a wheel
70225
- * pushing the box travels, one slide per push. Sideways only by default,
70226
- * because a page is hardly ever scrollable that way: a sideways scroll over
70227
- * the box can only have been meant for the box. Down the page it is the
70228
- * page's OWN gesture, and taking it would move a slide under someone who was
70229
- * scrolling the document — so a map that travels vertically has to be told
70230
- * (`travelByScroll` / `"y"` / `"xy"`) before a wheel moves it.
70231
- * @param {string} [props.duration="300ms"] - how long a slide change takes.
69533
+ * @param {boolean|"x"|"y"|"xy"} [props.travelByDrag=true] - whether a finger or
69534
+ * a pen pushing the slides travels: the browser pans them, a mouse does not.
69535
+ * Off where the gesture belongs to the content (a canvas one draws on, a map
69536
+ * one pans), or where the slides are steps of a form rather than a row one
69537
+ * browses.
69538
+ * @param {boolean|"x"|"y"|"xy"} [props.travelByScroll="x"] - whether a wheel or a
69539
+ * trackpad pushing the box travels. Sideways only by default, because a page is
69540
+ * hardly ever scrollable that way: a sideways scroll over the box can only have
69541
+ * been meant for the box. Down the page it is the page's OWN gesture, handed on
69542
+ * to what scrolls around the box, so a map that travels vertically has to be
69543
+ * told (`travelByScroll` / `"y"` / `"xy"`) before a wheel moves it.
69544
+ * @param {string} [props.duration="300ms"] - how long a slide change asked for
69545
+ * takes: a tab, a key, a command, a state. A hand goes at its own pace.
70232
69546
  * @param {"largest"|"frozen"} [props.sizing="largest"] - what decides the size
70233
69547
  * of the box. "largest": the largest slide, at every moment — the box follows
70234
69548
  * whatever the slides do. "frozen": the box is measured once and kept at that
@@ -70347,10 +69661,6 @@ const SlideContainer = ({
70347
69661
  // one being travelled TO while the track moves. What the next travel departs
70348
69662
  // from, because it is what one is looking at.
70349
69663
  const drawnAreaRef = useRef(undefined);
70350
- // The movement the track is playing, while it plays: the slide it is leaving
70351
- // and the slide it is going to. A hand landing on the box takes it over from
70352
- // its origin (see onStart in createTravelHandlers).
70353
- const movingRef = useRef(null);
70354
69664
  // Which way the travel about to be drawn goes, when whatever asked for it
70355
69665
  // knows: a window stepping off its last slide comes back on its first, and
70356
69666
  // only the press says that is a step forward — the map, read between those
@@ -70364,17 +69674,6 @@ const SlideContainer = ({
70364
69674
  // A focus transfer read off the interaction that asked for the travel, kept
70365
69675
  // until the slide it is meant for holds its final DOM (see handOverFocus).
70366
69676
  const focusHandOverRef = useRef(null);
70367
- // The gesture in hand, from the pointer that started it to the slides it
70368
- // brought alongside. Null when no finger is on the box.
70369
- const dragRef = useRef(null);
70370
- // Where the travel about to be drawn departs from, when that is not where the
70371
- // track rests: a slide let go of halfway carries on from under the finger.
70372
- // Read and dropped by the layout effect, which is the one drawing it.
70373
- const travelFromRef = useRef(null);
70374
- // The same fact for the indicator (--slide-travel-progress): how far the
70375
- // picture is from the slide ARRIVING when the travel starts, in boxes. Null
70376
- // for a travel nobody dragged, where a whole box is what is left to close.
70377
- const travelProgressFromRef = useRef(null);
70378
69677
  // One per element painting the progress: the box, plus everything following
70379
69678
  // it (see followerElementsRef). All started together and with the same
70380
69679
  // options, so they are one movement said in several places.
@@ -70385,6 +69684,23 @@ const SlideContainer = ({
70385
69684
  // draw in CSS alone, at the pace of the travel and under the finger, with
70386
69685
  // nothing measured per frame.
70387
69686
  const followerElementsRef = useRef([]);
69687
+ // Whether a hand has the row away from the current slide. A render meanwhile
69688
+ // must not send it back.
69689
+ const handMovingRef = useRef(false);
69690
+ // The slide a hand has just landed on, for the render it asks for: it is
69691
+ // already there, so there is nothing to play.
69692
+ const handLandedRef = useRef(null);
69693
+ // Where the box rests on the row, and what the row starts from: a slide that
69694
+ // holds on to the user ends the row on that side, and a scroll range starts at
69695
+ // its first reachable slide (see writeRowAtRest).
69696
+ const restPlaceRef = useRef({
69697
+ x: 0,
69698
+ y: 0
69699
+ });
69700
+ const originRef = useRef({
69701
+ x: 0,
69702
+ y: 0
69703
+ });
70388
69704
  // What the caller holds, however they hold it: a `current` they re-render
70389
69705
  // themselves, or a `signal` this container also writes (see tellCurrentChange).
70390
69706
  if (currentSignal && currentProp !== undefined) {
@@ -70643,10 +69959,10 @@ const SlideContainer = ({
70643
69959
  // (areaTowards knows the holes and a loop's two ends).
70644
69960
  const areasAlongside = area => {
70645
69961
  const alongside = [];
70646
- if (dragAxes?.includes("x")) {
69962
+ if (travelAxes?.includes("x")) {
70647
69963
  alongside.push(areaTowards(-1, 0, area), areaTowards(1, 0, area));
70648
69964
  }
70649
- if (dragAxes?.includes("y")) {
69965
+ if (travelAxes?.includes("y")) {
70650
69966
  alongside.push(areaTowards(0, -1, area), areaTowards(0, 1, area));
70651
69967
  }
70652
69968
  return alongside;
@@ -70675,13 +69991,87 @@ const SlideContainer = ({
70675
69991
  return added.length ? [...previous, ...added] : previous;
70676
69992
  });
70677
69993
  };
70678
-
70679
- // The travel is over: the stage is struck and every slide goes back where the
70680
- // map says it is. Nothing is seen moving for it — the slide on screen sits at
70681
- // the same place whatever the arrangement (its own offset and the track's are
70682
- // opposites and cancel out), and the others are off stage — as long as the
70683
- // two are written in one go, which is why this is done here rather than left
70684
- // to a render.
69994
+ const writeSlidePlace = (slideElement, {
69995
+ x,
69996
+ y
69997
+ }) => {
69998
+ slideElement.style.setProperty("--slide-left", `${x * 100}%`);
69999
+ slideElement.style.setProperty("--slide-top", `${y * 100}%`);
70000
+ };
70001
+ // Every slide where the map says it is, except what the slide on screen holds
70002
+ // the user from: stacked under it, so the row ends there on that side. The same
70003
+ // rule as the gate in goToArea. The row starts at its first reachable slide,
70004
+ // since a scroll range starts at 0 and what lies before cannot be scrolled to.
70005
+ const writeRowAtRest = (slideElements, placeOf, currentElement) => {
70006
+ const currentPlace = placeOf.get(readArea(currentElement)) || {
70007
+ x: 0,
70008
+ y: 0
70009
+ };
70010
+ const holdsNext = currentElement.hasAttribute("data-prevent-nav-next");
70011
+ const holdsPrevious = currentElement.hasAttribute("data-prevent-nav-previous");
70012
+ const isOutOfReach = ({
70013
+ x,
70014
+ y
70015
+ }) => {
70016
+ if (x === currentPlace.x && y === currentPlace.y) {
70017
+ return false;
70018
+ }
70019
+ const forward = y === currentPlace.y ? x > currentPlace.x : y > currentPlace.y;
70020
+ return forward ? holdsNext : holdsPrevious;
70021
+ };
70022
+ const origin = {
70023
+ x: currentPlace.x,
70024
+ y: currentPlace.y
70025
+ };
70026
+ for (const slideElement of slideElements) {
70027
+ const place = placeOf.get(readArea(slideElement)) || {
70028
+ x: 0,
70029
+ y: 0
70030
+ };
70031
+ if (isOutOfReach(place)) {
70032
+ continue;
70033
+ }
70034
+ if (place.x < origin.x) {
70035
+ origin.x = place.x;
70036
+ }
70037
+ if (place.y < origin.y) {
70038
+ origin.y = place.y;
70039
+ }
70040
+ }
70041
+ const rest = {
70042
+ x: currentPlace.x - origin.x,
70043
+ y: currentPlace.y - origin.y
70044
+ };
70045
+ for (const slideElement of slideElements) {
70046
+ const place = placeOf.get(readArea(slideElement)) || {
70047
+ x: 0,
70048
+ y: 0
70049
+ };
70050
+ const outOfReach = isOutOfReach(place);
70051
+ slideElement.toggleAttribute("data-off-row", outOfReach);
70052
+ writeSlidePlace(slideElement, outOfReach ? rest : {
70053
+ x: place.x - origin.x,
70054
+ y: place.y - origin.y
70055
+ });
70056
+ }
70057
+ originRef.current = origin;
70058
+ restPlaceRef.current = rest;
70059
+ };
70060
+ const sendRowToRest = currentElement => {
70061
+ const containerEl = containerRef.current;
70062
+ if (!restsOn(containerEl, currentElement)) {
70063
+ containerEl.scrollTo({
70064
+ left: currentElement.offsetLeft,
70065
+ top: currentElement.offsetTop,
70066
+ behavior: "instant"
70067
+ });
70068
+ }
70069
+ };
70070
+ // The travel is over: the stage is struck, every slide goes back where the map
70071
+ // says it is and the scroll position takes over from the track. Nothing is seen
70072
+ // moving for it, the slide on screen standing at the same place either way, as
70073
+ // long as it is all written in one go, which is why this is done here rather
70074
+ // than left to a render.
70685
70075
  const settleTravel = () => {
70686
70076
  const track = trackRef.current;
70687
70077
  if (!track) {
@@ -70697,33 +70087,19 @@ const SlideContainer = ({
70697
70087
  }
70698
70088
  stageRef.current = null;
70699
70089
  trackAnimationRef.current = null;
70700
- movingRef.current = null;
70701
70090
  // At rest the picture IS the current slide, whatever the last gesture wrote
70702
70091
  // there: an indicator has nothing left to lean towards.
70703
- paintTravelProgress(0);
70092
+ paintTravelProgress();
70093
+ writeRowAtRest(slideElements, placeOf, currentElement);
70704
70094
  for (const slideElement of slideElements) {
70705
- const {
70706
- x,
70707
- y
70708
- } = placeOf.get(readArea(slideElement)) || {
70709
- x: 0,
70710
- y: 0
70711
- };
70712
- slideElement.style.setProperty("--slide-offset", `${x * 100}% ${y * 100}%`);
70713
- slideElement.toggleAttribute("data-slide-offstage", slideElement !== currentElement);
70095
+ slideElement.removeAttribute("data-slide-offstage");
70714
70096
  }
70715
70097
  const currentArea = readArea(currentElement);
70716
70098
  drawnAreaRef.current = currentArea;
70717
- const {
70718
- x,
70719
- y
70720
- } = placeOf.get(currentArea) || {
70721
- x: 0,
70722
- y: 0
70723
- };
70724
- const offset = `${-x * 100}% ${-y * 100}%`;
70725
- offsetRef.current = offset;
70726
- track.style.setProperty("--slide-container-offset", offset);
70099
+ offsetRef.current = "0% 0%";
70100
+ track.style.setProperty("--slide-container-offset", "0% 0%");
70101
+ containerRef.current.removeAttribute("data-slide-travelling");
70102
+ sendRowToRest(currentElement);
70727
70103
  renderNear(currentArea, {
70728
70104
  alongside: true
70729
70105
  });
@@ -70776,11 +70152,12 @@ const SlideContainer = ({
70776
70152
  if (landsWithPage && trackAnimationRef.current) {
70777
70153
  trackAnimationRef.current.cancel();
70778
70154
  trackAnimationRef.current = null;
70779
- movingRef.current = null;
70780
70155
  cancelTravelProgressAnimation();
70781
- paintTravelProgress(0);
70156
+ paintTravelProgress();
70782
70157
  }
70783
- const still = noTravel || landsWithPage;
70158
+ const handLanded = handLandedRef.current === currentArea;
70159
+ handLandedRef.current = null;
70160
+ const still = noTravel || landsWithPage || handLanded;
70784
70161
  // Nothing is travelling, so nothing is staged: the picture to paint is the
70785
70162
  // map itself, and a stage left over from a travel that has just been given
70786
70163
  // up on would be painted instead of it.
@@ -70846,19 +70223,30 @@ const SlideContainer = ({
70846
70223
  // travel someone asked for has its own transfer waiting (focusHandOverRef),
70847
70224
  // and that one knows what was pressed.
70848
70225
  const focusIsLeaving = !focusHandOverRef.current && slideElements.some(slideElement => slideElement !== currentElement && slideElement.contains(document.activeElement));
70226
+ // Read before the row is written: what the slide holds decides where it ends.
70227
+ if (!stage) {
70228
+ writeRowAtRest(slideElements, placeOf, currentElement);
70229
+ }
70230
+ const onRow = ({
70231
+ x,
70232
+ y
70233
+ }) => ({
70234
+ x: x - originRef.current.x,
70235
+ y: y - originRef.current.y
70236
+ });
70849
70237
  for (const slideElement of slideElements) {
70850
70238
  const area = readArea(slideElement);
70851
- const {
70852
- x,
70853
- y
70854
- } = placeOfArea(area);
70855
- slideElement.style.setProperty("--slide-offset", `${x * 100}% ${y * 100}%`);
70856
70239
  const isCurrent = slideElement === currentElement;
70857
70240
  slideElement.toggleAttribute("data-current", isCurrent);
70858
70241
  slideElement.toggleAttribute("data-slide-displaced", !isCurrent);
70859
- // On stage: the two ends of the travel while there is one, and the slide
70860
- // being shown when there is not.
70861
- slideElement.toggleAttribute("data-slide-offstage", stage ? !stage.placeByArea.has(area) : !isCurrent);
70242
+ // Off stage only while a travel asked for plays: at rest a hand can bring in
70243
+ // any of them, and it would find them unpainted.
70244
+ const onStage = Boolean(stage) && stage.placeByArea.has(area);
70245
+ slideElement.toggleAttribute("data-slide-offstage", Boolean(stage) && !onStage);
70246
+ if (onStage) {
70247
+ slideElement.removeAttribute("data-off-row");
70248
+ writeSlidePlace(slideElement, onRow(stage.placeByArea.get(area)));
70249
+ }
70862
70250
  if (isCurrent) {
70863
70251
  // Reachable again first, so the focus below has somewhere to land: an
70864
70252
  // inert element cannot take it.
@@ -70866,12 +70254,14 @@ const SlideContainer = ({
70866
70254
  }
70867
70255
  }
70868
70256
  const track = trackRef.current;
70869
- const offset = `${-currentPlace.x * 100}% ${-currentPlace.y * 100}%`;
70257
+ containerRef.current.toggleAttribute("data-slide-travelling", Boolean(stage));
70258
+ // How far the track stands from where the scroll position rests: none at rest.
70259
+ const rowPlace = onRow(currentPlace);
70260
+ const restPlace = restPlaceRef.current;
70261
+ const offset = `${(restPlace.x - rowPlace.x) * 100}% ${(restPlace.y - rowPlace.y) * 100}%`;
70870
70262
  // The travel that was ASKED for is still one box, whatever is left of it to
70871
- // cover: a slide dragged most of the way there finishes in what is left of
70872
- // the duration rather than taking a full one over a few pixels.
70263
+ // cover: what is left takes what is left of the duration.
70873
70264
  const offsetTargetBefore = offsetRef.current;
70874
- const targetMoves = offset !== offsetTargetBefore;
70875
70265
  // Where the track IS, read before anything is written: a travel asked for
70876
70266
  // while another is still playing must carry on from what is on screen. The
70877
70267
  // previous TARGET is where the last travel was going, not where it got to —
@@ -70890,35 +70280,16 @@ const SlideContainer = ({
70890
70280
  const onScreenPx = trackOffsetPx(track, containerRef.current);
70891
70281
  offsetOnScreen = `${onScreenPx.x}px ${onScreenPx.y}px`;
70892
70282
  }
70893
- // …and where a slide let go of halfway was left, which is the same fact
70894
- // said by the gesture that put it there: the track is at rest as far as any
70895
- // animation is concerned, so nothing else could tell. Taken by the render
70896
- // that moves the track and by no other: a `current` held outside catches up
70897
- // with the gesture a render late (a parent's state, set from a handler that
70898
- // runs after the address is written and read back), and a render still
70899
- // standing on the slide being left would otherwise carry the finger's
70900
- // offset back to it — a travel home nobody asked for.
70901
- const offsetDragged = targetMoves ? travelFromRef.current : null;
70902
- if (targetMoves) {
70903
- travelFromRef.current = null;
70904
- }
70905
- const offsetBefore = offsetDragged ?? offsetOnScreen ?? offsetRef.current;
70283
+ const offsetBefore = offsetOnScreen ?? offsetRef.current;
70906
70284
  offsetRef.current = offset;
70907
- // Where the track ends up, always — the animation below only covers the way
70908
- // there, and when it is over this is what holds. Except under a hand-off
70909
- // still waiting for its travel: the track stays where the finger left it,
70910
- // until the render that moves it or the frame after which the gesture gives
70911
- // up on it (see settleTowards in onEnd).
70912
- if (!travelFromRef.current) {
70913
- track.style.setProperty("--slide-container-offset", offset);
70914
- }
70285
+ track.style.setProperty("--slide-container-offset", offset);
70915
70286
  // A travel to this very offset is already playing: left to play. Same
70916
70287
  // courtesy as the stage above — a re-render in the middle of a travel (a
70917
70288
  // signal read higher up answering) changes nothing here, and setting off
70918
70289
  // again from the on-screen position would replay what is left over a full
70919
70290
  // duration (the ask is zero, so ratioOfOneTravel answers 1), slowing the
70920
70291
  // travel at every re-render.
70921
- const sameTravelPlaying = travelInFlight && !offsetDragged && offsetTargetBefore === offset;
70292
+ const sameTravelPlaying = travelInFlight && offsetTargetBefore === offset;
70922
70293
  const travels = !sameTravelPlaying && !still && durationMs > 0 && offsetBefore !== undefined && offsetBefore !== offset;
70923
70294
  if (travels) {
70924
70295
  // The time it takes is the distance it has left to cover: a travel picked
@@ -70928,10 +70299,9 @@ const SlideContainer = ({
70928
70299
  // this only ever shortens it (a longer travel is not made slower, which
70929
70300
  // would make a two-box move drag).
70930
70301
  const travelRatio = ratioOfOneTravel(track, offsetBefore, offset, offsetTargetBefore);
70931
- // Already moving — under an animation or under a finger that has just
70932
- // let go — so there is no ease-in to play: it would stall the track for
70933
- // an instant right where the eye is following it.
70934
- const easing = travelInFlight || offsetDragged ? "ease-out" : "ease";
70302
+ // Already moving, so there is no ease-in to play: it would stall the track
70303
+ // for an instant right where the eye is following it.
70304
+ const easing = travelInFlight ? "ease-out" : "ease";
70935
70305
  // Cancelled rather than layered: two animations on the same property
70936
70306
  // would blend, and what one sees then is neither of the two moves.
70937
70307
  trackAnimationRef.current?.cancel();
@@ -70943,14 +70313,8 @@ const SlideContainer = ({
70943
70313
  duration: durationMs * travelRatio,
70944
70314
  easing
70945
70315
  });
70946
- movingRef.current = {
70947
- from: drawnArea,
70948
- to: currentArea
70949
- };
70950
- // The trait travels with the slides: from where the gesture left it when
70951
- // there was one, from a whole box away when the travel was asked for.
70952
- const progressFrom = travelProgressFromRef.current ?? (travelStep ? travelStep.x || travelStep.y : 0);
70953
- travelProgressFromRef.current = null;
70316
+ // The trait travels with the slides, from a whole box away.
70317
+ const progressFrom = travelStep ? travelStep.x || travelStep.y : 0;
70954
70318
  // The slide the picture leans on for the length of it is the one being
70955
70319
  // LEFT: it is the second one in the frame until the travel is over.
70956
70320
  animateTravelProgress(progressFrom, durationMs * travelRatio, easing, drawnArea);
@@ -70967,12 +70331,11 @@ const SlideContainer = ({
70967
70331
  trackAnimationRef.current.finished.then(settleTravel, () => {
70968
70332
  // cancelled by the next travel — that one carries the stage on
70969
70333
  });
70970
- } else if (stage && !dragRef.current?.axis && trackAnimationRef.current?.playState !== "running") {
70334
+ } else if (stage && trackAnimationRef.current?.playState !== "running") {
70971
70335
  // Staged with nothing left to play: a travel that was drawn and then had
70972
70336
  // its animation taken away (a duration set to 0, a re-render landing
70973
70337
  // between the two). Struck at once rather than left standing, since the
70974
- // thing it was standing for is over. A gesture in hand is a stage that is
70975
- // standing for something — the finger still holding it.
70338
+ // thing it was standing for is over.
70976
70339
  settleTravel();
70977
70340
  }
70978
70341
  // A window waiting for its travel to be over (see goToArea's own loop
@@ -71032,6 +70395,11 @@ const SlideContainer = ({
71032
70395
  renderNear(settlingArea, {
71033
70396
  alongside: !stageRef.current
71034
70397
  });
70398
+ // Something moved the row under it at rest: an opening, a page changing, a
70399
+ // slide added before it. Under a hand it stays where the hand has it.
70400
+ if (!stageRef.current && !handMovingRef.current) {
70401
+ sendRowToRest(currentElement);
70402
+ }
71035
70403
  // Said last, and after data-current has been written: what a travel would do
71036
70404
  // is read off the map from the slide that is now current, and off the locks
71037
70405
  // that slide is wearing in this very commit.
@@ -71040,9 +70408,6 @@ const SlideContainer = ({
71040
70408
  // — a single announcement for a render that may have changed several of the
71041
70409
  // facts at once.
71042
70410
  announceState();
71043
- // The finger has the last word: everything above drew the map at rest, and
71044
- // where the track actually is right now is where the gesture put it.
71045
- paintDrag();
71046
70411
  });
71047
70412
 
71048
70413
  /**
@@ -71055,7 +70420,8 @@ const SlideContainer = ({
71055
70420
  value,
71056
70421
  released,
71057
70422
  dx = 0,
71058
- dy = 0
70423
+ dy = 0,
70424
+ landed
71059
70425
  } = {}) => {
71060
70426
  // A window mid-roll cannot travel yet: it is on its way somewhere and the
71061
70427
  // content that goes with it has not moved, so a second travel would be
@@ -71183,7 +70549,7 @@ const SlideContainer = ({
71183
70549
  // A caller writing the change somewhere that keeps a trace — the URL, a
71184
70550
  // history — needs it to know whether a place was aimed at or browsed past.
71185
70551
  const cause = causeOfEvent(event);
71186
- if (commit === "rest") {
70552
+ if (commit === "rest" && !landed) {
71187
70553
  // The travel first, the change once it is over: a caller putting it
71188
70554
  // somewhere expensive or visible (the URL, the address bar, a server)
71189
70555
  // must not be asked for it sixty times a second, and the picture must not
@@ -71568,22 +70934,152 @@ const SlideContainer = ({
71568
70934
  return area;
71569
70935
  };
71570
70936
 
71571
- // Where the track is right now, as the gesture left it: the resting place of
71572
- // the slide being dragged, plus what the pointer has pulled since.
71573
- const paintDrag = () => {
71574
- const drag = dragRef.current;
71575
- const track = trackRef.current;
71576
- // Nothing to paint for a pointer that is only resting on the box: until it
71577
- // has an axis a gesture has moved nothing and knows no geometry.
71578
- if (!drag || !drag.axis || !track) {
70937
+ // Where a hand has the row, in the terms of --slide-travel-progress: from the
70938
+ // current slide while it stands next to it, and past that between the two it
70939
+ // stands between, as far as the hand carries it.
70940
+ const paintNativeRow = () => {
70941
+ if (trackAnimationRef.current?.playState === "running") {
71579
70942
  return;
71580
70943
  }
71581
- const x = drag.baseOffset.x + drag.pull.x;
71582
- const y = drag.baseOffset.y + drag.pull.y;
71583
- drag.offset = `${x}px ${y}px`;
71584
- track.style.setProperty("--slide-container-offset", drag.offset);
71585
- paintTravelProgress(drag.progress, drag.areaPulled);
70944
+ const containerEl = containerRef.current;
70945
+ const slideElements = Array.from(trackRef.current.children);
70946
+ const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current"));
70947
+ if (!currentElement) {
70948
+ return;
70949
+ }
70950
+ if (restsOn(containerEl, currentElement)) {
70951
+ if (handMovingRef.current) {
70952
+ paintTravelProgress();
70953
+ }
70954
+ return;
70955
+ }
70956
+ handMovingRef.current = true;
70957
+ const {
70958
+ base,
70959
+ toward,
70960
+ fraction,
70961
+ forward
70962
+ } = readRowStretch(containerEl, slideElements, currentElement);
70963
+ const baseArea = readArea(base);
70964
+ for (const element of travelPainters()) {
70965
+ if (base === currentElement) {
70966
+ element.removeAttribute(SLIDE_BASE_ATTRIBUTE);
70967
+ } else {
70968
+ element.setAttribute(SLIDE_BASE_ATTRIBUTE, baseArea);
70969
+ }
70970
+ element.style.setProperty("--slide-travel-progress", forward ? -fraction : fraction);
70971
+ }
70972
+ paintTravelToward(toward ? readArea(toward) : null);
70973
+ // What the hand reaches ahead of the neighbours built at rest is asked for on
70974
+ // the way: an empty slide goes by otherwise.
70975
+ renderNear(baseArea, {
70976
+ alongside: true
70977
+ });
71586
70978
  };
70979
+ const landNativeRow = scrollEndEvent => {
70980
+ if (!handMovingRef.current) {
70981
+ return;
70982
+ }
70983
+ const containerEl = containerRef.current;
70984
+ const {
70985
+ slideElements,
70986
+ placeOf
70987
+ } = readMap();
70988
+ const landedElement = pageAtRest(containerEl, slideElements);
70989
+ if (!landedElement) {
70990
+ return;
70991
+ }
70992
+ handMovingRef.current = false;
70993
+ paintTravelProgress();
70994
+ const area = readArea(landedElement);
70995
+ const currentArea = containerEl.getAttribute(SLIDE_CURRENT_ATTRIBUTE);
70996
+ if (area === currentArea) {
70997
+ settleTravel();
70998
+ return;
70999
+ }
71000
+ const event = scrollEndEvent ?? new Event("scrollend");
71001
+ if (loop) {
71002
+ // A window does not change slide, it rolls: back at rest in the render the
71003
+ // content moves in, the two being the same picture.
71004
+ const from = placeOf.get(currentArea) || {
71005
+ x: 0,
71006
+ y: 0
71007
+ };
71008
+ const to = placeOf.get(area) || {
71009
+ x: 0,
71010
+ y: 0
71011
+ };
71012
+ setNoTravel(true);
71013
+ onLoop?.({
71014
+ area,
71015
+ dx: Math.sign(to.x - from.x),
71016
+ dy: Math.sign(to.y - from.y),
71017
+ event
71018
+ });
71019
+ return;
71020
+ }
71021
+ handLandedRef.current = area;
71022
+ if (goToArea(area, {
71023
+ event,
71024
+ landed: true
71025
+ })) {
71026
+ return;
71027
+ }
71028
+ // Refused: the row is put back in view of the slide that stays.
71029
+ handLandedRef.current = null;
71030
+ const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current"));
71031
+ containerEl.scrollTo({
71032
+ left: currentElement.offsetLeft,
71033
+ top: currentElement.offsetTop,
71034
+ behavior: "smooth"
71035
+ });
71036
+ };
71037
+
71038
+ // Reached through a ref, and the listeners are never rebuilt for it: a landing
71039
+ // changes the current slide, and one is often already on its way when it does.
71040
+ const nativeRowHandlersRef = useRef(null);
71041
+ nativeRowHandlersRef.current = {
71042
+ paintNativeRow,
71043
+ landNativeRow
71044
+ };
71045
+ useLayoutEffect(() => {
71046
+ return watchRowScroll(containerRef.current, {
71047
+ onScroll: () => nativeRowHandlersRef.current.paintNativeRow(),
71048
+ onLand: event => nativeRowHandlersRef.current.landNativeRow(event)
71049
+ });
71050
+ }, []);
71051
+
71052
+ // A wheel on an axis only a finger travels (travelByScroll) is not this box's,
71053
+ // though a scroller takes it all the same: it goes to what scrolls inside, or
71054
+ // on to what scrolls around, which is where it was headed.
71055
+ const wheelRefusedAxes = Array.from(dragAxes ?? "").filter(axis => !scrollAxes?.includes(axis)).join("");
71056
+ useLayoutEffect(() => {
71057
+ if (!wheelRefusedAxes) {
71058
+ return undefined;
71059
+ }
71060
+ const containerEl = containerRef.current;
71061
+ const onWheel = wheelEvent => {
71062
+ const axis = Math.abs(wheelEvent.deltaX) > Math.abs(wheelEvent.deltaY) ? "x" : "y";
71063
+ if (!wheelRefusedAxes.includes(axis)) {
71064
+ return;
71065
+ }
71066
+ const delta = axis === "x" ? wheelEvent.deltaX : wheelEvent.deltaY;
71067
+ if (!delta || scrollRoomTowards(wheelEvent.target, containerEl, axis, delta > 0 ? -1 : 1)) {
71068
+ return;
71069
+ }
71070
+ wheelEvent.preventDefault();
71071
+ getScrollContainer(containerEl.parentElement)?.scrollBy({
71072
+ [axis === "x" ? "left" : "top"]: delta,
71073
+ behavior: "instant"
71074
+ });
71075
+ };
71076
+ containerEl.addEventListener("wheel", onWheel, {
71077
+ passive: false
71078
+ });
71079
+ return () => {
71080
+ containerEl.removeEventListener("wheel", onWheel);
71081
+ };
71082
+ }, [wheelRefusedAxes]);
71587
71083
 
71588
71084
  // Everything that draws this travel: the box itself and whoever follows it.
71589
71085
  const travelPainters = () => {
@@ -71716,13 +71212,12 @@ const SlideContainer = ({
71716
71212
  // current slide changes under it.
71717
71213
  const paintTravelProgress = (progress, area) => {
71718
71214
  for (const element of travelPainters()) {
71719
- if (progress) {
71720
- element.style.setProperty("--slide-travel-progress", progress);
71721
- } else {
71215
+ {
71722
71216
  element.style.removeProperty("--slide-travel-progress");
71217
+ element.removeAttribute(SLIDE_BASE_ATTRIBUTE);
71723
71218
  }
71724
71219
  }
71725
- paintTravelToward(progress ? area : null);
71220
+ paintTravelToward(null);
71726
71221
  };
71727
71222
  const cancelTravelProgressAnimation = () => {
71728
71223
  for (const animation of progressAnimationsRef.current) {
@@ -71742,7 +71237,7 @@ const SlideContainer = ({
71742
71237
  // draw is the one about to start.
71743
71238
  followerElementsRef.current = readFollowerElements(containerRef.current);
71744
71239
  cancelTravelProgressAnimation();
71745
- paintTravelProgress(0);
71240
+ paintTravelProgress();
71746
71241
  const painters = travelPainters();
71747
71242
  if (!painters.length || !from || !durationMs) {
71748
71243
  return;
@@ -71763,598 +71258,8 @@ const SlideContainer = ({
71763
71258
  // cancelled by the next travel — that one says where the trait goes
71764
71259
  });
71765
71260
  };
71766
-
71767
- // The two slides the gesture can bring in, placed one box either side of the
71768
- // one being dragged — the same stage a travel builds, except that both ends
71769
- // are set up at once because the finger has not said yet which way it goes.
71770
- const stageDrag = (drag, currentArea) => {
71771
- const {
71772
- slideElements,
71773
- placeOf
71774
- } = readMap();
71775
- const step = drag.axis === "x" ? {
71776
- x: 1,
71777
- y: 0
71778
- } : {
71779
- x: 0,
71780
- y: 1
71781
- };
71782
- const placeByArea = new Map();
71783
- placeByArea.set(drag.area, drag.basePlace);
71784
- if (drag.areaBack) {
71785
- placeByArea.set(drag.areaBack, {
71786
- x: drag.basePlace.x - step.x,
71787
- y: drag.basePlace.y - step.y
71788
- });
71789
- }
71790
- if (drag.areaOn) {
71791
- placeByArea.set(drag.areaOn, {
71792
- x: drag.basePlace.x + step.x,
71793
- y: drag.basePlace.y + step.y
71794
- });
71795
- }
71796
- stageRef.current = {
71797
- placeByArea,
71798
- area: currentArea
71799
- };
71800
- for (const slideElement of slideElements) {
71801
- const area = readArea(slideElement);
71802
- const {
71803
- x,
71804
- y
71805
- } = placeByArea.get(area) || placeOf.get(area) || {
71806
- x: 0,
71807
- y: 0
71808
- };
71809
- slideElement.style.setProperty("--slide-offset", `${x * 100}% ${y * 100}%`);
71810
- slideElement.toggleAttribute("data-slide-offstage", !placeByArea.has(area));
71811
- }
71812
- };
71813
-
71814
- // Let go of, and the slides go where the gesture leaves them: back on the
71815
- // slide being dragged when nothing travels, on to the slide arriving when a
71816
- // travel caught in flight is let carry on. Over the distance left to cover,
71817
- // so a slide barely moved snaps back and one dragged most of the way there
71818
- // takes its time.
71819
- const settleTowards = (drag, area) => {
71820
- const track = trackRef.current;
71821
- if (!track) {
71822
- return;
71823
- }
71824
- const stage = stageRef.current;
71825
- const {
71826
- placeOf
71827
- } = readMap();
71828
- const place = stage?.placeByArea.get(area) || placeOf.get(area) || {
71829
- x: 0,
71830
- y: 0
71831
- };
71832
- const restPx = {
71833
- x: -place.x * drag.box.width,
71834
- y: -place.y * drag.box.height
71835
- };
71836
- const nowPx = {
71837
- x: drag.baseOffset.x + drag.pull.x,
71838
- y: drag.baseOffset.y + drag.pull.y
71839
- };
71840
- const restOffset = `${restPx.x}px ${restPx.y}px`;
71841
- const durationMs = durationToMs(duration);
71842
- const size = drag.axis === "x" ? drag.box.width : drag.box.height;
71843
- const left = Math.abs(restPx[drag.axis] - nowPx[drag.axis]);
71844
- trackAnimationRef.current?.cancel();
71845
- trackAnimationRef.current = null;
71846
- track.style.setProperty("--slide-container-offset", restOffset);
71847
- if (!durationMs || !left) {
71848
- paintTravelProgress(0);
71849
- settleTravel();
71850
- return;
71851
- }
71852
- // Which other slide the picture leans on while it arrives: the one being
71853
- // pulled in when it comes back on the dragged slide, the dragged slide
71854
- // itself when it goes on to the one arriving — and the trait is then a
71855
- // box short of that one, which is what it has to close.
71856
- const arrivesOnBase = area === drag.area;
71857
- const sign = drag.pull[drag.axis] > 0 ? 1 : -1;
71858
- const progressFrom = arrivesOnBase ? drag.progress : drag.progress - sign;
71859
- const otherArea = arrivesOnBase ? drag.areaPulled : drag.area;
71860
- movingRef.current = {
71861
- from: otherArea,
71862
- to: area
71863
- };
71864
- animateTravelProgress(progressFrom, durationMs * (left / size), "ease-out", otherArea);
71865
- const animation = track.animate([{
71866
- translate: `${nowPx.x}px ${nowPx.y}px`
71867
- }, {
71868
- translate: restOffset
71869
- }], {
71870
- duration: durationMs * (left / size),
71871
- easing: "ease-out"
71872
- });
71873
- trackAnimationRef.current = animation;
71874
- animation.finished.then(settleTravel, () => {
71875
- // cancelled by a travel asked for since — that one carries the stage on
71876
- });
71877
- };
71878
-
71879
- // A travel that is playing when a gesture takes hold of it is STOPPED where it
71880
- // stands, at the moment the hand holds it (see readTravelInFlight for when
71881
- // that is): the track stays exactly under the eye, and the hand answers it
71882
- // from there.
71883
- // A gesture that turns out to be nothing lets the travel carry on from where
71884
- // it was caught (see onGiveUp).
71885
- const catchTravelInFlight = () => {
71886
- const trackElement = trackRef.current;
71887
- const travelCaught = trackAnimationRef.current;
71888
- if (!travelCaught || travelCaught.playState !== "running") {
71889
- return null;
71890
- }
71891
- const onScreenPx = trackOffsetPx(trackElement, containerRef.current);
71892
- const offsetOnScreen = `${onScreenPx.x}px ${onScreenPx.y}px`;
71893
- travelCaught.cancel();
71894
- // Where it was, held: cancelling an animation puts the track back on the
71895
- // value underneath it, which is the far end of the travel — the very jump
71896
- // this is about.
71897
- trackElement.style.setProperty("--slide-container-offset", offsetOnScreen);
71898
- const moving = movingRef.current || {};
71899
- return {
71900
- trackElement,
71901
- onScreenPx,
71902
- offsetOnScreen,
71903
- offsetTarget: offsetRef.current,
71904
- from: moving.from,
71905
- to: moving.to
71906
- };
71907
- };
71908
- // Which way a travel still playing is going, read without touching it: a
71909
- // hand landing on something moving has no axis left to decide (a first pixel
71910
- // of tremor read as one gives the gesture up), and which WAY along it the
71911
- // travel goes is what tells the next swipe from a catch. `way` is the sign a
71912
- // pull going the same way would have.
71913
- const readTravelInFlight = () => {
71914
- const trackElement = trackRef.current;
71915
- const animation = trackAnimationRef.current;
71916
- if (!animation || animation.playState !== "running") {
71917
- return null;
71918
- }
71919
- const onScreenPx = trackOffsetPx(trackElement, containerRef.current);
71920
- const targetPx = offsetToPx(offsetRef.current, trackElement.getBoundingClientRect());
71921
- const towardsX = targetPx.x - onScreenPx.x;
71922
- const towardsY = targetPx.y - onScreenPx.y;
71923
- const axis = Math.abs(towardsX) >= Math.abs(towardsY) ? "x" : "y";
71924
- const towards = axis === "x" ? towardsX : towardsY;
71925
- if (Math.abs(towards) < 1) {
71926
- // On its last pixel: nothing left to catch or to push.
71927
- return null;
71928
- }
71929
- return {
71930
- axis,
71931
- way: towards > 0 ? 1 : -1
71932
- };
71933
- };
71934
-
71935
- // What a travel gesture does to the slides, whoever asked for it: a pointer
71936
- // dragging the box and a wheel pushing it sideways ask for the same travel,
71937
- // so they are answered by the same callbacks and only the reading of the
71938
- // input differs (see drag_to_travel.js). The rules of the gesture are read
71939
- // there, the geometry of the slides here.
71940
- const createTravelHandlers = caughtAtStart => {
71941
- let caughtTravel = caughtAtStart;
71942
- // The travel in hand, as the slides see it: where the one being dragged
71943
- // stands, what is either side of it, and how far the gesture has taken it.
71944
- const drag = {
71945
- axis: null,
71946
- area: null,
71947
- areaBack: null,
71948
- areaOn: null,
71949
- areaPulled: null,
71950
- box: null,
71951
- basePlace: null,
71952
- baseOffset: null,
71953
- pull: {
71954
- x: 0,
71955
- y: 0
71956
- },
71957
- progress: 0,
71958
- offset: null,
71959
- gesture: null
71960
- };
71961
- return {
71962
- drag,
71963
- onStart: ({
71964
- axis,
71965
- sign,
71966
- target,
71967
- caught
71968
- }) => {
71969
- // A travel left playing under the press, which the hand has now taken
71970
- // hold of: stopped here, where it stands at this moment.
71971
- if (caught) {
71972
- caughtTravel = catchTravelInFlight();
71973
- }
71974
- // Everything positional is read HERE rather than when the pointer
71975
- // landed: the travel that was playing then may have arrived since, and
71976
- // it is what the slides are doing at the moment the gesture takes them
71977
- // over that the gesture must carry on from.
71978
- const track = trackRef.current;
71979
- const {
71980
- slideElements,
71981
- placeOf
71982
- } = readMap();
71983
- const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current")) || slideElements[0];
71984
- const box = track.getBoundingClientRect();
71985
- if (!currentElement || !box.width || !box.height) {
71986
- return false;
71987
- }
71988
- const currentArea = readArea(currentElement);
71989
- const stage = stageRef.current;
71990
- // Where a slide stands: where the stage put it while a travel is
71991
- // playing, its place on the map otherwise.
71992
- const placeOfArea = someArea => stage?.placeByArea.get(someArea) || placeOf.get(someArea) || {
71993
- x: 0,
71994
- y: 0
71995
- };
71996
- // A travel caught in flight is measured from the slide it SET OFF from,
71997
- // with the slide it was going to as the one thing the hand can bring
71998
- // in — the way drag_to_travel reads a `slack`: how far the box already
71999
- // sits from its resting place, towards what is being pulled in. That is
72000
- // what makes its answer "carry on" mean arriving. Based on the slide
72001
- // arriving instead, the slack would point back at the origin, and a
72002
- // hand that merely touched the box would send it all the way back.
72003
- // A movement with no origin of its own (the track re-placed without a
72004
- // travel) is dragged from the slide shown, like any slide at rest.
72005
- const origin = caughtTravel && caughtTravel.from !== caughtTravel.to ? caughtTravel.from : null;
72006
- let area;
72007
- let areaBack;
72008
- let areaOn;
72009
- if (origin) {
72010
- area = origin;
72011
- const towardsEnd = axis === "x" ? placeOfArea(currentArea).x > placeOfArea(origin).x : placeOfArea(currentArea).y > placeOfArea(origin).y;
72012
- areaBack = towardsEnd ? undefined : currentArea;
72013
- areaOn = towardsEnd ? currentArea : undefined;
72014
- } else {
72015
- area = currentArea;
72016
- areaBack = axis === "x" ? areaTowards(-1, 0) : areaTowards(0, -1);
72017
- areaOn = axis === "x" ? areaTowards(1, 0) : areaTowards(0, 1);
72018
- // The hold goToArea reads at the release, read again HERE, off the
72019
- // same slide and the same attribute: a slide that will refuse the
72020
- // arrival must not offer the journey. A locked direction simply has
72021
- // nowhere to go for the length of this gesture — the one case the
72022
- // gesture already knows, being the last slide of a walk. The hand
72023
- // then gets the wall it can lean on and never walk through (see
72024
- // drag_to_travel), the slide behind it stays offstage instead of
72025
- // being read on the way, and the release has nothing left to refuse.
72026
- // Nothing here about `released` (--navi-done): that is one particular
72027
- // departure letting go, decided as it happens, and a gesture armed
72028
- // before it has no such thing to read — the attribute as rendered is
72029
- // what the hand is answered from.
72030
- if (currentElement.hasAttribute("data-prevent-nav-previous")) {
72031
- areaBack = undefined;
72032
- }
72033
- if (currentElement.hasAttribute("data-prevent-nav-next")) {
72034
- areaOn = undefined;
72035
- }
72036
- if (!areaBack && !areaOn ||
72037
- // Something else with a better claim on the gesture: a scroller
72038
- // between the finger and the slide, with room left that way.
72039
- scrollRoomTowards(target, currentElement, axis, sign)) {
72040
- return false;
72041
- }
72042
- }
72043
- const basePlace = placeOfArea(area);
72044
- const baseOffset = {
72045
- x: -basePlace.x * box.width,
72046
- y: -basePlace.y * box.height
72047
- };
72048
- // Where the track IS: a travel grabbed mid-flight was already stopped
72049
- // where the eye saw it, at the press — this only reads that place, so
72050
- // the gesture starts from it rather than from where the map rests.
72051
- const onScreen = caughtTravel ? caughtTravel.onScreenPx : baseOffset;
72052
- caughtTravel = null;
72053
- trackAnimationRef.current?.cancel();
72054
- trackAnimationRef.current = null;
72055
- cancelTravelProgressAnimation();
72056
- followerElementsRef.current = readFollowerElements(containerRef.current);
72057
- drag.axis = axis;
72058
- drag.areaBack = areaBack;
72059
- drag.areaOn = areaOn;
72060
- drag.area = area;
72061
- drag.box = box;
72062
- drag.basePlace = basePlace;
72063
- drag.baseOffset = baseOffset;
72064
- const size = axis === "x" ? box.width : box.height;
72065
- // Where the track was when the gesture took it over — nowhere, unless
72066
- // it was travelling. Every pull is measured from it.
72067
- const slack = axis === "x" ? onScreen.x - baseOffset.x : onScreen.y - baseOffset.y;
72068
- drag.pull = {
72069
- x: 0,
72070
- y: 0,
72071
- [axis]: slack
72072
- };
72073
- drag.progress = slack / size;
72074
- drag.areaPulled = slack > 0 ? areaBack : slack < 0 ? areaOn : null;
72075
- stageDrag(drag, currentArea);
72076
- // From here the box is busy, whichever input asked: a wheel gesture and
72077
- // a press must not both be moving the same track.
72078
- dragRef.current = drag;
72079
- return {
72080
- size,
72081
- slack,
72082
- travelBack: Boolean(areaBack),
72083
- travelOn: Boolean(areaOn)
72084
- };
72085
- },
72086
- onPull: ({
72087
- axis,
72088
- pulled,
72089
- progress
72090
- }) => {
72091
- drag.pull = {
72092
- ...drag.pull,
72093
- [axis]: pulled
72094
- };
72095
- drag.progress = progress;
72096
- drag.areaPulled = pulled > 0 ? drag.areaBack : drag.areaOn;
72097
- paintDrag();
72098
- },
72099
- // The slide being brought in walked whole and the hand still going, or a
72100
- // travel caught on its way thrown on: either way the hand asks for the
72101
- // slide past it, and the gesture moves on to it. Nothing on screen moves
72102
- // for that — the slide reached becomes the one dragged, the one the hand
72103
- // came from stays next to it for a hand that turns around, and the slide
72104
- // past it is put on the other side.
72105
- onEdge: ({
72106
- axis,
72107
- sign
72108
- }) => {
72109
- const reached = sign > 0 ? drag.areaBack : drag.areaOn;
72110
- const {
72111
- slideElements,
72112
- placeOf
72113
- } = readMap();
72114
- const reachedElement = reached && slideElements.find(slideElement => readArea(slideElement) === reached);
72115
- if (!reachedElement) {
72116
- return false;
72117
- }
72118
- // The gate goToArea reads at the release, asked of the slide the hand
72119
- // is now in: a slide holding on to the user is not walked through.
72120
- const holds = reachedElement.hasAttribute(sign > 0 ? "data-prevent-nav-previous" : "data-prevent-nav-next");
72121
- const past = holds ? undefined : axis === "x" ? areaTowards(-sign, 0, reached) : areaTowards(0, -sign, reached);
72122
- if (!past) {
72123
- return false;
72124
- }
72125
- // Two slides from the one at rest, so possibly never rendered: asked
72126
- // for now, and built before the frame that brings it in is painted.
72127
- renderNear(past, {
72128
- alongside: false
72129
- });
72130
- const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current")) || slideElements[0];
72131
- const basePlace = stageRef.current?.placeByArea.get(reached) || placeOf.get(reached) || {
72132
- x: 0,
72133
- y: 0
72134
- };
72135
- drag.areaBack = sign > 0 ? past : drag.area;
72136
- drag.areaOn = sign > 0 ? drag.area : past;
72137
- drag.area = reached;
72138
- drag.basePlace = basePlace;
72139
- drag.baseOffset = {
72140
- x: -basePlace.x * drag.box.width,
72141
- y: -basePlace.y * drag.box.height
72142
- };
72143
- stageDrag(drag, readArea(currentElement));
72144
- return {
72145
- size: axis === "x" ? drag.box.width : drag.box.height,
72146
- travelBack: Boolean(drag.areaBack),
72147
- travelOn: Boolean(drag.areaOn)
72148
- };
72149
- },
72150
- onEnd: ({
72151
- axis,
72152
- sign,
72153
- travels,
72154
- event
72155
- }) => {
72156
- dragRef.current = null;
72157
- // Where the gesture leaves the slides: on the one it was pulling in
72158
- // when it says so, back on the one it was dragging otherwise.
72159
- const destination = travels ? sign > 0 ? drag.areaBack : drag.areaOn : drag.area;
72160
- const {
72161
- slideElements,
72162
- placeOf
72163
- } = readMap();
72164
- const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current")) || slideElements[0];
72165
- const currentArea = currentElement && readArea(currentElement);
72166
- if (!destination || destination === currentArea) {
72167
- // Nothing to ask of the state: the slide it names is already the one
72168
- // shown — a drag let go of short of a travel, a travel caught in
72169
- // flight and let carry on. The track goes there on its own.
72170
- settleTowards(drag, destination || drag.area);
72171
- return;
72172
- }
72173
- // Where the slide is being left, for the travel to depart from instead
72174
- // of from the map.
72175
- travelFromRef.current = drag.offset;
72176
- // …and where the indicator is being left, said about the slide that is
72177
- // ARRIVING: a box short of the slide being pulled in, right where the
72178
- // finger left it when going back to the slide being dragged.
72179
- travelProgressFromRef.current = travels ? drag.progress - sign : drag.progress;
72180
- // Which way the travel goes, read off where the two slides stand
72181
- // rather than off the pull: a travel caught in flight and put back
72182
- // goes the way it came, whatever the finger did meanwhile.
72183
- const stage = stageRef.current;
72184
- const placeOfArea = someArea => stage?.placeByArea.get(someArea) || placeOf.get(someArea) || {
72185
- x: 0,
72186
- y: 0
72187
- };
72188
- const towards = Math.sign(placeOfArea(destination)[axis] - placeOfArea(currentArea)[axis]);
72189
- const moved = goToArea(destination, {
72190
- forward: towards > 0,
72191
- event,
72192
- dx: axis === "x" ? towards : 0,
72193
- dy: axis === "y" ? towards : 0
72194
- });
72195
- if (!moved) {
72196
- // Nowhere to go after all — a slide holding on to the user
72197
- // (preventNav), or a caller that refused the change.
72198
- travelFromRef.current = null;
72199
- travelProgressFromRef.current = null;
72200
- settleTowards(drag, currentArea);
72201
- return;
72202
- }
72203
- // A container whose `current` is held outside and was not moved: no
72204
- // render moved the track, so the hand-off is still waiting and the
72205
- // track still under where the finger left it. One frame is all it
72206
- // takes to know.
72207
- requestAnimationFrame(() => {
72208
- if (travelFromRef.current) {
72209
- travelFromRef.current = null;
72210
- travelProgressFromRef.current = null;
72211
- settleTowards(drag, currentArea);
72212
- }
72213
- });
72214
- },
72215
- onGiveUp: () => {
72216
- dragRef.current = null;
72217
- // A press that never became a gesture: what it stopped goes on its way,
72218
- // from where the finger caught it and over what is left of the travel.
72219
- if (!caughtTravel) {
72220
- return;
72221
- }
72222
- const {
72223
- trackElement: trackCaught,
72224
- offsetOnScreen,
72225
- offsetTarget,
72226
- onScreenPx: caughtOnScreenPx,
72227
- from,
72228
- to
72229
- } = caughtTravel;
72230
- caughtTravel = null;
72231
- movingRef.current = {
72232
- from,
72233
- to
72234
- };
72235
- if (offsetOnScreen === offsetTarget) {
72236
- settleTravel();
72237
- return;
72238
- }
72239
- const durationMs = durationToMs(duration);
72240
- // What is LEFT of it, at the pace it had: a travel caught nine tenths
72241
- // of the way there and let go of does not start its duration again.
72242
- const boxRect = trackCaught.getBoundingClientRect();
72243
- const targetPx = offsetToPx(offsetTarget, boxRect);
72244
- const leftToCover = Math.abs(drag.axis === "y" ? targetPx.y - caughtOnScreenPx.y : targetPx.x - caughtOnScreenPx.x);
72245
- const size = drag.axis === "y" ? boxRect.height : boxRect.width;
72246
- const travelRatio = size ? leftToCover / size : 1;
72247
- trackCaught.style.setProperty("--slide-container-offset", offsetTarget);
72248
- trackAnimationRef.current = trackCaught.animate([{
72249
- translate: offsetOnScreen
72250
- }, {
72251
- translate: offsetTarget
72252
- }], {
72253
- duration: durationMs * travelRatio,
72254
- easing: "ease-out"
72255
- });
72256
- trackAnimationRef.current.finished.then(settleTravel, () => {
72257
- // taken over by a travel asked for since
72258
- });
72259
- }
72260
- };
72261
- };
72262
-
72263
- // Whether a gesture may begin at all: one travel at a time, and a window
72264
- // mid-roll has nothing to drag yet — it is on its way somewhere and the
72265
- // content that goes with it has not moved (see goToArea).
72266
- const canStartTravel = () => dragAxes && !dragRef.current && !rollingRef.current;
72267
- const startDrag = pointerDownEvent => {
72268
- if (!canStartTravel()) {
72269
- return;
72270
- }
72271
- const travelInFlight = readTravelInFlight();
72272
- // Left playing when the hand can take it: the press is then far more
72273
- // likely the next swipe than a catch, and stopping the travel under it is
72274
- // what that swipe feels as a stall — the gesture listens to the hand first
72275
- // and says which it was (see inFlight in drag_to_travel.js). On an axis the
72276
- // hand cannot drag, it is caught at the press, as a travel under a finger
72277
- // that starts something else.
72278
- const leftPlaying = Boolean(travelInFlight && dragAxes.includes(travelInFlight.axis));
72279
- const caughtTravel = travelInFlight && !leftPlaying ? catchTravelInFlight() : null;
72280
- const handlers = createTravelHandlers(caughtTravel);
72281
- const gesture = startDragToTravel(pointerDownEvent, {
72282
- element: containerRef.current,
72283
- axes: dragAxes,
72284
- inFlight: leftPlaying ? travelInFlight : false,
72285
- // The next swipe over a travel still playing: one slide further, and the
72286
- // travel goes on there from where it is — the way a second arrow press
72287
- // sends it — without ever having stopped under the hand.
72288
- onPushOn: ({
72289
- axis,
72290
- sign,
72291
- event
72292
- }) => {
72293
- if (trackAnimationRef.current?.playState !== "running") {
72294
- return false;
72295
- }
72296
- // Nothing is held: the box is free for the travel that follows.
72297
- dragRef.current = null;
72298
- if (axis === "x") {
72299
- move(-sign, 0, event);
72300
- } else {
72301
- move(0, -sign, event);
72302
- }
72303
- return true;
72304
- },
72305
- ...handlers
72306
- });
72307
- if (!gesture) {
72308
- // Not a press this box can be about — something that reads the pointer
72309
- // itself, a box below it that travels the same way. Whatever the press
72310
- // stopped on its way in goes back on its way.
72311
- handlers.onGiveUp();
72312
- return;
72313
- }
72314
- handlers.drag.gesture = gesture;
72315
- dragRef.current = handlers.drag;
72316
- };
72317
-
72318
- // A wheel pushing the box sideways asks for a SLIDE, not for a place between
72319
- // two: one push, one slide — the same thing an arrow key asks for, and it
72320
- // plays at its own pace rather than under a hand (see watchWheelTravel).
72321
- // Watched for the whole life of the box because such a gesture has no press
72322
- // to start it: it begins with its first event.
72323
- //
72324
- // Reached through a ref and never rebuilt for it: a travel CHANGES which
72325
- // slide is current, so a watcher listening on that would be torn down halfway
72326
- // through the very gesture moving it.
72327
- const travelOneStepRef = useRef(null);
72328
- travelOneStepRef.current = ({
72329
- axis,
72330
- sign,
72331
- event
72332
- }) => {
72333
- if (dragRef.current) {
72334
- // A hand is holding the slides. They are its until it lets go.
72335
- return;
72336
- }
72337
- if (axis === "x") {
72338
- move(-sign, 0, event);
72339
- } else {
72340
- move(0, -sign, event);
72341
- }
72342
- };
72343
- useLayoutEffect(() => {
72344
- if (!scrollAxes) {
72345
- return undefined;
72346
- }
72347
- return watchWheelTravel(containerRef.current, {
72348
- axes: scrollAxes,
72349
- onStep: detail => travelOneStepRef.current(detail)
72350
- });
72351
- }, [scrollAxes]);
72352
-
72353
- // A gesture is listening on things that outlive this component.
72354
71261
  useLayoutEffect(() => {
72355
71262
  return () => {
72356
- dragRef.current?.gesture?.stop();
72357
- dragRef.current = null;
72358
71263
  cancelTravelProgressAnimation();
72359
71264
  };
72360
71265
  }, []);
@@ -72483,31 +71388,22 @@ const SlideContainer = ({
72483
71388
  // names, and to write the place this box stands on (see `signal`).
72484
71389
  ,
72485
71390
  "data-slide-shares-state": sharesState ? "" : undefined
72486
- // Which axes a touch may travel on, said in the DOM: what the browser
72487
- // does with a finger is decided by CSS (touch-action) before any of this
72488
- // has seen the gesture — and it is also what a box HOLDING this one reads
72489
- // to know the gesture is not its own (see drag_to_travel.js).
72490
- ,
72491
- "data-travel-by-drag": dragAxes ?? undefined
72492
- // …and said as a listener too, from the same render: a touch this box may
72493
- // take has to be refusable before the finger lands, and being registered
72494
- // is the whole of it — see keepTouchRefusable for why a JSX prop is
72495
- // enough here (an element-level touchmove listener is non-passive).
72496
- // Without it, a swipe the box has taken can be cancelled mid-gesture by
72497
- // the browser scrolling the page along the axis touch-action leaves it.
72498
- ,
72499
- onTouchMove: dragAxes ? keepTouchRefusable : undefined
72500
- // The same fact for a wheel, and only for that second reason: this box
72501
- // takes the push, whatever the box around it also travels on.
71391
+ // Which axes the row scrolls on under a hand, and which of them a finger may
71392
+ // not take (see the css above).
72502
71393
  ,
72503
- "data-travel-by-wheel": scrollAxes ?? undefined
72504
- // The same fact, read by the shared gesture stylesheet: what scrolls
72505
- // inside a box that travels must not spill onto the page behind it (see
72506
- // drag_to_travel.js).
71394
+ "data-slide-hand": travelAxes ?? undefined,
71395
+ "data-slide-no-touch": Array.from(scrollAxes ?? "").filter(axis => !dragAxes?.includes(axis)).join("") || undefined
71396
+ // Read by what stands around the box: a press landing here may become a
71397
+ // scroll, so a control acting on the press waits for its click, and a box
71398
+ // around that a finger pushes leaves this axis alone (see drag_to_travel.js).
72507
71399
  ,
72508
- "data-drag-travel": travelAxes ?? undefined,
71400
+ "data-travel-by-drag": dragAxes ?? undefined,
72509
71401
  onPointerDown: e => {
72510
- startDrag(e);
71402
+ // A finger on a travel still playing: it is the row's from here, and the
71403
+ // row is where the travel is going.
71404
+ if (e.pointerType !== "mouse") {
71405
+ trackAnimationRef.current?.finish();
71406
+ }
72511
71407
  rest.onPointerDown?.(e);
72512
71408
  }
72513
71409
  // The focusable one, and a Tab stop: a slide is not (see Slide), so the
@@ -74184,7 +73080,7 @@ const css$H = /* css */`@property --x-popup-group-top {
74184
73080
  transition: none;
74185
73081
  }
74186
73082
 
74187
- :is(:root[data-navi-route-transition] .navi_popup_group:modal, :root[data-navi-route-travel] .navi_popup_group:modal) {
73083
+ :root[data-navi-route-transition] .navi_popup_group:modal {
74188
73084
  view-transition-class: navi_popup;
74189
73085
  }
74190
73086
 
@@ -74192,10 +73088,6 @@ const css$H = /* css */`@property --x-popup-group-top {
74192
73088
  :root[data-navi-route-transition]:not([data-navi-route-transition-target="area"]) .navi_popup_group:modal {
74193
73089
  view-transition-name: match-element;
74194
73090
  }
74195
-
74196
- :root[data-navi-route-travel] .navi_popup_group:modal {
74197
- view-transition-name: match-element;
74198
- }
74199
73091
  }
74200
73092
  `;
74201
73093
 
@@ -76153,8 +75045,7 @@ const popupCss = /* css */ `
76153
75045
  }
76154
75046
  }
76155
75047
 
76156
- /* While the PAGES are the ones moving — a route transition, a route travel —
76157
- an open popup takes a picture of its own. The pictures of such a movement
75048
+ /* While the PAGES are the ones moving, in a route transition, an open popup takes a picture of its own. The pictures of such a movement
76158
75049
  are drawn in the top layer, above everything the document paints, and a
76159
75050
  popup lives in the top layer too: uncaptured, it is simply covered for the
76160
75051
  length of the movement, so it disappears the instant its page starts to
@@ -76164,9 +75055,7 @@ const popupCss = /* css */ `
76164
75055
  The class is how navi reaches that picture afterwards: its clock below,
76165
75056
  and its place above the pages (the z-order in nav/route_transition.jsx). */
76166
75057
  :root[data-navi-route-transition] .navi_popover[aria-expanded="true"],
76167
- :root[data-navi-route-transition] .navi_dialog[aria-expanded="true"],
76168
- :root[data-navi-route-travel] .navi_popover[aria-expanded="true"],
76169
- :root[data-navi-route-travel] .navi_dialog[aria-expanded="true"] {
75058
+ :root[data-navi-route-transition] .navi_dialog[aria-expanded="true"] {
76170
75059
  view-transition-class: navi_popup;
76171
75060
  }
76172
75061
 
@@ -76190,9 +75079,7 @@ const popupCss = /* css */ `
76190
75079
  :root[data-navi-route-transition]:not(
76191
75080
  [data-navi-route-transition-target="area"]
76192
75081
  )
76193
- .navi_dialog[aria-expanded="true"],
76194
- :root[data-navi-route-travel] .navi_popover[aria-expanded="true"],
76195
- :root[data-navi-route-travel] .navi_dialog[aria-expanded="true"] {
75082
+ .navi_dialog[aria-expanded="true"] {
76196
75083
  view-transition-name: match-element;
76197
75084
  }
76198
75085
  }
@@ -76204,13 +75091,205 @@ const popupCss = /* css */ `
76204
75091
  :root[data-navi-route-transition]::view-transition-new(.navi_popup) {
76205
75092
  animation-duration: var(--navi-route-transition-duration, 300ms);
76206
75093
  }
76207
- :root[data-navi-route-travel]::view-transition-group(.navi_popup),
76208
- :root[data-navi-route-travel]::view-transition-old(.navi_popup),
76209
- :root[data-navi-route-travel]::view-transition-new(.navi_popup) {
76210
- animation-duration: var(--navi-route-travel-duration, 300ms);
76211
- }
76212
75094
  `;
76213
75095
 
75096
+ /**
75097
+ * A modal dialog covered by another modal dialog is not in use, so it does not
75098
+ * answer the keyboard: a sheet raising one leaves what is behind it where it
75099
+ * is, under the keyboard like anything else behind that sheet.
75100
+ *
75101
+ * Two things make an open dialog follow the keyboard, and both are held here
75102
+ * for as long as it is covered:
75103
+ * - its ceiling (--dialog-maxmax-height), which CSS keeps tracking
75104
+ * --navi-app-height. Written back resolved, inline. `freezeSize` cannot do it:
75105
+ * a max-* wins over the width/height it writes;
75106
+ * - its placement, which reads the live viewport. Deferred, see
75107
+ * deferPlacementWhileCovered.
75108
+ *
75109
+ * A dialog back on top answers the screen as it is, at once, with one exception:
75110
+ * a keyboard on its way out. It is still counted for a while after the sheet
75111
+ * closes (the browser takes its time, then --navi-vvh waits for the resize to
75112
+ * settle, see responsive.js), and answering that makes the dialog jump up and
75113
+ * back down. A keyboard belongs to a focused field, so the focus says which case
75114
+ * this is: on a field the keyboard stays, anywhere else it is leaving and the
75115
+ * dialog waits for the viewport to change. No timer: how long a keyboard takes
75116
+ * to leave is unknown, and a duration would also delay a keyboard that stays.
75117
+ *
75118
+ * Only modal dialogs stack here. What sits under a non-modal one is still
75119
+ * usable, and keeps following the keyboard.
75120
+ */
75121
+
75122
+
75123
+ const CEILING = "--dialog-maxmax-height";
75124
+
75125
+ const modalStack = [];
75126
+
75127
+ /**
75128
+ * @param {HTMLDialogElement} dialogEl - The modal dialog being shown.
75129
+ * @param {object} params
75130
+ * @param {() => void} params.place - Places the dialog against the viewport as
75131
+ * it is. Called on a dialog that was held once it answers the viewport again.
75132
+ * @returns {() => void} To call when the dialog closes.
75133
+ */
75134
+ const enterModalStack = (dialogEl, { place }) => {
75135
+ const entry = { dialogEl, place, hold: null };
75136
+ const entryBelow = modalStack.at(-1);
75137
+ if (entryBelow) {
75138
+ holdEntry(entryBelow);
75139
+ }
75140
+ modalStack.push(entry);
75141
+ return () => {
75142
+ const index = modalStack.indexOf(entry);
75143
+ const wasOnTop = index === modalStack.length - 1;
75144
+ modalStack.splice(index, 1);
75145
+ dropHold(entry);
75146
+ if (wasOnTop && modalStack.length > 0) {
75147
+ releaseBackOnTop(modalStack.at(-1), { closedDialogEl: dialogEl });
75148
+ }
75149
+ };
75150
+ };
75151
+
75152
+ /**
75153
+ * Answers true when the dialog must not be placed right now, and remembers to
75154
+ * place it on release.
75155
+ *
75156
+ * Only while the room differs from the one it was covered with: the placement
75157
+ * reads the live viewport whatever triggered it, so with the same room it lands
75158
+ * where the dialog already is, and content growing under the sheet is answered
75159
+ * as usual.
75160
+ */
75161
+ const deferPlacementWhileCovered = (dialogEl) => {
75162
+ const entry = modalStack.find((candidate) => candidate.dialogEl === dialogEl);
75163
+ if (!entry || !entry.hold) {
75164
+ return false;
75165
+ }
75166
+ if (readRoom() === entry.hold.room) {
75167
+ return false;
75168
+ }
75169
+ entry.hold.placementOwed = true;
75170
+ return true;
75171
+ };
75172
+
75173
+ const holdEntry = (entry) => {
75174
+ if (entry.hold) {
75175
+ // Covered again before the keyboard had left: the ceiling it holds is still
75176
+ // the one it had before all of this.
75177
+ entry.hold.stopWaiting?.();
75178
+ entry.hold.stopWaiting = null;
75179
+ return;
75180
+ }
75181
+ const { dialogEl, place } = entry;
75182
+ holdCeiling(dialogEl);
75183
+ entry.hold = {
75184
+ room: readRoom(),
75185
+ placementOwed: false,
75186
+ stopWaiting: null,
75187
+ windowWidth: window.innerWidth,
75188
+ windowHeight: window.innerHeight,
75189
+ // A rotation, which a covered dialog has to fit, is told from a keyboard by
75190
+ // the window's size: the keyboard leaves it alone (see safe_area.md). Read
75191
+ // once the visual viewport settles, not on the window's own resize: that one
75192
+ // can fire first, while --navi-vvh still describes the screen being left.
75193
+ stopWatchingWindow: subscribeVisualViewportResizeSettled(() => {
75194
+ const { hold } = entry;
75195
+ if (
75196
+ window.innerWidth === hold.windowWidth &&
75197
+ window.innerHeight === hold.windowHeight
75198
+ ) {
75199
+ return;
75200
+ }
75201
+ hold.windowWidth = window.innerWidth;
75202
+ hold.windowHeight = window.innerHeight;
75203
+ holdCeiling(dialogEl);
75204
+ hold.room = readRoom();
75205
+ hold.placementOwed = false;
75206
+ place();
75207
+ }),
75208
+ };
75209
+ };
75210
+
75211
+ const releaseBackOnTop = (entry, { closedDialogEl }) => {
75212
+ const { hold } = entry;
75213
+ if (!hold) {
75214
+ return;
75215
+ }
75216
+ // Nothing taken away, or a keyboard that stays: the room read here is the
75217
+ // screen as it is. A field in the dialog that closed is one still leaving: a
75218
+ // dialog leaving the tree only gives the focus back after the commit.
75219
+ const { activeElement } = document;
75220
+ if (
75221
+ readRoom() >= hold.room ||
75222
+ (isEditableTarget(activeElement) && !closedDialogEl.contains(activeElement))
75223
+ ) {
75224
+ release(entry);
75225
+ return;
75226
+ }
75227
+ // Any change, not the room it was covered with: the bars of a mobile browser
75228
+ // may come back different, and the wait would never end.
75229
+ const roomWithKeyboardLeaving = readRoom();
75230
+ const releaseIfViewportChanged = () => {
75231
+ if (readRoom() !== roomWithKeyboardLeaving) {
75232
+ release(entry);
75233
+ }
75234
+ };
75235
+ // The focus can come back after the close (a popup leaving the tree gives it
75236
+ // back once the commit is over), and the keyboard then stays after all.
75237
+ const releaseIfFieldFocused = () => {
75238
+ if (isEditableTarget(document.activeElement)) {
75239
+ release(entry);
75240
+ }
75241
+ };
75242
+ document.addEventListener("focusin", releaseIfFieldFocused, {
75243
+ capture: true,
75244
+ });
75245
+ const unsubscribes = [
75246
+ visualViewportHeightSignal.subscribe(releaseIfViewportChanged),
75247
+ subscribeVirtualKeyboardGeometryChange(releaseIfViewportChanged),
75248
+ ];
75249
+ hold.stopWaiting = () => {
75250
+ document.removeEventListener("focusin", releaseIfFieldFocused, {
75251
+ capture: true,
75252
+ });
75253
+ for (const unsubscribe of unsubscribes) {
75254
+ unsubscribe();
75255
+ }
75256
+ };
75257
+ };
75258
+
75259
+ const release = (entry) => {
75260
+ const { placementOwed } = entry.hold;
75261
+ dropHold(entry);
75262
+ if (placementOwed) {
75263
+ entry.place();
75264
+ }
75265
+ };
75266
+
75267
+ const dropHold = (entry) => {
75268
+ const { hold, dialogEl } = entry;
75269
+ if (!hold) {
75270
+ return;
75271
+ }
75272
+ hold.stopWaiting?.();
75273
+ hold.stopWatchingWindow();
75274
+ dialogEl.style.removeProperty(CEILING);
75275
+ entry.hold = null;
75276
+ };
75277
+
75278
+ // The declared ceiling rather than the used max-height: it is the one cap that
75279
+ // follows the keyboard, and the other caps (maxHeight) keep their say. Its
75280
+ // computed value has every var() resolved, so it stops following once written
75281
+ // back. Registered with inherits: false, it does not reach a dialog nested
75282
+ // inside this one.
75283
+ const holdCeiling = (dialogEl) => {
75284
+ dialogEl.style.removeProperty(CEILING);
75285
+ const ceiling = getComputedStyle(dialogEl).getPropertyValue(CEILING);
75286
+ dialogEl.style.setProperty(CEILING, ceiling);
75287
+ };
75288
+
75289
+ // An overlaying keyboard leaves the viewport alone and arrives on its own.
75290
+ const readRoom = () =>
75291
+ visualViewportHeightSignal.peek() - getVirtualKeyboardOverlayHeight();
75292
+
76214
75293
  /**
76215
75294
  * Pushing a popup docked to an edge (a side panel, a bottom sheet) back the way
76216
75295
  * it came in closes it: it follows the finger while it travels, and letting go
@@ -78617,6 +77696,9 @@ const useDialogProps = props => {
78617
77696
  const rectEffect = visibleRectEffect(positionedAncestor, (visibleRect, {
78618
77697
  event
78619
77698
  }) => {
77699
+ if (deferPlacementWhileCovered(dialogEl)) {
77700
+ return;
77701
+ }
78620
77702
  positionDialog(event);
78621
77703
  // Only for what can have taken height away from the dialog — a
78622
77704
  // scroll never does, and re-scrolling on one would fight the finger
@@ -78636,6 +77718,17 @@ const useDialogProps = props => {
78636
77718
  freezeSize(dialogEl);
78637
77719
  }
78638
77720
  rectEffect.observeSize(dialogEl);
77721
+ if (isModal) {
77722
+ addCleanup(enterModalStack(dialogEl, {
77723
+ // "resize": what it was held from is a viewport change, and it answers
77724
+ // the way it answers one, animated. Called directly because the
77725
+ // effect's dedup already saw that viewport go by.
77726
+ place: () => {
77727
+ positionDialog(new CustomEvent("resize"));
77728
+ keepFocusedElementVisible(dialogEl);
77729
+ }
77730
+ }));
77731
+ }
78639
77732
  // Content that sizes itself once on screen (a shrink-wrapped BadgeList)
78640
77733
  // says so, after the placement read it: placed again on the spot, where
78641
77734
  // observeSize answers a frame late. Stopped here, since a popup further up
@@ -82429,6 +81522,13 @@ const PickerCustom = props => {
82429
81522
  };
82430
81523
  },
82431
81524
  keyDown: e => {
81525
+ // Pressed in a popup this picker holds: the key is that popup's, and
81526
+ // `false` keeps it from the default reaction too, which would refuse
81527
+ // it on a read-only picker and take a Space from the button it was
81528
+ // pressed on.
81529
+ if (isPressOnLayerOver(e.target, e.currentTarget)) {
81530
+ return false;
81531
+ }
82432
81532
  // A shortcut that applies has been through the gate already, under
82433
81533
  // its own name and intent: the key is answered. Handed back as the
82434
81534
  // reaction, its boolean would be asked again, unnamed — as a write,
@@ -85938,7 +85038,7 @@ const css$w = /* css */`@property --list-group-label-height {
85938
85038
  }
85939
85039
 
85940
85040
  @supports (view-transition-group: contain) {
85941
- :root:not([data-navi-route-transition], [data-navi-route-travel]) .navi_list_container[data-item-transition] {
85041
+ :root:not([data-navi-route-transition]) .navi_list_container[data-item-transition] {
85942
85042
  view-transition-name: match-element;
85943
85043
  view-transition-class: navi_list_transition;
85944
85044
  view-transition-group: contain;
@@ -87512,8 +86612,8 @@ const useListScrollSync = ({
87512
86612
  return undefined;
87513
86613
  }
87514
86614
  return routePage.leavingSignal.subscribe(leaving => {
86615
+ pageLeftRef.current = leaving;
87515
86616
  if (leaving) {
87516
- pageLeftRef.current = true;
87517
86617
  letGo();
87518
86618
  }
87519
86619
  });
@@ -108893,7 +107993,7 @@ const css = /* css */`.navi_side_panel {
108893
107993
  --dialog-maxmax-width: var(--navi-app-width);
108894
107994
 
108895
107995
  &[navi-side="left"], &[navi-side="right"] {
108896
- height: var(--navi-side-panel-height, var(--navi-app-height));
107996
+ height: var(--navi-side-panel-height, var(--dialog-maxmax-height));
108897
107997
  }
108898
107998
 
108899
107999
  &[navi-side="top"], &[navi-side="bottom"] {