@jsenv/navi 0.29.523 → 0.30.0

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, 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, onAncestorReopen, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, setVirtualKeyboardOverlaysContent, subscribeVirtualKeyboardGeometryChange, getVirtualKeyboardOverlayHeight } from "@jsenv/dom";
6
+ import { createPubSub, createIterableWeakSet, elementIsFocusable, getScrollContainer, dispatchInternalCustomEvent, dispatchCustomEvent, 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, subscribeWindowResizeSettled, 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, useReducer, useEffect, 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, 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";
@@ -417,8 +417,7 @@ const flushSyncRendering = (fn) => {
417
417
  * it was left, and the bottom arrives under a page that has already jumped. So
418
418
  * whoever writes an arrival's offset builds first the boxes that offset shows
419
419
  * (buildDeferredBoxesAbove), in the same task — synchronously, since what comes
420
- * right after it (the offset, a route travel measuring its window) reads the
421
- * layout.
420
+ * right after it (the offset) reads the layout.
422
421
  */
423
422
 
424
423
  const deferredBoxes = new Set();
@@ -519,9 +518,8 @@ const readTopInWindow = element => {
519
518
  * around Preact's `options.debounceRendering`, and two of them installed
520
519
  * independently restore each other in the wrong order when they let go — every
521
520
  * render queued in between is then handed to a wrapper nobody will ever
522
- * release. Everything that photographs a navigation (RouteTravel's box, a
523
- * route transition) must therefore hold through this module, never through a
524
- * wrapper of its own.
521
+ * release. Everything that photographs a navigation must therefore hold through
522
+ * this module, never through a wrapper of its own.
525
523
  */
526
524
 
527
525
 
@@ -9829,8 +9827,8 @@ const haveSameMembers = (setA, setB) => {
9829
9827
  * A route matching is a signal changing, and the page it selects reaches the
9830
9828
  * DOM only once Preact has rendered — an unknown number of passes later, in an
9831
9829
  * unknown number of microtasks. Anyone who needs the page as it IS rather than
9832
- * as it has been decided (a travel about to have its picture taken by the
9833
- * browser, see route_travel.jsx) waits for this instead of counting.
9830
+ * as it has been decided (a route transition about to have its picture taken by
9831
+ * the browser) waits for this instead of counting.
9834
9832
  *
9835
9833
  * A page waiting on data is announced too, by the boundary showing its loading
9836
9834
  * state (see Loading in use_async_data.jsx): what the container could put on
@@ -23591,10 +23589,10 @@ const readNumberFromDom = (element, attribute, defaultValue) => {
23591
23589
  *
23592
23590
  * A popup is painted OVER the page and stays a DOM descendant of whatever it
23593
23591
  * was written in — a dialog inside the card that opens it, a callout beside the
23594
- * button it belongs to. Its pointer events therefore bubble through boxes it
23595
- * covers, and a box reading a gesture off one of them answers for something
23596
- * nobody aimed at it: a finger held in a sheet opening the card underneath the
23597
- * sheet, a card the sheet is drawn from and that the finger cannot even reach.
23592
+ * button it belongs to. Its pointer and key events therefore bubble through
23593
+ * boxes it covers, and a box reading a gesture off one of them answers for
23594
+ * something nobody aimed at it: a finger held in a sheet opening the card
23595
+ * underneath the sheet, an arrow pressed in a sheet walking the slides behind it.
23598
23596
  *
23599
23597
  * The same list, read for the same reason, as the drag sources in @jsenv/dom
23600
23598
  * (see DRAG_IGNORED_SELECTOR in drag_to.js) and as the selection rule in
@@ -24333,26 +24331,7 @@ const ensureDocumentStartViewTransition = () => {
24333
24331
  startViewTransitionPolyfill.isPolyfill = true;
24334
24332
  document.startViewTransition = startViewTransitionPolyfill;
24335
24333
  }
24336
- return startViewTransition$2;
24337
- };
24338
-
24339
- // A transition a finger is holding still (see route_travel.jsx): it has to be
24340
- // let go of before any other one starts, and this is the only place that knows
24341
- // a new one is about to. There is a single transition per document — starting
24342
- // one SKIPS the one in flight — and the hold that keeps pictures under a finger
24343
- // is written against whatever transition is running, because everything the
24344
- // gesture must carry along (a trait under a tab row) belongs to that same
24345
- // transition and has no name of its own here. Left on, it takes hold of the
24346
- // transition that has just replaced ours: born paused, held by nobody, it never
24347
- // finishes, and its pictures stand over a page that cannot be touched anymore.
24348
- let releaseHeldViewTransition = null;
24349
- const holdViewTransition = (release) => {
24350
- releaseHeldViewTransition = release;
24351
- return () => {
24352
- if (releaseHeldViewTransition === release) {
24353
- releaseHeldViewTransition = null;
24354
- }
24355
- };
24334
+ return startViewTransition$1;
24356
24335
  };
24357
24336
 
24358
24337
  // The last transition started here, for code that runs a callback of the
@@ -24362,12 +24341,7 @@ const holdViewTransition = (release) => {
24362
24341
  let lastViewTransition = null;
24363
24342
  const peekLastViewTransition = () => lastViewTransition;
24364
24343
 
24365
- const startViewTransition$2 = (updateCallback) => {
24366
- if (releaseHeldViewTransition) {
24367
- const release = releaseHeldViewTransition;
24368
- releaseHeldViewTransition = null;
24369
- release();
24370
- }
24344
+ const startViewTransition$1 = (updateCallback) => {
24371
24345
  const viewTransition = document.startViewTransition(updateCallback);
24372
24346
  viewTransition.updateCallbackDone.catch(ignoreSkip);
24373
24347
  viewTransition.ready.catch(ignoreSkip);
@@ -26236,6 +26210,9 @@ const createOnKeyDownForShortcutArray = (
26236
26210
  };
26237
26211
  };
26238
26212
  /**
26213
+ * A key pressed in a popup nested in the listening element is not offered to
26214
+ * these shortcuts: see applyKeyboardShortcuts.
26215
+ *
26239
26216
  * @param {object} shortcuts
26240
26217
  * @param {object} [options]
26241
26218
  * @param {(target: Element) => boolean} [options.ownsTarget] Whether a
@@ -26261,6 +26238,13 @@ const createOnKeyDownForShortcuts = (shortcuts, { ownsTarget } = {}) => {
26261
26238
  const applyKeyboardShortcuts = (shortcuts, keyboardEvent, { ownsTarget }) => {
26262
26239
  const currentTarget = keyboardEvent.currentTarget;
26263
26240
  const target = keyboardEvent.target;
26241
+ // A popup stays a DOM descendant of what it was written in, so a key pressed
26242
+ // in it bubbles through boxes the reader cannot reach while it is open. Those
26243
+ // boxes have no shortcut to answer it with: an arrow in a sheet does not walk
26244
+ // the slides behind it, and Escape closes the popup it was pressed in.
26245
+ if (isPressOnLayerOver(target, currentTarget)) {
26246
+ return null;
26247
+ }
26264
26248
  let canIntercept;
26265
26249
  if (target === currentTarget) {
26266
26250
  // the event occurs on the element itself so we are sure we won't interfere with native behaviors of child elements
@@ -26496,6 +26480,9 @@ const isSameKey = (browserEventKey, key) => {
26496
26480
  * well, whether the gate lets the interaction through or refuses it and says
26497
26481
  * why — the same as a number field's arrows.
26498
26482
  *
26483
+ * A key pressed in a popup nested in the element is not this element's, like a
26484
+ * press there (see isPressOnLayerOver).
26485
+ *
26499
26486
  * The interaction is dispatched under its full name, colon included, so
26500
26487
  * `findEvent(event, "keyboard:ctrl+backspace")` gets back to it — the shortcut
26501
26488
  * that asked is what the action wants to know, not that a key went down.
@@ -32008,8 +31995,7 @@ const LoadingFallback = ({
32008
31995
  // A page that suspends never gets to say it is on screen — its own effects
32009
31996
  // are held with it — so this says it for it: what the document shows of the
32010
31997
  // page arriving is this. Anyone waiting for the page to be there before
32011
- // moving (a travel about to have its picture taken, see route_travel.jsx)
32012
- // would otherwise wait for a render that cannot happen until the data does.
31998
+ // moving (a route transition about to have its picture taken) would otherwise wait for a render that cannot happen until the data does.
32013
31999
  useLayoutEffect(() => {
32014
32000
  publishRouteRender();
32015
32001
  });
@@ -36377,29 +36363,6 @@ const samePages = (pages, otherPages) => {
36377
36363
 
36378
36364
  installImportMetaCssBuild(import.meta);
36379
36365
 
36380
- /**
36381
- * Keep every container showing the page it is showing, whatever the routes say.
36382
- *
36383
- * For a caller whose picture of a page is LIVE and must not follow the router:
36384
- * a travel being undone shows the page it is going back to on both sides at
36385
- * once if the router is allowed to swap under it (see route_travel.jsx). Only
36386
- * the pages are held still — the rest of the document goes on rendering, which
36387
- * is the whole reason this lives here rather than in Preact's own scheduler.
36388
- */
36389
- let routeRenderFrozen = false;
36390
- const routeRenderFrozenSignal = signal(0);
36391
- const freezeRouteRender = () => {
36392
- routeRenderFrozen = true;
36393
- return () => {
36394
- if (!routeRenderFrozen) {
36395
- return;
36396
- }
36397
- routeRenderFrozen = false;
36398
- // Read during every container's render, so letting go brings them all back.
36399
- routeRenderFrozenSignal.value++;
36400
- };
36401
- };
36402
-
36403
36366
  /**
36404
36367
  * Keep the page a container leaves, hidden, until its route transition is over.
36405
36368
  *
@@ -36470,8 +36433,11 @@ const debug$1 = (...args) => {
36470
36433
  * collectBranches).
36471
36434
  */
36472
36435
  const Route = props => {
36436
+ const inRow = useContext(RouteRowContext);
36473
36437
  if (props.children) {
36474
- return jsx(RouteContainer, {
36438
+ return inRow ? jsx(RouteRowContainer, {
36439
+ ...props
36440
+ }) : jsx(RouteContainer, {
36475
36441
  ...props
36476
36442
  });
36477
36443
  }
@@ -36535,6 +36501,105 @@ const collectRoutePages = children => {
36535
36501
  return pages;
36536
36502
  };
36537
36503
 
36504
+ /**
36505
+ * Whether the next container down is a row a hand scrolls: every page has a
36506
+ * place on it from the start, and the browser moves between them (see
36507
+ * RouteTravel's `nativeScroll`). Told to one container, not to the pages it holds.
36508
+ */
36509
+ const RouteRowContext = createContext(false);
36510
+
36511
+ // A container as a row: one pane per leaf, in the order they are written. A page
36512
+ // still builds when its route first matches, since a route's actions only ask
36513
+ // while it does: before that its pane is empty. Once built it stays, with what
36514
+ // it holds and where it was scrolled to, so a hand brings back the page itself
36515
+ // and the address coming back to it finds it there.
36516
+ //
36517
+ // A page not on screen is treated as one kept while leaving, except that this
36518
+ // can be undone: it does not re-render for what it reads (isShown), and it steps
36519
+ // out of whatever it fills outside its pane (leavingSignal).
36520
+ const RouteRowContainer = ({
36521
+ children
36522
+ }) => {
36523
+ const {
36524
+ activeBranch,
36525
+ leaves
36526
+ } = collectBranches(children);
36527
+ const childrenRef = useRef(null);
36528
+ childrenRef.current = children;
36529
+ const readShownBranchRef = useRef(null);
36530
+ if (!readShownBranchRef.current) {
36531
+ readShownBranchRef.current = () => collectBranches(childrenRef.current).activeBranch;
36532
+ }
36533
+ const parentPage = useContext(RoutePageContext);
36534
+ const pagesRef = useRef(new Map());
36535
+ const pageOf = branch => {
36536
+ let page = pagesRef.current.get(branch.id);
36537
+ if (!page) {
36538
+ page = createRoutePage(readShownBranchRef.current, parentPage);
36539
+ pagesRef.current.set(branch.id, page);
36540
+ }
36541
+ page.branchId = branch.id;
36542
+ // Never answering for another pane: two leaves rendering the same element
36543
+ // are two pages here, each in its pane, where a container carries one over.
36544
+ page.element = null;
36545
+ return page;
36546
+ };
36547
+ const shownPage = activeBranch ? pageOf(activeBranch) : null;
36548
+ const containerKey = useRef({}).current;
36549
+ useLayoutEffect(() => {
36550
+ publishRouteRender();
36551
+ reportContainerShows(containerKey, {
36552
+ parentPage,
36553
+ page: shownPage,
36554
+ branchType: activeBranch ? activeBranch.type : null,
36555
+ routePage: branchRoutePage(activeBranch)
36556
+ });
36557
+ });
36558
+ useLayoutEffect(() => {
36559
+ return () => {
36560
+ forgetContainer(containerKey);
36561
+ };
36562
+ }, []);
36563
+ if (activeBranch && activeBranch.type === "fallback") {
36564
+ // An address the row has no place for: nothing to scroll between.
36565
+ return jsx(RouteRowPane, {
36566
+ branch: activeBranch,
36567
+ page: shownPage,
36568
+ shown: true
36569
+ }, "fallback");
36570
+ }
36571
+ return leaves.map(leaf => jsx(RouteRowPane, {
36572
+ branch: leaf,
36573
+ page: pageOf(leaf),
36574
+ shown: activeBranch !== null && leaf.id === activeBranch.id
36575
+ }, leaf.id));
36576
+ };
36577
+ const RouteRowPane = ({
36578
+ branch,
36579
+ page,
36580
+ shown
36581
+ }) => {
36582
+ // The very vnode rendered last, while not on screen: Preact does not look into
36583
+ // it, and a page nobody can see costs nothing when the address moves.
36584
+ const contentRef = useRef(null);
36585
+ if (shown) {
36586
+ contentRef.current = h(RouteRowContext.Provider, {
36587
+ value: false
36588
+ }, h(RoutePageContext.Provider, {
36589
+ value: page
36590
+ }, branch.node));
36591
+ }
36592
+ useLayoutEffect(() => {
36593
+ page.ownLeavingSignal.value = !shown;
36594
+ }, [shown]);
36595
+ return jsx("div", {
36596
+ "data-route-row-pane": "",
36597
+ "data-built": contentRef.current ? "" : undefined,
36598
+ inert: !shown,
36599
+ children: contentRef.current
36600
+ });
36601
+ };
36602
+
36538
36603
  // RouteContainer: traverses children statically per render, finds the active branch,
36539
36604
  // and renders only that branch — or the fallback if nothing matches.
36540
36605
  // No contexts, no state of its own: it reads the route signals directly, and
@@ -36551,16 +36616,7 @@ const RouteContainer = ({
36551
36616
  pages
36552
36617
  } = collectBranches(children);
36553
36618
  const fallbackPage = fallbackBranch ? fallbackBranch.page : null;
36554
-
36555
- // Told to hold still: what is on screen stays on screen. Kept as the very
36556
- // vnode that was rendered last time, which is how Preact is told there is
36557
- // nothing to look at in that subtree.
36558
- const frozen = routeRenderFrozenSignal.value >= 0 && routeRenderFrozen;
36559
- const shownBranchRef = useRef(null);
36560
- if (!frozen) {
36561
- shownBranchRef.current = activeBranch;
36562
- }
36563
- const branch = shownBranchRef.current || activeBranch;
36619
+ const branch = activeBranch;
36564
36620
  // What this container would show if it rendered now, by the same rule as
36565
36621
  // the render above — asked from outside any render (see isShown below).
36566
36622
  const childrenRef = useRef(null);
@@ -36568,9 +36624,6 @@ const RouteContainer = ({
36568
36624
  const readShownBranchRef = useRef(null);
36569
36625
  if (!readShownBranchRef.current) {
36570
36626
  readShownBranchRef.current = () => {
36571
- if (routeRenderFrozen) {
36572
- return shownBranchRef.current;
36573
- }
36574
36627
  return collectBranches(childrenRef.current).activeBranch;
36575
36628
  };
36576
36629
  }
@@ -36865,7 +36918,7 @@ const branchRouteNode = branch => {
36865
36918
  // Walk JSX children vnodes (without rendering) to build a branch list and
36866
36919
  // find the active one in the same pass. Anything that is not a <Route> is read
36867
36920
  // through and kept around the branch it holds (see below).
36868
- // Returns { matchingBranch, fallbackBranch, activeBranch, pages }.
36921
+ // Returns { matchingBranch, fallbackBranch, activeBranch, pages, leaves }.
36869
36922
  //
36870
36923
  // Each branch is given an id from where it is written among the children,
36871
36924
  // which is what tells a container it changed page: the vnodes themselves are
@@ -36880,6 +36933,8 @@ const collectBranches = (children, path = "") => {
36880
36933
  // matching or a leaf matching under it is enough to select it, and the
36881
36934
  // fallback then stands down.
36882
36935
  const pages = [];
36936
+ // Every leaf, wrappers kept, whichever matches: what a row has a pane for.
36937
+ const leaves = [];
36883
36938
  const visit = child => {
36884
36939
  if (!child || child === true || child === false) {
36885
36940
  return;
@@ -36909,11 +36964,15 @@ const collectBranches = (children, path = "") => {
36909
36964
  const {
36910
36965
  matchingBranch: matchingInside,
36911
36966
  fallbackBranch: fallbackInside,
36912
- pages: pagesInside
36967
+ pages: pagesInside,
36968
+ leaves: leavesInside
36913
36969
  } = collectBranches(wrapperChildren, `${id}.`);
36914
36970
  for (const pageInside of pagesInside) {
36915
36971
  pages.push(pageInside);
36916
36972
  }
36973
+ for (const leafInside of leavesInside) {
36974
+ leaves.push(wrapBranch(leafInside, child));
36975
+ }
36917
36976
  if (matchingInside && !matchingBranch) {
36918
36977
  matchingBranch = wrapBranch(matchingInside, child);
36919
36978
  }
@@ -36986,6 +37045,7 @@ const collectBranches = (children, path = "") => {
36986
37045
  id,
36987
37046
  node: child
36988
37047
  };
37048
+ leaves.push(branch);
36989
37049
  pages.push({
36990
37050
  route,
36991
37051
  params: routeParams
@@ -37014,7 +37074,8 @@ const collectBranches = (children, path = "") => {
37014
37074
  matchingBranch,
37015
37075
  fallbackBranch,
37016
37076
  activeBranch,
37017
- pages
37077
+ pages,
37078
+ leaves
37018
37079
  };
37019
37080
  };
37020
37081
  const describeContainer = ({
@@ -37184,14 +37245,7 @@ const releaseTransitionDestination = (owner) => {
37184
37245
  *
37185
37246
  * A control therefore may ask to keep answering, and while a route transition
37186
37247
  * plays the press is caught at the document and handed to it when it fell
37187
- * inside its rectangle — which is where the hand thinks it pressed. The same
37188
- * move route_travel.jsx makes for the box a finger reaches for mid-flight.
37189
- *
37190
- * A `RouteTravel` holds this too, for the bars it leaves live. A control there
37191
- * gets its click on its own except in the first frames of the travel, when a
37192
- * press falls to the document root like any other; the part of a bar photographed
37193
- * for the travel (`data-navi-fixed-bar-overhang`, fixed_bar.jsx) is deaf
37194
- * throughout.
37248
+ * inside its rectangle — which is where the hand thinks it pressed.
37195
37249
  *
37196
37250
  * ASKED FOR, never derived, and that is the whole safety of it: only the
37197
37251
  * application knows the control does not move. One that travels with the pages
@@ -38078,7 +38132,7 @@ const holdTransitionWindow = (
38078
38132
  ) => {
38079
38133
  const bandBefore = stateBefore.band;
38080
38134
  const rectAfter = element.getBoundingClientRect();
38081
- const bandAfter = readBand();
38135
+ readBand();
38082
38136
  const outerScroll = readOuterScroll(element);
38083
38137
  // The state being left, at where its box stands under the offset the box
38084
38138
  // is measured under NOW: what scrolled outside the box between the two
@@ -38112,9 +38166,7 @@ const holdTransitionWindow = (
38112
38166
  setTransitionValue(WINDOW_OLD_LEFT_PROPERTY, `${rectBefore.left}px`);
38113
38167
  setTransitionValue(WINDOW_NEW_TOP_PROPERTY, `${rectAfter.top}px`);
38114
38168
  setTransitionValue(WINDOW_NEW_LEFT_PROPERTY, `${rectAfter.left}px`);
38115
- const oldBand = furnitureLive
38116
- ? bandTheCutMayBeRelaxedTo(bandBefore, bandAfter, rectAfter)
38117
- : bandBefore;
38169
+ const oldBand = bandBefore;
38118
38170
  setTransitionValue(OLD_BAND_TOP_PROPERTY, `${oldBand.top}px`);
38119
38171
  setTransitionValue(OLD_BAND_RIGHT_PROPERTY, `${oldBand.right}px`);
38120
38172
  setTransitionValue(OLD_BAND_BOTTOM_PROPERTY, `${oldBand.bottom}px`);
@@ -38155,21 +38207,6 @@ const placeTransitionWindow = (owner, groupName) => {
38155
38207
  document.documentElement.setAttribute(WINDOW_PLACED_ATTRIBUTE, "");
38156
38208
  };
38157
38209
 
38158
- /**
38159
- * Whether a point of the window lies in a band the furniture keeps: the pictures
38160
- * are cut at those bands, so what is seen there is never a page, however far the
38161
- * box runs under it.
38162
- */
38163
- const isPointInTransitionBand = (clientX, clientY) => {
38164
- const band = readBand();
38165
- return (
38166
- clientX < band.left ||
38167
- clientX > window.innerWidth - band.right ||
38168
- clientY < band.top ||
38169
- clientY > window.innerHeight - band.bottom
38170
- );
38171
- };
38172
-
38173
38210
  // The live layout takes the box back. A discontinuity by construction — the
38174
38211
  // window stands at the held height, the box is at its own — and an invisible
38175
38212
  // one: the page arriving is fully in place, and the strip below it that the
@@ -38252,8 +38289,7 @@ const shiftRect = (rect, byTop, byLeft) => {
38252
38289
  // What has scrolled OUTSIDE the box: the offsets of every scroller above the
38253
38290
  // one the pages scroll in, the viewport's included. That nearest scroller is
38254
38291
  // the pages' own — an offset there is a page scrolled, and it is not counted;
38255
- // a hidden overflow counts as one so that the box asked about is the same the
38256
- // travel asks about (see pagesScrollTheDocument in route_travel.jsx). What is
38292
+ // a hidden overflow counts as one. What is
38257
38293
  // pinned to the viewport (fixed, in the top layer) is not carried by the
38258
38294
  // document's offset, and the walk stops under it.
38259
38295
  const readOuterScroll = (element) => {
@@ -38321,40 +38357,6 @@ const readBand = () => {
38321
38357
  };
38322
38358
  };
38323
38359
 
38324
- // How far the cut may be opened for the picture being left when the furniture
38325
- // is LIVE under the pictures — the band its own state kept free, which is the
38326
- // whole point of photographing it: furniture present in only one of the two
38327
- // states is not the frame, it is part of what changes, and cutting the page
38328
- // being left at a bar it never had shows its header being sliced instead of
38329
- // leaving.
38330
- //
38331
- // Never past what the picture ARRIVING needs, though: the cut is one line for
38332
- // both pictures, so opening it for one opens it for the other. A box that runs
38333
- // under the live furniture it arrives beside — a page scrolled below a top bar
38334
- // — would then be watched painting over that bar for the length of the
38335
- // movement, which is what the cut exists to prevent. On such an edge the
38336
- // arriving band stands, and the page being left is cut as it always was.
38337
- //
38338
- // With the furniture photographed (a route transition) none of this holds: a
38339
- // bar only the arriving state has is a picture ordered over the page it
38340
- // belongs to, and a page arriving under it paints nothing over it. The band
38341
- // of the state being left is then published as it was, so the page being
38342
- // left is never cut at a bar it never had.
38343
- const bandTheCutMayBeRelaxedTo = (bandBefore, bandAfter, rectAfter) => {
38344
- return {
38345
- top: rectAfter.top < bandAfter.top ? bandAfter.top : bandBefore.top,
38346
- right:
38347
- rectAfter.right > window.innerWidth - bandAfter.right
38348
- ? bandAfter.right
38349
- : bandBefore.right,
38350
- bottom:
38351
- rectAfter.bottom > window.innerHeight - bandAfter.bottom
38352
- ? bandAfter.bottom
38353
- : bandBefore.bottom,
38354
- left: rectAfter.left < bandAfter.left ? bandAfter.left : bandBefore.left,
38355
- };
38356
- };
38357
-
38358
38360
  /**
38359
38361
  * A view transition already playing, walked back to where it started.
38360
38362
  *
@@ -38530,7 +38532,7 @@ const easedProgress = (x, [x1, y1, x2, y2]) => {
38530
38532
  };
38531
38533
 
38532
38534
  installImportMetaCssBuild(import.meta);
38533
- const startViewTransition$1 = ensureDocumentStartViewTransition();
38535
+ const startViewTransition = ensureDocumentStartViewTransition();
38534
38536
  const TRANSITION_ATTRIBUTE = "data-navi-route-transition";
38535
38537
  const TRANSITION_TYPE_ATTRIBUTE = "data-navi-route-transition-type";
38536
38538
  const TRANSITION_DURATION_PROPERTY = "--navi-route-transition-duration";
@@ -38562,12 +38564,6 @@ const AREA_NAME = "navi-route-transition";
38562
38564
  // the pages', or the document's own when the pages ARE the document (see
38563
38565
  // findLeadAnimation).
38564
38566
  const LEAD_NAMES = [AREA_NAME, "root"];
38565
- // route_travel.jsx wears this on the root for the length of one of its
38566
- // travels (its TRAVEL_ATTRIBUTE — a comment there mirrors this one). Read by
38567
- // name rather than imported: importing route_travel.jsx would pull the whole
38568
- // travel machinery into an application that only defines transitions.
38569
- const ROUTE_TRAVEL_ATTRIBUTE = "data-navi-route-travel";
38570
-
38571
38567
  // The same movements, written once and played on either target: the document,
38572
38568
  // or the marked area. The guard keeps the two exclusive — with an area marked,
38573
38569
  // the root pictures must NOT move (they carry the whole viewport, blank bands
@@ -39379,10 +39375,10 @@ const rebuildWatcher = () => {
39379
39375
  from,
39380
39376
  to
39381
39377
  } of relations) {
39382
- if (from && pageIndexOf$1(pages, from) === -1) {
39378
+ if (from && pageIndexOf(pages, from) === -1) {
39383
39379
  pages.push(from);
39384
39380
  }
39385
- if (pageIndexOf$1(pages, to) === -1) {
39381
+ if (pageIndexOf(pages, to) === -1) {
39386
39382
  pages.push(to);
39387
39383
  }
39388
39384
  }
@@ -39470,7 +39466,7 @@ const rebuildWatcher = () => {
39470
39466
  // What plays when no relation matched (see defineRouteDefaultTransition), what
39471
39467
  // the navigation now landing asked for on its own (see readNavigationRequest),
39472
39468
  // where it goes, and whether it found an answer already — a relation's
39473
- // transition, a "none", a RouteTravel travel. The last three are read at the
39469
+ // transition, a "none". The last three are read at the
39474
39470
  // start of every navigation, so they are always about the latest one.
39475
39471
  let defaultTransition = null;
39476
39472
  let navigationRequest = null;
@@ -39721,14 +39717,6 @@ const beginTransition = ({
39721
39717
  }) => {
39722
39718
  navigationAnimated = true;
39723
39719
  const documentElement = document.documentElement;
39724
- // One navigation, one animator. A RouteTravel box already travelling this
39725
- // change owns the document's transition — and possibly a finger; starting
39726
- // one here on top would skip its pictures mid-slide. A pair of routes must
39727
- // be animated by RouteTravel or by a route transition, never both.
39728
- if (documentElement.hasAttribute(ROUTE_TRAVEL_ATTRIBUTE)) {
39729
- 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.");
39730
- return;
39731
- }
39732
39720
  navigationDecision = {
39733
39721
  type,
39734
39722
  direction,
@@ -39813,7 +39801,7 @@ const beginTransition = ({
39813
39801
  // calls that callback a frame later, and a navigation that has already
39814
39802
  // been decided renders its page in between — a wait armed then waits for
39815
39803
  // something that has already happened.
39816
- const renderWait = armRouteRenderWait$1();
39804
+ const renderWait = armRouteRenderWait();
39817
39805
  // What the browser ACTUALLY captured, read once the pictures exist: it is
39818
39806
  // the only place the silent misconfigurations show. They are all about the
39819
39807
  // same thing — a movement playing on pictures that are not the pages.
@@ -39842,7 +39830,7 @@ const beginTransition = ({
39842
39830
  break;
39843
39831
  }
39844
39832
  };
39845
- const viewTransition = startViewTransition$1(async () => {
39833
+ const viewTransition = startViewTransition(async () => {
39846
39834
  // The picture the browser is about to take must be of the page that was
39847
39835
  // asked for, and a route matching is not yet a page rendered. Whatever
39848
39836
  // is awaited here must be able to resolve without a frame: the document
@@ -39922,7 +39910,7 @@ const beginTransition = ({
39922
39910
  }
39923
39911
  };
39924
39912
  transition.viewTransition = viewTransition;
39925
- viewTransition.ready.then(viewTransitionReady, ignoreSkipped$1);
39913
+ viewTransition.ready.then(viewTransitionReady, ignoreSkipped);
39926
39914
  viewTransition.finished.then(end, end);
39927
39915
  };
39928
39916
 
@@ -40030,7 +40018,7 @@ const turnRunningTransitionRound = (fromUrl, url) => {
40030
40018
  const absoluteUrl = url => url ? new URL(url, window.location.href).href : null;
40031
40019
 
40032
40020
  // A transition skipped by another one starting is an outcome, not a failure.
40033
- const ignoreSkipped$1 = () => {};
40021
+ const ignoreSkipped = () => {};
40034
40022
 
40035
40023
  // The names the browser captured, read off the pictures themselves: what was
40036
40024
  // asked for in CSS and what was taken are not the same question (see
@@ -40099,7 +40087,7 @@ const warnAboutNamesEscapingArea = (areaElement, capturedNames) => {
40099
40087
  continue;
40100
40088
  }
40101
40089
  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.";
40102
- 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).`);
40090
+ 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).`);
40103
40091
  return;
40104
40092
  }
40105
40093
  };
@@ -40120,7 +40108,7 @@ const warnOnce = (id, message) => {
40120
40108
  // answer it is its own business, and the render is the moment the picture can
40121
40109
  // be taken. Listening starts before the change, or a render landing while the
40122
40110
  // change settles is a render nobody heard.
40123
- const armRouteRenderWait$1 = () => {
40111
+ const armRouteRenderWait = () => {
40124
40112
  let stopListening;
40125
40113
  const rendered = new Promise(resolve => {
40126
40114
  stopListening = observeRouteRender(resolve);
@@ -40160,7 +40148,7 @@ const samePage$1 = (a, b) => {
40160
40148
  }
40161
40149
  return a.route === b.route && compareTwoJsValues(a.params, b.params);
40162
40150
  };
40163
- const pageIndexOf$1 = (pages, page) => pages.findIndex(candidate => samePage$1(candidate, page));
40151
+ const pageIndexOf = (pages, page) => pages.findIndex(candidate => samePage$1(candidate, page));
40164
40152
 
40165
40153
  // The FIRST page that answers, and every page read all the same: a page that
40166
40154
  // is not the current one today is the one that must wake the reader tomorrow.
@@ -40243,1451 +40231,720 @@ const installRouteTransitionCss = () => {
40243
40231
  installTransitionFurnitureCss();
40244
40232
  };
40245
40233
 
40246
- installImportMetaCssBuild(import.meta);
40247
- // The pictures carrying a travel, among everything else the movement takes
40248
- // along (see findLeadAnimation).
40249
- const TRAVEL_LEAD_NAMES = ["navi-route-travel"];
40250
-
40251
- // A browser with no view transitions of its own has no picture of the page
40252
- // being left, so there is nothing to drag: the gesture is still read (it is how
40253
- // one asks to change tab with a thumb), and the change happens on release, as a
40254
- // press on the tab would have done.
40255
- const CAN_KEEP_PICTURE = Boolean(document.startViewTransition && !document.startViewTransition.isPolyfill);
40256
- const startViewTransition = ensureDocumentStartViewTransition();
40257
-
40258
- // route_transition.js reads this attribute by name (not imported, see its
40259
- // ROUTE_TRAVEL_ATTRIBUTE) to refuse animating a navigation a travel already
40260
- // animates — keep the two in sync.
40261
- const TRAVEL_ATTRIBUTE = "data-navi-route-travel";
40262
- // Which way the pages move, said on the document: the pictures of a transition
40263
- // hang off the root, not off the box that travels, so the box's own `axis` has
40264
- // to be lent to the document for the length of the travel.
40265
- const TRAVEL_AXIS_ATTRIBUTE = "data-navi-route-travel-axis";
40266
- // While a finger holds the travel: the pictures stand still and go exactly
40267
- // where it says (see the CSS, and scrubTravel).
40268
- const HOLD_ATTRIBUTE = "data-navi-route-travel-held";
40269
- // A travel a finger set off, for its whole life — including what plays out
40270
- // after the finger is gone. It moves by another law than one asked for by a
40271
- // press (see the CSS).
40272
- const DRAGGED_ATTRIBUTE = "data-navi-route-travel-dragged";
40273
- // A travel that changed its mind about where it was going (see
40274
- // redirectTravel). Only the pages can be aimed somewhere else: everything the
40275
- // transition carries was measured once, at the start, against a destination
40276
- // this travel is no longer going to.
40277
- const TURNED_ATTRIBUTE = "data-navi-route-travel-turned";
40278
- // The name the box wears while it travels, and only then (see nameForTravel).
40279
- const TRAVEL_NAME = "navi-route-travel";
40280
- const css$16 = /* css */`.navi_route_travel {
40281
- position: relative;
40234
+ /**
40235
+ * Where a row of routes scrolled by the browser stands, for what is drawn about
40236
+ * it elsewhere: the bar under the tabs, in a <Nav> that sits outside the box and
40237
+ * knows nothing of it. A tab says which page it is through its address, so a
40238
+ * nav follows any row it holds tabs for, with nothing to wire.
40239
+ *
40240
+ * Said per scroll and not through a signal: a number written on one element is
40241
+ * all it takes, and a signal would make that a render.
40242
+ */
40243
+ const followers = new Set();
40282
40244
 
40283
- &[data-travel-by-drag="x"] {
40284
- touch-action: pan-y;
40285
- }
40245
+ const followRouteRows = (onMove) => {
40246
+ followers.add(onMove);
40247
+ return () => {
40248
+ followers.delete(onMove);
40249
+ };
40250
+ };
40286
40251
 
40287
- &[data-travel-by-drag="y"] {
40288
- touch-action: pan-x;
40252
+ /**
40253
+ * @param {{basePath: string, towardPath: string|null, progress: number}|null} move
40254
+ * - the two pages the row stands between, by the pathname of their address,
40255
+ * and how far past the first in the terms of --slide-travel-progress. `null`
40256
+ * once it has stopped.
40257
+ */
40258
+ const publishRouteRowMove = (move) => {
40259
+ for (const follower of followers) {
40260
+ follower(move);
40289
40261
  }
40290
- }
40291
-
40292
- :root[data-navi-route-travel] {
40293
- view-transition-name: none;
40262
+ };
40294
40263
 
40295
- &::view-transition, &::view-transition-group(*), &::view-transition-image-pair(*), &::view-transition-old(*), &::view-transition-new(*) {
40296
- pointer-events: none;
40297
- }
40264
+ /**
40265
+ * A row of pages the browser scrolls itself, and what is left to read from it.
40266
+ *
40267
+ * The hand is not interpreted here: the browser follows it, on its compositor,
40268
+ * and it alone knows what the page cannot be told (a notch from a trackpad, the
40269
+ * end of a stroke from its momentum, which scroller a stream is latched on). What
40270
+ * remains is reading where the row stands and when it has come to rest.
40271
+ *
40272
+ * Rows are measured off their pages rather than by rank and width: a row that
40273
+ * ends early because a page is held leaves what lies beyond out (`data-off-row`),
40274
+ * and ranks would then lie about where the others are. An `offsetLeft` is rounded,
40275
+ * but by half a pixel at most whatever the rank, which a rank multiplied by a
40276
+ * rounded width is not. It also ignores a transform: a row inside a popup still
40277
+ * growing into place measures as it will stand.
40278
+ *
40279
+ * Each scroller must be the offsetParent of its pages.
40280
+ */
40298
40281
 
40299
- &::view-transition-old(navi-route-travel), &::view-transition-new(navi-route-travel) {
40300
- object-fit: none;
40301
- object-position: top left;
40302
- mix-blend-mode: normal;
40303
- height: auto;
40304
- }
40282
+ // What the rest of a scroll is worth while a page sits a rounding away from it.
40283
+ const AT_REST_DISTANCE = 1;
40284
+ // How long without a scroll event means the row stopped. The browser's word is
40285
+ // not enough: Firefox says `scrollend` once, when the wheel goes quiet, some 200ms
40286
+ // before its snap has finished and never again, and Safari before 26.2 does not
40287
+ // say it at all.
40288
+ const STOPPED_SILENCE = 150;
40305
40289
 
40306
- &::view-transition-old(navi-route-travel) {
40307
- top: calc(var(--navi-transition-window-old-top) - var(--navi-transition-window-top));
40308
- left: calc(var(--navi-transition-window-old-left) - var(--navi-transition-window-left));
40309
- width: auto;
40310
- }
40290
+ const isOnRow = (page) =>
40291
+ page.offsetWidth > 0 && !page.hasAttribute("data-off-row");
40311
40292
 
40312
- &::view-transition-new(navi-route-travel) {
40313
- top: calc(var(--navi-transition-window-new-top) - var(--navi-transition-window-top));
40314
- left: calc(var(--navi-transition-window-new-left) - var(--navi-transition-window-left));
40315
- width: auto;
40316
- }
40293
+ const restsOn = (scroller, page) =>
40294
+ Math.abs(page.offsetLeft - scroller.scrollLeft) <= AT_REST_DISTANCE &&
40295
+ Math.abs(page.offsetTop - scroller.scrollTop) <= AT_REST_DISTANCE;
40317
40296
 
40318
- &::view-transition-group(navi-route-travel), &::view-transition-image-pair(navi-route-travel) {
40319
- overflow: clip;
40320
- }
40297
+ /**
40298
+ * @returns {Element|undefined} the page the row rests on, nothing while it stands
40299
+ * between two: a hand can pause midway, and an engine may call that the end of
40300
+ * a scroll.
40301
+ */
40302
+ const pageAtRest = (scroller, pages) =>
40303
+ pages.find((page) => isOnRow(page) && restsOn(scroller, page));
40321
40304
 
40322
- &::view-transition-group(navi-route-travel) {
40323
- top: calc(var(--navi-transition-window-top) - var(--navi-transition-window-new-top));
40324
- left: calc(var(--navi-transition-window-left) - var(--navi-transition-window-new-left));
40325
- width: var(--navi-transition-window-width);
40326
- height: var(--navi-transition-window-height);
40327
- --navi-route-travel-clip-top: max(0px,
40328
- min(var(--navi-safe-area-inset-top) + var(--navi-transition-cover-top),
40329
- var(--navi-transition-old-band-top)) - var(--navi-transition-window-top));
40330
- --navi-route-travel-clip-left: max(0px,
40331
- min(var(--navi-safe-area-inset-left) + var(--navi-transition-cover-left),
40332
- var(--navi-transition-old-band-left)) - var(--navi-transition-window-left));
40333
- --navi-route-travel-clip-bottom: max(0px,
40334
- var(--navi-transition-window-top) +
40335
- var(--navi-transition-window-height) +
40336
- min(var(--navi-safe-area-inset-bottom) +
40337
- var(--navi-transition-cover-bottom),
40338
- var(--navi-transition-old-band-bottom)) -
40339
- 100dvh);
40340
- --navi-route-travel-clip-right: max(0px,
40341
- var(--navi-transition-window-left) +
40342
- var(--navi-transition-window-width) +
40343
- min(var(--navi-safe-area-inset-right) +
40344
- var(--navi-transition-cover-right),
40345
- var(--navi-transition-old-band-right)) -
40346
- 100dvw);
40347
- clip-path: inset(var(--navi-route-travel-clip-top) var(--navi-route-travel-clip-right)
40348
- var(--navi-route-travel-clip-bottom)
40349
- var(--navi-route-travel-clip-left));
40350
- animation-duration: var(--navi-route-travel-duration, .3s);
40351
- animation-name: none;
40305
+ /**
40306
+ * Where the row stands between two neighbouring pages, on the side it has moved
40307
+ * to from `page`: a hand is not limited to the next one, so what it has passed
40308
+ * since is walked over. A map moves along one axis at a time, the one it has
40309
+ * moved furthest on.
40310
+ *
40311
+ * @returns {{base: Element, toward: Element|undefined, fraction: number, forward: boolean}}
40312
+ * the last page the row has passed (`page` itself until then), the one after,
40313
+ * and how far between the two from 0 to 1. Nothing `toward` past the last.
40314
+ */
40315
+ const readRowStretch = (scroller, pages, page) => {
40316
+ const alongX =
40317
+ Math.abs(scroller.scrollLeft - page.offsetLeft) >=
40318
+ Math.abs(scroller.scrollTop - page.offsetTop);
40319
+ const position = alongX ? "offsetLeft" : "offsetTop";
40320
+ const cross = alongX ? "offsetTop" : "offsetLeft";
40321
+ const scrolled = alongX ? scroller.scrollLeft : scroller.scrollTop;
40322
+ const forward = scrolled > page[position];
40323
+ const step = forward ? 1 : -1;
40324
+ const line = pages
40325
+ .filter(
40326
+ (candidate) => isOnRow(candidate) && candidate[cross] === page[cross],
40327
+ )
40328
+ .sort((pageA, pageB) => pageA[position] - pageB[position]);
40329
+ let index = line.indexOf(page);
40330
+ let base = page;
40331
+ let toward = line[index + step];
40332
+ while (
40333
+ toward &&
40334
+ (forward ? toward[position] <= scrolled : toward[position] >= scrolled)
40335
+ ) {
40336
+ index += step;
40337
+ base = toward;
40338
+ toward = line[index + step];
40352
40339
  }
40340
+ const span = toward ? Math.abs(toward[position] - base[position]) : 0;
40341
+ const fraction = span ? Math.abs(scrolled - base[position]) / span : 0;
40342
+ return { base, toward, fraction, forward };
40343
+ };
40353
40344
 
40354
- &[data-navi-transition-window-placed]::view-transition-group(navi-route-travel) {
40355
- top: calc(var(--navi-transition-window-top) + var(--navi-transition-viewport-top));
40356
- left: calc(var(--navi-transition-window-left) +
40357
- var(--navi-transition-viewport-left));
40358
- transform: none;
40359
- }
40360
- }
40345
+ /**
40346
+ * @param {Element} scroller
40347
+ * @param {object} options
40348
+ * @param {() => void} options.onScroll - the row moved.
40349
+ * @param {(event: Event|null) => void} options.onLand - the row may have stopped.
40350
+ * Read `pageAtRest` to know whether it stopped on a page. Heard more than once
40351
+ * for one landing, so what it does must find nothing to do the second time.
40352
+ * @returns {() => void} stop listening.
40353
+ */
40354
+ const watchRowScroll = (scroller, { onScroll, onLand }) => {
40355
+ let stoppedTimeout = null;
40356
+ let touching = false;
40357
+ const landOnSilence = () => {
40358
+ // A finger holding the row still: nothing has stopped.
40359
+ if (!touching) {
40360
+ onLand(null);
40361
+ }
40362
+ };
40363
+ const armSilence = () => {
40364
+ clearTimeout(stoppedTimeout);
40365
+ stoppedTimeout = setTimeout(landOnSilence, STOPPED_SILENCE);
40366
+ };
40367
+ const onScrollEvent = () => {
40368
+ onScroll();
40369
+ armSilence();
40370
+ };
40371
+ const onTouchStart = () => {
40372
+ touching = true;
40373
+ clearTimeout(stoppedTimeout);
40374
+ };
40375
+ const onTouchEnd = (touchEvent) => {
40376
+ touching = touchEvent.touches.length > 0;
40377
+ if (!touching) {
40378
+ armSilence();
40379
+ }
40380
+ };
40381
+ scroller.addEventListener("scroll", onScrollEvent, { passive: true });
40382
+ scroller.addEventListener("scrollend", onLand);
40383
+ scroller.addEventListener("touchstart", onTouchStart, { passive: true });
40384
+ scroller.addEventListener("touchend", onTouchEnd, { passive: true });
40385
+ scroller.addEventListener("touchcancel", onTouchEnd, { passive: true });
40386
+ return () => {
40387
+ clearTimeout(stoppedTimeout);
40388
+ scroller.removeEventListener("scroll", onScrollEvent);
40389
+ scroller.removeEventListener("scrollend", onLand);
40390
+ scroller.removeEventListener("touchstart", onTouchStart);
40391
+ scroller.removeEventListener("touchend", onTouchEnd);
40392
+ scroller.removeEventListener("touchcancel", onTouchEnd);
40393
+ };
40394
+ };
40361
40395
 
40362
- :root[data-navi-route-travel-held] {
40363
- &::view-transition-group(*), &::view-transition-old(*), &::view-transition-new(*) {
40364
- animation-play-state: paused;
40365
- }
40396
+ installImportMetaCssBuild(import.meta);
40397
+ const css$16 = /* css */`.navi_route_travel {
40398
+ position: relative;
40366
40399
  }
40367
40400
 
40368
- :root[data-navi-route-travel] {
40369
- &::view-transition-old(navi-route-travel), &::view-transition-new(navi-route-travel) {
40370
- animation-duration: var(--navi-route-travel-duration, .3s);
40371
- animation-timing-function: ease;
40372
- animation-fill-mode: both;
40373
- }
40374
- }
40401
+ [data-route-travel-row] {
40402
+ overflow-anchor: none;
40403
+ overscroll-behavior-x: contain;
40404
+ scroll-snap-type: x mandatory;
40405
+ scrollbar-width: none;
40406
+ flex: auto;
40407
+ grid-template-rows: minmax(0, 1fr);
40408
+ grid-auto-columns: 100%;
40409
+ grid-auto-flow: column;
40410
+ min-width: 0;
40411
+ min-height: 0;
40412
+ display: grid;
40413
+ position: relative;
40414
+ overflow: auto hidden;
40375
40415
 
40376
- :root[data-navi-route-travel-turned] {
40377
- &::view-transition-group(*) {
40416
+ &::-webkit-scrollbar {
40378
40417
  display: none;
40379
40418
  }
40380
40419
 
40381
- &::view-transition-group(navi-route-travel) {
40382
- display: block;
40383
- }
40384
- }
40385
-
40386
- :root[data-navi-route-travel-dragged] {
40387
- &::view-transition-group(navi-route-travel), &::view-transition-old(navi-route-travel), &::view-transition-new(navi-route-travel) {
40388
- animation-timing-function: linear;
40389
- }
40390
- }
40391
-
40392
- :root[data-navi-route-travel="forward"] {
40393
- &::view-transition-old(navi-route-travel) {
40394
- animation-name: navi-route-travel-leave-towards-start;
40420
+ &[data-route-travel-row="code"] {
40421
+ overflow-x: hidden;
40395
40422
  }
40396
40423
 
40397
- &::view-transition-new(navi-route-travel) {
40398
- animation-name: navi-route-travel-enter-from-end;
40399
- }
40400
- }
40401
-
40402
- :root[data-navi-route-travel="back"] {
40403
- &::view-transition-old(navi-route-travel) {
40404
- animation-name: navi-route-travel-leave-towards-end;
40405
- }
40406
-
40407
- &::view-transition-new(navi-route-travel) {
40408
- animation-name: navi-route-travel-enter-from-start;
40409
- }
40410
- }
40411
-
40412
- :root[data-navi-route-travel-axis="y"] {
40413
- &[data-navi-route-travel="forward"] {
40414
- &::view-transition-old(navi-route-travel) {
40415
- animation-name: navi-route-travel-leave-towards-top;
40416
- }
40417
-
40418
- &::view-transition-new(navi-route-travel) {
40419
- animation-name: navi-route-travel-enter-from-bottom;
40420
- }
40421
- }
40422
-
40423
- &[data-navi-route-travel="back"] {
40424
- &::view-transition-old(navi-route-travel) {
40425
- animation-name: navi-route-travel-leave-towards-bottom;
40426
- }
40427
-
40428
- &::view-transition-new(navi-route-travel) {
40429
- animation-name: navi-route-travel-enter-from-top;
40430
- }
40431
- }
40432
- }
40433
-
40434
- @keyframes navi-route-travel-leave-towards-start {
40435
- from {
40436
- translate: 0;
40437
- }
40438
-
40439
- to {
40440
- translate: -100%;
40441
- }
40442
- }
40443
-
40444
- @keyframes navi-route-travel-enter-from-end {
40445
- from {
40446
- translate: 100%;
40447
- }
40448
-
40449
- to {
40450
- translate: 0;
40451
- }
40452
- }
40453
-
40454
- @keyframes navi-route-travel-leave-towards-end {
40455
- from {
40456
- translate: 0;
40457
- }
40458
-
40459
- to {
40460
- translate: 100%;
40461
- }
40462
- }
40463
-
40464
- @keyframes navi-route-travel-enter-from-start {
40465
- from {
40466
- translate: -100%;
40467
- }
40468
-
40469
- to {
40470
- translate: 0;
40471
- }
40472
- }
40473
-
40474
- @keyframes navi-route-travel-leave-towards-top {
40475
- from {
40476
- translate: 0;
40424
+ &[data-route-travel-row="still"] {
40425
+ overflow: visible;
40477
40426
  }
40478
40427
 
40479
- to {
40480
- translate: 0 -100%;
40481
- }
40482
- }
40483
-
40484
- @keyframes navi-route-travel-enter-from-bottom {
40485
- from {
40486
- translate: 0 100%;
40428
+ & > [data-route-row-pane] {
40429
+ scroll-snap-align: start;
40430
+ scroll-snap-stop: always;
40431
+ flex-direction: column;
40432
+ grid-row: 1;
40433
+ min-width: 0;
40434
+ min-height: 0;
40435
+ display: flex;
40487
40436
  }
40488
40437
 
40489
- to {
40490
- translate: 0;
40438
+ &:not([data-moving]) > [data-route-row-pane][inert] {
40439
+ visibility: hidden;
40491
40440
  }
40492
- }
40493
40441
 
40494
- @keyframes navi-route-travel-leave-towards-bottom {
40495
- from {
40496
- translate: 0;
40442
+ & > [data-route-row-pane][inert] {
40443
+ top: var(--navi-route-row-pin, 0);
40444
+ max-height: 100dvh;
40445
+ position: relative;
40446
+ overflow: clip;
40497
40447
  }
40498
40448
 
40499
- to {
40500
- translate: 0 100%;
40449
+ & > [data-route-row-live] {
40450
+ flex-direction: column;
40451
+ grid-row: 1;
40452
+ min-width: 0;
40453
+ min-height: 0;
40454
+ display: flex;
40455
+ position: relative;
40501
40456
  }
40502
- }
40503
40457
 
40504
- @keyframes navi-route-travel-enter-from-top {
40505
- from {
40506
- translate: 0 -100%;
40458
+ & > [data-route-row-pictures] {
40459
+ display: contents;
40507
40460
  }
40508
40461
 
40509
- to {
40510
- translate: 0;
40462
+ & > [data-route-row-pictures] > * {
40463
+ contain: layout paint;
40464
+ pointer-events: none;
40465
+ grid-row: 1;
40466
+ min-width: 0;
40511
40467
  }
40512
40468
  }
40513
40469
  `;
40514
40470
 
40471
+ // How far a hand must have pulled before the address moves for a page that is not
40472
+ // there yet: past what a hand that only shook covers, so that a shake costs
40473
+ // neither a navigation nor the requests of a page.
40474
+ const SET_OFF_PULL = 16;
40475
+
40515
40476
  /**
40516
- * The page a reader is still on while a hand travels: `{ route, params }`, the
40517
- * page the travel set off from, or `null` when the URL is the truth.
40477
+ * What the URL says while a hand holds the row is where it is heading, for a page
40478
+ * not built yet: it builds by the address moving. A counter, a title or a
40479
+ * breadcrumb around the box must not move for a pull that is let go of: it reads
40480
+ * this first. The page the reader is still on while that lasts, and `null` when
40481
+ * the row has come to rest, which is when `scrollsnapchange` fires and a pager's
40482
+ * `settledPage` changes. A travel no hand set off holds nothing: it goes where it
40483
+ * goes.
40518
40484
  *
40519
- * A travel changes the URL before it arrives, so what reads the URL reads where
40520
- * the hand is heading. A counter, a title or a breadcrumb around the box must
40521
- * not move for a pull that is let go of: it reads this first. It holds for as
40522
- * long as the pictures move, whichever way they end up going, and is back to
40523
- * `null` when they stop, which is when `scrollsnapchange` fires and a pager's
40524
- * `settledPage` changes. A page walked whole under the hand has arrived too. A
40525
- * travel no hand set off (a tab pressed, a wheel) holds nothing: it goes where
40526
- * it goes.
40527
- *
40528
- * One for the document, as there is one view transition at a time. `route` says
40529
- * which row, for a page holding several boxes.
40485
+ * One for the document. `route` says which row, for a page holding several boxes.
40530
40486
  */
40531
40487
  const routeTravelSettledPageSignal = signal(null);
40532
- // Which travel wrote it, the only one that may clear it: a travel ending after
40533
- // another has taken over leaves that one's answer alone.
40534
- let travelHoldingSettledPage = null;
40535
40488
 
40536
40489
  /**
40537
40490
  * @type {import("preact").FunctionComponent<{
40538
40491
  * routes?: Array<object|{route: object, params?: object}>,
40539
- * axis?: "x"|"y",
40540
40492
  * travelByDrag?: boolean,
40541
40493
  * onTravel?: (detail: {route: object, params: object|undefined, cause: string}) => void|Promise<void>,
40542
40494
  * }>}
40543
- * @param {Array<object|{route: object, params?: object}>} [props.routes] - the
40544
- * tabs, in the order they are shown. Read from the <Route> children by
40545
- * default, in the order they are written: the router already holds that list,
40546
- * and asking a caller to write it twice is asking for the two to disagree.
40547
- * Pass it to say another order, when the pages are not children of this box,
40548
- * or to name a tab the children cannot — the section a <Route fallback> shows
40549
- * is a tab like the others, and only its params say which one.
40550
- *
40551
- * An entry is a route, or `{ route, params }` when the tabs of the row are a
40552
- * PARAM of one route rather than routes of their own (the form
40553
- * `<Route routeParams>` selects a branch on, and the form that lets a link
40554
- * with no params reopen the section one was looking at). Written as bare
40555
- * routes, three tabs of one route are the same object three times: there is
40556
- * then one tab, and nowhere to travel.
40557
- * @param {"x"|"y"} [props.axis="x"] - which way the pages are laid out.
40558
- * @param {boolean} [props.travelByDrag=true] - whether a pointer dragging the
40559
- * page travels. Off where the gesture belongs to the content.
40560
- * @param {(detail: {route: object, params: object|undefined, cause: "drag"|"wheel"|"revert"}) => void|Promise<void>} [props.onTravel]
40561
- * - how to go to a tab. The default REPLACES the current history entry
40562
- * rather than pushing one: a swipe is how one browses a page, not a place one
40563
- * aimed at, and three swipes back and forth must not bury the way out of the
40564
- * page under six entries. A tab pressed goes the way its <Link> says —
40565
- * `<Link replace>` for a row of tabs, so a press and a swipe towards the
40566
- * same neighbour write the same history.
40567
- *
40568
- * Every other prop lands on the box itself (`id`, `data-testid`, `className`,
40569
- * …), which is how one of these is named: a page can hold several — a row of
40570
- * sections inside the box the whole application travels in — and the class they
40571
- * share plus their axis are not enough to tell them apart from the outside.
40572
- *
40573
- * The pages are cut at the edge of this box while they travel, and at the app's
40574
- * safe area the box runs under, which is written on the transition's own
40575
- * pseudo-elements — no overflow of the document reaches pictures drawn in the
40576
- * top layer. It needs nothing of the browser beyond view transitions
40577
- * themselves: a browser without them navigates without the movement, and the
40578
- * gesture applies its change on release instead of dragging a picture that does
40579
- * not exist.
40580
- *
40581
- * While a travel plays, the rest of the page is NOT taken as a picture: the
40582
- * root's name is dropped for that time (`view-transition-name: none`), so what
40583
- * stands around the box — a tab row beside it — stays live and answers a press
40584
- * mid-travel. The application's own rule for the root applies again the moment
40585
- * the travel is over. What stands out of a `FixedBar` is the exception, when it
40586
- * asks to stay over the pages (see `data-navi-fixed-bar-overhang` there).
40495
+ * @param {Array<object|{route: object, params?: object}>} [props.routes] - a window
40496
+ * over something longer, in order. Read from the <Route> children by default,
40497
+ * in the order they are written. Given here, the children are one page rendered
40498
+ * once, whatever the address, and may be anything: use it when the pages are one
40499
+ * route with other params, or not children of this box. An entry is a route, or
40500
+ * `{ route, params }`.
40501
+ * @param {boolean} [props.travelByDrag=true] - whether a hand travels. Off where the
40502
+ * gesture belongs to the content; a tab pressed still does.
40503
+ * @param {(detail: {route: object, params: object|undefined, cause: "drag"}) => void|Promise<void>} [props.onTravel]
40504
+ * - how to go to a page a hand has brought in. The default REPLACES the current
40505
+ * history entry rather than pushing one: a swipe is how one browses a page, not a
40506
+ * place one aimed at, and three swipes back and forth must not bury the way out
40507
+ * of the page under six entries. A tab pressed goes the way its <Link> says —
40508
+ * `<Link replace>` for a row of tabs, so a press and a swipe towards the same
40509
+ * neighbour write the same history.
40510
+ *
40511
+ * Every other prop lands on the box itself (`id`, `data-testid`, `className`, …).
40512
+ * The box has to outlive every page of its row: around those <Route>s, or around
40513
+ * one <Route> whose params are the tabs, never inside one of the pages.
40587
40514
  */
40588
40515
  const RouteTravel = ({
40589
40516
  routes: routesProp,
40590
- axis = "x",
40591
40517
  travelByDrag = true,
40592
40518
  onTravel = ({
40593
40519
  route,
40594
40520
  params
40595
- }) => route.redirectTo(params),
40521
+ }) => route.navTo(params, {
40522
+ replace: true,
40523
+ routeTransition: "none"
40524
+ }),
40596
40525
  className,
40597
40526
  children,
40598
40527
  ...rest
40599
40528
  }) => {
40600
40529
  import.meta.css = [css$16, "@jsenv/navi/src/nav/route_travel.jsx"];
40601
- installTransitionWindowCss();
40602
- const elementRef = useRef();
40603
- const gestureRef = useRef(null);
40604
- // The travel in hand: the transition keeping the picture of the page being
40605
- // left, the animations the finger drives, and what to do with them once the
40606
- // browser has them ready. Null when no page is on its way anywhere.
40607
- const travelRef = useRef(null);
40608
- // The page this box has ASKED for and is still waiting to see arrive.
40609
- // Routing is asynchronous: a travel's own navigation lands well after the
40610
- // travel decided anything about it — sometimes after the travel was undone —
40611
- // and read back as "the page changed" it would start a second travel nobody
40612
- // asked for, over pictures that are already showing something else.
40613
- const pageAskedForRef = useRef(null);
40614
- // The latest way to answer a gesture, for a watcher that outlives every
40615
- // render (see the wheel effect below).
40616
- const travelHandlersRef = useRef(null);
40617
- const pointerDownRef = useRef(null);
40530
+ const rowRef = useRef();
40531
+ const liveRef = useRef();
40532
+ const picturesRef = useRef();
40533
+ const windowed = Boolean(routesProp);
40534
+ const [, render] = useReducer(count => count + 1, 0);
40618
40535
  const pagesFromChildren = useMemo(() => collectRoutePages(children), [children]);
40619
40536
  const pagesFromProp = useMemo(() => routesProp && routesProp.map(normalizePage), [routesProp]);
40620
- const pages = pagesFromProp || pagesFromChildren;
40621
-
40622
- // Which page is on screen, read from the pages themselves: every one of them
40623
- // is read, so this re-renders when any of them starts or stops matching — and
40624
- // for a row whose tabs are params of one route, when the params move from one
40625
- // tab to the next (see pageIsCurrent).
40537
+ // Held while the row moves: a window recentred under a hand would move the very
40538
+ // panes it is pushing. It is taken as soon as the row has stopped.
40539
+ const pagesAsked = pagesFromProp || pagesFromChildren;
40540
+ const movementRef = useRef(null);
40541
+ const pagesRef = useRef(pagesAsked);
40542
+ if (!movementRef.current) {
40543
+ pagesRef.current = pagesAsked;
40544
+ }
40545
+ const pages = pagesRef.current;
40626
40546
  const currentIndex = currentPageIndex(pages);
40627
- // The page that was on screen when the change now happening was asked for:
40628
- // a travel is between two of them, and by the time anything renders the first
40629
- // one is already gone. Written after each render (below), so a subscriber
40630
- // reading it — they all run before Preact flushes — reads the one being left.
40631
- // The page and not its place in the row: a row given again around the page
40632
- // on screen (a window over a list, read anew around it) puts that page at
40633
- // another index without anything having moved.
40634
- const currentPage = currentIndex === -1 ? null : pages[currentIndex];
40635
- const currentPageRef = useRef(currentPage);
40636
-
40637
- // Where a page is asked for, whoever asks: a page is a route AND the params
40638
- // that say which of its tabs, and a caller told only the route would send the
40639
- // row back to whichever tab the URL already says (see redirectTo).
40640
- const travelTo = (page, cause) => onTravel({
40641
- route: page.route,
40642
- params: page.params,
40643
- cause
40644
- });
40645
-
40646
- // One travel, whoever asked for it: a finger, a tab pressed, the browser's
40647
- // own back button. What differs is only who moves it — the finger drives it
40648
- // frame by frame (`scrub`), everything else lets it play.
40649
- const beginTravel = ({
40650
- page,
40651
- fromPage,
40652
- direction,
40653
- scrub,
40654
- change
40655
- }) => {
40656
- const travel = {
40657
- page,
40658
- // The page this set off from, kept rather than looked up again: the URL
40659
- // changes as it sets off, so a moment later nothing on screen remembers
40660
- // where it started.
40661
- fromPage,
40662
- direction,
40663
- scrub,
40664
- ratio: 0,
40665
- // The animations of the pictures, once the browser has them. Held still
40666
- // by CSS until then, so an empty hand here costs nothing: there is
40667
- // nothing to stop, only nowhere to put them yet.
40668
- animations: null,
40669
- ended: false,
40670
- // The box as it set off, kept rather than read from the ref: the
40671
- // navigation a travel asks for can take the box down (see the update
40672
- // callback), and the name it wears is still given back to it.
40673
- element: elementRef.current
40547
+ // Where the page of a window stands: the address, or where it last was for an
40548
+ // address the row has no place for.
40549
+ const liveIndexRef = useRef(0);
40550
+ if (currentIndex !== -1) {
40551
+ liveIndexRef.current = currentIndex;
40552
+ }
40553
+ const liveIndex = liveIndexRef.current;
40554
+ const latestRef = useRef(null);
40555
+ latestRef.current = {
40556
+ pages,
40557
+ currentIndex,
40558
+ liveIndex,
40559
+ onTravel,
40560
+ travelByDrag
40561
+ };
40562
+
40563
+ // The page this box asked for: routing lands later, and read back as somebody
40564
+ // changing the address it would send the row where the hand already is.
40565
+ const askedPageRef = useRef(null);
40566
+ const panesOf = row => Array.from(row.children).filter(child => child.hasAttribute("data-route-row-pane"));
40567
+ const pathOf = ({
40568
+ route,
40569
+ params
40570
+ }) => new URL(route.buildUrl(params), window.location.href).pathname;
40571
+
40572
+ // One movement, whoever set it off, from the first thing that moves to the row
40573
+ // at rest. Null when nothing moves.
40574
+ const beginMovement = fields => {
40575
+ const row = rowRef.current;
40576
+ if (movementRef.current) {
40577
+ Object.assign(movementRef.current, fields);
40578
+ return movementRef.current;
40579
+ }
40580
+ row.setAttribute("data-moving", "");
40581
+ const movement = {
40582
+ travelling: false,
40583
+ pictures: new Map(),
40584
+ resumeScrollRecording: null,
40585
+ scrollsDocument: getScrollContainer(row.parentElement, {
40586
+ includeHidden: true
40587
+ }) === document.documentElement,
40588
+ ...fields
40674
40589
  };
40675
- travelRef.current = travel;
40676
- travelHoldingSettledPage = travel;
40677
- routeTravelSettledPageSignal.value = scrub ? fromPage : null;
40678
- // A press landing on a playing travel lands on the document root: the box
40679
- // is captured by the transition and cannot be pointed at, so neither its
40680
- // touch-action nor its own touchmove listener is on the touch's path. Left
40681
- // there, the browser takes the finger's first vertical pixels for a scroll
40682
- // and cancels the pointer under a travel that was just caught. Refusable
40683
- // from the root for the length of the travel, the gesture that catches it
40684
- // refuses every touchmove of that press (it is started from the grab), and
40685
- // the finger's only answer is the travel it landed on.
40686
- document.documentElement.addEventListener("touchmove", keepTouchRefusable, {
40687
- passive: false
40590
+ movementRef.current = movement;
40591
+ if (movement.scrollsDocument) {
40592
+ // Whatever the browser does with the offset meanwhile (clamping it to a page
40593
+ // shorter than the trip) is not the reader scrolling, and the address it
40594
+ // would be written against may already be another page's.
40595
+ movement.resumeScrollRecording = suspendScrollRecording();
40596
+ row.style.setProperty("--navi-route-row-pin", `${pinOf(row)}px`);
40597
+ }
40598
+ return movement;
40599
+ };
40600
+
40601
+ // A clone of the page in the pane it stands in, before anything changes it. In
40602
+ // that pane and the same height: the row keeps its size, so the document its
40603
+ // offset, and the page one looked at is where it was.
40604
+ const leavePictureOfLive = movement => {
40605
+ const live = liveRef.current;
40606
+ const index = latestRef.current.liveIndex;
40607
+ const picture = live.cloneNode(true);
40608
+ picture.removeAttribute("data-route-row-live");
40609
+ picture.setAttribute("inert", "");
40610
+ picture.setAttribute("aria-hidden", "true");
40611
+ picture.style.gridColumn = String(index + 1);
40612
+ picture.style.top = "";
40613
+ movement.pictures.get(index)?.remove();
40614
+ movement.pictures.set(index, picture);
40615
+ // After the live page in the document: an id it holds still finds the page.
40616
+ picturesRef.current.append(picture);
40617
+ copyScrollPositions(live, picture);
40618
+ if (movement.scrollsDocument) {
40619
+ live.style.top = `${pinOf(rowRef.current)}px`;
40620
+ }
40621
+ };
40622
+ const askFor = page => {
40623
+ const {
40624
+ onTravel
40625
+ } = latestRef.current;
40626
+ askedPageRef.current = page;
40627
+ notePageChange();
40628
+ return onTravel({
40629
+ route: page.route,
40630
+ params: page.params,
40631
+ cause: "drag"
40688
40632
  });
40689
- // Whether the document's offset is this row's business at all (see
40690
- // pagesScrollTheDocument), asked once and before anything is swapped.
40691
- travel.scrollsDocument = pagesScrollTheDocument(travel.element);
40692
- if (travel.scrollsDocument) {
40693
- // One of the tabs is about to be swapped out from under that scrollport,
40694
- // and whatever the browser does with the offset meanwhile — clamping it
40695
- // to a page shorter than the trip — is not the reader scrolling. The url
40696
- // it would be written against is already the arriving tab's, so recorded
40697
- // it is that tab's own position that the tab being left destroys. Deaf
40698
- // for the whole travel, which is exactly as long as the offset belongs
40699
- // to nobody (see endTravel).
40700
- travel.resumeScrollRecording = suspendScrollRecording();
40701
- }
40702
- // Taken before the picture is: the browser reads the name off the DOM as it
40703
- // stands when the transition starts, and this box is only a picture of its
40704
- // own for as long as it is the one travelling. Where it travels to is said
40705
- // at the same moment and for the same reason: whoever names something for
40706
- // a movement between two pages decides on it now (see
40707
- // transition_destination.js).
40708
- nameForTravel(travel.element);
40709
- holdTransitionDestination(travel, page.route.buildUrl(page.params));
40710
- // The bars stay live, except what one of them has photographed on its own
40711
- // (see fixed_bar.jsx), which is deaf like any picture and may have asked to
40712
- // keep answering.
40713
- holdTransitionPress(travel, travel.element);
40714
- document.documentElement.setAttribute(TRAVEL_ATTRIBUTE, direction);
40715
- document.documentElement.setAttribute(TRAVEL_AXIS_ATTRIBUTE, axis);
40716
- if (scrub) {
40717
- holdPictures(travel);
40718
- document.documentElement.setAttribute(DRAGGED_ATTRIBUTE, "");
40719
- }
40720
- pageAskedForRef.current = page;
40721
- // The box as it stands before anything moves, and the band the furniture
40722
- // around it leaves free: rendering is held, so both are still the page
40723
- // being left (see transition_window.js).
40724
- const stateBefore = measureTransitionWindowState(travel.element);
40725
- // The hold a navigation already took, if this travel is the answer to one:
40726
- // taking another would be taking a hold on a page that is holding still.
40727
- const releaseRendering = takeoverRoutingRenderingHold();
40728
- // The picture the browser is about to take must be of the page that was
40729
- // asked for, and a route matching is not yet a page rendered. Watched from
40730
- // here rather than from inside the callback below: the browser calls that
40731
- // callback a frame later, and a navigation that has already been decided
40732
- // (what follows a send, a command) renders its page in between. A wait
40733
- // armed then waits for something that has already happened — until the
40734
- // browser gives up on the transition, leaving the page it was leaving on
40735
- // screen and an error nobody asked for.
40736
- const renderWait = armRouteRenderWait();
40737
- const viewTransition = startViewTransition(async () => {
40738
- // Let go of too early before the browser got here, a frame or several on
40739
- // a slow phone. Skipping a transition does not cancel this callback, and
40740
- // the router asked from here would take the page anyway, with nobody left
40741
- // to bring it back.
40742
- if (travel.reverting) {
40743
- renderWait.stop();
40744
- releaseRendering();
40745
- return;
40746
- }
40747
- travel.calledBack = true;
40748
- // Whatever is awaited here must be able to resolve without the page being
40749
- // rendered: the document is frozen for the whole of this callback, and a
40750
- // frame never comes — waiting for one waits until the browser gives up on
40751
- // the transition. And it stays frozen exactly this long, so this is also
40752
- // the shortest thing there is to keep short.
40753
- await whilePageRenders(page, async () => {
40754
- releaseRendering();
40755
- if (change) {
40756
- await change();
40757
- }
40758
- }, renderWait);
40759
- // Where the arriving tab was left, or its top when it has never been
40760
- // read. A travel is an arrival — the tab pressed is another route, and
40761
- // the replace it navigates by is the only thing about it that says
40762
- // otherwise — and this is the one place that knows it. Read from the
40763
- // travel rather than from the page it set off for: a travel aimed
40764
- // somewhere else while it waited lands where it is aimed now.
40765
- if (travel.scrollsDocument) {
40633
+ };
40634
+ const sayRowMoves = () => {
40635
+ const row = rowRef.current;
40636
+ const panes = panesOf(row);
40637
+ if (panes.length < 2) {
40638
+ return;
40639
+ }
40640
+ const {
40641
+ pages
40642
+ } = latestRef.current;
40643
+ const {
40644
+ base,
40645
+ toward,
40646
+ fraction
40647
+ } = readRowStretch(row, panes, panes[0]);
40648
+ publishRouteRowMove({
40649
+ basePath: pathOf(pages[panes.indexOf(base)]),
40650
+ towardPath: toward ? pathOf(pages[panes.indexOf(toward)]) : null,
40651
+ progress: -fraction
40652
+ });
40653
+ };
40654
+
40655
+ // The row has stopped, on the page of the address: everything held for the
40656
+ // movement is let go of in one go, before anything paints.
40657
+ const endMovement = () => {
40658
+ const row = rowRef.current;
40659
+ const movement = movementRef.current;
40660
+ if (!movement) {
40661
+ return;
40662
+ }
40663
+ movementRef.current = null;
40664
+ row.removeAttribute("data-moving");
40665
+ publishRouteRowMove(null);
40666
+ routeTravelSettledPageSignal.value = null;
40667
+ if (liveRef.current) {
40668
+ liveRef.current.style.top = "";
40669
+ }
40670
+ for (const picture of movement.pictures.values()) {
40671
+ picture.remove();
40672
+ }
40673
+ if (movement.scrollsDocument) {
40674
+ row.style.removeProperty("--navi-route-row-pin");
40675
+ const {
40676
+ pages,
40677
+ currentIndex
40678
+ } = latestRef.current;
40679
+ if (currentIndex !== -1) {
40766
40680
  const {
40767
40681
  route,
40768
40682
  params
40769
- } = travel.page;
40683
+ } = pages[currentIndex];
40770
40684
  arriveAtScrollPosition(route.buildUrl(params));
40771
40685
  }
40772
- // The box itself may be gone: rendered inside one of the pages of its
40773
- // row rather than around them, it went down with the page being left,
40774
- // and there is no box left for the page arriving to be photographed in.
40775
- // The navigation stands — it is what was asked for — and the movement
40776
- // is dropped.
40777
- if (!travel.element.isConnected) {
40778
- travel.viewTransition.skipTransition();
40779
- endTravel(travel);
40780
- return;
40781
- }
40782
- // The page arriving is in the DOM and the transition has not started
40783
- // playing: the one moment both boxes can be known. After the scroll,
40784
- // which is what the box is measured through: the two states are at the
40785
- // same place in the layout without being at the same place in the window
40786
- // (see transition_window.js).
40787
- // The bars stay live under the pictures for a travel: only the box is
40788
- // photographed (see the TRAVEL_ATTRIBUTE css).
40789
- holdTransitionWindow(travel, travel.element, stateBefore, {
40790
- furnitureLive: true
40791
- });
40792
- });
40793
- travel.viewTransition = viewTransition;
40794
- // Subscribed first, so it reads before the scrub below writes anything.
40795
- viewTransition.ready.then(() => {
40796
- placeTransitionWindow(travel, TRAVEL_NAME);
40797
- }, ignoreSkipped);
40798
- if (scrub) {
40799
- // Said only now: the release has to have something to let go of, and the
40800
- // transition did not exist a line above.
40801
- travel.dropHold = holdViewTransition(() => {
40802
- viewTransition.skipTransition();
40803
- endTravel(travel);
40804
- });
40686
+ movement.resumeScrollRecording();
40805
40687
  }
40806
- // Another transition starting SKIPS this one — there is only ever one in a
40807
- // document. That must end the travel here and now, and above all lift the
40808
- // hold: the hold is written in CSS against whatever transition is running
40809
- // (it has to be, so that everything named — a trait under a tab row —
40810
- // follows the same finger), so left on it would take hold of the transition
40811
- // that has just replaced ours. Born paused, with nobody holding it, that
40812
- // one never finishes: its pictures stand over a page that cannot be
40813
- // touched anymore.
40814
- viewTransition.finished.catch(() => {
40815
- // A transition that fails before it ever calls back leaves the page held:
40816
- // whoever asked for the hold gives it back, here as everywhere else.
40817
- renderWait.stop();
40818
- releaseRendering();
40819
- endTravel(travel);
40820
- });
40821
- if (!scrub) {
40822
- // Nobody is holding it: it plays as any transition does, and is over when
40823
- // the browser says so.
40824
- viewTransition.finished.then(() => {
40825
- endTravel(travel);
40826
- }, ignoreSkipped);
40827
- return travel;
40828
- }
40829
- // The pictures exist from `ready` on, and the finger may have moved a long
40830
- // way by then — held at their start meanwhile, so this is where they catch
40831
- // up with it rather than where they set off.
40832
- viewTransition.ready.then(() => {
40833
- if (travel === travelRef.current) {
40834
- scrubTravel(travel, travel.ratio);
40835
- }
40836
- }, ignoreSkipped);
40837
- return travel;
40688
+ // A window recentred meanwhile is taken now.
40689
+ render();
40838
40690
  };
40839
40691
 
40840
- // A page change nobody here asked for: a tab pressed, a key, the back button.
40841
- // The transition is started from what the router SAYS rather than from a
40842
- // render, because a render is one flush too late — by then the DOM holds the
40843
- // new page and the picture of the old one cannot be taken anymore.
40844
- //
40845
- // Watched as a position in the row rather than route by route. A route
40846
- // announces its own status, and the row's tabs can all be one route: the
40847
- // announcement then says a section changed without saying which is on screen,
40848
- // and it says it about things this row does not move for (a route that has
40849
- // now been visited, a param of its own that is not a tab of this row). Worse,
40850
- // a status is published from inside the routing and the params it carries are
40851
- // the ones known at that instant — a section that lands as a signal settles
40852
- // is announced late, or not at all. The signals ARE the position, so the
40853
- // position is read from them: one computed over the whole row, notified once
40854
- // per move, whichever route moved and whether by matching or by params.
40692
+ // The address and the row, in step. The opening and a row given again around the
40693
+ // page on screen have nothing to show for it; somebody else moving the address
40694
+ // (a tab pressed, the back button) is a travel.
40695
+ const answeredPageRef = useRef(null);
40855
40696
  useLayoutEffect(() => {
40856
- const currentIndexSignal = computed(() => currentPageIndex(pages));
40857
- const onRowMove = index => {
40858
- if (index === -1) {
40859
- return;
40860
- }
40861
- const page = pages[index];
40862
- if (samePage(page, currentPageRef.current)) {
40863
- // Where the row already was — the first reading of all, a row given
40864
- // again around the page on screen, and any move this box has already
40865
- // taken note of (a render writes it too). What it was still waiting
40866
- // for is here nonetheless, so the wait is called off.
40867
- if (samePage(pageAskedForRef.current, page)) {
40868
- pageAskedForRef.current = null;
40869
- }
40870
- return;
40871
- }
40872
- // Whoever asked for it: a tab of this row can be a param, and a move
40873
- // between two of them is a page change the path does not show (see
40874
- // page_change.js).
40875
- notePageChange();
40876
- // A page this box asked for itself — a travel's own navigation, or one
40877
- // it had given up waiting on: what arrives here is the answer to a
40878
- // question already answered, not somebody going somewhere.
40879
- if (samePage(pageAskedForRef.current, page)) {
40880
- pageAskedForRef.current = null;
40881
- currentPageRef.current = page;
40882
- return;
40883
- }
40884
- // Somewhere else arrived first: whatever this box was still waiting for
40885
- // is not coming, or no longer means anything. Forgotten here rather
40886
- // than kept, or the next press on that very tab would be taken for the
40887
- // late answer to a question nobody remembers asking.
40888
- pageAskedForRef.current = null;
40889
- // Asked for a page while one was already on its way: the travel in
40890
- // flight is the answer, aimed somewhere else. Starting a second one on
40891
- // top would leave this one's pictures to be dropped mid-slide.
40892
- if (travelRef.current) {
40893
- currentPageRef.current = page;
40894
- retargetTravel(travelRef.current, page);
40895
- return;
40896
- }
40897
- const fromIndex = pageIndexOf(pages, currentPageRef.current);
40898
- currentPageRef.current = page;
40899
- if (fromIndex === -1) {
40900
- // Arriving from outside this row: there is no pair of pages to show,
40901
- // so there is nothing to travel between.
40902
- return;
40903
- }
40904
- beginTravel({
40905
- page,
40906
- fromPage: pages[fromIndex],
40907
- direction: index > fromIndex ? "forward" : "back",
40908
- scrub: false
40909
- });
40910
- };
40911
- // `subscribe` rather than `effect`: it hands the value to a callback that
40912
- // is NOT being tracked, and what this one does — navigate, ask the router
40913
- // for another page — reads and writes the very signals the row is watched
40914
- // through.
40915
- return currentIndexSignal.subscribe(onRowMove);
40916
- }, [pages]);
40917
-
40918
- // Rendering is held for the length of a navigation, so that whatever picture
40919
- // this box is about to take is of the page being LEFT (see
40920
- // rendering_hold.js).
40921
- useLayoutEffect(() => {
40922
- const stopWatchingStart = observeBeforeRouting(holdRenderingForRouting);
40923
- // Nobody may have had a picture to take: this navigation is not always one
40924
- // this box travels. Whoever wanted the hold took it over (beginTravel)
40925
- // while the change was being applied, and left nothing here.
40926
- const stopWatchingEnd = observeAfterRouting(releaseRoutingRenderingHold);
40927
- return () => {
40928
- stopWatchingStart();
40929
- stopWatchingEnd();
40930
- };
40931
- }, []);
40932
-
40933
- // What the next announcement will compare itself against: written after the
40934
- // render that shows it, so it is always the page one is looking at.
40935
- useLayoutEffect(() => {
40936
- currentPageRef.current = currentPage;
40937
- }, [currentPage]);
40938
-
40939
- // Let go of far enough: the movement carries on from under the finger, at its
40940
- // own pace, to the end.
40941
- const finishTravel = travel => {
40942
- // Nobody is driving it anymore. `scrub` is who MOVES the pictures, not what
40943
- // set them off: left standing after the release, this travel would go on
40944
- // claiming a hand that is no longer there — and everything that asks
40945
- // "is somebody holding this?" before touching it (a press on the tab one
40946
- // came from, a wheel push) would be answered yes and do nothing, while the
40947
- // pages carry on to a page the router has already left.
40948
- travel.scrub = false;
40949
- releaseHold();
40950
- travel.viewTransition.finished.then(() => endTravel(travel), () => endTravel(travel));
40951
- };
40952
-
40953
- // Let go of too early: the pages go back the way they came, and the URL with
40954
- // them. The way back is not a travel of its own — the same animations are run
40955
- // backwards, and the page that was left is put back under the picture BEFORE
40956
- // the picture is dropped, so the two are the same thing at the moment they
40957
- // are swapped and nothing is seen changing.
40958
- const revertTravel = travel => {
40959
- travel.reverting = true;
40960
- // A revert somebody ASKED for — a press on the tab this travel came from —
40961
- // has a problem the others do not: that page is already back, and the
40962
- // picture being brought in is LIVE, so it shows the page one is going back
40963
- // to. Both sides then show the same thing and the way back is invisible:
40964
- // one presses, and one is simply there.
40965
- //
40966
- // So the pages are held where they are until the pictures have finished
40967
- // going back. Only the pages: unlike the hold a navigation takes to have
40968
- // its picture taken, nothing is being photographed here — everything else
40969
- // may go on rendering, and a tab row beside the box keeps answering.
40970
- const releaseRendering = freezeRouteRender();
40971
- const animations = travelAnimations(travel);
40972
- walkPicturesHome(animations, TRAVEL_LEAD_NAMES);
40973
- releaseHold(travel);
40974
- whenPicturesArrived(animations).then(async () => {
40975
- try {
40976
- pageAskedForRef.current = travel.fromPage;
40977
- // The page that was left is put back UNDER the picture before the
40978
- // picture is dropped, so the two are the same thing at the moment they
40979
- // are swapped: that only holds once the page is really back.
40980
- if (pageIsCurrent(travel.fromPage)) {
40981
- // It never left: the press that set this revert off put it back
40982
- // there, and the pages have been held where they were until now.
40983
- // Nothing to ask for, and nothing to wait for — waiting anyway is a
40984
- // render that never comes and a page frozen under its own pictures.
40985
- releaseRendering();
40986
- } else {
40987
- await whilePageRenders(travel.fromPage, () => onTravel({
40988
- route: travel.fromPage.route,
40989
- params: travel.fromPage.params,
40990
- cause: "revert"
40991
- }));
40992
- }
40993
- travel.viewTransition.skipTransition();
40994
- } finally {
40995
- // Refused before the browser called back, nothing was swapped: the
40996
- // offset is still the page's own, and no render is on its way to say so.
40997
- if (travel.scrollsDocument && travel.calledBack) {
40998
- // The page coming back was read somewhere else, and the offset
40999
- // currently on the document is the one the page that came in put
41000
- // there. Given back once the page is really back — the render is
41001
- // what makes the document tall enough to hold that offset again —
41002
- // and the recording stays deaf until then, over the clamp the swap
41003
- // back makes on the way. Its own deafness rather than the travel's:
41004
- // the travel ends here, and this outlives it by a render. Written
41005
- // once the commit that says so has ended, as arriveAtScrollPosition
41006
- // asks.
41007
- const resumeScrollRecording = suspendScrollRecording();
41008
- const stopWatchingRender = observeRouteRender(() => {
41009
- stopWatchingRender();
41010
- queueMicrotask(() => {
41011
- const {
41012
- route,
41013
- params
41014
- } = travel.fromPage;
41015
- arriveAtScrollPosition(route.buildUrl(params));
41016
- resumeScrollRecording();
41017
- });
41018
- });
41019
- }
41020
- releaseRendering();
41021
- // A travel ENDS, whatever happened on the way back: put the state back,
41022
- // fail to drop the picture, be interrupted by something else — the one
41023
- // thing that must not happen is a travel that stays "in flight"
41024
- // forever. Nothing would lift the hold, the pictures would stand where
41025
- // they are over a page that cannot be touched, and every gesture after
41026
- // this one would find the box busy.
41027
- endTravel(travel);
40697
+ const row = rowRef.current;
40698
+ const page = currentIndex === -1 ? null : pages[currentIndex];
40699
+ const pane = panesOf(row)[currentIndex];
40700
+ const answered = answeredPageRef.current;
40701
+ if (samePage(answered, page)) {
40702
+ if (pane && !movementRef.current && !restsOn(row, pane)) {
40703
+ row.scrollTo({
40704
+ left: pane.offsetLeft,
40705
+ behavior: "instant"
40706
+ });
41028
40707
  }
41029
- });
41030
- };
41031
-
41032
- // Taken back in hand: the pictures stop where they are and answer the finger
41033
- // again (see the CSS hold).
41034
- const holdTravel = travel => {
41035
- if (travel.ended || travel.reverting) {
41036
40708
  return;
41037
40709
  }
41038
- travel.scrub = true;
41039
- holdPictures(travel);
41040
- // Whatever asks for a transition next takes ours away (there is one per
41041
- // document): it must find this one already let go of, or it inherits a hold
41042
- // nobody is holding.
41043
- travel.dropHold = holdViewTransition(() => {
41044
- travel.viewTransition?.skipTransition();
41045
- endTravel(travel);
41046
- });
41047
- };
41048
-
41049
- // The same travel, aimed at another page. The still it starts from does not
41050
- // change — only what is being brought in against it, and that one is LIVE:
41051
- // pointing the router elsewhere is all it takes for the picture to show that
41052
- // page instead.
41053
- const redirectTravel = (travel, page, direction) => {
41054
- travel.page = page;
41055
- // Everything the transition carries that is NOT the pages was measured
41056
- // against a destination this travel is no longer going to (see the CSS).
41057
- document.documentElement.setAttribute(TURNED_ATTRIBUTE, "");
41058
- if (direction === travel.direction) {
41059
- // Same way, another page: the pictures in hand are already the right
41060
- // pair, and nothing has to move.
40710
+ answeredPageRef.current = page;
40711
+ if (!page || !pane) {
41061
40712
  return;
41062
40713
  }
41063
- travel.direction = direction;
41064
- travel.ratio = 0;
41065
- // The other way round is another pair of keyframes, and naming another
41066
- // animation builds another Animation: whatever was collected answers to
41067
- // nobody now. They start again from the beginning, which is where a travel
41068
- // that turns around is.
41069
- travel.animations = null;
41070
- document.documentElement.setAttribute(TRAVEL_ATTRIBUTE, direction);
41071
- };
41072
-
41073
- // Somebody asked for a page while one was on its way. Where they asked for
41074
- // decides what that means.
41075
- const retargetTravel = (travel, page) => {
41076
- if (travel.scrub || travel.reverting || travel.ended || travel.noPicture) {
41077
- // A hand is holding the pages, or they are already on their way back:
41078
- // either way this travel's end is decided by somebody else.
41079
- return;
41080
- }
41081
- if (samePage(page, travel.page)) {
41082
- // Already on its way there.
41083
- return;
41084
- }
41085
- if (samePage(page, travel.fromPage)) {
41086
- // Back where it set off from: that is not another travel, it is this one
41087
- // undone — the same pictures, run backwards.
41088
- revertTravel(travel);
40714
+ const asked = askedPageRef.current;
40715
+ if (asked && samePage(asked, page)) {
40716
+ askedPageRef.current = null;
40717
+ // The hand landed before the address did.
40718
+ if (movementRef.current?.landed && restsOn(row, pane)) {
40719
+ Promise.resolve().then(endMovement);
40720
+ }
41089
40721
  return;
41090
40722
  }
41091
- const fromIndex = pageIndexOf(pages, travel.fromPage);
41092
- const toIndex = pageIndexOf(pages, page);
41093
- if (fromIndex === -1 || toIndex === -1) {
40723
+ if (!answered || restsOn(row, pane)) {
40724
+ if (!restsOn(row, pane)) {
40725
+ row.scrollTo({
40726
+ left: pane.offsetLeft,
40727
+ behavior: "instant"
40728
+ });
40729
+ }
41094
40730
  return;
41095
40731
  }
41096
- redirectTravel(travel, page, toIndex > fromIndex ? "forward" : "back");
41097
- };
40732
+ beginMovement({
40733
+ travelling: true
40734
+ });
40735
+ row.scrollTo({
40736
+ left: pane.offsetLeft,
40737
+ behavior: "smooth"
40738
+ });
40739
+ // In this very commit: the tab pressed already wears its bar, and the first
40740
+ // scroll event is a frame away.
40741
+ sayRowMoves();
40742
+ });
41098
40743
 
41099
- // One page further than the travel in flight is going, the same way: what
41100
- // a second swipe asks for over a page still arriving. Reached the way a tab
41101
- // pressed while it plays reaches it — the same pictures, the router pointed
41102
- // one page further (see redirectTravel) — so nothing restarts and nothing
41103
- // stops. Past the last page there is nothing to aim at, and the travel
41104
- // simply arrives where it was going.
41105
- const aimOnePageFurther = (travel, sign) => {
41106
- const fromIndex = pageIndexOf(pages, travel.page);
41107
- const page = sign > 0 ? pages[fromIndex - 1] : pages[fromIndex + 1];
41108
- if (fromIndex === -1 || !page) {
41109
- return;
41110
- }
41111
- redirectTravel(travel, page, travel.direction);
41112
- pageAskedForRef.current = page;
41113
- travelTo(page, "drag");
41114
- };
41115
- const endTravel = travel => {
41116
- if (travel.ended) {
41117
- return;
40744
+ // Somebody else is about to move the address to another page of a window: its
40745
+ // picture has to be taken now, while it still shows the page being left.
40746
+ useLayoutEffect(() => {
40747
+ if (!windowed) {
40748
+ return undefined;
41118
40749
  }
41119
- travel.ended = true;
41120
- travel.dropHold?.();
41121
- travel.dropHold = null;
41122
- if (travelHoldingSettledPage === travel) {
41123
- travelHoldingSettledPage = null;
41124
- routeTravelSettledPageSignal.value = null;
41125
- }
41126
- if (travelRef.current === travel) {
41127
- document.documentElement.removeEventListener("touchmove", keepTouchRefusable);
41128
- }
41129
- // The offset on the scrollport belongs to a page again — the one that
41130
- // arrived, or the one put back. A travel that never got as far as saying
41131
- // which still has to give the recording back; the gesture's own
41132
- // pseudo-travel never took it (see noPicture).
41133
- travel.resumeScrollRecording?.();
41134
- // Its own hold, always — whether or not this travel is still the current
41135
- // one. Nobody else will lift it.
41136
- releaseHold(travel);
41137
- if (travelRef.current === travel) {
41138
- travelRef.current = null;
41139
- // Given back, so another box may wear it: kept, two of them on a page
41140
- // would both answer to it and the browser refuses the whole transition
41141
- // rather than pick.
41142
- unnameAfterTravel(travel.element);
41143
- document.documentElement.removeAttribute(TRAVEL_ATTRIBUTE);
41144
- document.documentElement.removeAttribute(TRAVEL_AXIS_ATTRIBUTE);
41145
- document.documentElement.removeAttribute(DRAGGED_ATTRIBUTE);
41146
- document.documentElement.removeAttribute(TURNED_ATTRIBUTE);
41147
- releaseTransitionWindow(travel);
41148
- releaseTransitionDestination(travel);
41149
- releaseTransitionPress(travel);
41150
- }
41151
- };
41152
-
41153
- // What a gesture is about, whichever hand made it: a thumb dragging the page
41154
- // and two fingers pushing it sideways on a trackpad ask for the same travel,
41155
- // so they are answered by the same three callbacks and only the reading of
41156
- // the input differs (see drag_to_travel.js).
41157
- const boxSizeOnAxis = () => {
41158
- const box = elementRef.current.getBoundingClientRect();
41159
- return axis === "x" ? box.width : box.height;
41160
- };
41161
- const travelHandlers = {
41162
- onStart: ({
41163
- sign,
41164
- target
40750
+ return observeBeforeRouting(({
40751
+ url
41165
40752
  }) => {
41166
- const size = boxSizeOnAxis();
41167
- const travelInFlight = travelRef.current;
41168
- if (travelInFlight) {
41169
- // A travel is already playing, and a second one cannot be started on
41170
- // top of it: there is one picture of the page being left, and it is
41171
- // taken. So the gesture takes over THIS travel instead of asking for
41172
- // another — which is what a hand reaching for a page still sliding is
41173
- // asking for anyway. It is refused only when there is nothing to take
41174
- // over (no picture at all).
41175
- //
41176
- // Never given up, even then: a gesture handed back to the browser is a
41177
- // page that rocks under a travel that is already moving.
41178
- // A travel being undone is not up for grabs either: it is already on
41179
- // its way back and its end is decided. Held again mid-revert, its
41180
- // animations would never finish — and the wait for them never resolves,
41181
- // so the pictures stay where they are, over a page that cannot be
41182
- // touched anymore.
41183
- if (travelInFlight.noPicture || travelInFlight.ended || travelInFlight.reverting) {
41184
- return {
41185
- size,
41186
- travelBack: false,
41187
- travelOn: false
41188
- };
41189
- }
41190
- // Stopped here, where it stands now: left playing under the press
41191
- // until the hand said it holds it (see inFlight in onPointerDown).
41192
- holdTravel(travelInFlight);
41193
- // Where the pictures stand right now, said as a pull: what the finger
41194
- // continues from, so nothing jumps when it takes them over.
41195
- const ratio = ratioOfTravel(travelInFlight);
41196
- travelInFlight.ratio = ratio;
41197
- const pulledSign = travelInFlight.direction === "back" ? 1 : -1;
41198
- return {
41199
- size,
41200
- slack: ratio * size * pulledSign,
41201
- // One box, the one being travelled. Either of its ends can be walked
41202
- // out of and the hand carries on into the next page, but that is a
41203
- // travel of its own and the gesture asks for it when it gets there
41204
- // (see onEdge) — from here there is one page on its way.
41205
- travelBack: travelInFlight.direction === "back",
41206
- travelOn: travelInFlight.direction === "forward"
41207
- };
41208
- }
41209
- // Dragging the page towards the end of the axis brings in what is
41210
- // BEFORE it, the way pushing a sheet to the right reveals its left.
41211
- const page = sign > 0 ? pages[currentIndex - 1] : pages[currentIndex + 1];
41212
- if (!page || !size || scrollRoomTowards(target, elementRef.current, axis, sign)) {
41213
- return false;
41214
- }
41215
- if (!CAN_KEEP_PICTURE) {
41216
- travelRef.current = {
41217
- page,
41218
- noPicture: true,
41219
- ended: false
41220
- };
41221
- return {
41222
- size,
41223
- travelBack: sign > 0,
41224
- travelOn: sign < 0
41225
- };
41226
- }
41227
- return {
41228
- size,
41229
- travelBack: sign > 0,
41230
- travelOn: sign < 0,
41231
- // A page change costs what a slide does not, whatever the hand does
41232
- // next: a navigation, a render, the requests of the page arriving, and
41233
- // those of the page coming back. So nothing is asked of the router
41234
- // until the hand has really pulled, and the neighbour is read then:
41235
- // turned around before that, there is nothing to undo.
41236
- setOff: ({
41237
- sign: signNow
41238
- }) => {
41239
- const pageThatWay = signNow > 0 ? pages[currentIndex - 1] : pages[currentIndex + 1];
41240
- if (!pageThatWay || travelRef.current) {
41241
- return false;
41242
- }
41243
- beginTravel({
41244
- page: pageThatWay,
41245
- fromPage: pages[currentIndex],
41246
- direction: signNow > 0 ? "back" : "forward",
41247
- scrub: true,
41248
- change: () => travelTo(pageThatWay, "drag")
41249
- });
41250
- return {
41251
- travelBack: signNow > 0,
41252
- travelOn: signNow < 0
41253
- };
41254
- }
41255
- };
41256
- },
41257
- onPull: ({
41258
- progress
41259
- }) => {
41260
- const travel = travelRef.current;
41261
- if (!travel || travel.noPicture) {
40753
+ const {
40754
+ pages,
40755
+ currentIndex
40756
+ } = latestRef.current;
40757
+ const asked = askedPageRef.current;
40758
+ if (asked || currentIndex === -1) {
41262
40759
  return;
41263
40760
  }
41264
- // Only what goes the way the gesture set off: a hand turning around
41265
- // mid-drag is putting the page back, and there is no second picture to
41266
- // show it anything else.
41267
- const ratio = travel.direction === "back" ? progress : -progress;
41268
- travel.ratio = ratio > 0 ? ratio : 0;
41269
- scrubTravel(travel, travel.ratio);
41270
- },
41271
- // A page walked all the way to one of its ends and the finger still going:
41272
- // what it asks for is the page past that end, and it has said so by not
41273
- // stopping. Which end decides how it is answered, and the two are not the
41274
- // same amount of work.
41275
- onEdge: ({
41276
- sign,
41277
- thrown
41278
- }) => {
41279
- const travel = travelRef.current;
41280
- if (thrown) {
41281
- // Caught, then thrown on before its end: the page after is reached by
41282
- // aiming the travel in hand further, and let go of it arrives there.
41283
- // There is no box to hand over — a new one would mean jumping these
41284
- // pictures the rest of the way first, a page seen leaping — so none
41285
- // is answered, and the gesture lets the travel go as it is.
41286
- if (travel && !travel.noPicture && !travel.ended && !travel.reverting) {
41287
- aimOnePageFurther(travel, sign);
41288
- }
41289
- return false;
40761
+ const path = new URL(url, window.location.href).pathname;
40762
+ if (pathOf(pages[currentIndex]) === path || !pages.some(page => pathOf(page) === path)) {
40763
+ return;
41290
40764
  }
41291
- if (!travel || travel.noPicture || travel.ended || travel.reverting || !CAN_KEEP_PICTURE) {
41292
- return false;
40765
+ const movement = beginMovement({
40766
+ travelling: true
40767
+ });
40768
+ leavePictureOfLive(movement);
40769
+ });
40770
+ }, [windowed]);
40771
+ useLayoutEffect(() => {
40772
+ const row = rowRef.current;
40773
+ const onScroll = () => {
40774
+ const {
40775
+ pages,
40776
+ currentIndex,
40777
+ travelByDrag
40778
+ } = latestRef.current;
40779
+ const panes = panesOf(row);
40780
+ if (panes.length < 2) {
40781
+ return;
41293
40782
  }
41294
- // Where the page in hand is coming from, said the way the gesture says
41295
- // it: dragging towards the end of the axis brings in what comes BEFORE.
41296
- const pulledSign = travel.direction === "back" ? 1 : -1;
41297
- const direction = sign > 0 ? "back" : "forward";
41298
- if (sign === pulledSign) {
41299
- // Walked whole. What the pictures show is the page that has arrived,
41300
- // and the one leaving is gone: there is no pair left to travel with, so
41301
- // the next travel needs pictures of its own — a navigation, a render, a
41302
- // snapshot, and for those few frames the page does not follow the
41303
- // finger before catching up with it (see `ready` in beginTravel). At
41304
- // the start of a gesture that gap is invisible, the hand has barely
41305
- // moved; here the hand is at full speed, and this is what it costs.
41306
- //
41307
- // Where we are is what THIS travel was bringing in — the URL changed as
41308
- // it set off, so `currentIndex` belongs to a render this gesture is
41309
- // older than.
41310
- const fromIndex = pageIndexOf(pages, travel.page);
41311
- const page = direction === "back" ? pages[fromIndex - 1] : pages[fromIndex + 1];
41312
- if (fromIndex === -1 || !page) {
41313
- return false;
41314
- }
41315
- // At their very end before they are let go of: what ends the travel in
41316
- // hand is the next transition starting (there is one per document, and
41317
- // the funnel skips ours), and a picture skipped short of its end is a
41318
- // page seen jumping the last few pixels.
41319
- scrubTravel(travel, 1);
41320
- travel.ratio = 1;
41321
- beginTravel({
41322
- page,
41323
- fromPage: travel.page,
41324
- direction,
41325
- scrub: true,
41326
- change: () => travelTo(page, "drag")
41327
- });
41328
- return {
41329
- size: boxSizeOnAxis(),
41330
- travelBack: sign > 0,
41331
- travelOn: sign < 0
41332
- };
40783
+ const moving = movementRef.current;
40784
+ const pane = panes[currentIndex];
40785
+ if (!moving && pane && restsOn(row, pane)) {
40786
+ // Written by this box, at rest: nobody is moving anything.
40787
+ return;
41333
40788
  }
41334
- // Walked back to where it began, and out the other side. Nothing has to
41335
- // be built here: the pictures in hand are ALREADY the pair this new
41336
- // travel needs — the still of the page it starts from is the same one,
41337
- // and the picture being brought in is live, so pointing the router at the
41338
- // other neighbour is enough for it to show that one instead. The travel
41339
- // turns around where it stands, on the same transition and under the same
41340
- // hand, and there is no gap at all.
41341
- const fromIndex = pageIndexOf(pages, travel.fromPage);
41342
- const page = direction === "back" ? pages[fromIndex - 1] : pages[fromIndex + 1];
41343
- if (fromIndex === -1 || !page) {
41344
- return false;
40789
+ const movement = moving || beginMovement({});
40790
+ sayRowMoves();
40791
+ if (movement.travelling || !travelByDrag || !pane) {
40792
+ return;
41345
40793
  }
41346
- redirectTravel(travel, page, direction);
41347
- pageAskedForRef.current = page;
41348
- travelTo(page, "drag");
41349
- return {
41350
- size: boxSizeOnAxis(),
41351
- travelBack: sign > 0,
41352
- travelOn: sign < 0
41353
- };
41354
- },
41355
- // The next swipe over a page still arriving: the page after it, and the
41356
- // travel in flight goes there — the same pictures, the router pointed one
41357
- // page further, exactly what a tab pressed while it plays does (see
41358
- // retargetTravel). Nothing is held and nothing restarts.
41359
- onPushOn: ({
41360
- sign
41361
- }) => {
41362
- const travel = travelRef.current;
41363
- if (!travel || travel.noPicture || travel.ended || travel.reverting || travel.scrub) {
41364
- return false;
40794
+ const pulled = row.scrollLeft - pane.offsetLeft;
40795
+ if (Math.abs(pulled) < SET_OFF_PULL) {
40796
+ return;
41365
40797
  }
41366
- aimOnePageFurther(travel, sign);
41367
- return true;
41368
- },
41369
- onEnd: ({
41370
- travels
41371
- }) => {
41372
- gestureRef.current = null;
41373
- const travel = travelRef.current;
41374
- if (!travel) {
41375
- // The travel this gesture was holding ended under it. Nothing left to
41376
- // decide, but the hold is this gesture's own doing and nobody else will
41377
- // take it off — a hold nobody lifts is a page nobody can touch.
41378
- releaseHold();
40798
+ const index = pulled > 0 ? currentIndex + 1 : currentIndex - 1;
40799
+ const paneTowards = panes[index];
40800
+ const page = pages[index];
40801
+ if (!paneTowards || samePage(askedPageRef.current, page)) {
41379
40802
  return;
41380
40803
  }
41381
- if (travel.noPicture) {
41382
- travelRef.current = null;
41383
- if (travels) {
41384
- travelTo(travel.page, "drag");
40804
+ if (windowed) {
40805
+ if (movement.pictures.has(index)) {
40806
+ return;
41385
40807
  }
40808
+ leavePictureOfLive(movement);
40809
+ } else if (paneTowards.hasAttribute("data-built")) {
41386
40810
  return;
41387
40811
  }
41388
- if (travels) {
41389
- finishTravel(travel);
41390
- return;
40812
+ if (routeTravelSettledPageSignal.peek() === null) {
40813
+ routeTravelSettledPageSignal.value = pages[currentIndex];
41391
40814
  }
41392
- revertTravel(travel);
41393
- }
41394
- };
41395
- const onPointerDown = pointerDownEvent => {
41396
- if (!travelByDrag || gestureRef.current) {
41397
- return;
41398
- }
41399
- // A travel playing under the press is left playing: a hand landing on a
41400
- // page still sliding is far more often the next swipe than a catch, and
41401
- // stopping the pages at the press is what that swipe feels as a stall. The
41402
- // gesture listens to the hand first and says which it was — the next page
41403
- // (onPushOn) or this one, held (onStart) — see inFlight in
41404
- // drag_to_travel.js. Either way the press is not refused: a gesture given
41405
- // back to the browser is a page that rocks under a travel already moving.
41406
- const travelInFlight = travelRef.current;
41407
- const leftPlaying = travelInFlight && !travelInFlight.noPicture && !travelInFlight.ended && !travelInFlight.reverting && !travelInFlight.scrub;
41408
- if (currentIndex === -1) {
41409
- return;
41410
- }
41411
- const giveUp = () => {
41412
- gestureRef.current = null;
40815
+ askFor(page);
41413
40816
  };
41414
- const gesture = startDragToTravel(pointerDownEvent, {
41415
- element: elementRef.current,
41416
- axes: axis,
41417
- inFlight: leftPlaying ? {
41418
- axis,
41419
- // The way a pull bringing in the page on its way would go:
41420
- // dragging towards the end of the axis brings in what is BEFORE.
41421
- way: travelInFlight.direction === "back" ? 1 : -1
41422
- } : false,
41423
- ...travelHandlers,
41424
- onGiveUp: giveUp
41425
- });
41426
- if (!gesture) {
41427
- // Not a press this box can be about — something that reads the pointer
41428
- // itself, a box below it that travels the same way.
41429
- giveUp();
41430
- return;
41431
- }
41432
- gestureRef.current = gesture;
41433
- };
41434
-
41435
- // A press aimed at a page while a travel is playing does not always reach the
41436
- // box: the browser's transition covers the page and the press can be delivered
41437
- // to the document root instead, whatever the pictures are told about pointer
41438
- // events. So while a travel plays, the press is caught at the document and
41439
- // handed to the box when it fell where a page is SEEN, which is where the hand
41440
- // thinks it pressed.
41441
- //
41442
- // Decided by the position and never by the target: in the first frames of a
41443
- // travel a press on a live bar falls to the root like any other, and what lies
41444
- // under a picture is not what the hand is aiming at.
41445
- pointerDownRef.current = onPointerDown;
41446
- useLayoutEffect(() => {
41447
- const onDocumentPointerDown = pointerDownEvent => {
41448
- if (!travelRef.current) {
40817
+ const onLand = () => {
40818
+ const movement = movementRef.current;
40819
+ if (!movement) {
41449
40820
  return;
41450
40821
  }
41451
- const boxElement = elementRef.current;
41452
- if (!boxElement || boxElement.contains(pointerDownEvent.target)) {
41453
- // It got there on its own.
40822
+ const panes = panesOf(row);
40823
+ const pane = pageAtRest(row, panes);
40824
+ if (!pane) {
41454
40825
  return;
41455
40826
  }
40827
+ movement.landed = true;
41456
40828
  const {
41457
- left,
41458
- right,
41459
- top,
41460
- bottom
41461
- } = boxElement.getBoundingClientRect();
41462
- const {
41463
- clientX,
41464
- clientY
41465
- } = pointerDownEvent;
41466
- if (clientX < left || clientX > right || clientY < top || clientY > bottom) {
40829
+ pages
40830
+ } = latestRef.current;
40831
+ const page = pages[panes.indexOf(pane)];
40832
+ if (!page || pageIsCurrent(page)) {
40833
+ if (!askedPageRef.current || samePage(askedPageRef.current, page)) {
40834
+ endMovement();
40835
+ }
41467
40836
  return;
41468
40837
  }
41469
- // A box scrolled under a bar still has the press in its rectangle, but
41470
- // the pictures are cut at that bar: the press is aimed at the bar.
41471
- if (isPointInTransitionBand(clientX, clientY)) {
40838
+ if (samePage(askedPageRef.current, page)) {
41472
40839
  return;
41473
40840
  }
41474
- pointerDownRef.current(pointerDownEvent);
40841
+ if (windowed && !movement.pictures.has(panes.indexOf(pane))) {
40842
+ leavePictureOfLive(movement);
40843
+ }
40844
+ Promise.resolve(askFor(page)).then(() => {
40845
+ if (pageIsCurrent(page)) {
40846
+ return;
40847
+ }
40848
+ // Refused, or sent elsewhere: the row says where the address is.
40849
+ askedPageRef.current = null;
40850
+ const {
40851
+ currentIndex
40852
+ } = latestRef.current;
40853
+ const paneCurrent = panesOf(row)[currentIndex];
40854
+ if (paneCurrent) {
40855
+ movement.travelling = true;
40856
+ row.scrollTo({
40857
+ left: paneCurrent.offsetLeft,
40858
+ behavior: "smooth"
40859
+ });
40860
+ }
40861
+ });
41475
40862
  };
41476
- document.addEventListener("pointerdown", onDocumentPointerDown, true);
40863
+ const stopWatching = watchRowScroll(row, {
40864
+ onScroll,
40865
+ onLand
40866
+ });
41477
40867
  return () => {
41478
- document.removeEventListener("pointerdown", onDocumentPointerDown, true);
40868
+ stopWatching();
40869
+ movementRef.current?.resumeScrollRecording?.();
40870
+ movementRef.current = null;
40871
+ publishRouteRowMove(null);
41479
40872
  };
41480
- }, []);
40873
+ }, [windowed]);
41481
40874
 
41482
- // A wheel pushing the box sideways asks for a PAGE, not for a place between
41483
- // two: one push, one neighbour — the same thing a tab pressed asks for, and
41484
- // it plays at its own pace rather than under a hand (see watchWheelTravel).
41485
- const travelOneStep = sign => {
41486
- const travelInFlight = travelRef.current;
41487
- if (travelInFlight?.scrub) {
41488
- // A hand is holding the pages. They are its until it lets go.
41489
- return;
41490
- }
41491
- // Where the box is going, which is not where it is: a step asked for while
41492
- // a travel plays is the page after the one on its way.
41493
- const fromPage = travelInFlight ? travelInFlight.page : pages[currentIndex];
41494
- const fromIndex = pageIndexOf(pages, fromPage);
41495
- if (fromIndex === -1) {
41496
- return;
41497
- }
41498
- const page = sign > 0 ? pages[fromIndex - 1] : pages[fromIndex + 1];
41499
- if (!page) {
40875
+ // A window's page lands on a pane as soon as the address does. The row has come
40876
+ // to rest on it or will: when it already has, nothing else will say so.
40877
+ useLayoutEffect(() => {
40878
+ const movement = movementRef.current;
40879
+ if (!movement?.landed || currentIndex === -1) {
41500
40880
  return;
41501
40881
  }
41502
- if (travelInFlight) {
41503
- // Finished where it stands before the next one sets off: what ends it is
41504
- // that transition starting, and a picture dropped short of its end is a
41505
- // page seen jumping the last few pixels.
41506
- scrubTravel(travelInFlight, 1);
41507
- travelInFlight.ratio = 1;
41508
- }
41509
- beginTravel({
41510
- page,
41511
- fromPage,
41512
- direction: sign > 0 ? "back" : "forward",
41513
- scrub: false,
41514
- change: () => travelTo(page, "wheel")
41515
- });
41516
- };
41517
-
41518
- // Reached through a ref, and the watcher is never rebuilt for it: a travel
41519
- // CHANGES the current page, so anything listening on `currentIndex` would be
41520
- // torn down halfway through the very gesture that is moving it.
41521
- travelHandlersRef.current = travelHandlers;
41522
- const travelOneStepRef = useRef(null);
41523
- travelOneStepRef.current = travelOneStep;
41524
- useLayoutEffect(() => {
41525
- if (!travelByDrag) {
41526
- return undefined;
40882
+ const pane = panesOf(rowRef.current)[currentIndex];
40883
+ if (pane && restsOn(rowRef.current, pane)) {
40884
+ Promise.resolve().then(endMovement);
41527
40885
  }
41528
- return watchWheelTravel(elementRef.current, {
41529
- axes: axis,
41530
- onStep: ({
41531
- sign
41532
- }) => travelOneStepRef.current(sign)
41533
- });
41534
- }, [travelByDrag, axis]);
40886
+ }, [currentIndex]);
40887
+ const rowKind = pages.length < 2 && windowed ? "still" : travelByDrag ? "" : "code";
41535
40888
  return jsx("div", {
41536
40889
  ...rest,
41537
- ref: elementRef,
41538
40890
  className: className ? `navi_route_travel ${className}` : "navi_route_travel",
41539
- "data-axis": axis
41540
- // What travels here, and on which axis: read by the shared gesture
41541
- // stylesheet, which keeps this box's scrolling from spilling onto the
41542
- // page (see drag_to_travel.js).
41543
- ,
41544
- "data-drag-travel": travelByDrag ? axis : undefined
41545
- // The same fact said once per gesture, and for the other question the
41546
- // DOM answers: a box that travels INSIDE this one — a row of slides in a
41547
- // page — takes the axis it walks, and these are what it reads to know
41548
- // this box walks it too.
41549
- ,
41550
- "data-travel-by-drag": travelByDrag ? axis : undefined,
41551
- "data-travel-by-wheel": travelByDrag ? axis : undefined
41552
- // A touch this box may take has to be refusable before the finger lands,
41553
- // or the browser can cancel an established travel mid-swipe by scrolling
41554
- // the axis touch-action leaves it — see keepTouchRefusable for why a JSX
41555
- // prop is enough (an element-level touchmove listener is non-passive).
41556
- ,
41557
- onTouchMove: travelByDrag ? keepTouchRefusable : undefined,
41558
- onPointerDown: onPointerDown,
41559
- children: children
40891
+ children: jsx("div", {
40892
+ ref: rowRef,
40893
+ "data-route-travel-row": rowKind
40894
+ // What a box around this one and a control acting on the press read: this
40895
+ // axis is taken here (see drag_to_travel.js).
40896
+ ,
40897
+ "data-travel-by-drag": travelByDrag && rowKind !== "still" ? "x" : undefined,
40898
+ children: windowed ? jsxs(Fragment, {
40899
+ children: [pages.map((page, index) => jsx("div", {
40900
+ "data-route-row-pane": "",
40901
+ style: {
40902
+ gridColumn: index + 1
40903
+ }
40904
+ }, index)), jsx("div", {
40905
+ ref: liveRef,
40906
+ "data-route-row-live": "",
40907
+ style: {
40908
+ gridColumn: liveIndex + 1
40909
+ },
40910
+ children: children
40911
+ }), jsx("div", {
40912
+ ref: picturesRef,
40913
+ "data-route-row-pictures": ""
40914
+ })]
40915
+ }) : jsx(RouteRowContext.Provider, {
40916
+ value: true,
40917
+ children: children
40918
+ })
40919
+ })
41560
40920
  });
41561
40921
  };
41562
40922
 
41563
- // Nobody holds the pictures anymore: whatever they were told (a time to stand
41564
- // at, a direction to run in) is what they carry on from.
41565
- // Which travel is keeping the pictures still, if any. The hold belongs to the
41566
- // travel that took it and only that one may give it back: a travel ending after
41567
- // another has taken over must not lift a hold it no longer owns, and — the way
41568
- // this went wrong — a travel whose end comes once something else has replaced
41569
- // it must still lift its OWN. A hold left behind is a page frozen under
41570
- // pictures nobody is holding.
41571
- let travelHoldingPictures = null;
41572
- const holdPictures = travel => {
41573
- travelHoldingPictures = travel;
41574
- document.documentElement.setAttribute(HOLD_ATTRIBUTE, "");
41575
- };
41576
- const releaseHold = travel => {
41577
- if (travel && travelHoldingPictures !== travel) {
41578
- return;
41579
- }
41580
- travelHoldingPictures = null;
41581
- document.documentElement.removeAttribute(HOLD_ATTRIBUTE);
41582
- };
41583
-
41584
- // The animations of the pictures, asked for again until there are some: they
41585
- // come into existence with the transition, several frames after it was asked
41586
- // for, and the gesture has already begun by then. Kept once found — the set
41587
- // does not change for the length of one travel.
41588
- const travelAnimations = travel => {
41589
- if (travel.animations) {
41590
- return travel.animations;
41591
- }
41592
- const animations = viewTransitionAnimations();
41593
- if (animations.length) {
41594
- travel.animations = animations;
41595
- }
41596
- return animations;
41597
- };
41598
-
41599
- // How far a travel has come, read off the pictures themselves rather than off
41600
- // what the last gesture wrote: one let go of is still moving, and a hand
41601
- // reaching for it must find it where it IS.
41602
- const ratioOfTravel = travel => {
41603
- const animations = travelAnimations(travel);
41604
- const animation = findLeadAnimation(animations, TRAVEL_LEAD_NAMES) || animations[0];
41605
- if (!animation) {
41606
- return travel.ratio;
41607
- }
41608
- const timing = animation.effect.getComputedTiming();
41609
- const duration = timing.delay + timing.activeDuration;
41610
- if (!duration) {
41611
- return travel.ratio;
41612
- }
41613
- return animation.currentTime / duration;
41614
- };
41615
-
41616
- // Where the two pictures stand, said as a moment in the movement they would
41617
- // have played on their own: the browser knows how they move (it is written in
41618
- // CSS), so the finger only has to say how far in. They are held still by CSS
41619
- // while this lasts, so a time written here is a place they stay at.
41620
- const scrubTravel = (travel, ratio) => {
41621
- for (const animation of travelAnimations(travel)) {
41622
- const timing = animation.effect.getComputedTiming();
41623
- const duration = timing.delay + timing.activeDuration;
41624
- animation.currentTime = ratio * duration;
41625
- }
40923
+ // How far down the row the reader is looking, for a page brought in: a page on
40924
+ // screen scrolls with the document, and one arriving has to start from there.
40925
+ const pinOf = row => {
40926
+ const {
40927
+ top
40928
+ } = row.getBoundingClientRect();
40929
+ return top < 0 ? -top : 0;
41626
40930
  };
41627
40931
 
41628
- // A page change, carried out and then waited for until the page it selects is
41629
- // really on screen. The container doing the swapping is the only one who knows
41630
- // when that is (observeRouteRender): a route matching is a signal changing, and
41631
- // how many passes Preact takes to answer it is its own business.
41632
- //
41633
- // Nothing is waited for when the change did not take — a route refused, a
41634
- // redirect somewhere else. There is no page on its way then, and this runs
41635
- // inside the callback of a view transition: the browser has stopped rendering
41636
- // and is waiting on this very promise to take its picture, so a wait that never
41637
- // ends is a page frozen under a transition that never became ready.
41638
- // Listening starts before the change, or a render landing while the change is
41639
- // being awaited is a render nobody heard. Armed apart from the wait itself
41640
- // because the two do not always happen at the same moment: a view transition
41641
- // calls its update callback a frame after it is started, and the render can
41642
- // land in that gap — see beginTravel, which arms this the moment the travel is
41643
- // decided and hands it over.
41644
- const armRouteRenderWait = () => {
41645
- let stopListening;
41646
- const rendered = new Promise(resolve => {
41647
- stopListening = observeRouteRender(resolve);
41648
- });
41649
- return {
41650
- rendered,
41651
- stop: () => stopListening()
41652
- };
41653
- };
41654
- const whilePageRenders = async (page, change, wait = armRouteRenderWait()) => {
41655
- try {
41656
- await change();
41657
- if (pageIsCurrent(page)) {
41658
- await wait.rendered;
40932
+ // What a clone does not take: where what scrolls inside was left.
40933
+ const copyScrollPositions = (from, to) => {
40934
+ const fromElements = from.querySelectorAll("*");
40935
+ const toElements = to.querySelectorAll("*");
40936
+ for (let i = 0; i < fromElements.length; i++) {
40937
+ const {
40938
+ scrollTop,
40939
+ scrollLeft
40940
+ } = fromElements[i];
40941
+ if (scrollTop || scrollLeft) {
40942
+ toElements[i].scrollTop = scrollTop;
40943
+ toElements[i].scrollLeft = scrollLeft;
41659
40944
  }
41660
- } finally {
41661
- wait.stop();
41662
40945
  }
41663
40946
  };
41664
40947
 
41665
- // Whether the document's offset belongs to the tabs. The pages of a row scroll
41666
- // the document when nothing between the box and the viewport is a scroll
41667
- // container: the tab on screen is then what makes the document tall, and the
41668
- // offset on it is that tab's — it has to be given back with the tab, and the
41669
- // browser's clamping of it while pages are swapped has to be ignored.
41670
- //
41671
- // A box that lives inside a scroller of its own — a frame in an article, a
41672
- // panel beside other content — shares nothing with the document: the offset
41673
- // there is the surrounding page's, the reader never left it, and a travel has
41674
- // no business moving it. Each of its pages brings its own scrollport, which
41675
- // goes away with the page and has nothing to restore.
41676
- //
41677
- // includeHidden, because an `overflow: hidden` ancestor is one of those
41678
- // scrollers: it shows no scrollbar and still holds an offset of its own, and
41679
- // what is under it never reaches the document. A wrapper that merely clips
41680
- // (`overflow: clip`) holds nothing and is walked straight through — see
41681
- // is_scrollable.js.
41682
- const pagesScrollTheDocument = element => {
41683
- // What window.scrollTo moves. A body whose overflow is the viewport's is
41684
- // walked through to it (see overflowGoesToViewport in @jsenv/dom); a body
41685
- // the walk stops on scrolls itself, out of the viewport's reach.
41686
- return getScrollContainer(element, {
41687
- includeHidden: true
41688
- }) === document.documentElement;
41689
- };
41690
-
41691
40948
  // A page of the row: a route, and the params that say which of its tabs when
41692
40949
  // several of them share it. Written as a bare route by a caller whose tabs are
41693
40950
  // routes of their own — which is the same page with nothing to tell apart.
@@ -41708,8 +40965,6 @@ const samePage = (a, b) => {
41708
40965
  }
41709
40966
  return a.route === b.route && compareTwoJsValues(a.params, b.params);
41710
40967
  };
41711
- const pageIndexOf = (pages, page) => pages.findIndex(candidate => samePage(candidate, page));
41712
-
41713
40968
  // The FIRST page that answers, as with the branches of a <Route>: several
41714
40969
  // routes match at once — a literal one and the parameterized one it is a case of
41715
40970
  // ("/games/new" is also a "/games/:gameId"), a section and the page inside it —
@@ -41730,20 +40985,6 @@ const currentPageIndex = pages => {
41730
40985
  return currentIndex;
41731
40986
  };
41732
40987
 
41733
- // A transition skipped by another one starting is an outcome, not a failure.
41734
- const ignoreSkipped = () => {};
41735
-
41736
- // The name is lent to the box that is travelling and taken back afterwards.
41737
- // There is one transition in a document at a time, so one box wears it at a
41738
- // time — and the others, unnamed, are simply not captured: they stay live
41739
- // under the pictures rather than being frozen with the page.
41740
- const nameForTravel = element => {
41741
- element.style.viewTransitionName = TRAVEL_NAME;
41742
- };
41743
- const unnameAfterTravel = element => {
41744
- element.style.viewTransitionName = "";
41745
- };
41746
-
41747
40988
  /**
41748
40989
  * What covers the box a route movement plays in, from INSIDE the document: a
41749
40990
  * sticky row of tabs above a `<RouteTravel>`, a header pinned to the top of a
@@ -56662,9 +55903,7 @@ const LinkPseudoElements = ["::-navi-loader", "::-navi-indicator"];
56662
55903
  * press on it is aimed at what the reader can see. Say it there, and only
56663
55904
  * there: a control that TRAVELS with the pages has a rectangle where it will
56664
55905
  * be, not where it is seen, and a press handed to it fires something nobody
56665
- * aimed at. A `RouteTravel` honours it as well, for a control in a `FixedBar`:
56666
- * the part it photographs (`data-navi-fixed-bar-overhang`) is deaf for the
56667
- * whole travel, and the rest for its first frames.
55906
+ * aimed at.
56668
55907
  * @param {boolean} [props.replace] - Go to the destination by TAKING THE PLACE
56669
55908
  * of the current history entry instead of stacking onto it: the link stays a
56670
55909
  * link (an address, a middle click, the keyboard, `aria-current`), only the
@@ -57153,6 +56392,10 @@ const SlideContainerContext = createContext(null);
57153
56392
  // ([data-slide-ways~="right"]).
57154
56393
  const SLIDE_CURRENT_ATTRIBUTE = "data-slide-current";
57155
56394
  const SLIDE_TOWARD_ATTRIBUTE = "data-slide-travel-toward";
56395
+ // The slide --slide-travel-progress counts from, when that is not the current
56396
+ // one: a hand on a row the browser scrolls can carry it past several slides, and
56397
+ // what follows it goes on between the two it is standing between.
56398
+ const SLIDE_BASE_ATTRIBUTE = "data-slide-travel-base";
57156
56399
  // Every way out that would DO something right now, in the words the commands
57157
56400
  // use: a direction ("left", "right", "up", "down") when there is a slide that
57158
56401
  // way, plus "first" and "last" when one is not already standing at that end of
@@ -57390,11 +56633,15 @@ const css$_ = /* css */`@layer navi {
57390
56633
  }
57391
56634
  }
57392
56635
 
56636
+ .navi_nav[data-nav-following-row] .navi_current_indicator {
56637
+ visibility: hidden;
56638
+ }
56639
+
57393
56640
  .navi_nav .navi_link[data-href-current] .navi_current_indicator {
57394
56641
  view-transition-name: var(--nav-indicator-name);
57395
56642
  }
57396
56643
 
57397
- :root:is([data-navi-route-transition], [data-navi-route-travel]) .navi_nav:not([data-nav-between-tabs]) .navi_current_indicator {
56644
+ :root[data-navi-route-transition] .navi_nav:not([data-nav-between-tabs]) .navi_current_indicator {
57398
56645
  view-transition-name: none;
57399
56646
  }
57400
56647
 
@@ -57525,6 +56772,64 @@ const NavStyleCSSVars = {
57525
56772
  currentIndicatorColor: "--nav-current-indicator-color",
57526
56773
  currentIndicatorSize: ["--nav-current-indicator-size", "height"]
57527
56774
  };
56775
+
56776
+ // Where the bar is, where it is headed and in which colour at each end: the
56777
+ // numbers the CSS above interpolates between. Measured off the DOM (offsets force
56778
+ // a layout, the colours a style resolve), so asked only when the two tabs change.
56779
+ const writeIndicatorGeometry = (navElement, {
56780
+ baseTabElement,
56781
+ towardTabElement,
56782
+ towardIsAfter,
56783
+ vertical
56784
+ }) => {
56785
+ const measure = tabElement => vertical ? {
56786
+ position: tabElement.offsetTop,
56787
+ length: tabElement.offsetHeight
56788
+ } : {
56789
+ position: tabElement.offsetLeft,
56790
+ length: tabElement.offsetWidth
56791
+ };
56792
+ // The colour a tab asks the bar to be under it. Read from the tab and not
56793
+ // from the row, so a tab that says nothing gives the row's value back —
56794
+ // it inherits it (see the css above) — and one that says something gives
56795
+ // its own. Empty when neither resolves to anything: the css then falls
56796
+ // back on its own, rather than being handed a colour that is not one.
56797
+ const colorOf = tabElement => getComputedStyle(tabElement).getPropertyValue("--link-current-indicator-color").trim();
56798
+ const writeColor = (property, color) => {
56799
+ if (color) {
56800
+ navElement.style.setProperty(property, color);
56801
+ } else {
56802
+ navElement.style.removeProperty(property);
56803
+ }
56804
+ };
56805
+ const baseMeasure = measure(baseTabElement);
56806
+ let positionDelta = 0;
56807
+ let lengthDelta = 0;
56808
+ let towardSign = 0;
56809
+ let towardColor = "";
56810
+ if (towardTabElement) {
56811
+ const towardMeasure = measure(towardTabElement);
56812
+ // What one box of travel is worth in pixels of this row, signed so that
56813
+ // the trait is exactly on the other tab when the progress is at its own
56814
+ // end: the container counts +1 when the picture leans on a slide sitting
56815
+ // BEFORE the current one and -1 when it sits after.
56816
+ const sign = towardIsAfter ? -1 : 1;
56817
+ positionDelta = (towardMeasure.position - baseMeasure.position) * sign;
56818
+ lengthDelta = (towardMeasure.length - baseMeasure.length) * sign;
56819
+ towardSign = sign;
56820
+ towardColor = colorOf(towardTabElement);
56821
+ }
56822
+ const {
56823
+ style
56824
+ } = navElement;
56825
+ style.setProperty("--nav-indicator-position", baseMeasure.position);
56826
+ style.setProperty("--nav-indicator-length", baseMeasure.length);
56827
+ style.setProperty("--nav-indicator-position-delta", positionDelta);
56828
+ style.setProperty("--nav-indicator-length-delta", lengthDelta);
56829
+ style.setProperty("--nav-indicator-toward-sign", towardSign);
56830
+ writeColor("--nav-indicator-color", colorOf(baseTabElement));
56831
+ writeColor("--nav-indicator-color-toward", towardColor);
56832
+ };
57528
56833
  const positionOfCurrentIndicator = (currentIndicator, vertical) => {
57529
56834
  if (currentIndicator === true) {
57530
56835
  return vertical ? "left" : "bottom";
@@ -57597,6 +56902,8 @@ const Nav = ({
57597
56902
  const [currentSlideArea, setCurrentSlideArea] = useState(undefined);
57598
56903
  const slideContainerElementRef = useRef(null);
57599
56904
  const indicatorPosition = slideContainer ? positionOfCurrentIndicator(currentIndicator, vertical) : null;
56905
+ // A row of routes draws that same bar only while it follows a row scrolling.
56906
+ const rowIndicatorPosition = slideContainer ? null : positionOfCurrentIndicator(currentIndicator, vertical);
57600
56907
 
57601
56908
  // Which tab of this row is the one being shown: the slide the container says
57602
56909
  // it is on, or, for a row of routes, the link the browser is on.
@@ -57609,7 +56916,9 @@ const Nav = ({
57609
56916
  if (!containerElement) {
57610
56917
  return navElement.querySelector("[data-href-current]");
57611
56918
  }
57612
- const currentArea = containerElement.getAttribute(SLIDE_CURRENT_ATTRIBUTE);
56919
+ // The tab the trait counts from: the current one, unless a hand has carried
56920
+ // the row past it.
56921
+ const currentArea = containerElement.getAttribute(SLIDE_BASE_ATTRIBUTE) ?? containerElement.getAttribute(SLIDE_CURRENT_ATTRIBUTE);
57613
56922
  if (currentArea === null) {
57614
56923
  return null;
57615
56924
  }
@@ -57669,56 +56978,13 @@ const Nav = ({
57669
56978
  tabCount: tabElements.length,
57670
56979
  vertical
57671
56980
  };
57672
- const measure = tabElement => vertical ? {
57673
- position: tabElement.offsetTop,
57674
- length: tabElement.offsetHeight
57675
- } : {
57676
- position: tabElement.offsetLeft,
57677
- length: tabElement.offsetWidth
57678
- };
57679
- // The colour a tab asks the bar to be under it. Read from the tab and not
57680
- // from the row, so a tab that says nothing gives the row's value back —
57681
- // it inherits it (see the css above) — and one that says something gives
57682
- // its own. Empty when neither resolves to anything: the css then falls
57683
- // back on its own, rather than being handed a colour that is not one.
57684
- const colorOf = tabElement => getComputedStyle(tabElement).getPropertyValue("--link-current-indicator-color").trim();
57685
- const writeColor = (property, color) => {
57686
- if (color) {
57687
- navElement.style.setProperty(property, color);
57688
- } else {
57689
- navElement.style.removeProperty(property);
57690
- }
57691
- };
57692
- const currentMeasure = measure(tabElements[currentIndex]);
57693
- const towardArea = towardAreaNow;
57694
- const towardIndex = tabElements.findIndex(tabElement => areaOf(tabElement) === towardArea);
57695
- let positionDelta = 0;
57696
- let lengthDelta = 0;
57697
- let towardSign = 0;
57698
- let towardColor = "";
57699
- if (towardIndex !== -1 && towardIndex !== currentIndex) {
57700
- const towardTabElement = tabElements[towardIndex];
57701
- const towardMeasure = measure(towardTabElement);
57702
- // What one box of travel is worth in pixels of this row, signed so that
57703
- // the trait is exactly on the other tab when the progress is at its own
57704
- // end: the container counts +1 when the picture leans on a slide sitting
57705
- // BEFORE the current one and -1 when it sits after.
57706
- const sign = towardIndex > currentIndex ? -1 : 1;
57707
- positionDelta = (towardMeasure.position - currentMeasure.position) * sign;
57708
- lengthDelta = (towardMeasure.length - currentMeasure.length) * sign;
57709
- towardSign = sign;
57710
- towardColor = colorOf(towardTabElement);
57711
- }
57712
- const {
57713
- style
57714
- } = navElement;
57715
- style.setProperty("--nav-indicator-position", currentMeasure.position);
57716
- style.setProperty("--nav-indicator-length", currentMeasure.length);
57717
- style.setProperty("--nav-indicator-position-delta", positionDelta);
57718
- style.setProperty("--nav-indicator-length-delta", lengthDelta);
57719
- style.setProperty("--nav-indicator-toward-sign", towardSign);
57720
- writeColor("--nav-indicator-color", colorOf(tabElements[currentIndex]));
57721
- writeColor("--nav-indicator-color-toward", towardColor);
56981
+ const towardIndex = tabElements.findIndex(tabElement => areaOf(tabElement) === towardAreaNow);
56982
+ writeIndicatorGeometry(navElement, {
56983
+ baseTabElement: tabElements[currentIndex],
56984
+ towardTabElement: towardIndex !== -1 && towardIndex !== currentIndex ? tabElements[towardIndex] : null,
56985
+ towardIsAfter: towardIndex > currentIndex,
56986
+ vertical
56987
+ });
57722
56988
  navElement.setAttribute("data-nav-indicator-measured", "");
57723
56989
  };
57724
56990
  // Reached through a ref by everything watching the DOM below: those watchers
@@ -57726,6 +56992,60 @@ const Nav = ({
57726
56992
  // about the row as it is now.
57727
56993
  const paintIndicatorGeometryRef = useRef(null);
57728
56994
  paintIndicatorGeometryRef.current = paintIndicatorGeometry;
56995
+
56996
+ // The bar following a row of routes the browser scrolls, between the two tabs
56997
+ // of the pages it stands between. A row this nav holds no tab for is another
56998
+ // nav's to draw.
56999
+ useLayoutEffect(() => {
57000
+ if (!rowIndicatorPosition) {
57001
+ return undefined;
57002
+ }
57003
+ let paintedBase = null;
57004
+ let paintedToward = null;
57005
+ const letGo = () => {
57006
+ const navElement = navRef.current;
57007
+ paintedBase = null;
57008
+ paintedToward = null;
57009
+ navElement?.removeAttribute("data-nav-following-row");
57010
+ navElement?.removeAttribute("data-nav-indicator-measured");
57011
+ navElement?.style.removeProperty("--slide-travel-progress");
57012
+ };
57013
+ const stopFollowing = followRouteRows(move => {
57014
+ const navElement = navRef.current;
57015
+ if (!navElement) {
57016
+ return;
57017
+ }
57018
+ if (!move) {
57019
+ letGo();
57020
+ return;
57021
+ }
57022
+ const tabElements = Array.from(navElement.querySelectorAll(".navi_link"));
57023
+ const tabOf = path => tabElements.find(tabElement => tabElement.pathname === path);
57024
+ const baseTabElement = tabOf(move.basePath);
57025
+ if (!baseTabElement) {
57026
+ letGo();
57027
+ return;
57028
+ }
57029
+ const towardTabElement = move.towardPath ? tabOf(move.towardPath) : null;
57030
+ if (baseTabElement !== paintedBase || towardTabElement !== paintedToward) {
57031
+ paintedBase = baseTabElement;
57032
+ paintedToward = towardTabElement;
57033
+ writeIndicatorGeometry(navElement, {
57034
+ baseTabElement,
57035
+ towardTabElement,
57036
+ towardIsAfter: tabElements.indexOf(towardTabElement) > tabElements.indexOf(baseTabElement),
57037
+ vertical
57038
+ });
57039
+ navElement.setAttribute("data-nav-indicator-measured", "");
57040
+ navElement.setAttribute("data-nav-following-row", "");
57041
+ }
57042
+ navElement.style.setProperty("--slide-travel-progress", move.progress);
57043
+ });
57044
+ return () => {
57045
+ stopFollowing();
57046
+ letGo();
57047
+ };
57048
+ }, [rowIndicatorPosition, vertical]);
57729
57049
  // What the last paint measured for — see the memo at the top of
57730
57050
  // paintIndicatorGeometry.
57731
57051
  const indicatorPaintedForRef = useRef(null);
@@ -58084,7 +57404,7 @@ const Nav = ({
58084
57404
  "data-expand-y": expand || expandY ? "" : undefined,
58085
57405
  "data-vertical": vertical ? "" : undefined,
58086
57406
  "data-panel-position": panelPosition,
58087
- "data-nav-indicator": indicatorPosition ?? undefined
57407
+ "data-nav-indicator": indicatorPosition ?? rowIndicatorPosition ?? undefined
58088
57408
  // "write your travel here too": a custom property cannot be read across
58089
57409
  // the DOM, so the container paints its progress onto this element and the
58090
57410
  // trait follows in CSS alone (see SlideContainer's followerElements).
@@ -58108,7 +57428,7 @@ const Nav = ({
58108
57428
  "--nav-indicator-name": indicatorNameRef.current
58109
57429
  } : props.style,
58110
57430
  styleCSSVars: NavStyleCSSVars,
58111
- children: [indicatorPosition && jsx("span", {
57431
+ children: [(indicatorPosition || rowIndicatorPosition) && jsx("span", {
58112
57432
  className: "navi_nav_indicator"
58113
57433
  }), jsx(NavContext.Provider, {
58114
57434
  value: navContextValue,
@@ -59302,17 +58622,6 @@ const css$Y = /* css */`@layer navi {
59302
58622
  left: calc(-1 * var(--navi-fixed-bar-border-width));
59303
58623
  clip-path: inset(0 0 0 -100vw);
59304
58624
  }
59305
-
59306
- @supports (view-transition-name: match-element) {
59307
- :root[data-navi-route-travel] .navi_fixed_bar_shadow, :root[data-navi-route-travel] [data-navi-fixed-bar-overhang] {
59308
- view-transition-name: match-element;
59309
- view-transition-class: navi_fixed_bar_over_pages;
59310
- }
59311
- }
59312
-
59313
- :root[data-navi-route-travel-turned]::view-transition-group(.navi_fixed_bar_over_pages) {
59314
- display: block;
59315
- }
59316
58625
  `;
59317
58626
  const FixedBarStyleCSSVars = {
59318
58627
  // width/height go through the vars the size calc reads: written as plain
@@ -59364,16 +58673,7 @@ const FixedBarStyleCSSVars = {
59364
58673
  * `height`/`width`.
59365
58674
  * @param {string} [props.shadow] - What the bar casts over the content, in
59366
58675
  * `box-shadow` syntax, cut at the bar's box on every side but the content's.
59367
- * Takes no room. Prefer it to a `boxShadow` or a `filter` on the bar: those
59368
- * are covered by the pages while a `RouteTravel` plays, this one stays over
59369
- * them.
59370
- *
59371
- * A child standing out of the bar's box (a raised button, the notch around it)
59372
- * wears `data-navi-fixed-bar-overhang`: unmarked, the pages of a `RouteTravel`
59373
- * cover it wherever they have paint behind it. Marked, it stays over them, and
59374
- * like anything photographed it is deaf for the length of the travel —
59375
- * `pressableDuringRouteTransition` on a control inside it keeps that control
59376
- * answering. The rest of the bar stays live.
58676
+ * Takes no room.
59377
58677
  */
59378
58678
  const FixedBar = ({
59379
58679
  children,
@@ -62627,9 +61927,7 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
62627
61927
  * button in a bar the two states share, standing exactly where its picture is
62628
61928
  * drawn — must still close it. What `<Link pressableDuringRouteTransition>`
62629
61929
  * says, for the other half of a toggle. Only for a control that does not
62630
- * travel with the pages. A `RouteTravel` honours it as well, for a control in
62631
- * a `FixedBar`: the part it photographs (`data-navi-fixed-bar-overhang`) is
62632
- * deaf for the whole travel, and the rest for its first frames.
61930
+ * travel with the pages.
62633
61931
  * @param {Function} [action] What the press runs: a button holds no value, so
62634
61932
  * every press is one call. Inside a `<Form>` or a group it carries the value
62635
61933
  * around it, merged over the action's bound params — a value that is not an
@@ -64772,6 +64070,16 @@ const css$M = /* css */`@layer navi {
64772
64070
  }
64773
64071
  }
64774
64072
 
64073
+ @media (pointer: coarse) {
64074
+ & .navi_control_input {
64075
+ overflow: clip;
64076
+
64077
+ &::-webkit-slider-thumb {
64078
+ transform: scaleX(3);
64079
+ }
64080
+ }
64081
+ }
64082
+
64775
64083
  & .navi_input_range_accent_probe {
64776
64084
  background-color: var(--accent-color);
64777
64085
  visibility: hidden;
@@ -66539,7 +65847,9 @@ const InputPseudoElements = ["::-navi-loader"];
66539
65847
  * only: nothing moves and nothing is drawn. It overflows the range, centered
66540
65848
  * on its line, over whatever is next to it and takes the presses aimed there,
66541
65849
  * so leave it the room it needs (`marginY`). Different for a mouse and a
66542
- * finger: `--press-height` in a `@media (pointer: coarse)` rule.
65850
+ * finger: `--press-height` in a `@media (pointer: coarse)` rule. On Safari for
65851
+ * iOS a drag only starts close to the thumb, within three times its width; a
65852
+ * tap further along the line moves the value.
66543
65853
  */
66544
65854
  const Input = props => {
66545
65855
  resolveInputProps(props);
@@ -68108,26 +67418,47 @@ const css$I = /* css */`@property --slide-travel-progress {
68108
67418
 
68109
67419
  .navi_slide_container {
68110
67420
  border-radius: inherit;
67421
+ overflow-anchor: none;
67422
+ scroll-snap-type: both mandatory;
67423
+ scrollbar-width: none;
68111
67424
  flex: 0 auto;
68112
67425
  min-width: 0;
68113
67426
  min-height: 0;
68114
67427
  display: grid;
68115
67428
  position: relative;
68116
- overflow: clip;
67429
+ overflow: hidden;
67430
+
67431
+ &::-webkit-scrollbar {
67432
+ display: none;
67433
+ }
67434
+
67435
+ &[data-slide-travelling] {
67436
+ scroll-snap-type: none;
67437
+ }
67438
+
67439
+ &[data-slide-hand*="x"] {
67440
+ overscroll-behavior-x: contain;
67441
+ overflow-x: auto;
67442
+ }
67443
+
67444
+ &[data-slide-hand*="y"] {
67445
+ overscroll-behavior-y: contain;
67446
+ overflow-y: auto;
67447
+ }
68117
67448
 
68118
67449
  &:focus {
68119
67450
  outline: none;
68120
67451
  }
68121
67452
 
68122
- &[data-travel-by-drag="x"] {
67453
+ &[data-slide-no-touch="x"] {
68123
67454
  touch-action: pan-y;
68124
67455
  }
68125
67456
 
68126
- &[data-travel-by-drag="y"] {
67457
+ &[data-slide-no-touch="y"] {
68127
67458
  touch-action: pan-x;
68128
67459
  }
68129
67460
 
68130
- &[data-travel-by-drag="xy"] {
67461
+ &[data-slide-no-touch="xy"] {
68131
67462
  touch-action: none;
68132
67463
  }
68133
67464
 
@@ -68147,15 +67478,19 @@ const css$I = /* css */`@property --slide-travel-progress {
68147
67478
  display: grid;
68148
67479
 
68149
67480
  & > [data-slide] {
68150
- min-width: 0;
68151
- min-height: 0;
68152
- translate: var(--slide-offset, 0);
67481
+ top: var(--slide-top, 0);
67482
+ left: var(--slide-left, 0);
67483
+ scroll-snap-align: start;
67484
+ scroll-snap-stop: always;
68153
67485
  border-radius: 0;
68154
67486
  outline: none;
68155
67487
  grid-area: 1 / 1;
67488
+ min-width: 0;
67489
+ min-height: 0;
67490
+ position: relative;
68156
67491
  }
68157
67492
 
68158
- & > [data-slide][data-slide-offstage] {
67493
+ & > [data-slide][data-slide-offstage], & > [data-slide][data-off-row] {
68159
67494
  visibility: hidden;
68160
67495
  }
68161
67496
  }
@@ -68185,22 +67520,15 @@ const ratioOfOneTravel = (track, from, to, targetBefore) => {
68185
67520
  // Where the track stands right now, in pixels, read off the box it draws in
68186
67521
  // rather than off the value that moves it: mid-animation the browser reports
68187
67522
  // that value as a calc() of a percentage and a length ("calc(-43% - 119px)"),
68188
- // which no simple parse survives — read as 0, it puts the gesture a whole
68189
- // travel away from what the eye is looking at.
67523
+ // which no simple parse survives. Against the box the track rests in, past the
67524
+ // border (clientLeft/clientTop), and plus what the box has scrolled, which the
67525
+ // track's rectangle carries and its translate does not.
68190
67526
  const trackOffsetPx = (track, containerElement) => {
68191
67527
  const trackRect = track.getBoundingClientRect();
68192
67528
  const containerRect = containerElement.getBoundingClientRect();
68193
- // Against the box the track RESTS in — the content box, past the border
68194
- // (clientLeft/clientTop are the border widths; the box never has a
68195
- // scrollbar nor padding, see the top-of-file comment on padding). Measured
68196
- // against the border box, a border reads as that many pixels of translate
68197
- // nothing ever asked for — and it COMPOUNDS: every travel interrupted
68198
- // mid-flight re-measures the track and carries the error into its "from",
68199
- // so rapid back-and-forth gestures stack a 1px border into a visible
68200
- // vertical drift of the whole track.
68201
67529
  return {
68202
- x: trackRect.x - containerRect.x - containerElement.clientLeft,
68203
- y: trackRect.y - containerRect.y - containerElement.clientTop
67530
+ x: trackRect.x - containerRect.x - containerElement.clientLeft + containerElement.scrollLeft,
67531
+ y: trackRect.y - containerRect.y - containerElement.clientTop + containerElement.scrollTop
68204
67532
  };
68205
67533
  };
68206
67534
  const offsetToPx = (offset, box) => {
@@ -68377,7 +67705,7 @@ const causeOfEvent = event => {
68377
67705
  const {
68378
67706
  type
68379
67707
  } = event;
68380
- if (type === "pointerup" || type === "pointercancel") {
67708
+ if (type === "scrollend") {
68381
67709
  return "drag";
68382
67710
  }
68383
67711
  if (type === "keydown" || type === "keyup") {
@@ -68602,19 +67930,19 @@ const areaHeldOn = slideElement => {
68602
67930
  * go in it), so the surface says `data-slide-container-follows={id}` and the
68603
67931
  * arrows keep walking wherever the keyboard is in it. See the paragraph above
68604
67932
  * about what is drawn AROUND the travel.
68605
- * @param {boolean|"x"|"y"|"xy"} [props.travelByDrag=true] - whether a pointer
68606
- * dragging the slides travels. On by default: slides side by side are
68607
- * something one expects to push around with a thumb. Off where the gesture
68608
- * belongs to the content (a canvas one draws on, a map one pans), or where
68609
- * the slides are steps of a form rather than a row one browses.
68610
- * @param {boolean|"x"|"y"|"xy"} [props.travelByScroll="x"] - whether a wheel
68611
- * pushing the box travels, one slide per push. Sideways only by default,
68612
- * because a page is hardly ever scrollable that way: a sideways scroll over
68613
- * the box can only have been meant for the box. Down the page it is the
68614
- * page's OWN gesture, and taking it would move a slide under someone who was
68615
- * scrolling the document — so a map that travels vertically has to be told
68616
- * (`travelByScroll` / `"y"` / `"xy"`) before a wheel moves it.
68617
- * @param {string} [props.duration="300ms"] - how long a slide change takes.
67933
+ * @param {boolean|"x"|"y"|"xy"} [props.travelByDrag=true] - whether a finger or
67934
+ * a pen pushing the slides travels: the browser pans them, a mouse does not.
67935
+ * Off where the gesture belongs to the content (a canvas one draws on, a map
67936
+ * one pans), or where the slides are steps of a form rather than a row one
67937
+ * browses.
67938
+ * @param {boolean|"x"|"y"|"xy"} [props.travelByScroll="x"] - whether a wheel or a
67939
+ * trackpad pushing the box travels. Sideways only by default, because a page is
67940
+ * hardly ever scrollable that way: a sideways scroll over the box can only have
67941
+ * been meant for the box. Down the page it is the page's OWN gesture, handed on
67942
+ * to what scrolls around the box, so a map that travels vertically has to be
67943
+ * told (`travelByScroll` / `"y"` / `"xy"`) before a wheel moves it.
67944
+ * @param {string} [props.duration="300ms"] - how long a slide change asked for
67945
+ * takes: a tab, a key, a command, a state. A hand goes at its own pace.
68618
67946
  * @param {"largest"|"frozen"} [props.sizing="largest"] - what decides the size
68619
67947
  * of the box. "largest": the largest slide, at every moment — the box follows
68620
67948
  * whatever the slides do. "frozen": the box is measured once and kept at that
@@ -68733,10 +68061,6 @@ const SlideContainer = ({
68733
68061
  // one being travelled TO while the track moves. What the next travel departs
68734
68062
  // from, because it is what one is looking at.
68735
68063
  const drawnAreaRef = useRef(undefined);
68736
- // The movement the track is playing, while it plays: the slide it is leaving
68737
- // and the slide it is going to. A hand landing on the box takes it over from
68738
- // its origin (see onStart in createTravelHandlers).
68739
- const movingRef = useRef(null);
68740
68064
  // Which way the travel about to be drawn goes, when whatever asked for it
68741
68065
  // knows: a window stepping off its last slide comes back on its first, and
68742
68066
  // only the press says that is a step forward — the map, read between those
@@ -68750,17 +68074,6 @@ const SlideContainer = ({
68750
68074
  // A focus transfer read off the interaction that asked for the travel, kept
68751
68075
  // until the slide it is meant for holds its final DOM (see handOverFocus).
68752
68076
  const focusHandOverRef = useRef(null);
68753
- // The gesture in hand, from the pointer that started it to the slides it
68754
- // brought alongside. Null when no finger is on the box.
68755
- const dragRef = useRef(null);
68756
- // Where the travel about to be drawn departs from, when that is not where the
68757
- // track rests: a slide let go of halfway carries on from under the finger.
68758
- // Read and dropped by the layout effect, which is the one drawing it.
68759
- const travelFromRef = useRef(null);
68760
- // The same fact for the indicator (--slide-travel-progress): how far the
68761
- // picture is from the slide ARRIVING when the travel starts, in boxes. Null
68762
- // for a travel nobody dragged, where a whole box is what is left to close.
68763
- const travelProgressFromRef = useRef(null);
68764
68077
  // One per element painting the progress: the box, plus everything following
68765
68078
  // it (see followerElementsRef). All started together and with the same
68766
68079
  // options, so they are one movement said in several places.
@@ -68771,6 +68084,23 @@ const SlideContainer = ({
68771
68084
  // draw in CSS alone, at the pace of the travel and under the finger, with
68772
68085
  // nothing measured per frame.
68773
68086
  const followerElementsRef = useRef([]);
68087
+ // Whether a hand has the row away from the current slide. A render meanwhile
68088
+ // must not send it back.
68089
+ const handMovingRef = useRef(false);
68090
+ // The slide a hand has just landed on, for the render it asks for: it is
68091
+ // already there, so there is nothing to play.
68092
+ const handLandedRef = useRef(null);
68093
+ // Where the box rests on the row, and what the row starts from: a slide that
68094
+ // holds on to the user ends the row on that side, and a scroll range starts at
68095
+ // its first reachable slide (see writeRowAtRest).
68096
+ const restPlaceRef = useRef({
68097
+ x: 0,
68098
+ y: 0
68099
+ });
68100
+ const originRef = useRef({
68101
+ x: 0,
68102
+ y: 0
68103
+ });
68774
68104
  const currentFromCaller = currentSignal ? currentSignal.value : currentProp;
68775
68105
  const current = rollingArea ?? provisionalArea ?? currentAreaState;
68776
68106
  // The area of this box a value held outside is shown on: the slide of that
@@ -69022,10 +68352,10 @@ const SlideContainer = ({
69022
68352
  // (areaTowards knows the holes and a loop's two ends).
69023
68353
  const areasAlongside = area => {
69024
68354
  const alongside = [];
69025
- if (dragAxes?.includes("x")) {
68355
+ if (travelAxes?.includes("x")) {
69026
68356
  alongside.push(areaTowards(-1, 0, area), areaTowards(1, 0, area));
69027
68357
  }
69028
- if (dragAxes?.includes("y")) {
68358
+ if (travelAxes?.includes("y")) {
69029
68359
  alongside.push(areaTowards(0, -1, area), areaTowards(0, 1, area));
69030
68360
  }
69031
68361
  return alongside;
@@ -69054,13 +68384,87 @@ const SlideContainer = ({
69054
68384
  return added.length ? [...previous, ...added] : previous;
69055
68385
  });
69056
68386
  };
69057
-
69058
- // The travel is over: the stage is struck and every slide goes back where the
69059
- // map says it is. Nothing is seen moving for it — the slide on screen sits at
69060
- // the same place whatever the arrangement (its own offset and the track's are
69061
- // opposites and cancel out), and the others are off stage — as long as the
69062
- // two are written in one go, which is why this is done here rather than left
69063
- // to a render.
68387
+ const writeSlidePlace = (slideElement, {
68388
+ x,
68389
+ y
68390
+ }) => {
68391
+ slideElement.style.setProperty("--slide-left", `${x * 100}%`);
68392
+ slideElement.style.setProperty("--slide-top", `${y * 100}%`);
68393
+ };
68394
+ // Every slide where the map says it is, except what the slide on screen holds
68395
+ // the user from: stacked under it, so the row ends there on that side. The same
68396
+ // rule as the gate in goToArea. The row starts at its first reachable slide,
68397
+ // since a scroll range starts at 0 and what lies before cannot be scrolled to.
68398
+ const writeRowAtRest = (slideElements, placeOf, currentElement) => {
68399
+ const currentPlace = placeOf.get(readArea(currentElement)) || {
68400
+ x: 0,
68401
+ y: 0
68402
+ };
68403
+ const holdsNext = currentElement.hasAttribute("data-prevent-nav-next");
68404
+ const holdsPrevious = currentElement.hasAttribute("data-prevent-nav-previous");
68405
+ const isOutOfReach = ({
68406
+ x,
68407
+ y
68408
+ }) => {
68409
+ if (x === currentPlace.x && y === currentPlace.y) {
68410
+ return false;
68411
+ }
68412
+ const forward = y === currentPlace.y ? x > currentPlace.x : y > currentPlace.y;
68413
+ return forward ? holdsNext : holdsPrevious;
68414
+ };
68415
+ const origin = {
68416
+ x: currentPlace.x,
68417
+ y: currentPlace.y
68418
+ };
68419
+ for (const slideElement of slideElements) {
68420
+ const place = placeOf.get(readArea(slideElement)) || {
68421
+ x: 0,
68422
+ y: 0
68423
+ };
68424
+ if (isOutOfReach(place)) {
68425
+ continue;
68426
+ }
68427
+ if (place.x < origin.x) {
68428
+ origin.x = place.x;
68429
+ }
68430
+ if (place.y < origin.y) {
68431
+ origin.y = place.y;
68432
+ }
68433
+ }
68434
+ const rest = {
68435
+ x: currentPlace.x - origin.x,
68436
+ y: currentPlace.y - origin.y
68437
+ };
68438
+ for (const slideElement of slideElements) {
68439
+ const place = placeOf.get(readArea(slideElement)) || {
68440
+ x: 0,
68441
+ y: 0
68442
+ };
68443
+ const outOfReach = isOutOfReach(place);
68444
+ slideElement.toggleAttribute("data-off-row", outOfReach);
68445
+ writeSlidePlace(slideElement, outOfReach ? rest : {
68446
+ x: place.x - origin.x,
68447
+ y: place.y - origin.y
68448
+ });
68449
+ }
68450
+ originRef.current = origin;
68451
+ restPlaceRef.current = rest;
68452
+ };
68453
+ const sendRowToRest = currentElement => {
68454
+ const containerEl = containerRef.current;
68455
+ if (!restsOn(containerEl, currentElement)) {
68456
+ containerEl.scrollTo({
68457
+ left: currentElement.offsetLeft,
68458
+ top: currentElement.offsetTop,
68459
+ behavior: "instant"
68460
+ });
68461
+ }
68462
+ };
68463
+ // The travel is over: the stage is struck, every slide goes back where the map
68464
+ // says it is and the scroll position takes over from the track. Nothing is seen
68465
+ // moving for it, the slide on screen standing at the same place either way, as
68466
+ // long as it is all written in one go, which is why this is done here rather
68467
+ // than left to a render.
69064
68468
  const settleTravel = () => {
69065
68469
  const track = trackRef.current;
69066
68470
  if (!track) {
@@ -69076,33 +68480,19 @@ const SlideContainer = ({
69076
68480
  }
69077
68481
  stageRef.current = null;
69078
68482
  trackAnimationRef.current = null;
69079
- movingRef.current = null;
69080
68483
  // At rest the picture IS the current slide, whatever the last gesture wrote
69081
68484
  // there: an indicator has nothing left to lean towards.
69082
- paintTravelProgress(0);
68485
+ paintTravelProgress();
68486
+ writeRowAtRest(slideElements, placeOf, currentElement);
69083
68487
  for (const slideElement of slideElements) {
69084
- const {
69085
- x,
69086
- y
69087
- } = placeOf.get(readArea(slideElement)) || {
69088
- x: 0,
69089
- y: 0
69090
- };
69091
- slideElement.style.setProperty("--slide-offset", `${x * 100}% ${y * 100}%`);
69092
- slideElement.toggleAttribute("data-slide-offstage", slideElement !== currentElement);
68488
+ slideElement.removeAttribute("data-slide-offstage");
69093
68489
  }
69094
68490
  const currentArea = readArea(currentElement);
69095
68491
  drawnAreaRef.current = currentArea;
69096
- const {
69097
- x,
69098
- y
69099
- } = placeOf.get(currentArea) || {
69100
- x: 0,
69101
- y: 0
69102
- };
69103
- const offset = `${-x * 100}% ${-y * 100}%`;
69104
- offsetRef.current = offset;
69105
- track.style.setProperty("--slide-container-offset", offset);
68492
+ offsetRef.current = "0% 0%";
68493
+ track.style.setProperty("--slide-container-offset", "0% 0%");
68494
+ containerRef.current.removeAttribute("data-slide-travelling");
68495
+ sendRowToRest(currentElement);
69106
68496
  renderNear(currentArea, {
69107
68497
  alongside: true
69108
68498
  });
@@ -69155,11 +68545,12 @@ const SlideContainer = ({
69155
68545
  if (landsWithPage && trackAnimationRef.current) {
69156
68546
  trackAnimationRef.current.cancel();
69157
68547
  trackAnimationRef.current = null;
69158
- movingRef.current = null;
69159
68548
  cancelTravelProgressAnimation();
69160
- paintTravelProgress(0);
68549
+ paintTravelProgress();
69161
68550
  }
69162
- const still = noTravel || landsWithPage;
68551
+ const handLanded = handLandedRef.current === currentArea;
68552
+ handLandedRef.current = null;
68553
+ const still = noTravel || landsWithPage || handLanded;
69163
68554
  // Nothing is travelling, so nothing is staged: the picture to paint is the
69164
68555
  // map itself, and a stage left over from a travel that has just been given
69165
68556
  // up on would be painted instead of it.
@@ -69225,19 +68616,30 @@ const SlideContainer = ({
69225
68616
  // travel someone asked for has its own transfer waiting (focusHandOverRef),
69226
68617
  // and that one knows what was pressed.
69227
68618
  const focusIsLeaving = !focusHandOverRef.current && slideElements.some(slideElement => slideElement !== currentElement && slideElement.contains(document.activeElement));
68619
+ // Read before the row is written: what the slide holds decides where it ends.
68620
+ if (!stage) {
68621
+ writeRowAtRest(slideElements, placeOf, currentElement);
68622
+ }
68623
+ const onRow = ({
68624
+ x,
68625
+ y
68626
+ }) => ({
68627
+ x: x - originRef.current.x,
68628
+ y: y - originRef.current.y
68629
+ });
69228
68630
  for (const slideElement of slideElements) {
69229
68631
  const area = readArea(slideElement);
69230
- const {
69231
- x,
69232
- y
69233
- } = placeOfArea(area);
69234
- slideElement.style.setProperty("--slide-offset", `${x * 100}% ${y * 100}%`);
69235
68632
  const isCurrent = slideElement === currentElement;
69236
68633
  slideElement.toggleAttribute("data-current", isCurrent);
69237
68634
  slideElement.toggleAttribute("data-slide-displaced", !isCurrent);
69238
- // On stage: the two ends of the travel while there is one, and the slide
69239
- // being shown when there is not.
69240
- slideElement.toggleAttribute("data-slide-offstage", stage ? !stage.placeByArea.has(area) : !isCurrent);
68635
+ // Off stage only while a travel asked for plays: at rest a hand can bring in
68636
+ // any of them, and it would find them unpainted.
68637
+ const onStage = Boolean(stage) && stage.placeByArea.has(area);
68638
+ slideElement.toggleAttribute("data-slide-offstage", Boolean(stage) && !onStage);
68639
+ if (onStage) {
68640
+ slideElement.removeAttribute("data-off-row");
68641
+ writeSlidePlace(slideElement, onRow(stage.placeByArea.get(area)));
68642
+ }
69241
68643
  if (isCurrent) {
69242
68644
  // Reachable again first, so the focus below has somewhere to land: an
69243
68645
  // inert element cannot take it.
@@ -69245,12 +68647,14 @@ const SlideContainer = ({
69245
68647
  }
69246
68648
  }
69247
68649
  const track = trackRef.current;
69248
- const offset = `${-currentPlace.x * 100}% ${-currentPlace.y * 100}%`;
68650
+ containerRef.current.toggleAttribute("data-slide-travelling", Boolean(stage));
68651
+ // How far the track stands from where the scroll position rests: none at rest.
68652
+ const rowPlace = onRow(currentPlace);
68653
+ const restPlace = restPlaceRef.current;
68654
+ const offset = `${(restPlace.x - rowPlace.x) * 100}% ${(restPlace.y - rowPlace.y) * 100}%`;
69249
68655
  // The travel that was ASKED for is still one box, whatever is left of it to
69250
- // cover: a slide dragged most of the way there finishes in what is left of
69251
- // the duration rather than taking a full one over a few pixels.
68656
+ // cover: what is left takes what is left of the duration.
69252
68657
  const offsetTargetBefore = offsetRef.current;
69253
- const targetMoves = offset !== offsetTargetBefore;
69254
68658
  // Where the track IS, read before anything is written: a travel asked for
69255
68659
  // while another is still playing must carry on from what is on screen. The
69256
68660
  // previous TARGET is where the last travel was going, not where it got to —
@@ -69269,35 +68673,16 @@ const SlideContainer = ({
69269
68673
  const onScreenPx = trackOffsetPx(track, containerRef.current);
69270
68674
  offsetOnScreen = `${onScreenPx.x}px ${onScreenPx.y}px`;
69271
68675
  }
69272
- // …and where a slide let go of halfway was left, which is the same fact
69273
- // said by the gesture that put it there: the track is at rest as far as any
69274
- // animation is concerned, so nothing else could tell. Taken by the render
69275
- // that moves the track and by no other: a `current` held outside catches up
69276
- // with the gesture a render late (a parent's state, set from a handler that
69277
- // runs after the address is written and read back), and a render still
69278
- // standing on the slide being left would otherwise carry the finger's
69279
- // offset back to it — a travel home nobody asked for.
69280
- const offsetDragged = targetMoves ? travelFromRef.current : null;
69281
- if (targetMoves) {
69282
- travelFromRef.current = null;
69283
- }
69284
- const offsetBefore = offsetDragged ?? offsetOnScreen ?? offsetRef.current;
68676
+ const offsetBefore = offsetOnScreen ?? offsetRef.current;
69285
68677
  offsetRef.current = offset;
69286
- // Where the track ends up, always — the animation below only covers the way
69287
- // there, and when it is over this is what holds. Except under a hand-off
69288
- // still waiting for its travel: the track stays where the finger left it,
69289
- // until the render that moves it or the frame after which the gesture gives
69290
- // up on it (see settleTowards in onEnd).
69291
- if (!travelFromRef.current) {
69292
- track.style.setProperty("--slide-container-offset", offset);
69293
- }
68678
+ track.style.setProperty("--slide-container-offset", offset);
69294
68679
  // A travel to this very offset is already playing: left to play. Same
69295
68680
  // courtesy as the stage above — a re-render in the middle of a travel (a
69296
68681
  // signal read higher up answering) changes nothing here, and setting off
69297
68682
  // again from the on-screen position would replay what is left over a full
69298
68683
  // duration (the ask is zero, so ratioOfOneTravel answers 1), slowing the
69299
68684
  // travel at every re-render.
69300
- const sameTravelPlaying = travelInFlight && !offsetDragged && offsetTargetBefore === offset;
68685
+ const sameTravelPlaying = travelInFlight && offsetTargetBefore === offset;
69301
68686
  const travels = !sameTravelPlaying && !still && durationMs > 0 && offsetBefore !== undefined && offsetBefore !== offset;
69302
68687
  if (travels) {
69303
68688
  // The time it takes is the distance it has left to cover: a travel picked
@@ -69307,10 +68692,9 @@ const SlideContainer = ({
69307
68692
  // this only ever shortens it (a longer travel is not made slower, which
69308
68693
  // would make a two-box move drag).
69309
68694
  const travelRatio = ratioOfOneTravel(track, offsetBefore, offset, offsetTargetBefore);
69310
- // Already moving — under an animation or under a finger that has just
69311
- // let go — so there is no ease-in to play: it would stall the track for
69312
- // an instant right where the eye is following it.
69313
- const easing = travelInFlight || offsetDragged ? "ease-out" : "ease";
68695
+ // Already moving, so there is no ease-in to play: it would stall the track
68696
+ // for an instant right where the eye is following it.
68697
+ const easing = travelInFlight ? "ease-out" : "ease";
69314
68698
  // Cancelled rather than layered: two animations on the same property
69315
68699
  // would blend, and what one sees then is neither of the two moves.
69316
68700
  trackAnimationRef.current?.cancel();
@@ -69322,14 +68706,8 @@ const SlideContainer = ({
69322
68706
  duration: durationMs * travelRatio,
69323
68707
  easing
69324
68708
  });
69325
- movingRef.current = {
69326
- from: drawnArea,
69327
- to: currentArea
69328
- };
69329
- // The trait travels with the slides: from where the gesture left it when
69330
- // there was one, from a whole box away when the travel was asked for.
69331
- const progressFrom = travelProgressFromRef.current ?? (travelStep ? travelStep.x || travelStep.y : 0);
69332
- travelProgressFromRef.current = null;
68709
+ // The trait travels with the slides, from a whole box away.
68710
+ const progressFrom = travelStep ? travelStep.x || travelStep.y : 0;
69333
68711
  // The slide the picture leans on for the length of it is the one being
69334
68712
  // LEFT: it is the second one in the frame until the travel is over.
69335
68713
  animateTravelProgress(progressFrom, durationMs * travelRatio, easing, drawnArea);
@@ -69346,12 +68724,11 @@ const SlideContainer = ({
69346
68724
  trackAnimationRef.current.finished.then(settleTravel, () => {
69347
68725
  // cancelled by the next travel — that one carries the stage on
69348
68726
  });
69349
- } else if (stage && !dragRef.current?.axis && trackAnimationRef.current?.playState !== "running") {
68727
+ } else if (stage && trackAnimationRef.current?.playState !== "running") {
69350
68728
  // Staged with nothing left to play: a travel that was drawn and then had
69351
68729
  // its animation taken away (a duration set to 0, a re-render landing
69352
68730
  // between the two). Struck at once rather than left standing, since the
69353
- // thing it was standing for is over. A gesture in hand is a stage that is
69354
- // standing for something — the finger still holding it.
68731
+ // thing it was standing for is over.
69355
68732
  settleTravel();
69356
68733
  }
69357
68734
  // A window waiting for its travel to be over (see goToArea's own loop
@@ -69411,6 +68788,11 @@ const SlideContainer = ({
69411
68788
  renderNear(settlingArea, {
69412
68789
  alongside: !stageRef.current
69413
68790
  });
68791
+ // Something moved the row under it at rest: an opening, a page changing, a
68792
+ // slide added before it. Under a hand it stays where the hand has it.
68793
+ if (!stageRef.current && !handMovingRef.current) {
68794
+ sendRowToRest(currentElement);
68795
+ }
69414
68796
  // Said last, and after data-current has been written: what a travel would do
69415
68797
  // is read off the map from the slide that is now current, and off the locks
69416
68798
  // that slide is wearing in this very commit.
@@ -69419,9 +68801,6 @@ const SlideContainer = ({
69419
68801
  // — a single announcement for a render that may have changed several of the
69420
68802
  // facts at once.
69421
68803
  announceState();
69422
- // The finger has the last word: everything above drew the map at rest, and
69423
- // where the track actually is right now is where the gesture put it.
69424
- paintDrag();
69425
68804
  });
69426
68805
 
69427
68806
  /**
@@ -69434,7 +68813,8 @@ const SlideContainer = ({
69434
68813
  value,
69435
68814
  released,
69436
68815
  dx = 0,
69437
- dy = 0
68816
+ dy = 0,
68817
+ landed
69438
68818
  } = {}) => {
69439
68819
  // A window mid-roll cannot travel yet: it is on its way somewhere and the
69440
68820
  // content that goes with it has not moved, so a second travel would be
@@ -69562,7 +68942,7 @@ const SlideContainer = ({
69562
68942
  // A caller writing the change somewhere that keeps a trace — the URL, a
69563
68943
  // history — needs it to know whether a place was aimed at or browsed past.
69564
68944
  const cause = causeOfEvent(event);
69565
- if (commit === "rest") {
68945
+ if (commit === "rest" && !landed) {
69566
68946
  // The travel first, the change once it is over: a caller putting it
69567
68947
  // somewhere expensive or visible (the URL, the address bar, a server)
69568
68948
  // must not be asked for it sixty times a second, and the picture must not
@@ -69947,22 +69327,152 @@ const SlideContainer = ({
69947
69327
  return area;
69948
69328
  };
69949
69329
 
69950
- // Where the track is right now, as the gesture left it: the resting place of
69951
- // the slide being dragged, plus what the pointer has pulled since.
69952
- const paintDrag = () => {
69953
- const drag = dragRef.current;
69954
- const track = trackRef.current;
69955
- // Nothing to paint for a pointer that is only resting on the box: until it
69956
- // has an axis a gesture has moved nothing and knows no geometry.
69957
- if (!drag || !drag.axis || !track) {
69330
+ // Where a hand has the row, in the terms of --slide-travel-progress: from the
69331
+ // current slide while it stands next to it, and past that between the two it
69332
+ // stands between, as far as the hand carries it.
69333
+ const paintNativeRow = () => {
69334
+ if (trackAnimationRef.current?.playState === "running") {
69335
+ return;
69336
+ }
69337
+ const containerEl = containerRef.current;
69338
+ const slideElements = Array.from(trackRef.current.children);
69339
+ const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current"));
69340
+ if (!currentElement) {
69341
+ return;
69342
+ }
69343
+ if (restsOn(containerEl, currentElement)) {
69344
+ if (handMovingRef.current) {
69345
+ paintTravelProgress();
69346
+ }
69347
+ return;
69348
+ }
69349
+ handMovingRef.current = true;
69350
+ const {
69351
+ base,
69352
+ toward,
69353
+ fraction,
69354
+ forward
69355
+ } = readRowStretch(containerEl, slideElements, currentElement);
69356
+ const baseArea = readArea(base);
69357
+ for (const element of travelPainters()) {
69358
+ if (base === currentElement) {
69359
+ element.removeAttribute(SLIDE_BASE_ATTRIBUTE);
69360
+ } else {
69361
+ element.setAttribute(SLIDE_BASE_ATTRIBUTE, baseArea);
69362
+ }
69363
+ element.style.setProperty("--slide-travel-progress", forward ? -fraction : fraction);
69364
+ }
69365
+ paintTravelToward(toward ? readArea(toward) : null);
69366
+ // What the hand reaches ahead of the neighbours built at rest is asked for on
69367
+ // the way: an empty slide goes by otherwise.
69368
+ renderNear(baseArea, {
69369
+ alongside: true
69370
+ });
69371
+ };
69372
+ const landNativeRow = scrollEndEvent => {
69373
+ if (!handMovingRef.current) {
69374
+ return;
69375
+ }
69376
+ const containerEl = containerRef.current;
69377
+ const {
69378
+ slideElements,
69379
+ placeOf
69380
+ } = readMap();
69381
+ const landedElement = pageAtRest(containerEl, slideElements);
69382
+ if (!landedElement) {
69383
+ return;
69384
+ }
69385
+ handMovingRef.current = false;
69386
+ paintTravelProgress();
69387
+ const area = readArea(landedElement);
69388
+ const currentArea = containerEl.getAttribute(SLIDE_CURRENT_ATTRIBUTE);
69389
+ if (area === currentArea) {
69390
+ settleTravel();
69391
+ return;
69392
+ }
69393
+ const event = scrollEndEvent ?? new Event("scrollend");
69394
+ if (loop) {
69395
+ // A window does not change slide, it rolls: back at rest in the render the
69396
+ // content moves in, the two being the same picture.
69397
+ const from = placeOf.get(currentArea) || {
69398
+ x: 0,
69399
+ y: 0
69400
+ };
69401
+ const to = placeOf.get(area) || {
69402
+ x: 0,
69403
+ y: 0
69404
+ };
69405
+ setNoTravel(true);
69406
+ onLoop?.({
69407
+ area,
69408
+ dx: Math.sign(to.x - from.x),
69409
+ dy: Math.sign(to.y - from.y),
69410
+ event
69411
+ });
69412
+ return;
69413
+ }
69414
+ handLandedRef.current = area;
69415
+ if (goToArea(area, {
69416
+ event,
69417
+ landed: true
69418
+ })) {
69958
69419
  return;
69959
69420
  }
69960
- const x = drag.baseOffset.x + drag.pull.x;
69961
- const y = drag.baseOffset.y + drag.pull.y;
69962
- drag.offset = `${x}px ${y}px`;
69963
- track.style.setProperty("--slide-container-offset", drag.offset);
69964
- paintTravelProgress(drag.progress, drag.areaPulled);
69421
+ // Refused: the row is put back in view of the slide that stays.
69422
+ handLandedRef.current = null;
69423
+ const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current"));
69424
+ containerEl.scrollTo({
69425
+ left: currentElement.offsetLeft,
69426
+ top: currentElement.offsetTop,
69427
+ behavior: "smooth"
69428
+ });
69429
+ };
69430
+
69431
+ // Reached through a ref, and the listeners are never rebuilt for it: a landing
69432
+ // changes the current slide, and one is often already on its way when it does.
69433
+ const nativeRowHandlersRef = useRef(null);
69434
+ nativeRowHandlersRef.current = {
69435
+ paintNativeRow,
69436
+ landNativeRow
69965
69437
  };
69438
+ useLayoutEffect(() => {
69439
+ return watchRowScroll(containerRef.current, {
69440
+ onScroll: () => nativeRowHandlersRef.current.paintNativeRow(),
69441
+ onLand: event => nativeRowHandlersRef.current.landNativeRow(event)
69442
+ });
69443
+ }, []);
69444
+
69445
+ // A wheel on an axis only a finger travels (travelByScroll) is not this box's,
69446
+ // though a scroller takes it all the same: it goes to what scrolls inside, or
69447
+ // on to what scrolls around, which is where it was headed.
69448
+ const wheelRefusedAxes = Array.from(dragAxes ?? "").filter(axis => !scrollAxes?.includes(axis)).join("");
69449
+ useLayoutEffect(() => {
69450
+ if (!wheelRefusedAxes) {
69451
+ return undefined;
69452
+ }
69453
+ const containerEl = containerRef.current;
69454
+ const onWheel = wheelEvent => {
69455
+ const axis = Math.abs(wheelEvent.deltaX) > Math.abs(wheelEvent.deltaY) ? "x" : "y";
69456
+ if (!wheelRefusedAxes.includes(axis)) {
69457
+ return;
69458
+ }
69459
+ const delta = axis === "x" ? wheelEvent.deltaX : wheelEvent.deltaY;
69460
+ if (!delta || scrollRoomTowards(wheelEvent.target, containerEl, axis, delta > 0 ? -1 : 1)) {
69461
+ return;
69462
+ }
69463
+ wheelEvent.preventDefault();
69464
+ getScrollContainer(containerEl.parentElement)?.scrollBy({
69465
+ [axis === "x" ? "left" : "top"]: delta,
69466
+ behavior: "instant"
69467
+ });
69468
+ };
69469
+ containerEl.addEventListener("wheel", onWheel, {
69470
+ passive: false
69471
+ });
69472
+ return () => {
69473
+ containerEl.removeEventListener("wheel", onWheel);
69474
+ };
69475
+ }, [wheelRefusedAxes]);
69966
69476
 
69967
69477
  // Everything that draws this travel: the box itself and whoever follows it.
69968
69478
  const travelPainters = () => {
@@ -70095,13 +69605,12 @@ const SlideContainer = ({
70095
69605
  // current slide changes under it.
70096
69606
  const paintTravelProgress = (progress, area) => {
70097
69607
  for (const element of travelPainters()) {
70098
- if (progress) {
70099
- element.style.setProperty("--slide-travel-progress", progress);
70100
- } else {
69608
+ {
70101
69609
  element.style.removeProperty("--slide-travel-progress");
69610
+ element.removeAttribute(SLIDE_BASE_ATTRIBUTE);
70102
69611
  }
70103
69612
  }
70104
- paintTravelToward(progress ? area : null);
69613
+ paintTravelToward(null);
70105
69614
  };
70106
69615
  const cancelTravelProgressAnimation = () => {
70107
69616
  for (const animation of progressAnimationsRef.current) {
@@ -70121,7 +69630,7 @@ const SlideContainer = ({
70121
69630
  // draw is the one about to start.
70122
69631
  followerElementsRef.current = readFollowerElements(containerRef.current);
70123
69632
  cancelTravelProgressAnimation();
70124
- paintTravelProgress(0);
69633
+ paintTravelProgress();
70125
69634
  const painters = travelPainters();
70126
69635
  if (!painters.length || !from || !durationMs) {
70127
69636
  return;
@@ -70142,598 +69651,8 @@ const SlideContainer = ({
70142
69651
  // cancelled by the next travel — that one says where the trait goes
70143
69652
  });
70144
69653
  };
70145
-
70146
- // The two slides the gesture can bring in, placed one box either side of the
70147
- // one being dragged — the same stage a travel builds, except that both ends
70148
- // are set up at once because the finger has not said yet which way it goes.
70149
- const stageDrag = (drag, currentArea) => {
70150
- const {
70151
- slideElements,
70152
- placeOf
70153
- } = readMap();
70154
- const step = drag.axis === "x" ? {
70155
- x: 1,
70156
- y: 0
70157
- } : {
70158
- x: 0,
70159
- y: 1
70160
- };
70161
- const placeByArea = new Map();
70162
- placeByArea.set(drag.area, drag.basePlace);
70163
- if (drag.areaBack) {
70164
- placeByArea.set(drag.areaBack, {
70165
- x: drag.basePlace.x - step.x,
70166
- y: drag.basePlace.y - step.y
70167
- });
70168
- }
70169
- if (drag.areaOn) {
70170
- placeByArea.set(drag.areaOn, {
70171
- x: drag.basePlace.x + step.x,
70172
- y: drag.basePlace.y + step.y
70173
- });
70174
- }
70175
- stageRef.current = {
70176
- placeByArea,
70177
- area: currentArea
70178
- };
70179
- for (const slideElement of slideElements) {
70180
- const area = readArea(slideElement);
70181
- const {
70182
- x,
70183
- y
70184
- } = placeByArea.get(area) || placeOf.get(area) || {
70185
- x: 0,
70186
- y: 0
70187
- };
70188
- slideElement.style.setProperty("--slide-offset", `${x * 100}% ${y * 100}%`);
70189
- slideElement.toggleAttribute("data-slide-offstage", !placeByArea.has(area));
70190
- }
70191
- };
70192
-
70193
- // Let go of, and the slides go where the gesture leaves them: back on the
70194
- // slide being dragged when nothing travels, on to the slide arriving when a
70195
- // travel caught in flight is let carry on. Over the distance left to cover,
70196
- // so a slide barely moved snaps back and one dragged most of the way there
70197
- // takes its time.
70198
- const settleTowards = (drag, area) => {
70199
- const track = trackRef.current;
70200
- if (!track) {
70201
- return;
70202
- }
70203
- const stage = stageRef.current;
70204
- const {
70205
- placeOf
70206
- } = readMap();
70207
- const place = stage?.placeByArea.get(area) || placeOf.get(area) || {
70208
- x: 0,
70209
- y: 0
70210
- };
70211
- const restPx = {
70212
- x: -place.x * drag.box.width,
70213
- y: -place.y * drag.box.height
70214
- };
70215
- const nowPx = {
70216
- x: drag.baseOffset.x + drag.pull.x,
70217
- y: drag.baseOffset.y + drag.pull.y
70218
- };
70219
- const restOffset = `${restPx.x}px ${restPx.y}px`;
70220
- const durationMs = durationToMs(duration);
70221
- const size = drag.axis === "x" ? drag.box.width : drag.box.height;
70222
- const left = Math.abs(restPx[drag.axis] - nowPx[drag.axis]);
70223
- trackAnimationRef.current?.cancel();
70224
- trackAnimationRef.current = null;
70225
- track.style.setProperty("--slide-container-offset", restOffset);
70226
- if (!durationMs || !left) {
70227
- paintTravelProgress(0);
70228
- settleTravel();
70229
- return;
70230
- }
70231
- // Which other slide the picture leans on while it arrives: the one being
70232
- // pulled in when it comes back on the dragged slide, the dragged slide
70233
- // itself when it goes on to the one arriving — and the trait is then a
70234
- // box short of that one, which is what it has to close.
70235
- const arrivesOnBase = area === drag.area;
70236
- const sign = drag.pull[drag.axis] > 0 ? 1 : -1;
70237
- const progressFrom = arrivesOnBase ? drag.progress : drag.progress - sign;
70238
- const otherArea = arrivesOnBase ? drag.areaPulled : drag.area;
70239
- movingRef.current = {
70240
- from: otherArea,
70241
- to: area
70242
- };
70243
- animateTravelProgress(progressFrom, durationMs * (left / size), "ease-out", otherArea);
70244
- const animation = track.animate([{
70245
- translate: `${nowPx.x}px ${nowPx.y}px`
70246
- }, {
70247
- translate: restOffset
70248
- }], {
70249
- duration: durationMs * (left / size),
70250
- easing: "ease-out"
70251
- });
70252
- trackAnimationRef.current = animation;
70253
- animation.finished.then(settleTravel, () => {
70254
- // cancelled by a travel asked for since — that one carries the stage on
70255
- });
70256
- };
70257
-
70258
- // A travel that is playing when a gesture takes hold of it is STOPPED where it
70259
- // stands, at the moment the hand holds it (see readTravelInFlight for when
70260
- // that is): the track stays exactly under the eye, and the hand answers it
70261
- // from there.
70262
- // A gesture that turns out to be nothing lets the travel carry on from where
70263
- // it was caught (see onGiveUp).
70264
- const catchTravelInFlight = () => {
70265
- const trackElement = trackRef.current;
70266
- const travelCaught = trackAnimationRef.current;
70267
- if (!travelCaught || travelCaught.playState !== "running") {
70268
- return null;
70269
- }
70270
- const onScreenPx = trackOffsetPx(trackElement, containerRef.current);
70271
- const offsetOnScreen = `${onScreenPx.x}px ${onScreenPx.y}px`;
70272
- travelCaught.cancel();
70273
- // Where it was, held: cancelling an animation puts the track back on the
70274
- // value underneath it, which is the far end of the travel — the very jump
70275
- // this is about.
70276
- trackElement.style.setProperty("--slide-container-offset", offsetOnScreen);
70277
- const moving = movingRef.current || {};
70278
- return {
70279
- trackElement,
70280
- onScreenPx,
70281
- offsetOnScreen,
70282
- offsetTarget: offsetRef.current,
70283
- from: moving.from,
70284
- to: moving.to
70285
- };
70286
- };
70287
- // Which way a travel still playing is going, read without touching it: a
70288
- // hand landing on something moving has no axis left to decide (a first pixel
70289
- // of tremor read as one gives the gesture up), and which WAY along it the
70290
- // travel goes is what tells the next swipe from a catch. `way` is the sign a
70291
- // pull going the same way would have.
70292
- const readTravelInFlight = () => {
70293
- const trackElement = trackRef.current;
70294
- const animation = trackAnimationRef.current;
70295
- if (!animation || animation.playState !== "running") {
70296
- return null;
70297
- }
70298
- const onScreenPx = trackOffsetPx(trackElement, containerRef.current);
70299
- const targetPx = offsetToPx(offsetRef.current, trackElement.getBoundingClientRect());
70300
- const towardsX = targetPx.x - onScreenPx.x;
70301
- const towardsY = targetPx.y - onScreenPx.y;
70302
- const axis = Math.abs(towardsX) >= Math.abs(towardsY) ? "x" : "y";
70303
- const towards = axis === "x" ? towardsX : towardsY;
70304
- if (Math.abs(towards) < 1) {
70305
- // On its last pixel: nothing left to catch or to push.
70306
- return null;
70307
- }
70308
- return {
70309
- axis,
70310
- way: towards > 0 ? 1 : -1
70311
- };
70312
- };
70313
-
70314
- // What a travel gesture does to the slides, whoever asked for it: a pointer
70315
- // dragging the box and a wheel pushing it sideways ask for the same travel,
70316
- // so they are answered by the same callbacks and only the reading of the
70317
- // input differs (see drag_to_travel.js). The rules of the gesture are read
70318
- // there, the geometry of the slides here.
70319
- const createTravelHandlers = caughtAtStart => {
70320
- let caughtTravel = caughtAtStart;
70321
- // The travel in hand, as the slides see it: where the one being dragged
70322
- // stands, what is either side of it, and how far the gesture has taken it.
70323
- const drag = {
70324
- axis: null,
70325
- area: null,
70326
- areaBack: null,
70327
- areaOn: null,
70328
- areaPulled: null,
70329
- box: null,
70330
- basePlace: null,
70331
- baseOffset: null,
70332
- pull: {
70333
- x: 0,
70334
- y: 0
70335
- },
70336
- progress: 0,
70337
- offset: null,
70338
- gesture: null
70339
- };
70340
- return {
70341
- drag,
70342
- onStart: ({
70343
- axis,
70344
- sign,
70345
- target,
70346
- caught
70347
- }) => {
70348
- // A travel left playing under the press, which the hand has now taken
70349
- // hold of: stopped here, where it stands at this moment.
70350
- if (caught) {
70351
- caughtTravel = catchTravelInFlight();
70352
- }
70353
- // Everything positional is read HERE rather than when the pointer
70354
- // landed: the travel that was playing then may have arrived since, and
70355
- // it is what the slides are doing at the moment the gesture takes them
70356
- // over that the gesture must carry on from.
70357
- const track = trackRef.current;
70358
- const {
70359
- slideElements,
70360
- placeOf
70361
- } = readMap();
70362
- const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current")) || slideElements[0];
70363
- const box = track.getBoundingClientRect();
70364
- if (!currentElement || !box.width || !box.height) {
70365
- return false;
70366
- }
70367
- const currentArea = readArea(currentElement);
70368
- const stage = stageRef.current;
70369
- // Where a slide stands: where the stage put it while a travel is
70370
- // playing, its place on the map otherwise.
70371
- const placeOfArea = someArea => stage?.placeByArea.get(someArea) || placeOf.get(someArea) || {
70372
- x: 0,
70373
- y: 0
70374
- };
70375
- // A travel caught in flight is measured from the slide it SET OFF from,
70376
- // with the slide it was going to as the one thing the hand can bring
70377
- // in — the way drag_to_travel reads a `slack`: how far the box already
70378
- // sits from its resting place, towards what is being pulled in. That is
70379
- // what makes its answer "carry on" mean arriving. Based on the slide
70380
- // arriving instead, the slack would point back at the origin, and a
70381
- // hand that merely touched the box would send it all the way back.
70382
- // A movement with no origin of its own (the track re-placed without a
70383
- // travel) is dragged from the slide shown, like any slide at rest.
70384
- const origin = caughtTravel && caughtTravel.from !== caughtTravel.to ? caughtTravel.from : null;
70385
- let area;
70386
- let areaBack;
70387
- let areaOn;
70388
- if (origin) {
70389
- area = origin;
70390
- const towardsEnd = axis === "x" ? placeOfArea(currentArea).x > placeOfArea(origin).x : placeOfArea(currentArea).y > placeOfArea(origin).y;
70391
- areaBack = towardsEnd ? undefined : currentArea;
70392
- areaOn = towardsEnd ? currentArea : undefined;
70393
- } else {
70394
- area = currentArea;
70395
- areaBack = axis === "x" ? areaTowards(-1, 0) : areaTowards(0, -1);
70396
- areaOn = axis === "x" ? areaTowards(1, 0) : areaTowards(0, 1);
70397
- // The hold goToArea reads at the release, read again HERE, off the
70398
- // same slide and the same attribute: a slide that will refuse the
70399
- // arrival must not offer the journey. A locked direction simply has
70400
- // nowhere to go for the length of this gesture — the one case the
70401
- // gesture already knows, being the last slide of a walk. The hand
70402
- // then gets the wall it can lean on and never walk through (see
70403
- // drag_to_travel), the slide behind it stays offstage instead of
70404
- // being read on the way, and the release has nothing left to refuse.
70405
- // Nothing here about `released` (--navi-done): that is one particular
70406
- // departure letting go, decided as it happens, and a gesture armed
70407
- // before it has no such thing to read — the attribute as rendered is
70408
- // what the hand is answered from.
70409
- if (currentElement.hasAttribute("data-prevent-nav-previous")) {
70410
- areaBack = undefined;
70411
- }
70412
- if (currentElement.hasAttribute("data-prevent-nav-next")) {
70413
- areaOn = undefined;
70414
- }
70415
- if (!areaBack && !areaOn ||
70416
- // Something else with a better claim on the gesture: a scroller
70417
- // between the finger and the slide, with room left that way.
70418
- scrollRoomTowards(target, currentElement, axis, sign)) {
70419
- return false;
70420
- }
70421
- }
70422
- const basePlace = placeOfArea(area);
70423
- const baseOffset = {
70424
- x: -basePlace.x * box.width,
70425
- y: -basePlace.y * box.height
70426
- };
70427
- // Where the track IS: a travel grabbed mid-flight was already stopped
70428
- // where the eye saw it, at the press — this only reads that place, so
70429
- // the gesture starts from it rather than from where the map rests.
70430
- const onScreen = caughtTravel ? caughtTravel.onScreenPx : baseOffset;
70431
- caughtTravel = null;
70432
- trackAnimationRef.current?.cancel();
70433
- trackAnimationRef.current = null;
70434
- cancelTravelProgressAnimation();
70435
- followerElementsRef.current = readFollowerElements(containerRef.current);
70436
- drag.axis = axis;
70437
- drag.areaBack = areaBack;
70438
- drag.areaOn = areaOn;
70439
- drag.area = area;
70440
- drag.box = box;
70441
- drag.basePlace = basePlace;
70442
- drag.baseOffset = baseOffset;
70443
- const size = axis === "x" ? box.width : box.height;
70444
- // Where the track was when the gesture took it over — nowhere, unless
70445
- // it was travelling. Every pull is measured from it.
70446
- const slack = axis === "x" ? onScreen.x - baseOffset.x : onScreen.y - baseOffset.y;
70447
- drag.pull = {
70448
- x: 0,
70449
- y: 0,
70450
- [axis]: slack
70451
- };
70452
- drag.progress = slack / size;
70453
- drag.areaPulled = slack > 0 ? areaBack : slack < 0 ? areaOn : null;
70454
- stageDrag(drag, currentArea);
70455
- // From here the box is busy, whichever input asked: a wheel gesture and
70456
- // a press must not both be moving the same track.
70457
- dragRef.current = drag;
70458
- return {
70459
- size,
70460
- slack,
70461
- travelBack: Boolean(areaBack),
70462
- travelOn: Boolean(areaOn)
70463
- };
70464
- },
70465
- onPull: ({
70466
- axis,
70467
- pulled,
70468
- progress
70469
- }) => {
70470
- drag.pull = {
70471
- ...drag.pull,
70472
- [axis]: pulled
70473
- };
70474
- drag.progress = progress;
70475
- drag.areaPulled = pulled > 0 ? drag.areaBack : drag.areaOn;
70476
- paintDrag();
70477
- },
70478
- // The slide being brought in walked whole and the hand still going, or a
70479
- // travel caught on its way thrown on: either way the hand asks for the
70480
- // slide past it, and the gesture moves on to it. Nothing on screen moves
70481
- // for that — the slide reached becomes the one dragged, the one the hand
70482
- // came from stays next to it for a hand that turns around, and the slide
70483
- // past it is put on the other side.
70484
- onEdge: ({
70485
- axis,
70486
- sign
70487
- }) => {
70488
- const reached = sign > 0 ? drag.areaBack : drag.areaOn;
70489
- const {
70490
- slideElements,
70491
- placeOf
70492
- } = readMap();
70493
- const reachedElement = reached && slideElements.find(slideElement => readArea(slideElement) === reached);
70494
- if (!reachedElement) {
70495
- return false;
70496
- }
70497
- // The gate goToArea reads at the release, asked of the slide the hand
70498
- // is now in: a slide holding on to the user is not walked through.
70499
- const holds = reachedElement.hasAttribute(sign > 0 ? "data-prevent-nav-previous" : "data-prevent-nav-next");
70500
- const past = holds ? undefined : axis === "x" ? areaTowards(-sign, 0, reached) : areaTowards(0, -sign, reached);
70501
- if (!past) {
70502
- return false;
70503
- }
70504
- // Two slides from the one at rest, so possibly never rendered: asked
70505
- // for now, and built before the frame that brings it in is painted.
70506
- renderNear(past, {
70507
- alongside: false
70508
- });
70509
- const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current")) || slideElements[0];
70510
- const basePlace = stageRef.current?.placeByArea.get(reached) || placeOf.get(reached) || {
70511
- x: 0,
70512
- y: 0
70513
- };
70514
- drag.areaBack = sign > 0 ? past : drag.area;
70515
- drag.areaOn = sign > 0 ? drag.area : past;
70516
- drag.area = reached;
70517
- drag.basePlace = basePlace;
70518
- drag.baseOffset = {
70519
- x: -basePlace.x * drag.box.width,
70520
- y: -basePlace.y * drag.box.height
70521
- };
70522
- stageDrag(drag, readArea(currentElement));
70523
- return {
70524
- size: axis === "x" ? drag.box.width : drag.box.height,
70525
- travelBack: Boolean(drag.areaBack),
70526
- travelOn: Boolean(drag.areaOn)
70527
- };
70528
- },
70529
- onEnd: ({
70530
- axis,
70531
- sign,
70532
- travels,
70533
- event
70534
- }) => {
70535
- dragRef.current = null;
70536
- // Where the gesture leaves the slides: on the one it was pulling in
70537
- // when it says so, back on the one it was dragging otherwise.
70538
- const destination = travels ? sign > 0 ? drag.areaBack : drag.areaOn : drag.area;
70539
- const {
70540
- slideElements,
70541
- placeOf
70542
- } = readMap();
70543
- const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current")) || slideElements[0];
70544
- const currentArea = currentElement && readArea(currentElement);
70545
- if (!destination || destination === currentArea) {
70546
- // Nothing to ask of the state: the slide it names is already the one
70547
- // shown — a drag let go of short of a travel, a travel caught in
70548
- // flight and let carry on. The track goes there on its own.
70549
- settleTowards(drag, destination || drag.area);
70550
- return;
70551
- }
70552
- // Where the slide is being left, for the travel to depart from instead
70553
- // of from the map.
70554
- travelFromRef.current = drag.offset;
70555
- // …and where the indicator is being left, said about the slide that is
70556
- // ARRIVING: a box short of the slide being pulled in, right where the
70557
- // finger left it when going back to the slide being dragged.
70558
- travelProgressFromRef.current = travels ? drag.progress - sign : drag.progress;
70559
- // Which way the travel goes, read off where the two slides stand
70560
- // rather than off the pull: a travel caught in flight and put back
70561
- // goes the way it came, whatever the finger did meanwhile.
70562
- const stage = stageRef.current;
70563
- const placeOfArea = someArea => stage?.placeByArea.get(someArea) || placeOf.get(someArea) || {
70564
- x: 0,
70565
- y: 0
70566
- };
70567
- const towards = Math.sign(placeOfArea(destination)[axis] - placeOfArea(currentArea)[axis]);
70568
- const moved = goToArea(destination, {
70569
- forward: towards > 0,
70570
- event,
70571
- dx: axis === "x" ? towards : 0,
70572
- dy: axis === "y" ? towards : 0
70573
- });
70574
- if (!moved) {
70575
- // Nowhere to go after all — a slide holding on to the user
70576
- // (preventNav), or a caller that refused the change.
70577
- travelFromRef.current = null;
70578
- travelProgressFromRef.current = null;
70579
- settleTowards(drag, currentArea);
70580
- return;
70581
- }
70582
- // A container whose `current` is held outside and was not moved: no
70583
- // render moved the track, so the hand-off is still waiting and the
70584
- // track still under where the finger left it. One frame is all it
70585
- // takes to know.
70586
- requestAnimationFrame(() => {
70587
- if (travelFromRef.current) {
70588
- travelFromRef.current = null;
70589
- travelProgressFromRef.current = null;
70590
- settleTowards(drag, currentArea);
70591
- }
70592
- });
70593
- },
70594
- onGiveUp: () => {
70595
- dragRef.current = null;
70596
- // A press that never became a gesture: what it stopped goes on its way,
70597
- // from where the finger caught it and over what is left of the travel.
70598
- if (!caughtTravel) {
70599
- return;
70600
- }
70601
- const {
70602
- trackElement: trackCaught,
70603
- offsetOnScreen,
70604
- offsetTarget,
70605
- onScreenPx: caughtOnScreenPx,
70606
- from,
70607
- to
70608
- } = caughtTravel;
70609
- caughtTravel = null;
70610
- movingRef.current = {
70611
- from,
70612
- to
70613
- };
70614
- if (offsetOnScreen === offsetTarget) {
70615
- settleTravel();
70616
- return;
70617
- }
70618
- const durationMs = durationToMs(duration);
70619
- // What is LEFT of it, at the pace it had: a travel caught nine tenths
70620
- // of the way there and let go of does not start its duration again.
70621
- const boxRect = trackCaught.getBoundingClientRect();
70622
- const targetPx = offsetToPx(offsetTarget, boxRect);
70623
- const leftToCover = Math.abs(drag.axis === "y" ? targetPx.y - caughtOnScreenPx.y : targetPx.x - caughtOnScreenPx.x);
70624
- const size = drag.axis === "y" ? boxRect.height : boxRect.width;
70625
- const travelRatio = size ? leftToCover / size : 1;
70626
- trackCaught.style.setProperty("--slide-container-offset", offsetTarget);
70627
- trackAnimationRef.current = trackCaught.animate([{
70628
- translate: offsetOnScreen
70629
- }, {
70630
- translate: offsetTarget
70631
- }], {
70632
- duration: durationMs * travelRatio,
70633
- easing: "ease-out"
70634
- });
70635
- trackAnimationRef.current.finished.then(settleTravel, () => {
70636
- // taken over by a travel asked for since
70637
- });
70638
- }
70639
- };
70640
- };
70641
-
70642
- // Whether a gesture may begin at all: one travel at a time, and a window
70643
- // mid-roll has nothing to drag yet — it is on its way somewhere and the
70644
- // content that goes with it has not moved (see goToArea).
70645
- const canStartTravel = () => dragAxes && !dragRef.current && !rollingRef.current;
70646
- const startDrag = pointerDownEvent => {
70647
- if (!canStartTravel()) {
70648
- return;
70649
- }
70650
- const travelInFlight = readTravelInFlight();
70651
- // Left playing when the hand can take it: the press is then far more
70652
- // likely the next swipe than a catch, and stopping the travel under it is
70653
- // what that swipe feels as a stall — the gesture listens to the hand first
70654
- // and says which it was (see inFlight in drag_to_travel.js). On an axis the
70655
- // hand cannot drag, it is caught at the press, as a travel under a finger
70656
- // that starts something else.
70657
- const leftPlaying = Boolean(travelInFlight && dragAxes.includes(travelInFlight.axis));
70658
- const caughtTravel = travelInFlight && !leftPlaying ? catchTravelInFlight() : null;
70659
- const handlers = createTravelHandlers(caughtTravel);
70660
- const gesture = startDragToTravel(pointerDownEvent, {
70661
- element: containerRef.current,
70662
- axes: dragAxes,
70663
- inFlight: leftPlaying ? travelInFlight : false,
70664
- // The next swipe over a travel still playing: one slide further, and the
70665
- // travel goes on there from where it is — the way a second arrow press
70666
- // sends it — without ever having stopped under the hand.
70667
- onPushOn: ({
70668
- axis,
70669
- sign,
70670
- event
70671
- }) => {
70672
- if (trackAnimationRef.current?.playState !== "running") {
70673
- return false;
70674
- }
70675
- // Nothing is held: the box is free for the travel that follows.
70676
- dragRef.current = null;
70677
- if (axis === "x") {
70678
- move(-sign, 0, event);
70679
- } else {
70680
- move(0, -sign, event);
70681
- }
70682
- return true;
70683
- },
70684
- ...handlers
70685
- });
70686
- if (!gesture) {
70687
- // Not a press this box can be about — something that reads the pointer
70688
- // itself, a box below it that travels the same way. Whatever the press
70689
- // stopped on its way in goes back on its way.
70690
- handlers.onGiveUp();
70691
- return;
70692
- }
70693
- handlers.drag.gesture = gesture;
70694
- dragRef.current = handlers.drag;
70695
- };
70696
-
70697
- // A wheel pushing the box sideways asks for a SLIDE, not for a place between
70698
- // two: one push, one slide — the same thing an arrow key asks for, and it
70699
- // plays at its own pace rather than under a hand (see watchWheelTravel).
70700
- // Watched for the whole life of the box because such a gesture has no press
70701
- // to start it: it begins with its first event.
70702
- //
70703
- // Reached through a ref and never rebuilt for it: a travel CHANGES which
70704
- // slide is current, so a watcher listening on that would be torn down halfway
70705
- // through the very gesture moving it.
70706
- const travelOneStepRef = useRef(null);
70707
- travelOneStepRef.current = ({
70708
- axis,
70709
- sign,
70710
- event
70711
- }) => {
70712
- if (dragRef.current) {
70713
- // A hand is holding the slides. They are its until it lets go.
70714
- return;
70715
- }
70716
- if (axis === "x") {
70717
- move(-sign, 0, event);
70718
- } else {
70719
- move(0, -sign, event);
70720
- }
70721
- };
70722
- useLayoutEffect(() => {
70723
- if (!scrollAxes) {
70724
- return undefined;
70725
- }
70726
- return watchWheelTravel(containerRef.current, {
70727
- axes: scrollAxes,
70728
- onStep: detail => travelOneStepRef.current(detail)
70729
- });
70730
- }, [scrollAxes]);
70731
-
70732
- // A gesture is listening on things that outlive this component.
70733
69654
  useLayoutEffect(() => {
70734
69655
  return () => {
70735
- dragRef.current?.gesture?.stop();
70736
- dragRef.current = null;
70737
69656
  cancelTravelProgressAnimation();
70738
69657
  };
70739
69658
  }, []);
@@ -70862,31 +69781,22 @@ const SlideContainer = ({
70862
69781
  // names, and to write the place this box stands on (see `signal`).
70863
69782
  ,
70864
69783
  "data-slide-shares-state": sharesState ? "" : undefined
70865
- // Which axes a touch may travel on, said in the DOM: what the browser
70866
- // does with a finger is decided by CSS (touch-action) before any of this
70867
- // has seen the gesture — and it is also what a box HOLDING this one reads
70868
- // to know the gesture is not its own (see drag_to_travel.js).
70869
- ,
70870
- "data-travel-by-drag": dragAxes ?? undefined
70871
- // …and said as a listener too, from the same render: a touch this box may
70872
- // take has to be refusable before the finger lands, and being registered
70873
- // is the whole of it — see keepTouchRefusable for why a JSX prop is
70874
- // enough here (an element-level touchmove listener is non-passive).
70875
- // Without it, a swipe the box has taken can be cancelled mid-gesture by
70876
- // the browser scrolling the page along the axis touch-action leaves it.
69784
+ // Which axes the row scrolls on under a hand, and which of them a finger may
69785
+ // not take (see the css above).
70877
69786
  ,
70878
- onTouchMove: dragAxes ? keepTouchRefusable : undefined
70879
- // The same fact for a wheel, and only for that second reason: this box
70880
- // takes the push, whatever the box around it also travels on.
69787
+ "data-slide-hand": travelAxes ?? undefined,
69788
+ "data-slide-no-touch": Array.from(scrollAxes ?? "").filter(axis => !dragAxes?.includes(axis)).join("") || undefined
69789
+ // Read by what stands around the box: a press landing here may become a
69790
+ // scroll, so a control acting on the press waits for its click, and a box
69791
+ // around that a finger pushes leaves this axis alone (see drag_to_travel.js).
70881
69792
  ,
70882
- "data-travel-by-wheel": scrollAxes ?? undefined
70883
- // The same fact, read by the shared gesture stylesheet: what scrolls
70884
- // inside a box that travels must not spill onto the page behind it (see
70885
- // drag_to_travel.js).
70886
- ,
70887
- "data-drag-travel": travelAxes ?? undefined,
69793
+ "data-travel-by-drag": dragAxes ?? undefined,
70888
69794
  onPointerDown: e => {
70889
- startDrag(e);
69795
+ // A finger on a travel still playing: it is the row's from here, and the
69796
+ // row is where the travel is going.
69797
+ if (e.pointerType !== "mouse") {
69798
+ trackAnimationRef.current?.finish();
69799
+ }
70890
69800
  rest.onPointerDown?.(e);
70891
69801
  }
70892
69802
  // The focusable one, and a Tab stop: a slide is not (see Slide), so the
@@ -72503,7 +71413,7 @@ const css$H = /* css */`@property --x-popup-group-top {
72503
71413
  transition: none;
72504
71414
  }
72505
71415
 
72506
- :is(:root[data-navi-route-transition] .navi_popup_group:modal, :root[data-navi-route-travel] .navi_popup_group:modal) {
71416
+ :root[data-navi-route-transition] .navi_popup_group:modal {
72507
71417
  view-transition-class: navi_popup;
72508
71418
  }
72509
71419
 
@@ -72511,10 +71421,6 @@ const css$H = /* css */`@property --x-popup-group-top {
72511
71421
  :root[data-navi-route-transition]:not([data-navi-route-transition-target="area"]) .navi_popup_group:modal {
72512
71422
  view-transition-name: match-element;
72513
71423
  }
72514
-
72515
- :root[data-navi-route-travel] .navi_popup_group:modal {
72516
- view-transition-name: match-element;
72517
- }
72518
71424
  }
72519
71425
  `;
72520
71426
 
@@ -74472,8 +73378,7 @@ const popupCss = /* css */ `
74472
73378
  }
74473
73379
  }
74474
73380
 
74475
- /* While the PAGES are the ones moving — a route transition, a route travel —
74476
- an open popup takes a picture of its own. The pictures of such a movement
73381
+ /* 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
74477
73382
  are drawn in the top layer, above everything the document paints, and a
74478
73383
  popup lives in the top layer too: uncaptured, it is simply covered for the
74479
73384
  length of the movement, so it disappears the instant its page starts to
@@ -74483,9 +73388,7 @@ const popupCss = /* css */ `
74483
73388
  The class is how navi reaches that picture afterwards: its clock below,
74484
73389
  and its place above the pages (the z-order in nav/route_transition.jsx). */
74485
73390
  :root[data-navi-route-transition] .navi_popover[aria-expanded="true"],
74486
- :root[data-navi-route-transition] .navi_dialog[aria-expanded="true"],
74487
- :root[data-navi-route-travel] .navi_popover[aria-expanded="true"],
74488
- :root[data-navi-route-travel] .navi_dialog[aria-expanded="true"] {
73391
+ :root[data-navi-route-transition] .navi_dialog[aria-expanded="true"] {
74489
73392
  view-transition-class: navi_popup;
74490
73393
  }
74491
73394
 
@@ -74509,9 +73412,7 @@ const popupCss = /* css */ `
74509
73412
  :root[data-navi-route-transition]:not(
74510
73413
  [data-navi-route-transition-target="area"]
74511
73414
  )
74512
- .navi_dialog[aria-expanded="true"],
74513
- :root[data-navi-route-travel] .navi_popover[aria-expanded="true"],
74514
- :root[data-navi-route-travel] .navi_dialog[aria-expanded="true"] {
73415
+ .navi_dialog[aria-expanded="true"] {
74515
73416
  view-transition-name: match-element;
74516
73417
  }
74517
73418
  }
@@ -74523,13 +73424,192 @@ const popupCss = /* css */ `
74523
73424
  :root[data-navi-route-transition]::view-transition-new(.navi_popup) {
74524
73425
  animation-duration: var(--navi-route-transition-duration, 300ms);
74525
73426
  }
74526
- :root[data-navi-route-travel]::view-transition-group(.navi_popup),
74527
- :root[data-navi-route-travel]::view-transition-old(.navi_popup),
74528
- :root[data-navi-route-travel]::view-transition-new(.navi_popup) {
74529
- animation-duration: var(--navi-route-travel-duration, 300ms);
74530
- }
74531
73427
  `;
74532
73428
 
73429
+ /**
73430
+ * A modal dialog covered by another modal dialog is not in use, so it does not
73431
+ * answer the keyboard: a sheet raising one leaves what is behind it where it
73432
+ * is, under the keyboard like anything else behind that sheet.
73433
+ *
73434
+ * Two things make an open dialog follow the keyboard, and both are held here
73435
+ * for as long as it is covered:
73436
+ * - its ceiling (--dialog-maxmax-height), which CSS keeps tracking
73437
+ * --navi-app-height. Written back resolved, inline. `freezeSize` cannot do it:
73438
+ * a max-* wins over the width/height it writes;
73439
+ * - its placement, which reads the live viewport. Deferred, see
73440
+ * deferPlacementWhileCovered.
73441
+ *
73442
+ * A dialog back on top answers the screen as it is, at once, with one exception:
73443
+ * a keyboard on its way out. It is still counted for a while after the sheet
73444
+ * closes (the browser takes its time, then --navi-vvh waits for the resize to
73445
+ * settle, see responsive.js), and answering that makes the dialog jump up and
73446
+ * back down. A keyboard belongs to a focused field, so the focus says which case
73447
+ * this is: on a field the keyboard stays, anywhere else it is leaving and the
73448
+ * dialog waits for the viewport to change. No timer: how long a keyboard takes
73449
+ * to leave is unknown, and a duration would also delay a keyboard that stays.
73450
+ *
73451
+ * Only modal dialogs stack here. What sits under a non-modal one is still
73452
+ * usable, and keeps following the keyboard.
73453
+ */
73454
+
73455
+
73456
+ const CEILING = "--dialog-maxmax-height";
73457
+
73458
+ const modalStack = [];
73459
+
73460
+ /**
73461
+ * @param {HTMLDialogElement} dialogEl - The modal dialog being shown.
73462
+ * @param {object} params
73463
+ * @param {() => void} params.place - Places the dialog against the viewport as
73464
+ * it is. Called on a dialog that was held once it answers the viewport again.
73465
+ * @returns {() => void} To call when the dialog closes.
73466
+ */
73467
+ const enterModalStack = (dialogEl, { place }) => {
73468
+ const entry = { dialogEl, place, hold: null };
73469
+ const entryBelow = modalStack.at(-1);
73470
+ if (entryBelow) {
73471
+ holdEntry(entryBelow);
73472
+ }
73473
+ modalStack.push(entry);
73474
+ return () => {
73475
+ const index = modalStack.indexOf(entry);
73476
+ const wasOnTop = index === modalStack.length - 1;
73477
+ modalStack.splice(index, 1);
73478
+ dropHold(entry);
73479
+ if (wasOnTop && modalStack.length > 0) {
73480
+ releaseBackOnTop(modalStack.at(-1), { closedDialogEl: dialogEl });
73481
+ }
73482
+ };
73483
+ };
73484
+
73485
+ /**
73486
+ * Answers true when the dialog must not be placed right now, and remembers to
73487
+ * place it on release.
73488
+ *
73489
+ * Only while the room differs from the one it was covered with: the placement
73490
+ * reads the live viewport whatever triggered it, so with the same room it lands
73491
+ * where the dialog already is, and content growing under the sheet is answered
73492
+ * as usual.
73493
+ */
73494
+ const deferPlacementWhileCovered = (dialogEl) => {
73495
+ const entry = modalStack.find((candidate) => candidate.dialogEl === dialogEl);
73496
+ if (!entry || !entry.hold) {
73497
+ return false;
73498
+ }
73499
+ if (readRoom() === entry.hold.room) {
73500
+ return false;
73501
+ }
73502
+ entry.hold.placementOwed = true;
73503
+ return true;
73504
+ };
73505
+
73506
+ const holdEntry = (entry) => {
73507
+ if (entry.hold) {
73508
+ // Covered again before the keyboard had left: the ceiling it holds is still
73509
+ // the one it had before all of this.
73510
+ entry.hold.stopWaiting?.();
73511
+ entry.hold.stopWaiting = null;
73512
+ return;
73513
+ }
73514
+ const { dialogEl, place } = entry;
73515
+ holdCeiling(dialogEl);
73516
+ entry.hold = {
73517
+ room: readRoom(),
73518
+ placementOwed: false,
73519
+ stopWaiting: null,
73520
+ // The keyboard does not fire this one (see safe_area.md): this is the
73521
+ // window itself changing, a rotation, which a covered dialog has to fit.
73522
+ stopWatchingWindow: subscribeWindowResizeSettled(() => {
73523
+ holdCeiling(dialogEl);
73524
+ entry.hold.room = readRoom();
73525
+ entry.hold.placementOwed = false;
73526
+ place();
73527
+ }),
73528
+ };
73529
+ };
73530
+
73531
+ const releaseBackOnTop = (entry, { closedDialogEl }) => {
73532
+ const { hold } = entry;
73533
+ if (!hold) {
73534
+ return;
73535
+ }
73536
+ // Nothing taken away, or a keyboard that stays: the room read here is the
73537
+ // screen as it is. A field in the dialog that closed is one still leaving: a
73538
+ // dialog leaving the tree only gives the focus back after the commit.
73539
+ const { activeElement } = document;
73540
+ if (
73541
+ readRoom() >= hold.room ||
73542
+ (isEditableTarget(activeElement) && !closedDialogEl.contains(activeElement))
73543
+ ) {
73544
+ release(entry);
73545
+ return;
73546
+ }
73547
+ // Any change, not the room it was covered with: the bars of a mobile browser
73548
+ // may come back different, and the wait would never end.
73549
+ const roomWithKeyboardLeaving = readRoom();
73550
+ const releaseIfViewportChanged = () => {
73551
+ if (readRoom() !== roomWithKeyboardLeaving) {
73552
+ release(entry);
73553
+ }
73554
+ };
73555
+ // The focus can come back after the close (a popup leaving the tree gives it
73556
+ // back once the commit is over), and the keyboard then stays after all.
73557
+ const releaseIfFieldFocused = () => {
73558
+ if (isEditableTarget(document.activeElement)) {
73559
+ release(entry);
73560
+ }
73561
+ };
73562
+ document.addEventListener("focusin", releaseIfFieldFocused, {
73563
+ capture: true,
73564
+ });
73565
+ const unsubscribes = [
73566
+ visualViewportHeightSignal.subscribe(releaseIfViewportChanged),
73567
+ subscribeVirtualKeyboardGeometryChange(releaseIfViewportChanged),
73568
+ ];
73569
+ hold.stopWaiting = () => {
73570
+ document.removeEventListener("focusin", releaseIfFieldFocused, {
73571
+ capture: true,
73572
+ });
73573
+ for (const unsubscribe of unsubscribes) {
73574
+ unsubscribe();
73575
+ }
73576
+ };
73577
+ };
73578
+
73579
+ const release = (entry) => {
73580
+ const { placementOwed } = entry.hold;
73581
+ dropHold(entry);
73582
+ if (placementOwed) {
73583
+ entry.place();
73584
+ }
73585
+ };
73586
+
73587
+ const dropHold = (entry) => {
73588
+ const { hold, dialogEl } = entry;
73589
+ if (!hold) {
73590
+ return;
73591
+ }
73592
+ hold.stopWaiting?.();
73593
+ hold.stopWatchingWindow();
73594
+ dialogEl.style.removeProperty(CEILING);
73595
+ entry.hold = null;
73596
+ };
73597
+
73598
+ // The declared ceiling rather than the used max-height: it is the one cap that
73599
+ // follows the keyboard, and the other caps (maxHeight) keep their say. Its
73600
+ // computed value has every var() resolved, so it stops following once written
73601
+ // back. Registered with inherits: false, it does not reach a dialog nested
73602
+ // inside this one.
73603
+ const holdCeiling = (dialogEl) => {
73604
+ dialogEl.style.removeProperty(CEILING);
73605
+ const ceiling = getComputedStyle(dialogEl).getPropertyValue(CEILING);
73606
+ dialogEl.style.setProperty(CEILING, ceiling);
73607
+ };
73608
+
73609
+ // An overlaying keyboard leaves the viewport alone and arrives on its own.
73610
+ const readRoom = () =>
73611
+ visualViewportHeightSignal.peek() - getVirtualKeyboardOverlayHeight();
73612
+
74533
73613
  /**
74534
73614
  * Pushing a popup docked to an edge (a side panel, a bottom sheet) back the way
74535
73615
  * it came in closes it: it follows the finger while it travels, and letting go
@@ -76871,6 +75951,9 @@ const useDialogProps = props => {
76871
75951
  const rectEffect = visibleRectEffect(positionedAncestor, (visibleRect, {
76872
75952
  event
76873
75953
  }) => {
75954
+ if (deferPlacementWhileCovered(dialogEl)) {
75955
+ return;
75956
+ }
76874
75957
  positionDialog(event);
76875
75958
  // Only for what can have taken height away from the dialog — a
76876
75959
  // scroll never does, and re-scrolling on one would fight the finger
@@ -76890,6 +75973,17 @@ const useDialogProps = props => {
76890
75973
  freezeSize(dialogEl);
76891
75974
  }
76892
75975
  rectEffect.observeSize(dialogEl);
75976
+ if (isModal) {
75977
+ addCleanup(enterModalStack(dialogEl, {
75978
+ // "resize": what it was held from is a viewport change, and it answers
75979
+ // the way it answers one, animated. Called directly because the
75980
+ // effect's dedup already saw that viewport go by.
75981
+ place: () => {
75982
+ positionDialog(new CustomEvent("resize"));
75983
+ keepFocusedElementVisible(dialogEl);
75984
+ }
75985
+ }));
75986
+ }
76893
75987
  // Content that sizes itself once on screen (a shrink-wrapped BadgeList)
76894
75988
  // says so, after the placement read it: placed again on the spot, where
76895
75989
  // observeSize answers a frame late. Stopped here, since a popup further up
@@ -80627,6 +79721,13 @@ const PickerCustom = props => {
80627
79721
  };
80628
79722
  },
80629
79723
  keyDown: e => {
79724
+ // Pressed in a popup this picker holds: the key is that popup's, and
79725
+ // `false` keeps it from the default reaction too, which would refuse
79726
+ // it on a read-only picker and take a Space from the button it was
79727
+ // pressed on.
79728
+ if (isPressOnLayerOver(e.target, e.currentTarget)) {
79729
+ return false;
79730
+ }
80630
79731
  // A shortcut that applies has been through the gate already, under
80631
79732
  // its own name and intent: the key is answered. Handed back as the
80632
79733
  // reaction, its boolean would be asked again, unnamed — as a write,
@@ -84133,7 +83234,7 @@ const css$w = /* css */`@property --list-group-label-height {
84133
83234
  }
84134
83235
 
84135
83236
  @supports (view-transition-group: contain) {
84136
- :root:not([data-navi-route-transition], [data-navi-route-travel]) .navi_list_container[data-item-transition] {
83237
+ :root:not([data-navi-route-transition]) .navi_list_container[data-item-transition] {
84137
83238
  view-transition-name: match-element;
84138
83239
  view-transition-class: navi_list_transition;
84139
83240
  view-transition-group: contain;
@@ -85655,8 +84756,8 @@ const useListScrollSync = ({
85655
84756
  return undefined;
85656
84757
  }
85657
84758
  return routePage.leavingSignal.subscribe(leaving => {
84759
+ pageLeftRef.current = leaving;
85658
84760
  if (leaving) {
85659
- pageLeftRef.current = true;
85660
84761
  letGo();
85661
84762
  }
85662
84763
  });
@@ -106751,7 +105852,7 @@ const css = /* css */`.navi_side_panel {
106751
105852
  --dialog-maxmax-width: var(--navi-app-width);
106752
105853
 
106753
105854
  &[navi-side="left"], &[navi-side="right"] {
106754
- height: var(--navi-side-panel-height, var(--navi-app-height));
105855
+ height: var(--navi-side-panel-height, var(--dialog-maxmax-height));
106755
105856
  }
106756
105857
 
106757
105858
  &[navi-side="top"], &[navi-side="bottom"] {