@jsenv/navi 0.29.430 → 0.29.432

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.
@@ -6,7 +6,7 @@ import { installImportMetaCssBuild, coarsePointerSignal, windowHeightSignal, win
6
6
  import { createPubSub, elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, findEvent, mergeOneStyle, findFocusable, getElementSignature, chainEvent, isTouchDrivenEvent, getKeyboardEventDefaultAction, isPressDrivenClick, ELEMENT_SIZE_CHANGE, getScrollContainer, scrollIntoViewScoped, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, createValueEffect, findFocusDelegateTarget, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, getScrollContainerSet, 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, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, initFocusGroup, stringifyStyle as stringifyStyle$1, isScrollable, resolveOklchLightness, contrastColor, parsePositionArea, snapToPixel, trapFocusInside, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, setVirtualKeyboardOverlaysContent, subscribeVirtualKeyboardGeometryChange, getVirtualKeyboardOverlayHeight } 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
- import { options, isValidElement, createContext, render, h, toChildArray, createRef, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
9
+ import { options, isValidElement, createContext, render as render$1, h, toChildArray, createRef, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
10
10
  import { useErrorBoundary, useLayoutEffect, useContext, useState, useMemo, useRef, useEffect, useCallback, useReducer, useId } from "preact/hooks";
11
11
  import { humanizeI18n, setRuntimeLangSource, formatDuration, formatMonth, formatDay, resolveTimeRangePrecision, formatDatePlaceholder, toDate, getRelativeDay, formatDayRelative, formatMonthPlaceholder, formatWeekPlaceholder, formatDatetimePlaceholder, formatDatetime, toTimeOfDay, formatTimeOfDay, formatTime, formatMinuteDuration, formatSecondDuration, formatHourDuration, formatTimeRelative, formatNumber, interpolateText, installInterpolateJsx } from "@jsenv/humanize";
12
12
  export { createI18n, formatDatetime, formatDay, formatDayRelative, formatDuration, formatHourDuration, formatMinuteDuration, formatMonth, formatNumber, formatSecondDuration, formatTime, formatTimeOfDay, formatTimeRange, formatTimeRelative, interpolateText } from "@jsenv/humanize";
@@ -6750,6 +6750,55 @@ const applyNavigationToNavDepth = (navigationType, state) => {
6750
6750
  canNavForwardSignal.value = navDepth < navDepthMax;
6751
6751
  };
6752
6752
 
6753
+ /**
6754
+ * Whether the render running now is the one putting a new page on screen.
6755
+ *
6756
+ * A layer drawn over the pages — a popup bound to the address, the slides
6757
+ * inside it — can open, close or change place in the very navigation that
6758
+ * replaces the page beneath it: a link inside the layer leads to another page
6759
+ * and the layer's weak params are dropped, a back to the page it was open over
6760
+ * brings them back. For the layer that is not a movement of its own: the page
6761
+ * changing IS the movement. A route transition already carries it, photographed
6762
+ * as furniture with the page it belongs to (transition_furniture.js), and with
6763
+ * no transition the page cuts and the layer cuts with it. Played on its own as
6764
+ * well, it moves twice — an entrance or an exit over a page sliding past, or
6765
+ * appearing at once.
6766
+ *
6767
+ * So whatever reacts to such a change in a render — a popup's open state, a
6768
+ * slide container's current slide — asks here whether that render carries a
6769
+ * page change, and if so arrives where it goes without travelling there.
6770
+ *
6771
+ * A page is what navi animates between (route_page.js): a route, and the
6772
+ * params telling apart branches of one route. Which routes match is decided by
6773
+ * the path alone, so a navigation that changes the path changes the page; one
6774
+ * that keeps it changes the page only where a param IS a page — and navi knows
6775
+ * those only through the relation or the RouteTravel row that names them, so
6776
+ * that is who says so. A search param a page merely reads is not one: writing
6777
+ * it is the page answering, and a layer opened by it (the `?settings` its door
6778
+ * pushes, the back button taking it out) moves on its own.
6779
+ *
6780
+ * The answer lasts until the render applying the navigation is over: the held
6781
+ * one when somebody photographs the change, Preact's own otherwise, over by the
6782
+ * next frame (see whenPageRendered). Asked from a layout effect it is exact; an
6783
+ * opening deferred past the commit takes the answer with it, read when the
6784
+ * effect ran.
6785
+ */
6786
+
6787
+
6788
+ let renderingPageChange = false;
6789
+
6790
+ const notePageChange = () => {
6791
+ if (renderingPageChange) {
6792
+ return;
6793
+ }
6794
+ renderingPageChange = true;
6795
+ whenPageRendered(() => {
6796
+ renderingPageChange = false;
6797
+ });
6798
+ };
6799
+
6800
+ const isRenderingPageChange = () => renderingPageChange;
6801
+
6753
6802
  // Global signal registry for route template detection
6754
6803
  const globalSignalRegistry = new Map();
6755
6804
 
@@ -10333,8 +10382,12 @@ This prevents cross-test pollution and ensures clean state.`,
10333
10382
  // read again.
10334
10383
  const path = urlWithoutSearchAndHash(url);
10335
10384
  const pathKept = path === previousPath;
10385
+ // A new path is a new page: what the integration tells whatever reacts to
10386
+ // this change in the render it causes (see page_change.js). Not the first
10387
+ // one — there was no page before it, and what it mounts mounts as it is.
10388
+ const pageChanged = previousPath !== null && !pathKept;
10336
10389
  previousPath = path;
10337
- const returnValue = {};
10390
+ const returnValue = { pageChanged };
10338
10391
  const routeMatchInfoSet = new Set();
10339
10392
  for (const route of routeSet) {
10340
10393
  const routePrivateProperties = getRoutePrivateProperties(route);
@@ -11188,13 +11241,22 @@ const installScrollRestoration = () => {
11188
11241
  },
11189
11242
  { passive: true },
11190
11243
  );
11244
+ const positionOnLoad = positionByUrl.get(window.location.href);
11245
+ if (!positionOnLoad) {
11246
+ // Where the browser landed the first page, written down for the reason
11247
+ // startAtTop writes its top: no scroll event says so.
11248
+ positionByUrl.set(window.location.href, {
11249
+ x: window.scrollX,
11250
+ y: window.scrollY,
11251
+ });
11252
+ return;
11253
+ }
11191
11254
  // What a reload asks for, now that the browser has been told not to do it.
11192
11255
  // Once, and at the first render of a route: the position is only meaningful
11193
11256
  // once there is a page under it.
11194
11257
  // Written once the commit that rendered it has ended: said from inside it,
11195
11258
  // and what the offset shows may still have to be built (see scrollTo).
11196
- const positionOnLoad = positionByUrl.get(window.location.href);
11197
- if (positionOnLoad && (positionOnLoad.x || positionOnLoad.y)) {
11259
+ if (positionOnLoad.x || positionOnLoad.y) {
11198
11260
  const stopListening = observeRouteRender(() => {
11199
11261
  stopListening();
11200
11262
  queueMicrotask(() => {
@@ -11280,6 +11342,11 @@ const startAtTop = (url, { from } = {}) => {
11280
11342
  return;
11281
11343
  }
11282
11344
  arrivalCount++;
11345
+ // Written down as well as done: a document already at its top fires no
11346
+ // scroll event, and a page left without ever being scrolled would have no
11347
+ // position to come back to — the return would keep the offset of the page
11348
+ // being left.
11349
+ positionByUrl.set(new URL(url, window.location.href).href, { x: 0, y: 0 });
11283
11350
  window.scrollTo({ top: 0, left: 0, behavior: "instant" });
11284
11351
  };
11285
11352
  const isArrival = (url, { from }) => {
@@ -12148,11 +12215,14 @@ const applyRouting = (
12148
12215
  // The routes' actions follow the address by themselves (see routeAction):
12149
12216
  // what a navigation leaves to the routing is a reload, the one that asks
12150
12217
  // them again with the address unchanged.
12151
- const { reloadSet } = updateRoutes(url, {
12218
+ const { pageChanged, reloadSet } = updateRoutes(url, {
12152
12219
  navigationType,
12153
12220
  isVisited,
12154
12221
  // state,
12155
12222
  });
12223
+ if (pageChanged) {
12224
+ notePageChange();
12225
+ }
12156
12226
  if (!reloadSet || reloadSet.size === 0) {
12157
12227
  return {
12158
12228
  allResult: undefined,
@@ -15499,8 +15569,8 @@ const useOpenPropsEffectOnOpenController = (
15499
15569
  // Tracks whether the effect below has ever run before — only the very
15500
15570
  // first run gets the "mount already open" treatment (`open` truthy from
15501
15571
  // the start, or the uncontrolled, mount-only `defaultOpen`); every
15502
- // subsequent `open` change is a real, later toggle and should animate
15503
- // normally like any other interactive open/close.
15572
+ // subsequent `open` change is a real, later toggle and animates like any
15573
+ // interactive open/close — unless a page change carries it (see below).
15504
15574
  const isFirstRunRef = useRef(true);
15505
15575
  // What `open` was on the previous run of the effect below. preact re-runs
15506
15576
  // an effect for a change of its deps — or for none at all, when a
@@ -15520,6 +15590,12 @@ const useOpenPropsEffectOnOpenController = (
15520
15590
  isFirstRunRef.current = false;
15521
15591
  const openChanged = open !== lastRunOpenRef.current;
15522
15592
  lastRunOpenRef.current = open;
15593
+ // Opened or closed by the navigation that puts another page on screen: a
15594
+ // link inside the popup leading away, a back to the page it stood over.
15595
+ // The page changing is its movement, so it has none of its own — a route
15596
+ // transition carries it with its page, and without one it cuts with the
15597
+ // page (see page_change.js).
15598
+ const carriedByPageChange = isRenderingPageChange();
15523
15599
 
15524
15600
  if (isFirstRun) {
15525
15601
  // "interaction" is said even over what the caller holds reading open: a
@@ -15538,10 +15614,12 @@ const useOpenPropsEffectOnOpenController = (
15538
15614
  // other truthy value means it was simply already open: nothing was ever
15539
15615
  // shown as "closed" for the user to see it transition away from, so the
15540
15616
  // entrance is skipped (`silent`, see popover.jsx's own openEffect).
15541
- //
15617
+ // A popup built by an opening a page change carries is silent too,
15618
+ // whatever built it: read now, the answer only holds for this render.
15619
+ const silent = mountOpenReason !== "interaction" || carriedByPageChange;
15542
15620
  mountOpenOwedRef.current = () =>
15543
15621
  openController.open(new CustomEvent("open_by_prop", { detail: {} }), {
15544
- silent: mountOpenReason !== "interaction",
15622
+ silent,
15545
15623
  });
15546
15624
  }
15547
15625
  return undefined;
@@ -15573,11 +15651,13 @@ const useOpenPropsEffectOnOpenController = (
15573
15651
  });
15574
15652
  return undefined;
15575
15653
  }
15576
- openController.open(new CustomEvent("open_by_prop", { detail: {} }));
15654
+ openController.open(new CustomEvent("open_by_prop", { detail: {} }), {
15655
+ silent: carriedByPageChange,
15656
+ });
15577
15657
  } else {
15578
15658
  openController.requestClose(
15579
15659
  new CustomEvent("close_by_prop", { detail: {} }),
15580
- { isCancel: true },
15660
+ { isCancel: true, silent: carriedByPageChange },
15581
15661
  );
15582
15662
  }
15583
15663
  // The request can be refused (a busy form denying the close): the popup
@@ -16280,7 +16360,7 @@ const renderIntoCallout = (jsx$1, calloutMessageElement, {
16280
16360
  },
16281
16361
  children: jsx$1
16282
16362
  });
16283
- render(calloutJsx, calloutMessageElement);
16363
+ render$1(calloutJsx, calloutMessageElement);
16284
16364
  };
16285
16365
 
16286
16366
  // An HTML message is rendered through preact rather than innerHTML: what a
@@ -16303,7 +16383,7 @@ const renderHtmlIntoCallout = (html, calloutMessageElement, {
16303
16383
  // (a DOM node, an iframe); innerHTML alone would leave preact believing its
16304
16384
  // tree is still there.
16305
16385
  const clearCalloutMessage = calloutMessageElement => {
16306
- render(null, calloutMessageElement);
16386
+ render$1(null, calloutMessageElement);
16307
16387
  calloutMessageElement.innerHTML = "";
16308
16388
  };
16309
16389
  const domToVNodes = node => {
@@ -32196,10 +32276,222 @@ const useUrlSearchParam = (paramName, defaultValue) => {
32196
32276
  return [value, setSearchParamValue];
32197
32277
  };
32198
32278
 
32279
+ /**
32280
+ * The colour the browser paints around the page — Chrome's address bar on
32281
+ * Android, the status bar of an installed app — read from
32282
+ * `<meta name="theme-color">`. It sits right above the page's top edge, so it
32283
+ * reads as part of whatever is drawn there, and two layers decide it:
32284
+ *
32285
+ * - What the page asks for: the theme-color metas a `<Head>` brings. The last
32286
+ * Head to arrive wins, whatever order they leave in — the title's rule, for
32287
+ * the title's reason (see head.jsx).
32288
+ * - What the top layer lays over that edge: an open popup paints its backdrop
32289
+ * over what is under it, and its own surface too when it is flush with the
32290
+ * top edge. Popups sit above every Head, in opening order, and are painted
32291
+ * again from what is under them each time it changes — a page changing
32292
+ * under an open popup is dimmed by it all the same.
32293
+ *
32294
+ * The browser reads the first theme-color meta whose `media` matches, in tree
32295
+ * order: a Head appending its meta after an earlier one would never be read.
32296
+ * So navi writes the result into metas of its own placed before any other, and
32297
+ * a meta written in the HTML is the bottom of the stack. A popup paints over
32298
+ * every meta of the list, `media` kept, so whichever one the browser picks is
32299
+ * the one it would have picked, painted over.
32300
+ */
32301
+
32302
+ const headEntries = [];
32303
+ const popupLayers = [];
32304
+ const ownMetas = [];
32305
+
32306
+ /**
32307
+ * @param {Array<{ content: string, media: string|null }>} themeColors
32308
+ * @returns {() => void} Takes them away.
32309
+ */
32310
+ const pushHeadThemeColors = (themeColors) => {
32311
+ headEntries.push(themeColors);
32312
+ queueRender();
32313
+ return () => {
32314
+ headEntries.splice(headEntries.indexOf(themeColors), 1);
32315
+ queueRender();
32316
+ };
32317
+ };
32318
+
32319
+ /**
32320
+ * @param {object} paint
32321
+ * @param {string} [paint.themeColor] - Forces the colour, nothing is painted.
32322
+ * @param {string|null} [paint.backdropColor] - Laid over the page's colour.
32323
+ * @param {string|null} [paint.surfaceColor] - Laid over that, for a popup
32324
+ * flush with the top edge.
32325
+ * @returns {() => void} Takes the layer away.
32326
+ */
32327
+ const pushPopupThemeColor = ({
32328
+ themeColor,
32329
+ backdropColor,
32330
+ surfaceColor,
32331
+ }) => {
32332
+ let layer;
32333
+ if (themeColor) {
32334
+ layer = { color: themeColor };
32335
+ } else {
32336
+ const paints = [];
32337
+ for (const color of [backdropColor, surfaceColor]) {
32338
+ if (!color) {
32339
+ continue;
32340
+ }
32341
+ const rgba = parseColor(color);
32342
+ if (rgba[3] > 0) {
32343
+ paints.push(rgba);
32344
+ }
32345
+ }
32346
+ // Nothing laid over the edge: the colour under it stays, as it is.
32347
+ if (paints.length === 0) {
32348
+ return () => {};
32349
+ }
32350
+ layer = { paints };
32351
+ }
32352
+ popupLayers.push(layer);
32353
+ queueRender();
32354
+ return () => {
32355
+ popupLayers.splice(popupLayers.indexOf(layer), 1);
32356
+ queueRender();
32357
+ };
32358
+ };
32359
+
32360
+ let renderQueued = false;
32361
+ // Once per commit: a Head re-rendering leaves and arrives again in the same
32362
+ // commit, and the browser must not be handed the colour from in between.
32363
+ const queueRender = () => {
32364
+ if (renderQueued) {
32365
+ return;
32366
+ }
32367
+ renderQueued = true;
32368
+ queueMicrotask(render);
32369
+ };
32370
+
32371
+ const render = () => {
32372
+ renderQueued = false;
32373
+ const htmlMetas = Array.from(
32374
+ document.head.querySelectorAll('meta[name="theme-color"]'),
32375
+ ).filter((meta) => !ownMetas.includes(meta));
32376
+ const htmlColors = htmlMetas.map((meta) => ({
32377
+ content: meta.getAttribute("content"),
32378
+ media: meta.getAttribute("media"),
32379
+ }));
32380
+ let colors = [];
32381
+ for (let i = headEntries.length - 1; i >= 0; i--) {
32382
+ colors.push(...headEntries[i]);
32383
+ }
32384
+ colors.push(...htmlColors);
32385
+ for (const layer of popupLayers) {
32386
+ colors = paintLayer(layer, colors);
32387
+ }
32388
+ // What the HTML metas say reaches the browser without a copy.
32389
+ while (colors.length > 0 && htmlColors.includes(colors[colors.length - 1])) {
32390
+ colors.pop();
32391
+ }
32392
+
32393
+ while (ownMetas.length > colors.length) {
32394
+ ownMetas.pop().remove();
32395
+ }
32396
+ colors.forEach(({ content, media }, index) => {
32397
+ let meta = ownMetas[index];
32398
+ if (!meta) {
32399
+ meta = document.createElement("meta");
32400
+ meta.setAttribute("name", "theme-color");
32401
+ const previousMeta = ownMetas[index - 1];
32402
+ if (previousMeta) {
32403
+ previousMeta.after(meta);
32404
+ } else if (htmlMetas.length > 0) {
32405
+ htmlMetas[0].before(meta);
32406
+ } else {
32407
+ document.head.append(meta);
32408
+ }
32409
+ ownMetas.push(meta);
32410
+ }
32411
+ if (meta.getAttribute("content") !== content) {
32412
+ meta.setAttribute("content", content);
32413
+ }
32414
+ if (meta.getAttribute("media") !== media) {
32415
+ if (media === null) {
32416
+ meta.removeAttribute("media");
32417
+ } else {
32418
+ meta.setAttribute("media", media);
32419
+ }
32420
+ }
32421
+ });
32422
+ };
32423
+
32424
+ const paintLayer = (layer, colors) => {
32425
+ if (layer.color) {
32426
+ return [{ content: layer.color, media: null }];
32427
+ }
32428
+ const underColors =
32429
+ colors.length > 0 ? colors : [{ content: null, media: null }];
32430
+ const painted = [];
32431
+ for (const { content, media } of underColors) {
32432
+ let rgba = content === null ? null : parseUnderColor(content);
32433
+ for (const paint of layer.paints) {
32434
+ rgba = rgba ? composite(paint, rgba) : paint;
32435
+ }
32436
+ // Over no colour at all the browser keeps one of its own, which a
32437
+ // translucent paint cannot be laid over.
32438
+ if (Math.round(rgba[3] * 255) === 255) {
32439
+ painted.push({ content: toHex(rgba), media });
32440
+ }
32441
+ }
32442
+ return painted;
32443
+ };
32444
+
32445
+ // The browser shows a theme-color opaque, whatever alpha it was written with.
32446
+ const parseUnderColor = (color) => {
32447
+ const rgba = parseColor(color);
32448
+ if (rgba[3] === 0) {
32449
+ return null;
32450
+ }
32451
+ return [rgba[0], rgba[1], rgba[2], 1];
32452
+ };
32453
+
32454
+ // Source-over, the way the backdrop is drawn over the page.
32455
+ const composite = (over, under) => {
32456
+ const [r1, g1, b1, a1] = over;
32457
+ const [r2, g2, b2, a2] = under;
32458
+ const alpha = a1 + a2 * (1 - a1);
32459
+ const mix = (c1, c2) => (c1 * a1 + c2 * a2 * (1 - a1)) / alpha;
32460
+ return [mix(r1, r2), mix(g1, g2), mix(b1, b2), alpha];
32461
+ };
32462
+
32463
+ const toHex = ([r, g, b]) =>
32464
+ `#${[r, g, b]
32465
+ .map((channel) => Math.round(channel).toString(16).padStart(2, "0"))
32466
+ .join("")}`;
32467
+
32468
+ // A canvas reads every colour syntax the browser knows — oklch(), color(),
32469
+ // a keyword — and hands back sRGB bytes, which is what a theme-color meta is
32470
+ // sure to be understood in, whatever getComputedStyle answered with.
32471
+ let colorContext = null;
32472
+ const parseColor = (color) => {
32473
+ if (!colorContext) {
32474
+ const canvas = document.createElement("canvas");
32475
+ canvas.width = 1;
32476
+ canvas.height = 1;
32477
+ colorContext = canvas.getContext("2d", { willReadFrequently: true });
32478
+ }
32479
+ colorContext.clearRect(0, 0, 1, 1);
32480
+ // fillStyle ignores a colour it cannot parse: reset first, so that one draws
32481
+ // nothing rather than the previous colour.
32482
+ colorContext.fillStyle = "transparent";
32483
+ colorContext.fillStyle = color;
32484
+ colorContext.fillRect(0, 0, 1, 1);
32485
+ const [r, g, b, a] = colorContext.getImageData(0, 0, 1, 1).data;
32486
+ return [r, g, b, a / 255];
32487
+ };
32488
+
32199
32489
  // The title belongs to the last Head to arrive, whatever order they leave in:
32200
32490
  // the page being left by a route transition is taken down AFTER the page
32201
32491
  // arriving is up (see keepLeavingPages in route.jsx), and putting back the
32202
- // title it found would put its own page's title over the new one.
32492
+ // title it found would put its own page's title over the new one. A
32493
+ // theme-color meta follows the same rule, kept by theme_color.js, where the
32494
+ // popups of the top layer paint over it.
32203
32495
  const titleEntries = [];
32204
32496
  let titleWithoutHead = null;
32205
32497
  const Head = ({
@@ -32211,6 +32503,7 @@ const Head = ({
32211
32503
  }
32212
32504
  const childArray = Array.isArray(children) ? children : [children];
32213
32505
  const appendedElements = [];
32506
+ const themeColors = [];
32214
32507
  let titleEntry = null;
32215
32508
  for (const child of childArray) {
32216
32509
  if (!child) {
@@ -32223,6 +32516,13 @@ const Head = ({
32223
32516
  };
32224
32517
  continue;
32225
32518
  }
32519
+ if (child.type === "meta" && child.props.name === "theme-color") {
32520
+ themeColors.push({
32521
+ content: child.props.content,
32522
+ media: child.props.media ?? null
32523
+ });
32524
+ continue;
32525
+ }
32226
32526
  const el = document.createElement(child.type);
32227
32527
  const props = child.props || {};
32228
32528
  for (const [key, value] of Object.entries(props)) {
@@ -32238,7 +32538,9 @@ const Head = ({
32238
32538
  titleEntries.push(titleEntry);
32239
32539
  document.title = titleEntry.title;
32240
32540
  }
32541
+ const removeThemeColors = themeColors.length > 0 ? pushHeadThemeColors(themeColors) : null;
32241
32542
  return () => {
32543
+ removeThemeColors?.();
32242
32544
  if (titleEntry) {
32243
32545
  titleEntries.splice(titleEntries.indexOf(titleEntry), 1);
32244
32546
  const lastEntry = titleEntries[titleEntries.length - 1];
@@ -35185,6 +35487,9 @@ const rebuildWatcher = () => {
35185
35487
  if (fromIndex === index) {
35186
35488
  return;
35187
35489
  }
35490
+ // A page of a relation can be a param: a move between two of them is a
35491
+ // page change the path does not show (see page_change.js).
35492
+ notePageChange();
35188
35493
  if (navigationAnimated) {
35189
35494
  // The movement on screen was turned round for this very navigation (see
35190
35495
  // turnRunningTransitionRound): it is already being answered, by the
@@ -36549,6 +36854,10 @@ const RouteTravel = ({
36549
36854
  }
36550
36855
  return;
36551
36856
  }
36857
+ // Whoever asked for it: a tab of this row can be a param, and a move
36858
+ // between two of them is a page change the path does not show (see
36859
+ // page_change.js).
36860
+ notePageChange();
36552
36861
  const page = pages[index];
36553
36862
  // A page this box asked for itself — a travel's own navigation, or one
36554
36863
  // it had given up waiting on: what arrives here is the answer to a
@@ -53059,11 +53368,13 @@ const Expandable = props => {
53059
53368
  openController.openEffect = openEvent => {
53060
53369
  const contentContainer = contentContainerRef.current;
53061
53370
  // `silent`: the expandable was already open when the page appeared
53062
- // (`open`/`defaultOpen` at mount). Nothing changed for anyone to be told
53063
- // about, nothing was ever shown closed to move away from, and a page must
53064
- // not have its focus stolen by a section that was simply already open — so
53065
- // an opening that was never an opening plays nothing, says nothing and
53066
- // takes nothing. The action it carries still runs: its content is due.
53371
+ // (`open`/`defaultOpen` at mount, or the navigation putting that page on
53372
+ // screen opening it — see page_change.js). Nothing changed for anyone to
53373
+ // be told about, nothing was ever shown closed to move away from, and a
53374
+ // page must not have its focus stolen by a section that was simply
53375
+ // already open — so an opening that was never an opening plays nothing,
53376
+ // says nothing and takes nothing. The action it carries still runs: its
53377
+ // content is due.
53067
53378
  const silent = Boolean(openEvent.detail.silent);
53068
53379
  const revealing = animation && !silent && Boolean(contentContainer);
53069
53380
  // Where the last paint left the track — 0 when fully closed, partway when
@@ -53110,7 +53421,9 @@ const Expandable = props => {
53110
53421
  effectiveAction.abort();
53111
53422
  }
53112
53423
  cancelSettleWatch();
53113
- const collapsing = animation && Boolean(contentContainerAtClose);
53424
+ // `silent`: closed by the navigation that puts another page on screen,
53425
+ // which is its movement (see page_change.js).
53426
+ const collapsing = animation && !closeEvent.detail.silent && Boolean(contentContainerAtClose);
53114
53427
  if (collapsing) {
53115
53428
  // Now, while the content is still fully laid out — the collapsing
53116
53429
  // track uncovers a content frozen at that size.
@@ -60430,10 +60743,23 @@ const SlideContainer = ({
60430
60743
  y: 0
60431
60744
  };
60432
60745
  const durationMs = durationToMs(duration);
60746
+ // A change of slide the page changing carries is no travel: the page is
60747
+ // the movement, and the slides land where the address puts them (see
60748
+ // page_change.js). A travel still playing is cut on the spot, and the
60749
+ // indicator riding it with it.
60750
+ const landsWithPage = isRenderingPageChange() && (stageRef.current ? stageRef.current.area : drawnAreaRef.current) !== currentArea;
60751
+ if (landsWithPage && trackAnimationRef.current) {
60752
+ trackAnimationRef.current.cancel();
60753
+ trackAnimationRef.current = null;
60754
+ movingRef.current = null;
60755
+ cancelTravelProgressAnimation();
60756
+ paintTravelProgress(0);
60757
+ }
60758
+ const still = noTravel || landsWithPage;
60433
60759
  // Nothing is travelling, so nothing is staged: the picture to paint is the
60434
60760
  // map itself, and a stage left over from a travel that has just been given
60435
60761
  // up on would be painted instead of it.
60436
- if (noTravel) {
60762
+ if (still) {
60437
60763
  stageRef.current = null;
60438
60764
  }
60439
60765
  let stage = stageRef.current;
@@ -60441,7 +60767,7 @@ const SlideContainer = ({
60441
60767
  // between the picture and the slide arriving, on the axis it walks.
60442
60768
  let travelStep = null;
60443
60769
  const drawnArea = stage ? stage.area : drawnAreaRef.current;
60444
- const travelStarts = !noTravel && durationMs > 0 && drawnArea !== undefined && drawnArea !== currentArea && slideElements.some(slideElement => readArea(slideElement) === drawnArea);
60770
+ const travelStarts = !still && durationMs > 0 && drawnArea !== undefined && drawnArea !== currentArea && slideElements.some(slideElement => readArea(slideElement) === drawnArea);
60445
60771
  if (travelStarts) {
60446
60772
  // Where the slide being left IS — the place the stage gave it if a travel
60447
60773
  // was already playing (a press landing mid-travel departs from where the
@@ -60568,7 +60894,7 @@ const SlideContainer = ({
60568
60894
  // duration (the ask is zero, so ratioOfOneTravel answers 1), slowing the
60569
60895
  // travel at every re-render.
60570
60896
  const sameTravelPlaying = travelInFlight && !offsetDragged && offsetTargetBefore === offset;
60571
- const travels = !sameTravelPlaying && !noTravel && durationMs > 0 && offsetBefore !== undefined && offsetBefore !== offset;
60897
+ const travels = !sameTravelPlaying && !still && durationMs > 0 && offsetBefore !== undefined && offsetBefore !== offset;
60572
60898
  if (travels) {
60573
60899
  // The time it takes is the distance it has left to cover: a travel picked
60574
60900
  // up a fifth of the way through goes back in a fifth of the time, so what
@@ -64517,9 +64843,17 @@ const css$E = /* css */`
64517
64843
  on its own, sharp, above the wall: any wall on that frame — even a
64518
64844
  half-strength wash and blur — is a flash on the anchor. So the wall
64519
64845
  arrives from nothing, over the movement: on that frame the page is
64520
- untouched, and the anchor with it. */
64846
+ untouched, and the anchor with it.
64847
+ Keyed on the aria-expanded flip, like the local wall: openEffect reads
64848
+ the layout while the transitions are off, which spends @starting-style
64849
+ with nothing armed. @starting-style stays for an opening that reads
64850
+ nothing before the flip. A silent opening flips before the transitions
64851
+ come back, hence the :not(): its wall lands at once. */
64521
64852
  &[data-lifting] {
64522
- &::backdrop {
64853
+ &[aria-expanded="false"]::backdrop {
64854
+ opacity: 0;
64855
+ }
64856
+ &[aria-expanded="true"]:not([navi-transitions-off])::backdrop {
64523
64857
  opacity: 1;
64524
64858
  transition-property: opacity;
64525
64859
  transition-duration: var(--navi-popup-lift-duration, 0.25s);
@@ -64530,6 +64864,12 @@ const css$E = /* css */`
64530
64864
  }
64531
64865
  }
64532
64866
  }
64867
+ /* Off while the dialog's own transitions are off (switchTransitionsOff):
64868
+ the inline style that turns them off cannot reach a pseudo-element.
64869
+ After the rules above, which it must win over at equal weight. */
64870
+ &[navi-transitions-off]::backdrop {
64871
+ transition-property: none;
64872
+ }
64533
64873
 
64534
64874
  &[data-focus-visible],
64535
64875
  /* …or something filling it holds the keyboard and offers its ring to
@@ -64973,6 +65313,16 @@ const css$E = /* css */`
64973
65313
  * `saturate()`/`grayscale()` work too. How far what is behind withdraws is
64974
65314
  * a question of paint, not of what an outside click does — a backdrop that
64975
65315
  * closes can blur.
65316
+ * @param {string|false} [props.themeColor] - The colour the browser paints
65317
+ * above the page (`<meta name="theme-color">`: Chrome's address bar on
65318
+ * Android, an installed app's status bar) while this dialog is open.
65319
+ * Left out, it is what the dialog lays over the top edge of the screen: its
65320
+ * backdrop over the colour the page asked for (`<Head>`), and its own
65321
+ * background over that when it is flush with the top edge
65322
+ * (`marginWithContainer={0}` with `expand`/`expandY` or a top
65323
+ * `positionArea`). A colour forces it, for a surface whose colour cannot be
65324
+ * read (an image, a gradient); `false` leaves the page's colour alone.
65325
+ * `layer="top"` only.
64976
65326
  * @param {boolean} [props.scrollCapture] - Traps scroll gestures inside the
64977
65327
  * dialog so the page/container behind it can't scroll while it's open.
64978
65328
  * A `layer="local"` dialog always locks its own positioned ancestor's
@@ -65494,6 +65844,9 @@ const useDialogProps = props => {
65494
65844
  // so both objects below get them (see this file's CSS).
65495
65845
  backdropColor,
65496
65846
  backdropFilter,
65847
+ // Read at the opening, with the backdrop and the surface it would
65848
+ // otherwise be worked out from (see openEffect).
65849
+ themeColor,
65497
65850
  scrollCapture: scrollCaptureProp,
65498
65851
  // "auto" (default) → the dialog follows its content. "frozen" → measured
65499
65852
  // once, held at that size while open. See this prop's own JSDoc above.
@@ -65744,8 +66097,9 @@ const useDialogProps = props => {
65744
66097
  const dialogEl = ref.current;
65745
66098
  // A mount-time opening was never seen closed (see openEffect's own
65746
66099
  // `silent`): there is no box it comes from, because nothing was shown
65747
- // before it.
65748
- if (!dialogEl || opened && (!lifting || event.detail.silent)) {
66100
+ // before it. And a change a page change carries moves with that page,
66101
+ // in and out of no anchor (see page_change.js).
66102
+ if (!dialogEl || event.detail.silent || opened && !lifting) {
65749
66103
  applyChange();
65750
66104
  return;
65751
66105
  }
@@ -65785,8 +66139,9 @@ const useDialogProps = props => {
65785
66139
  }
65786
66140
 
65787
66141
  // Set by useOpenControllerByProps for the very first open triggered by
65788
- // `open`/`defaultOpen` already being truthy at mount — see popover.jsx's
65789
- // own openEffect for the full reasoning, mirrored here identically.
66142
+ // `open`/`defaultOpen` already being truthy at mount, and for an open a
66143
+ // page change carries — see popover.jsx's own openEffect for the full
66144
+ // reasoning, mirrored here identically.
65790
66145
  const silent = Boolean(e.detail.silent);
65791
66146
 
65792
66147
  // document.documentElement — the shared "no real container, use the
@@ -65830,7 +66185,7 @@ const useDialogProps = props => {
65830
66185
  // comment), even though Dialog never needs to measure/flip anything: it
65831
66186
  // still needs a genuinely rendered "closed" frame to transition from, not a
65832
66187
  // jump straight from not-shown to aria-expanded="true".
65833
- dialogEl.style.transitionProperty = "none";
66188
+ switchTransitionsOff(dialogEl);
65834
66189
  if (backdropEl) {
65835
66190
  disarmBackdropHideRef.current?.();
65836
66191
  disarmBackdropHideRef.current = null;
@@ -66063,18 +66418,28 @@ const useDialogProps = props => {
66063
66418
  dialogEl.setAttribute("aria-expanded", "true");
66064
66419
  backdropEl?.setAttribute("aria-expanded", "true");
66065
66420
  dialogEl.getBoundingClientRect();
66066
- dialogEl.style.transitionProperty = "";
66421
+ switchTransitionsOn(dialogEl);
66067
66422
  if (backdropEl) {
66068
66423
  backdropEl.style.transitionProperty = "";
66069
66424
  }
66070
66425
  } else {
66071
- dialogEl.style.transitionProperty = "";
66426
+ switchTransitionsOn(dialogEl);
66072
66427
  dialogEl.setAttribute("aria-expanded", "true");
66073
66428
  backdropEl?.setAttribute("aria-expanded", "true");
66074
66429
  if (backdropEl) {
66075
66430
  backdropEl.style.transitionProperty = "";
66076
66431
  }
66077
66432
  }
66433
+ // What the browser paints above the page goes with what is laid over its
66434
+ // top edge, from the start of the opening to the start of the closing: a
66435
+ // local dialog lays nothing there.
66436
+ if (isTopLayer && themeColor !== false) {
66437
+ addCleanup(pushPopupThemeColor({
66438
+ themeColor,
66439
+ backdropColor: backdrop ? getComputedStyle(dialogEl, "::backdrop").backgroundColor : null,
66440
+ surfaceColor: flushEdges.top ? getComputedStyle(dialogEl).backgroundColor : null
66441
+ }));
66442
+ }
66078
66443
  const cancelOpenInteractionSuppression = !silent && hasCssTransitionAnimation ? suppressPointerEventsDuringTransition(dialogEl) : null;
66079
66444
  // Handing the focus to a field is what raises the on-screen keyboard, and
66080
66445
  // the keyboard takes away the very room this dialog was just placed
@@ -66104,6 +66469,15 @@ const useDialogProps = props => {
66104
66469
  return closeEvent => {
66105
66470
  debugPopup(`"${closeEvent.type}" on ${getElementSignature(closeEvent.target)} -> closeDialog`);
66106
66471
  clearTextSelectionInside(dialogEl);
66472
+ // Closed by the navigation that puts another page on screen: it leaves
66473
+ // with that page, and plays no exit of its own (see page_change.js).
66474
+ const silent = Boolean(closeEvent.detail.silent);
66475
+ if (silent) {
66476
+ switchTransitionsOff(dialogEl);
66477
+ if (backdropEl) {
66478
+ backdropEl.style.transitionProperty = "none";
66479
+ }
66480
+ }
66107
66481
  dialogEl.setAttribute("aria-expanded", "false");
66108
66482
  if (!isTopLayer) {
66109
66483
  openLocalDialogCount = Math.max(0, openLocalDialogCount - 1);
@@ -66143,7 +66517,7 @@ const useDialogProps = props => {
66143
66517
  // next opening clears it (see openEffect's own unfreezeSize).
66144
66518
  freezeSize(dialogEl);
66145
66519
  cancelOpenInteractionSuppression?.();
66146
- if (hasCssTransitionAnimation) {
66520
+ if (hasCssTransitionAnimation && !silent) {
66147
66521
  suppressPointerEventsDuringTransition(dialogEl);
66148
66522
  }
66149
66523
  if (backdropEl) {
@@ -66154,6 +66528,15 @@ const useDialogProps = props => {
66154
66528
  }
66155
66529
  restoreFocus(closeEvent);
66156
66530
  cleanup();
66531
+ if (silent) {
66532
+ // The closed frame committed with nothing transitioning, before
66533
+ // transitions come back for the next opening.
66534
+ dialogEl.getBoundingClientRect();
66535
+ switchTransitionsOn(dialogEl);
66536
+ if (backdropEl) {
66537
+ backdropEl.style.transitionProperty = "";
66538
+ }
66539
+ }
66157
66540
  };
66158
66541
  };
66159
66542
  const onKeyDownShortcuts = createOnKeyDownForShortcuts({
@@ -66396,6 +66779,19 @@ const useDialogProps = props => {
66396
66779
  }
66397
66780
  return [!isTopLayer && backdrop ? backdropProps : null, contentProps];
66398
66781
  };
66782
+
66783
+ // The dialog's own transitions, off while a frame is pinned (the closed one an
66784
+ // entrance starts from, the open one a silent opening lands on, the closed one
66785
+ // a silent close leaves) and back on after it. A modal's wall is its native
66786
+ // ::backdrop, which takes no inline style: it reads the attribute instead.
66787
+ const switchTransitionsOff = dialogEl => {
66788
+ dialogEl.style.transitionProperty = "none";
66789
+ dialogEl.setAttribute("navi-transitions-off", "");
66790
+ };
66791
+ const switchTransitionsOn = dialogEl => {
66792
+ dialogEl.style.transitionProperty = "";
66793
+ dialogEl.removeAttribute("navi-transitions-off");
66794
+ };
66399
66795
  const DIALOG_PSEUDO_CLASSES = [":hover", ":active", ":focus", ":focus-visible", ":focus-within"];
66400
66796
 
66401
66797
  // Lets consumers pass animationDuration="0.5s" as a regular prop; Box maps
@@ -66846,6 +67242,12 @@ const css$D = /* css */`
66846
67242
  * `saturate()`/`grayscale()` work too. How far what is behind withdraws is
66847
67243
  * a question of paint, not of what an outside click does — a backdrop that
66848
67244
  * closes can blur.
67245
+ * @param {string|false} [props.themeColor] - The colour the browser paints
67246
+ * above the page (`<meta name="theme-color">`: Chrome's address bar on
67247
+ * Android, an installed app's status bar) while this popover is open.
67248
+ * Left out, it is the backdrop laid over the colour the page asked for
67249
+ * (`<Head>`), and the page's colour when there is no backdrop. A colour
67250
+ * forces it; `false` leaves the page's colour alone. `layer="top"` only.
66849
67251
  * @param {boolean} [props.scrollCapture] - Traps scroll gestures inside the
66850
67252
  * popover so the page/container behind it can't scroll while it's open.
66851
67253
  * @param {boolean} [props.focusCapture] - Traps Tab navigation inside the
@@ -67188,6 +67590,9 @@ const usePopoverProps = props => {
67188
67590
  // which is a sibling element (see this file's top comment).
67189
67591
  backdropColor,
67190
67592
  backdropFilter,
67593
+ // Read at the opening, with the backdrop it would otherwise be worked out
67594
+ // from (see openEffect).
67595
+ themeColor,
67191
67596
  group,
67192
67597
  scrollCapture,
67193
67598
  focusCapture,
@@ -67320,8 +67725,10 @@ const usePopoverProps = props => {
67320
67725
  // `open`/`defaultOpen` already being truthy at mount — there's nothing
67321
67726
  // to visually transition away from (nothing was ever shown as "closed"
67322
67727
  // to the user), so this open skips the animation entirely instead of
67323
- // playing it against a closed frame that was never actually seen. See
67324
- // the final commit step below for how that's done.
67728
+ // playing it against a closed frame that was never actually seen. Set
67729
+ // too for an open the page changing carries: that page is its movement
67730
+ // (see page_change.js). See the final commit step below for how that's
67731
+ // done.
67325
67732
  const silent = Boolean(e.detail.silent);
67326
67733
  const [cleanup, addCleanup] = createPubSub(true);
67327
67734
 
@@ -67757,7 +68164,7 @@ const usePopoverProps = props => {
67757
68164
  // focus in. Inlined rather than a standalone function since it only
67758
68165
  // has this one call site.
67759
68166
  //
67760
- // `silent` (mounting already open via `open`/`defaultOpen`) flips the
68167
+ // `silent` (mounting already open, or carried by a page change) flips the
67761
68168
  // order instead: aria-expanded first, *then* re-enable transitions, with
67762
68169
  // its own forced reflow in between the two — without that reflow, the
67763
68170
  // browser coalesces "flip aria-expanded" and "re-enable transitions"
@@ -67790,6 +68197,15 @@ const usePopoverProps = props => {
67790
68197
  backdropEl.style.transitionProperty = "";
67791
68198
  }
67792
68199
  }
68200
+ // What the browser paints above the page goes with what is laid over its
68201
+ // top edge, from the start of the opening to the start of the closing: a
68202
+ // local popover's backdrop stops at its container.
68203
+ if (isTopLayer && themeColor !== false) {
68204
+ addCleanup(pushPopupThemeColor({
68205
+ themeColor,
68206
+ backdropColor: backdropEl ? getComputedStyle(backdropEl).backgroundColor : null
68207
+ }));
68208
+ }
67793
68209
  const cancelOpenInteractionSuppression = !silent && hasCssTransitionAnimation ? suppressPointerEventsDuringTransition(popoverEl) : null;
67794
68210
  // autoFocus={false}: opening moves no focus at all — whoever has the
67795
68211
  // keyboard keeps it (combobox-style suggestions under an input being
@@ -67803,6 +68219,15 @@ const usePopoverProps = props => {
67803
68219
  // reason: only ever built here.
67804
68220
  return closeEvent => {
67805
68221
  clearTextSelectionInside(popoverEl);
68222
+ // Closed by the navigation that puts another page on screen: it leaves
68223
+ // with that page, and plays no exit of its own (see page_change.js).
68224
+ const silent = Boolean(closeEvent.detail.silent);
68225
+ if (silent) {
68226
+ popoverEl.style.transitionProperty = "none";
68227
+ if (backdropEl) {
68228
+ backdropEl.style.transitionProperty = "none";
68229
+ }
68230
+ }
67806
68231
  popoverEl.setAttribute("aria-expanded", "false");
67807
68232
  // Set regardless of isTopLayer — see the open side's own identical
67808
68233
  // comment (openEffect above) for why hidePopover() alone isn't
@@ -67829,7 +68254,7 @@ const usePopoverProps = props => {
67829
68254
  // open is its own separate call with no way to reach back into this
67830
68255
  // one).
67831
68256
  cancelOpenInteractionSuppression?.();
67832
- if (hasCssTransitionAnimation) {
68257
+ if (hasCssTransitionAnimation && !silent) {
67833
68258
  suppressPointerEventsDuringTransition(popoverEl);
67834
68259
  }
67835
68260
  if (backdropEl) {
@@ -67864,6 +68289,15 @@ const usePopoverProps = props => {
67864
68289
  popoverEl.removeAttribute("data-position-y-current");
67865
68290
  popoverEl.removeAttribute("data-position-x-current");
67866
68291
  cleanup();
68292
+ if (silent) {
68293
+ // The closed frame committed with nothing transitioning, before
68294
+ // transitions come back for the next opening.
68295
+ popoverEl.getBoundingClientRect();
68296
+ popoverEl.style.transitionProperty = "";
68297
+ if (backdropEl) {
68298
+ backdropEl.style.transitionProperty = "";
68299
+ }
68300
+ }
67867
68301
  };
67868
68302
  };
67869
68303
  const onKeyDownShortcuts = createOnKeyDownForShortcuts({
@@ -68187,6 +68621,10 @@ const css$C = /* css */`@layer navi {
68187
68621
  * identically): the wash the backdrop paints over what is behind.
68188
68622
  * @param {string} [props.backdropFilter] - Forwarded as-is: what that wash
68189
68623
  * does to the picture underneath, `"blur(4px)"` and the like.
68624
+ * @param {string|false} [props.themeColor] - Forwarded as-is: the colour the
68625
+ * browser paints above the page while the popup is open, worked out from
68626
+ * what it lays over the top edge when left out. A colour forces it, `false`
68627
+ * leaves the page's colour alone.
68190
68628
  * @param {boolean|"auto"|"fading"|"scaling"|"sliding"|`slide-from-${string}`|"expanding"|`expand-${string}`|"lifting"|{open: boolean|"auto"|"fading"|"scaling"|"sliding"|`slide-from-${string}`, close: "lifting"}} [props.animation]
68191
68629
  * - Forwarded to whichever renders — see either component's own doc.
68192
68630
  * `"expanding"`/`"expand-*"` are `Popover`'s own; `"lifting"` and
@@ -69184,6 +69622,26 @@ const PickerCustom = props => {
69184
69622
  if (isWithinPickerContent(e.target)) {
69185
69623
  return null;
69186
69624
  }
69625
+ // Still open at the click of a later press: that press brought no
69626
+ // mousedown to close on (a drag source cancelled its pointerdown),
69627
+ // so it closes here, as it opens here in that case. A mousedown
69628
+ // that came has closed it already; the press that opened it is
69629
+ // only ending.
69630
+ if (openController.opened && !openedDuringThisPress(openController)) {
69631
+ return {
69632
+ name: "click to close picker",
69633
+ intent: "read",
69634
+ prevented: () => {
69635
+ e.preventDefault();
69636
+ },
69637
+ allowed: () => {
69638
+ requestClose(e, {
69639
+ isCancel: true
69640
+ });
69641
+ e.preventDefault();
69642
+ }
69643
+ };
69644
+ }
69187
69645
  if (!opensOnPress) {
69188
69646
  // Neither the tap, nor the click a hold leaves behind: the press
69189
69647
  // is not what opens this picker.
@@ -69839,12 +70297,12 @@ const isScrolling = (element) => {
69839
70297
  if (element === undefined) {
69840
70298
  return scrollActivitySignal.peek();
69841
70299
  }
69842
- return asScroller(element).hasAttribute("navi-scrolling");
70300
+ return asScroller$1(element).hasAttribute("navi-scrolling");
69843
70301
  };
69844
70302
 
69845
70303
  // The page scroll is dispatched on the document; the element carrying the
69846
70304
  // attribute is the one CSS can reach, document.scrollingElement.
69847
- const asScroller = (eventTargetOrElement) => {
70305
+ const asScroller$1 = (eventTargetOrElement) => {
69848
70306
  if (eventTargetOrElement === document || eventTargetOrElement === window) {
69849
70307
  return document.scrollingElement;
69850
70308
  }
@@ -69855,7 +70313,7 @@ const idleTimeoutMap = new Map();
69855
70313
  document.addEventListener(
69856
70314
  "scroll",
69857
70315
  (e) => {
69858
- const scroller = asScroller(e.target);
70316
+ const scroller = asScroller$1(e.target);
69859
70317
  const idleTimeout = idleTimeoutMap.get(scroller);
69860
70318
  if (idleTimeout !== undefined) {
69861
70319
  clearTimeout(idleTimeout);
@@ -69877,6 +70335,111 @@ document.addEventListener(
69877
70335
  { capture: true, passive: true },
69878
70336
  );
69879
70337
 
70338
+ /*
70339
+ * Whether a scroller is gliding: moving on its own toward a place it was sent
70340
+ * to, with no hand on it — a key (Home, PageDown, Space), a smooth `scrollTo`
70341
+ * or `scrollIntoView`, the status bar tap on iOS. The other scrolls follow a
70342
+ * hand: a finger on the screen, a wheel turning.
70343
+ *
70344
+ * It matters to whoever writes a scroll position while one plays: the write is
70345
+ * an instant scroll, and the browser abandons the glide for it, short of where
70346
+ * it was going. A hand is not abandoned: its next report moves the scroller on
70347
+ * from wherever the write left it.
70348
+ *
70349
+ * A scroll is followed from its first "scroll" event to its "scrollend". It is
70350
+ * a hand's when a finger was down as it started (the fling a finger leaves
70351
+ * behind is still its own), when one is down now, or when a wheel turned
70352
+ * around the time it started or since. A finger is down from its
70353
+ * touchstart to its touchend, heard on what it touched: a list removes the
70354
+ * items scrolled out of its window while the finger is still on one of them,
70355
+ * and the touchend of a removed element never reaches the document.
70356
+ *
70357
+ * In a browser without "scrollend", nothing is said to glide: a scroll would
70358
+ * never be seen ending.
70359
+ */
70360
+
70361
+ // The scroll a wheel turn causes starts in the frame the turn is read, or the
70362
+ // next one. A turn further from it moved nothing (a wheel at an edge) and is no
70363
+ // hand on a scroll that came from elsewhere.
70364
+ const WHEEL_REACH = 100;
70365
+
70366
+ const flightMap = new WeakMap();
70367
+ let fingersDown = 0;
70368
+ let wheelTimeStamp = -Infinity;
70369
+
70370
+ /**
70371
+ * @param {Element} scrollerEl
70372
+ * @returns {boolean}
70373
+ */
70374
+ const isScrollGliding = (scrollerEl) => {
70375
+ const flight = flightMap.get(scrollerEl);
70376
+ if (!flight) {
70377
+ return false;
70378
+ }
70379
+ if (flight.fingerDown || fingersDown > 0) {
70380
+ return false;
70381
+ }
70382
+ if (wheelTimeStamp > flight.timeStamp - WHEEL_REACH) {
70383
+ return false;
70384
+ }
70385
+ return true;
70386
+ };
70387
+
70388
+ // The page scroll is dispatched on the document; the scroller asked about is
70389
+ // document.scrollingElement.
70390
+ const asScroller = (eventTarget) => {
70391
+ if (eventTarget === document || eventTarget === window) {
70392
+ return document.scrollingElement;
70393
+ }
70394
+ return eventTarget;
70395
+ };
70396
+
70397
+ const onTouchEnd = (e) => {
70398
+ fingersDown = e.touches.length;
70399
+ };
70400
+
70401
+ if ("onscrollend" in window) {
70402
+ // Capturing: "scroll" and "scrollend" do not bubble from an element.
70403
+ document.addEventListener(
70404
+ "scroll",
70405
+ (e) => {
70406
+ const scroller = asScroller(e.target);
70407
+ if (flightMap.has(scroller)) {
70408
+ return;
70409
+ }
70410
+ flightMap.set(scroller, {
70411
+ timeStamp: e.timeStamp,
70412
+ fingerDown: fingersDown > 0,
70413
+ });
70414
+ },
70415
+ { capture: true, passive: true },
70416
+ );
70417
+ document.addEventListener(
70418
+ "scrollend",
70419
+ (e) => {
70420
+ flightMap.delete(asScroller(e.target));
70421
+ },
70422
+ { capture: true, passive: true },
70423
+ );
70424
+ window.addEventListener(
70425
+ "wheel",
70426
+ (e) => {
70427
+ wheelTimeStamp = e.timeStamp;
70428
+ },
70429
+ { capture: true, passive: true },
70430
+ );
70431
+ window.addEventListener(
70432
+ "touchstart",
70433
+ (e) => {
70434
+ fingersDown = e.touches.length;
70435
+ const touchedEl = e.composedPath()[0];
70436
+ touchedEl.addEventListener("touchend", onTouchEnd, { passive: true });
70437
+ touchedEl.addEventListener("touchcancel", onTouchEnd, { passive: true });
70438
+ },
70439
+ { capture: true, passive: true },
70440
+ );
70441
+ }
70442
+
69880
70443
  const LoadingDotsSvg = () => {
69881
70444
  return jsxs("svg", {
69882
70445
  viewBox: "0 0 200 200",
@@ -72960,7 +73523,9 @@ const useListScrollSync = ({
72960
73523
  const scrolledFallback = scrolled === undefined || scrolled === null ? "start" : defaultScrolled ?? "start";
72961
73524
  const startPlaceRef = useRef({
72962
73525
  userTookOver: false,
72963
- wanted: scrolledWanted
73526
+ wanted: scrolledWanted,
73527
+ way: null,
73528
+ arrived: false
72964
73529
  });
72965
73530
  if (startPlaceRef.current.wanted !== scrolledWanted) {
72966
73531
  startPlaceRef.current.wanted = scrolledWanted;
@@ -73170,8 +73735,10 @@ const useListScrollSync = ({
73170
73735
  // keeps moving while the list is still finding out how many items it has and
73171
73736
  // how tall one is, so landing there once would land next to it. What ends the
73172
73737
  // hold is the user reaching for the list — a wheel, a finger, a key, a hand
73173
- // on the scrollbar — and not the scroll event itself, which the list provokes
73174
- // as much as the user does.
73738
+ // on the scrollbar — or anything else moving it. A scroll event alone does
73739
+ // not say which: the list provokes as many as the user does, so only one
73740
+ // landing off the way of the list's own placement ends it (see
73741
+ // sendHeldScroll).
73175
73742
  const placeWhereHeld = () => {
73176
73743
  if (scrolledWanted === "start" || scrolledWanted === undefined || startPlaceRef.current.userTookOver || !ref.current) {
73177
73744
  return;
@@ -73220,12 +73787,7 @@ const useListScrollSync = ({
73220
73787
  }
73221
73788
  const scrollerEl = getScroller();
73222
73789
  if (openAt === "end") {
73223
- anchorRef.current = null;
73224
- if (horizontal) {
73225
- scrollerEl.scrollLeft = scrollerEl.scrollWidth;
73226
- } else {
73227
- scrollerEl.scrollTop = scrollerEl.scrollHeight;
73228
- }
73790
+ sendHeldScroll(horizontal ? scrollerEl.scrollWidth : scrollerEl.scrollHeight);
73229
73791
  return;
73230
73792
  }
73231
73793
  if (typeof openAt === "number") {
@@ -73249,15 +73811,45 @@ const useListScrollSync = ({
73249
73811
  const itemRect = itemEl.getBoundingClientRect();
73250
73812
  const offsetWanted = resolveOpenOffset(getItemScrollInset(scrollerEl, itemEl, horizontal) + offset, horizontal ? viewportRect.width : viewportRect.height, horizontal ? itemRect.width : itemRect.height);
73251
73813
  const offsetNow = horizontal ? itemRect.left - viewportRect.left : itemRect.top - viewportRect.top;
73814
+ const scrollNow = horizontal ? scrollerEl.scrollLeft : scrollerEl.scrollTop;
73252
73815
  const delta = offsetNow - offsetWanted;
73253
73816
  if (delta > -0.5 && delta < 0.5) {
73817
+ sendHeldScroll(scrollNow);
73254
73818
  return;
73255
73819
  }
73820
+ sendHeldScroll(scrollNow + delta);
73821
+ };
73822
+ // Every placement goes through here, the ones that find the list in place
73823
+ // too: it keeps the way from where the scroller was to where it is sent,
73824
+ // which is how a scroll event tells the hold's own scrolls from anyone
73825
+ // else's (see letGoUnlessHeldScroll). The whole way, not just its end: under
73826
+ // `scroll-behavior: smooth` the write glides, and every position on the way
73827
+ // is the list's until it gets there.
73828
+ const sendHeldScroll = position => {
73829
+ const scrollerEl = getScroller();
73830
+ const from = horizontal ? scrollerEl.scrollLeft : scrollerEl.scrollTop;
73831
+ const max = horizontal ? scrollerEl.scrollWidth - scrollerEl.clientWidth : scrollerEl.scrollHeight - scrollerEl.clientHeight;
73832
+ let to = position;
73833
+ if (to > max) {
73834
+ to = max;
73835
+ }
73836
+ if (to < 0) {
73837
+ to = 0;
73838
+ }
73256
73839
  anchorRef.current = null;
73840
+ const place = startPlaceRef.current;
73841
+ place.way = {
73842
+ from,
73843
+ to
73844
+ };
73845
+ if (to === from) {
73846
+ place.arrived = true;
73847
+ return;
73848
+ }
73257
73849
  if (horizontal) {
73258
- scrollerEl.scrollLeft += delta;
73850
+ scrollerEl.scrollLeft = to;
73259
73851
  } else {
73260
- scrollerEl.scrollTop += delta;
73852
+ scrollerEl.scrollTop = to;
73261
73853
  }
73262
73854
  };
73263
73855
  useLayoutEffect(placeWhereHeld);
@@ -73330,6 +73922,19 @@ const useListScrollSync = ({
73330
73922
  evaluateWindowRef.current("items resized");
73331
73923
  return false;
73332
73924
  };
73925
+ // The box around a held list changing shape is the hold's to answer too: it
73926
+ // places the list again, the way it writes every scroll of its own (see
73927
+ // sendHeldScroll) — written any other way, the scroll would read as someone
73928
+ // else moving the list, and end the hold.
73929
+ const placeIfHeldRef = useRef(null);
73930
+ placeIfHeldRef.current = () => {
73931
+ // Read again: a scroll may have ended the hold since this render.
73932
+ if (!heldSomewhere || startPlaceRef.current.userTookOver) {
73933
+ return false;
73934
+ }
73935
+ placeWhereHeld();
73936
+ return true;
73937
+ };
73333
73938
  useLayoutEffect(() => {
73334
73939
  if (scrolledWanted === "start" || scrolledWanted === undefined || !ref.current) {
73335
73940
  return undefined;
@@ -73474,6 +74079,9 @@ const useListScrollSync = ({
73474
74079
  if (!entries.some(entry => entry.target === scrollerEl)) {
73475
74080
  return;
73476
74081
  }
74082
+ if (placeIfHeldRef.current()) {
74083
+ return;
74084
+ }
73477
74085
  const position = positionRef.current;
73478
74086
  if (!position) {
73479
74087
  return;
@@ -73586,6 +74194,15 @@ const useListScrollSync = ({
73586
74194
  return;
73587
74195
  }
73588
74196
  anchorRef.current = null;
74197
+ if (isScrollGliding(scrollerEl)) {
74198
+ // Nobody is looking at items standing still: they are passing by, on
74199
+ // their way somewhere the browser was asked to take them (a key, a smooth
74200
+ // scrollTo, the status bar). The write would be an instant scroll, and
74201
+ // the browser abandons the glide for it, short of where it was going.
74202
+ // Left alone, the items drawn above push the rest by `drift` while all
74203
+ // of it moves, and the glide lands.
74204
+ return;
74205
+ }
73589
74206
  scrolledByListRef.current = true;
73590
74207
  if (horizontal) {
73591
74208
  scrollerEl.scrollLeft += drift;
@@ -73913,9 +74530,44 @@ const useListScrollSync = ({
73913
74530
  reportPosition();
73914
74531
  return;
73915
74532
  }
74533
+ letGoUnlessHeldScroll();
73916
74534
  reportPosition();
73917
74535
  evaluateWindowRef.current("scroll");
73918
74536
  };
74537
+ // A scroll the hold did not write is someone else moving the list, which
74538
+ // ends the hold like a wheel does — the ones that come with no input event
74539
+ // at all: the status bar tap on iOS, find in page, a script.
74540
+ // Only once the list has stood where it holds itself, though. Until then
74541
+ // the page is still settling around it, and some of that is written after
74542
+ // the list placed itself: a navigation holding the rendering for its
74543
+ // transition takes the arriving page to its top once the page rendered
74544
+ // (whenRenderingResumes), and the hold puts it back at its next commit.
74545
+ const letGoUnlessHeldScroll = () => {
74546
+ const place = startPlaceRef.current;
74547
+ const way = place.way;
74548
+ if (place.userTookOver || place.wanted === "start" || place.wanted === undefined || !way) {
74549
+ return;
74550
+ }
74551
+ const position = horizontal ? scrollerEl.scrollLeft : scrollerEl.scrollTop;
74552
+ if (position > way.to - 1 && position < way.to + 1) {
74553
+ // From then on only that place is the list's, or a scroll back the
74554
+ // way it came would pass for its own.
74555
+ place.way = {
74556
+ from: way.to,
74557
+ to: way.to
74558
+ };
74559
+ place.arrived = true;
74560
+ return;
74561
+ }
74562
+ if (!place.arrived) {
74563
+ return;
74564
+ }
74565
+ const low = way.from < way.to ? way.from : way.to;
74566
+ const high = way.from < way.to ? way.to : way.from;
74567
+ if (position < low - 1 || position > high + 1) {
74568
+ place.userTookOver = true;
74569
+ }
74570
+ };
73919
74571
  // A page-level scroller does not emit "scroll" on the element itself
73920
74572
  // (document.scrollingElement); the document does.
73921
74573
  const scrollEventTarget = scrollerEl === document.scrollingElement ? document : scrollerEl;
@@ -77304,7 +77956,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
77304
77956
  *
77305
77957
  * In every form the list holds itself there while it is still finding out
77306
77958
  * how many items there are and how tall one is, and lets go the moment the
77307
- * user reaches for the list.
77959
+ * user reaches for the list, or anything else scrolls it (a script, find in
77960
+ * page, the status bar tap on iOS).
77308
77961
  * @param {(scrolled: {id: string, index: number, offset: number, visibleCount: number}) => void} [props.onScrolledChange]
77309
77962
  * Where the list is, as the user scrolls: the item at the top of the view and
77310
77963
  * how far below the place an item lands on its own (see `defaultScrolled`) it