@jsenv/navi 0.29.431 → 0.29.433

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, createEventGroupLogger, 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, getVirtualKeyboardOverlayHeight, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, setVirtualKeyboardOverlaysContent, subscribeVirtualKeyboardGeometryChange } 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, prefixFirstAndIndentRemainingLines, 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";
@@ -11537,6 +11537,11 @@ const [publishRouteRender, observeRouteRender] = createPubSub();
11537
11537
  const STORAGE_KEY = "navi_scroll_positions";
11538
11538
 
11539
11539
  const positionByUrl = new Map();
11540
+ // Where the document stands, as its scroll events last said, whoever it
11541
+ // belongs to: what a url shown without the document moving is given (see
11542
+ // stayInPlace). Null until the restoration is installed — nothing is recorded
11543
+ // before.
11544
+ let documentOffset = null;
11540
11545
  // url -> (scroller name -> position). The position is whatever the scroller
11541
11546
  // handed in: what it can put itself back on, in its own terms.
11542
11547
  const scrollerPositionsByUrl = new Map();
@@ -11653,29 +11658,33 @@ const installScrollRestoration = () => {
11653
11658
  }
11654
11659
  window.history.scrollRestoration = "manual";
11655
11660
  loadStore();
11661
+ documentOffset = { x: window.scrollX, y: window.scrollY };
11656
11662
  // Read as it happens rather than when leaving: a traverse changes the url
11657
11663
  // before anything here is told, so a position read then would be read for
11658
11664
  // the wrong page.
11659
11665
  window.addEventListener(
11660
11666
  "scroll",
11661
11667
  () => {
11668
+ documentOffset = { x: window.scrollX, y: window.scrollY };
11662
11669
  if (suspendCount) {
11663
11670
  return;
11664
11671
  }
11665
- positionByUrl.set(window.location.href, {
11666
- x: window.scrollX,
11667
- y: window.scrollY,
11668
- });
11672
+ positionByUrl.set(window.location.href, documentOffset);
11669
11673
  },
11670
11674
  { passive: true },
11671
11675
  );
11676
+ const positionOnLoad = positionByUrl.get(window.location.href);
11677
+ if (!positionOnLoad) {
11678
+ // The first page, where the browser landed it.
11679
+ stayInPlace(window.location.href);
11680
+ return;
11681
+ }
11672
11682
  // What a reload asks for, now that the browser has been told not to do it.
11673
11683
  // Once, and at the first render of a route: the position is only meaningful
11674
11684
  // once there is a page under it.
11675
11685
  // Written once the commit that rendered it has ended: said from inside it,
11676
11686
  // and what the offset shows may still have to be built (see scrollTo).
11677
- const positionOnLoad = positionByUrl.get(window.location.href);
11678
- if (positionOnLoad && (positionOnLoad.x || positionOnLoad.y)) {
11687
+ if (positionOnLoad.x || positionOnLoad.y) {
11679
11688
  const stopListening = observeRouteRender(() => {
11680
11689
  stopListening();
11681
11690
  queueMicrotask(() => {
@@ -11758,9 +11767,15 @@ const restoreScrollPositionOnReturn = (url) => {
11758
11767
  // app that scrolls an element of its own scrolls it itself.
11759
11768
  const startAtTop = (url, { from } = {}) => {
11760
11769
  if (!isArrival(url, { from })) {
11770
+ stayInPlace(url);
11761
11771
  return;
11762
11772
  }
11763
11773
  arrivalCount++;
11774
+ // Written down as well as done: a document already at its top fires no
11775
+ // scroll event, and a page left without ever being scrolled would have no
11776
+ // position to come back to — the return would keep the offset of the page
11777
+ // being left.
11778
+ positionByUrl.set(new URL(url, window.location.href).href, { x: 0, y: 0 });
11764
11779
  window.scrollTo({ top: 0, left: 0, behavior: "instant" });
11765
11780
  };
11766
11781
  const isArrival = (url, { from }) => {
@@ -11774,6 +11789,23 @@ const isArrival = (url, { from }) => {
11774
11789
  return true;
11775
11790
  };
11776
11791
 
11792
+ // A url the document is shown under without moving: a replace, a push that
11793
+ // keeps the page (see startAtTop), the first page loaded. No scroll event says
11794
+ // where it stands, so it is written down here, or a return to it would find
11795
+ // nothing and keep the offset of the page being left. The offset the scroll
11796
+ // events last told, not read again: a read here would lay the page out in the
11797
+ // middle of a routing, once per address a settling param writes.
11798
+ //
11799
+ // Deaf with the recording: a row of tabs replaces the url as it travels, and
11800
+ // the offset belongs to nobody until the row says where its tab lands (see
11801
+ // arriveAtScrollPosition).
11802
+ const stayInPlace = (url) => {
11803
+ if (documentOffset === null || suspendCount) {
11804
+ return;
11805
+ }
11806
+ positionByUrl.set(new URL(url, window.location.href).href, documentOffset);
11807
+ };
11808
+
11777
11809
  // The same arrival, for the page's own scrollers. The document is scrolled to
11778
11810
  // its top once the page is there; a list opens where it decides to in its
11779
11811
  // first render, so what it must not find is dropped before the routing
@@ -12249,14 +12281,18 @@ const setupBrowserIntegrationViaHistory = ({
12249
12281
  // also waits for the page it returns to: an offset is clamped to whatever
12250
12282
  // page the document holds when it is written.
12251
12283
  //
12252
- // A replace gets neither: it is the same place said differently — a param
12253
- // settling, a state written — and moving the reader for it would throw
12254
- // them out of a page they never left. A push that keeps the pathname is
12255
- // read the same way, by startAtTop itself. The one replace that IS an
12256
- // arrival is a row of tabs travelling, and the row says so for itself (see
12257
- // route_travel.jsx).
12284
+ // A replace leaves the document where it is: it is the same place said
12285
+ // differently — a param settling, a state written — and moving the reader
12286
+ // for it would throw them out of a page they never left. Where they stay
12287
+ // is written down for the new address (see stayInPlace). A push that keeps
12288
+ // the pathname is read the same way, by startAtTop itself. The one replace
12289
+ // that IS an arrival is a row of tabs travelling, and the row says so for
12290
+ // itself (see route_travel.jsx) — from inside the routing above, which is
12291
+ // why the replace is written down after it.
12258
12292
  if (navigationType === "push") {
12259
12293
  whenRenderingResumes(() => startAtTop(url, { from: urlLeft }));
12294
+ } else if (navigationType === "replace") {
12295
+ stayInPlace(url);
12260
12296
  } else if (navigationType === "traverse") {
12261
12297
  restoreScrollPositionOnReturn(url);
12262
12298
  }
@@ -16989,7 +17025,7 @@ const renderIntoCallout = (jsx$1, calloutMessageElement, {
16989
17025
  },
16990
17026
  children: jsx$1
16991
17027
  });
16992
- render(calloutJsx, calloutMessageElement);
17028
+ render$1(calloutJsx, calloutMessageElement);
16993
17029
  };
16994
17030
 
16995
17031
  // An HTML message is rendered through preact rather than innerHTML: what a
@@ -17012,7 +17048,7 @@ const renderHtmlIntoCallout = (html, calloutMessageElement, {
17012
17048
  // (a DOM node, an iframe); innerHTML alone would leave preact believing its
17013
17049
  // tree is still there.
17014
17050
  const clearCalloutMessage = calloutMessageElement => {
17015
- render(null, calloutMessageElement);
17051
+ render$1(null, calloutMessageElement);
17016
17052
  calloutMessageElement.innerHTML = "";
17017
17053
  };
17018
17054
  const domToVNodes = node => {
@@ -33210,10 +33246,222 @@ const useUrlSearchParam = (paramName, defaultValue) => {
33210
33246
  return [value, setSearchParamValue];
33211
33247
  };
33212
33248
 
33249
+ /**
33250
+ * The colour the browser paints around the page — Chrome's address bar on
33251
+ * Android, the status bar of an installed app — read from
33252
+ * `<meta name="theme-color">`. It sits right above the page's top edge, so it
33253
+ * reads as part of whatever is drawn there, and two layers decide it:
33254
+ *
33255
+ * - What the page asks for: the theme-color metas a `<Head>` brings. The last
33256
+ * Head to arrive wins, whatever order they leave in — the title's rule, for
33257
+ * the title's reason (see head.jsx).
33258
+ * - What the top layer lays over that edge: an open popup paints its backdrop
33259
+ * over what is under it, and its own surface too when it is flush with the
33260
+ * top edge. Popups sit above every Head, in opening order, and are painted
33261
+ * again from what is under them each time it changes — a page changing
33262
+ * under an open popup is dimmed by it all the same.
33263
+ *
33264
+ * The browser reads the first theme-color meta whose `media` matches, in tree
33265
+ * order: a Head appending its meta after an earlier one would never be read.
33266
+ * So navi writes the result into metas of its own placed before any other, and
33267
+ * a meta written in the HTML is the bottom of the stack. A popup paints over
33268
+ * every meta of the list, `media` kept, so whichever one the browser picks is
33269
+ * the one it would have picked, painted over.
33270
+ */
33271
+
33272
+ const headEntries = [];
33273
+ const popupLayers = [];
33274
+ const ownMetas = [];
33275
+
33276
+ /**
33277
+ * @param {Array<{ content: string, media: string|null }>} themeColors
33278
+ * @returns {() => void} Takes them away.
33279
+ */
33280
+ const pushHeadThemeColors = (themeColors) => {
33281
+ headEntries.push(themeColors);
33282
+ queueRender();
33283
+ return () => {
33284
+ headEntries.splice(headEntries.indexOf(themeColors), 1);
33285
+ queueRender();
33286
+ };
33287
+ };
33288
+
33289
+ /**
33290
+ * @param {object} paint
33291
+ * @param {string} [paint.themeColor] - Forces the colour, nothing is painted.
33292
+ * @param {string|null} [paint.backdropColor] - Laid over the page's colour.
33293
+ * @param {string|null} [paint.surfaceColor] - Laid over that, for a popup
33294
+ * flush with the top edge.
33295
+ * @returns {() => void} Takes the layer away.
33296
+ */
33297
+ const pushPopupThemeColor = ({
33298
+ themeColor,
33299
+ backdropColor,
33300
+ surfaceColor,
33301
+ }) => {
33302
+ let layer;
33303
+ if (themeColor) {
33304
+ layer = { color: themeColor };
33305
+ } else {
33306
+ const paints = [];
33307
+ for (const color of [backdropColor, surfaceColor]) {
33308
+ if (!color) {
33309
+ continue;
33310
+ }
33311
+ const rgba = parseColor(color);
33312
+ if (rgba[3] > 0) {
33313
+ paints.push(rgba);
33314
+ }
33315
+ }
33316
+ // Nothing laid over the edge: the colour under it stays, as it is.
33317
+ if (paints.length === 0) {
33318
+ return () => {};
33319
+ }
33320
+ layer = { paints };
33321
+ }
33322
+ popupLayers.push(layer);
33323
+ queueRender();
33324
+ return () => {
33325
+ popupLayers.splice(popupLayers.indexOf(layer), 1);
33326
+ queueRender();
33327
+ };
33328
+ };
33329
+
33330
+ let renderQueued = false;
33331
+ // Once per commit: a Head re-rendering leaves and arrives again in the same
33332
+ // commit, and the browser must not be handed the colour from in between.
33333
+ const queueRender = () => {
33334
+ if (renderQueued) {
33335
+ return;
33336
+ }
33337
+ renderQueued = true;
33338
+ queueMicrotask(render);
33339
+ };
33340
+
33341
+ const render = () => {
33342
+ renderQueued = false;
33343
+ const htmlMetas = Array.from(
33344
+ document.head.querySelectorAll('meta[name="theme-color"]'),
33345
+ ).filter((meta) => !ownMetas.includes(meta));
33346
+ const htmlColors = htmlMetas.map((meta) => ({
33347
+ content: meta.getAttribute("content"),
33348
+ media: meta.getAttribute("media"),
33349
+ }));
33350
+ let colors = [];
33351
+ for (let i = headEntries.length - 1; i >= 0; i--) {
33352
+ colors.push(...headEntries[i]);
33353
+ }
33354
+ colors.push(...htmlColors);
33355
+ for (const layer of popupLayers) {
33356
+ colors = paintLayer(layer, colors);
33357
+ }
33358
+ // What the HTML metas say reaches the browser without a copy.
33359
+ while (colors.length > 0 && htmlColors.includes(colors[colors.length - 1])) {
33360
+ colors.pop();
33361
+ }
33362
+
33363
+ while (ownMetas.length > colors.length) {
33364
+ ownMetas.pop().remove();
33365
+ }
33366
+ colors.forEach(({ content, media }, index) => {
33367
+ let meta = ownMetas[index];
33368
+ if (!meta) {
33369
+ meta = document.createElement("meta");
33370
+ meta.setAttribute("name", "theme-color");
33371
+ const previousMeta = ownMetas[index - 1];
33372
+ if (previousMeta) {
33373
+ previousMeta.after(meta);
33374
+ } else if (htmlMetas.length > 0) {
33375
+ htmlMetas[0].before(meta);
33376
+ } else {
33377
+ document.head.append(meta);
33378
+ }
33379
+ ownMetas.push(meta);
33380
+ }
33381
+ if (meta.getAttribute("content") !== content) {
33382
+ meta.setAttribute("content", content);
33383
+ }
33384
+ if (meta.getAttribute("media") !== media) {
33385
+ if (media === null) {
33386
+ meta.removeAttribute("media");
33387
+ } else {
33388
+ meta.setAttribute("media", media);
33389
+ }
33390
+ }
33391
+ });
33392
+ };
33393
+
33394
+ const paintLayer = (layer, colors) => {
33395
+ if (layer.color) {
33396
+ return [{ content: layer.color, media: null }];
33397
+ }
33398
+ const underColors =
33399
+ colors.length > 0 ? colors : [{ content: null, media: null }];
33400
+ const painted = [];
33401
+ for (const { content, media } of underColors) {
33402
+ let rgba = content === null ? null : parseUnderColor(content);
33403
+ for (const paint of layer.paints) {
33404
+ rgba = rgba ? composite(paint, rgba) : paint;
33405
+ }
33406
+ // Over no colour at all the browser keeps one of its own, which a
33407
+ // translucent paint cannot be laid over.
33408
+ if (Math.round(rgba[3] * 255) === 255) {
33409
+ painted.push({ content: toHex(rgba), media });
33410
+ }
33411
+ }
33412
+ return painted;
33413
+ };
33414
+
33415
+ // The browser shows a theme-color opaque, whatever alpha it was written with.
33416
+ const parseUnderColor = (color) => {
33417
+ const rgba = parseColor(color);
33418
+ if (rgba[3] === 0) {
33419
+ return null;
33420
+ }
33421
+ return [rgba[0], rgba[1], rgba[2], 1];
33422
+ };
33423
+
33424
+ // Source-over, the way the backdrop is drawn over the page.
33425
+ const composite = (over, under) => {
33426
+ const [r1, g1, b1, a1] = over;
33427
+ const [r2, g2, b2, a2] = under;
33428
+ const alpha = a1 + a2 * (1 - a1);
33429
+ const mix = (c1, c2) => (c1 * a1 + c2 * a2 * (1 - a1)) / alpha;
33430
+ return [mix(r1, r2), mix(g1, g2), mix(b1, b2), alpha];
33431
+ };
33432
+
33433
+ const toHex = ([r, g, b]) =>
33434
+ `#${[r, g, b]
33435
+ .map((channel) => Math.round(channel).toString(16).padStart(2, "0"))
33436
+ .join("")}`;
33437
+
33438
+ // A canvas reads every colour syntax the browser knows — oklch(), color(),
33439
+ // a keyword — and hands back sRGB bytes, which is what a theme-color meta is
33440
+ // sure to be understood in, whatever getComputedStyle answered with.
33441
+ let colorContext = null;
33442
+ const parseColor = (color) => {
33443
+ if (!colorContext) {
33444
+ const canvas = document.createElement("canvas");
33445
+ canvas.width = 1;
33446
+ canvas.height = 1;
33447
+ colorContext = canvas.getContext("2d", { willReadFrequently: true });
33448
+ }
33449
+ colorContext.clearRect(0, 0, 1, 1);
33450
+ // fillStyle ignores a colour it cannot parse: reset first, so that one draws
33451
+ // nothing rather than the previous colour.
33452
+ colorContext.fillStyle = "transparent";
33453
+ colorContext.fillStyle = color;
33454
+ colorContext.fillRect(0, 0, 1, 1);
33455
+ const [r, g, b, a] = colorContext.getImageData(0, 0, 1, 1).data;
33456
+ return [r, g, b, a / 255];
33457
+ };
33458
+
33213
33459
  // The title belongs to the last Head to arrive, whatever order they leave in:
33214
33460
  // the page being left by a route transition is taken down AFTER the page
33215
33461
  // arriving is up (see keepLeavingPages in route.jsx), and putting back the
33216
- // title it found would put its own page's title over the new one.
33462
+ // title it found would put its own page's title over the new one. A
33463
+ // theme-color meta follows the same rule, kept by theme_color.js, where the
33464
+ // popups of the top layer paint over it.
33217
33465
  const titleEntries = [];
33218
33466
  let titleWithoutHead = null;
33219
33467
  const Head = ({
@@ -33225,6 +33473,7 @@ const Head = ({
33225
33473
  }
33226
33474
  const childArray = Array.isArray(children) ? children : [children];
33227
33475
  const appendedElements = [];
33476
+ const themeColors = [];
33228
33477
  let titleEntry = null;
33229
33478
  for (const child of childArray) {
33230
33479
  if (!child) {
@@ -33237,6 +33486,13 @@ const Head = ({
33237
33486
  };
33238
33487
  continue;
33239
33488
  }
33489
+ if (child.type === "meta" && child.props.name === "theme-color") {
33490
+ themeColors.push({
33491
+ content: child.props.content,
33492
+ media: child.props.media ?? null
33493
+ });
33494
+ continue;
33495
+ }
33240
33496
  const el = document.createElement(child.type);
33241
33497
  const props = child.props || {};
33242
33498
  for (const [key, value] of Object.entries(props)) {
@@ -33252,7 +33508,9 @@ const Head = ({
33252
33508
  titleEntries.push(titleEntry);
33253
33509
  document.title = titleEntry.title;
33254
33510
  }
33511
+ const removeThemeColors = themeColors.length > 0 ? pushHeadThemeColors(themeColors) : null;
33255
33512
  return () => {
33513
+ removeThemeColors?.();
33256
33514
  if (titleEntry) {
33257
33515
  titleEntries.splice(titleEntries.indexOf(titleEntry), 1);
33258
33516
  const lastEntry = titleEntries[titleEntries.length - 1];
@@ -65885,9 +66143,17 @@ const css$E = /* css */`
65885
66143
  on its own, sharp, above the wall: any wall on that frame — even a
65886
66144
  half-strength wash and blur — is a flash on the anchor. So the wall
65887
66145
  arrives from nothing, over the movement: on that frame the page is
65888
- untouched, and the anchor with it. */
66146
+ untouched, and the anchor with it.
66147
+ Keyed on the aria-expanded flip, like the local wall: openEffect reads
66148
+ the layout while the transitions are off, which spends @starting-style
66149
+ with nothing armed. @starting-style stays for an opening that reads
66150
+ nothing before the flip. A silent opening flips before the transitions
66151
+ come back, hence the :not(): its wall lands at once. */
65889
66152
  &[data-lifting] {
65890
- &::backdrop {
66153
+ &[aria-expanded="false"]::backdrop {
66154
+ opacity: 0;
66155
+ }
66156
+ &[aria-expanded="true"]:not([navi-transitions-off])::backdrop {
65891
66157
  opacity: 1;
65892
66158
  transition-property: opacity;
65893
66159
  transition-duration: var(--navi-popup-lift-duration, 0.25s);
@@ -66347,6 +66613,16 @@ const css$E = /* css */`
66347
66613
  * `saturate()`/`grayscale()` work too. How far what is behind withdraws is
66348
66614
  * a question of paint, not of what an outside click does — a backdrop that
66349
66615
  * closes can blur.
66616
+ * @param {string|false} [props.themeColor] - The colour the browser paints
66617
+ * above the page (`<meta name="theme-color">`: Chrome's address bar on
66618
+ * Android, an installed app's status bar) while this dialog is open.
66619
+ * Left out, it is what the dialog lays over the top edge of the screen: its
66620
+ * backdrop over the colour the page asked for (`<Head>`), and its own
66621
+ * background over that when it is flush with the top edge
66622
+ * (`marginWithContainer={0}` with `expand`/`expandY` or a top
66623
+ * `positionArea`). A colour forces it, for a surface whose colour cannot be
66624
+ * read (an image, a gradient); `false` leaves the page's colour alone.
66625
+ * `layer="top"` only.
66350
66626
  * @param {boolean} [props.scrollCapture] - Traps scroll gestures inside the
66351
66627
  * dialog so the page/container behind it can't scroll while it's open.
66352
66628
  * A `layer="local"` dialog always locks its own positioned ancestor's
@@ -66870,6 +67146,9 @@ const useDialogProps = props => {
66870
67146
  // so both objects below get them (see this file's CSS).
66871
67147
  backdropColor,
66872
67148
  backdropFilter,
67149
+ // Read at the opening, with the backdrop and the surface it would
67150
+ // otherwise be worked out from (see openEffect).
67151
+ themeColor,
66873
67152
  scrollCapture: scrollCaptureProp,
66874
67153
  // "auto" (default) → the dialog follows its content. "frozen" → measured
66875
67154
  // once, held at that size while open. See this prop's own JSDoc above.
@@ -67510,6 +67789,16 @@ const useDialogProps = props => {
67510
67789
  backdropEl.style.transitionProperty = "";
67511
67790
  }
67512
67791
  }
67792
+ // What the browser paints above the page goes with what is laid over its
67793
+ // top edge, from the start of the opening to the start of the closing: a
67794
+ // local dialog lays nothing there.
67795
+ if (isTopLayer && themeColor !== false) {
67796
+ addCleanup(pushPopupThemeColor({
67797
+ themeColor,
67798
+ backdropColor: backdrop ? getComputedStyle(dialogEl, "::backdrop").backgroundColor : null,
67799
+ surfaceColor: flushEdges.top ? getComputedStyle(dialogEl).backgroundColor : null
67800
+ }));
67801
+ }
67513
67802
  const cancelOpenInteractionSuppression = !silent && hasCssTransitionAnimation ? suppressPointerEventsDuringTransition(dialogEl) : null;
67514
67803
  // Handing the focus to a field is what raises the on-screen keyboard, and
67515
67804
  // the keyboard takes away the very room this dialog was just placed
@@ -68339,6 +68628,12 @@ const css$D = /* css */`
68339
68628
  * `saturate()`/`grayscale()` work too. How far what is behind withdraws is
68340
68629
  * a question of paint, not of what an outside click does — a backdrop that
68341
68630
  * closes can blur.
68631
+ * @param {string|false} [props.themeColor] - The colour the browser paints
68632
+ * above the page (`<meta name="theme-color">`: Chrome's address bar on
68633
+ * Android, an installed app's status bar) while this popover is open.
68634
+ * Left out, it is the backdrop laid over the colour the page asked for
68635
+ * (`<Head>`), and the page's colour when there is no backdrop. A colour
68636
+ * forces it; `false` leaves the page's colour alone. `layer="top"` only.
68342
68637
  * @param {boolean} [props.scrollCapture] - Traps scroll gestures inside the
68343
68638
  * popover so the page/container behind it can't scroll while it's open.
68344
68639
  * @param {boolean} [props.focusCapture] - Traps Tab navigation inside the
@@ -68683,6 +68978,9 @@ const usePopoverProps = props => {
68683
68978
  // which is a sibling element (see this file's top comment).
68684
68979
  backdropColor,
68685
68980
  backdropFilter,
68981
+ // Read at the opening, with the backdrop it would otherwise be worked out
68982
+ // from (see openEffect).
68983
+ themeColor,
68686
68984
  group,
68687
68985
  scrollCapture,
68688
68986
  focusCapture,
@@ -69293,6 +69591,15 @@ const usePopoverProps = props => {
69293
69591
  backdropEl.style.transitionProperty = "";
69294
69592
  }
69295
69593
  }
69594
+ // What the browser paints above the page goes with what is laid over its
69595
+ // top edge, from the start of the opening to the start of the closing: a
69596
+ // local popover's backdrop stops at its container.
69597
+ if (isTopLayer && themeColor !== false) {
69598
+ addCleanup(pushPopupThemeColor({
69599
+ themeColor,
69600
+ backdropColor: backdropEl ? getComputedStyle(backdropEl).backgroundColor : null
69601
+ }));
69602
+ }
69296
69603
  const cancelOpenInteractionSuppression = !silent && hasCssTransitionAnimation ? suppressPointerEventsDuringTransition(popoverEl) : null;
69297
69604
  // autoFocus={false}: opening moves no focus at all — whoever has the
69298
69605
  // keyboard keeps it (combobox-style suggestions under an input being
@@ -69709,6 +70016,10 @@ const css$C = /* css */`@layer navi {
69709
70016
  * identically): the wash the backdrop paints over what is behind.
69710
70017
  * @param {string} [props.backdropFilter] - Forwarded as-is: what that wash
69711
70018
  * does to the picture underneath, `"blur(4px)"` and the like.
70019
+ * @param {string|false} [props.themeColor] - Forwarded as-is: the colour the
70020
+ * browser paints above the page while the popup is open, worked out from
70021
+ * what it lays over the top edge when left out. A colour forces it, `false`
70022
+ * leaves the page's colour alone.
69712
70023
  * @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]
69713
70024
  * - Forwarded to whichever renders — see either component's own doc.
69714
70025
  * `"expanding"`/`"expand-*"` are `Popover`'s own; `"lifting"` and
@@ -70720,6 +71031,26 @@ const PickerCustom = props => {
70720
71031
  if (isWithinPickerContent(e.target)) {
70721
71032
  return null;
70722
71033
  }
71034
+ // Still open at the click of a later press: that press brought no
71035
+ // mousedown to close on (a drag source cancelled its pointerdown),
71036
+ // so it closes here, as it opens here in that case. A mousedown
71037
+ // that came has closed it already; the press that opened it is
71038
+ // only ending.
71039
+ if (openController.opened && !openedDuringThisPress(openController)) {
71040
+ return {
71041
+ name: "click to close picker",
71042
+ intent: "read",
71043
+ prevented: () => {
71044
+ e.preventDefault();
71045
+ },
71046
+ allowed: () => {
71047
+ requestClose(e, {
71048
+ isCancel: true
71049
+ });
71050
+ e.preventDefault();
71051
+ }
71052
+ };
71053
+ }
70723
71054
  if (!opensOnPress) {
70724
71055
  // Neither the tap, nor the click a hold leaves behind: the press
70725
71056
  // is not what opens this picker.
@@ -71375,12 +71706,12 @@ const isScrolling = (element) => {
71375
71706
  if (element === undefined) {
71376
71707
  return scrollActivitySignal.peek();
71377
71708
  }
71378
- return asScroller(element).hasAttribute("navi-scrolling");
71709
+ return asScroller$1(element).hasAttribute("navi-scrolling");
71379
71710
  };
71380
71711
 
71381
71712
  // The page scroll is dispatched on the document; the element carrying the
71382
71713
  // attribute is the one CSS can reach, document.scrollingElement.
71383
- const asScroller = (eventTargetOrElement) => {
71714
+ const asScroller$1 = (eventTargetOrElement) => {
71384
71715
  if (eventTargetOrElement === document || eventTargetOrElement === window) {
71385
71716
  return document.scrollingElement;
71386
71717
  }
@@ -71391,7 +71722,7 @@ const idleTimeoutMap = new Map();
71391
71722
  document.addEventListener(
71392
71723
  "scroll",
71393
71724
  (e) => {
71394
- const scroller = asScroller(e.target);
71725
+ const scroller = asScroller$1(e.target);
71395
71726
  const idleTimeout = idleTimeoutMap.get(scroller);
71396
71727
  if (idleTimeout !== undefined) {
71397
71728
  clearTimeout(idleTimeout);
@@ -71413,6 +71744,119 @@ document.addEventListener(
71413
71744
  { capture: true, passive: true },
71414
71745
  );
71415
71746
 
71747
+ /*
71748
+ * Whether writing a scroll position now would cost the user the scroll in
71749
+ * progress. A write is an instant scroll, and the browser abandons for it any
71750
+ * scroll it is playing on its own, short of where that one was going: a key
71751
+ * (Home, PageDown, Space), a smooth `scrollTo` or `scrollIntoView`, the status
71752
+ * bar tap on iOS, the fling a finger leaves behind once it lifts. A scroll
71753
+ * following a hand is not abandoned: the wheel's next turn carries on, and on
71754
+ * most engines a finger keeps dragging from wherever the write left it.
71755
+ *
71756
+ * Not on iOS. Its finger places the content from where the drag began, so a
71757
+ * write under it is undone at the finger's next move — or, when the finger
71758
+ * lifts first, lands after the lift and stops the fling dead. And a fling
71759
+ * stops on any write during its momentum, as the status bar's scroll to the
71760
+ * top does (Safari 26.5 on the simulator: flings of 1700px stopped 130 to 340px
71761
+ * after the lift, 3 in 3; a write 50ms before the lift stopped 2 flings in 3).
71762
+ * No finger there is a hand a write could hold to. Chrome flings on through a
71763
+ * write, and loses nothing by not getting one.
71764
+ *
71765
+ * A scroll is followed from its first "scroll" event to its "scrollend", and
71766
+ * is a hand's while a finger is down, or when a wheel turned around the time it
71767
+ * started or since. A finger is down from its touchstart to its touchend,
71768
+ * heard on what it touched: a list removes the items scrolled out of its window
71769
+ * while the finger is still on one of them, and the touchend of a removed
71770
+ * element never reaches the document.
71771
+ *
71772
+ * In a browser without "scrollend", no write is said to interrupt anything: a
71773
+ * scroll would never be seen ending.
71774
+ */
71775
+
71776
+ // The scroll a wheel turn causes starts in the frame the turn is read, or the
71777
+ // next one. A turn further from it moved nothing (a wheel at an edge) and is no
71778
+ // hand on a scroll that came from elsewhere.
71779
+ const WHEEL_REACH = 100;
71780
+ // iOS's touch handling, the one engine whose finger drops writes (see above).
71781
+ const FINGER_KEEPS_WRITES = !window.CSS.supports(
71782
+ "-webkit-touch-callout",
71783
+ "none",
71784
+ );
71785
+
71786
+ const flightMap = new WeakMap();
71787
+ let fingersDown = 0;
71788
+ let wheelTimeStamp = -Infinity;
71789
+
71790
+ /**
71791
+ * @param {Element} scrollerEl
71792
+ * @returns {boolean}
71793
+ */
71794
+ const wouldInterruptScroll = (scrollerEl) => {
71795
+ const flight = flightMap.get(scrollerEl);
71796
+ if (!flight) {
71797
+ return false;
71798
+ }
71799
+ if (fingersDown > 0) {
71800
+ return !FINGER_KEEPS_WRITES;
71801
+ }
71802
+ if (wheelTimeStamp > flight.timeStamp - WHEEL_REACH) {
71803
+ return false;
71804
+ }
71805
+ return true;
71806
+ };
71807
+
71808
+ // The page scroll is dispatched on the document; the scroller asked about is
71809
+ // document.scrollingElement.
71810
+ const asScroller = (eventTarget) => {
71811
+ if (eventTarget === document || eventTarget === window) {
71812
+ return document.scrollingElement;
71813
+ }
71814
+ return eventTarget;
71815
+ };
71816
+
71817
+ const onTouchEnd = (e) => {
71818
+ fingersDown = e.touches.length;
71819
+ };
71820
+
71821
+ if ("onscrollend" in window) {
71822
+ // Capturing: "scroll" and "scrollend" do not bubble from an element.
71823
+ document.addEventListener(
71824
+ "scroll",
71825
+ (e) => {
71826
+ const scroller = asScroller(e.target);
71827
+ if (flightMap.has(scroller)) {
71828
+ return;
71829
+ }
71830
+ flightMap.set(scroller, { timeStamp: e.timeStamp });
71831
+ },
71832
+ { capture: true, passive: true },
71833
+ );
71834
+ document.addEventListener(
71835
+ "scrollend",
71836
+ (e) => {
71837
+ flightMap.delete(asScroller(e.target));
71838
+ },
71839
+ { capture: true, passive: true },
71840
+ );
71841
+ window.addEventListener(
71842
+ "wheel",
71843
+ (e) => {
71844
+ wheelTimeStamp = e.timeStamp;
71845
+ },
71846
+ { capture: true, passive: true },
71847
+ );
71848
+ window.addEventListener(
71849
+ "touchstart",
71850
+ (e) => {
71851
+ fingersDown = e.touches.length;
71852
+ const touchedEl = e.composedPath()[0];
71853
+ touchedEl.addEventListener("touchend", onTouchEnd, { passive: true });
71854
+ touchedEl.addEventListener("touchcancel", onTouchEnd, { passive: true });
71855
+ },
71856
+ { capture: true, passive: true },
71857
+ );
71858
+ }
71859
+
71416
71860
  const LoadingDotsSvg = () => {
71417
71861
  return jsxs("svg", {
71418
71862
  viewBox: "0 0 200 200",
@@ -74498,7 +74942,9 @@ const useListScrollSync = ({
74498
74942
  const scrolledFallback = scrolled === undefined || scrolled === null ? "start" : defaultScrolled ?? "start";
74499
74943
  const startPlaceRef = useRef({
74500
74944
  userTookOver: false,
74501
- wanted: scrolledWanted
74945
+ wanted: scrolledWanted,
74946
+ way: null,
74947
+ arrived: false
74502
74948
  });
74503
74949
  if (startPlaceRef.current.wanted !== scrolledWanted) {
74504
74950
  startPlaceRef.current.wanted = scrolledWanted;
@@ -74709,8 +75155,10 @@ const useListScrollSync = ({
74709
75155
  // keeps moving while the list is still finding out how many items it has and
74710
75156
  // how tall one is, so landing there once would land next to it. What ends the
74711
75157
  // hold is the user reaching for the list — a wheel, a finger, a key, a hand
74712
- // on the scrollbar — and not the scroll event itself, which the list provokes
74713
- // as much as the user does.
75158
+ // on the scrollbar — or anything else moving it. A scroll event alone does
75159
+ // not say which: the list provokes as many as the user does, so only one
75160
+ // landing off the way of the list's own placement ends it (see
75161
+ // sendHeldScroll).
74714
75162
  const placeWhereHeld = () => {
74715
75163
  if (scrolledWanted === "start" || scrolledWanted === undefined || startPlaceRef.current.userTookOver || !ref.current) {
74716
75164
  return;
@@ -74759,12 +75207,7 @@ const useListScrollSync = ({
74759
75207
  }
74760
75208
  const scrollerEl = getScroller();
74761
75209
  if (openAt === "end") {
74762
- anchorRef.current = null;
74763
- if (horizontal) {
74764
- scrollerEl.scrollLeft = scrollerEl.scrollWidth;
74765
- } else {
74766
- scrollerEl.scrollTop = scrollerEl.scrollHeight;
74767
- }
75210
+ sendHeldScroll(horizontal ? scrollerEl.scrollWidth : scrollerEl.scrollHeight);
74768
75211
  return;
74769
75212
  }
74770
75213
  if (typeof openAt === "number") {
@@ -74788,15 +75231,45 @@ const useListScrollSync = ({
74788
75231
  const itemRect = itemEl.getBoundingClientRect();
74789
75232
  const offsetWanted = resolveOpenOffset(getItemScrollInset(scrollerEl, itemEl, horizontal) + offset, horizontal ? viewportRect.width : viewportRect.height, horizontal ? itemRect.width : itemRect.height);
74790
75233
  const offsetNow = horizontal ? itemRect.left - viewportRect.left : itemRect.top - viewportRect.top;
75234
+ const scrollNow = horizontal ? scrollerEl.scrollLeft : scrollerEl.scrollTop;
74791
75235
  const delta = offsetNow - offsetWanted;
74792
75236
  if (delta > -0.5 && delta < 0.5) {
75237
+ sendHeldScroll(scrollNow);
74793
75238
  return;
74794
75239
  }
75240
+ sendHeldScroll(scrollNow + delta);
75241
+ };
75242
+ // Every placement goes through here, the ones that find the list in place
75243
+ // too: it keeps the way from where the scroller was to where it is sent,
75244
+ // which is how a scroll event tells the hold's own scrolls from anyone
75245
+ // else's (see letGoUnlessHeldScroll). The whole way, not just its end: under
75246
+ // `scroll-behavior: smooth` the write glides, and every position on the way
75247
+ // is the list's until it gets there.
75248
+ const sendHeldScroll = position => {
75249
+ const scrollerEl = getScroller();
75250
+ const from = horizontal ? scrollerEl.scrollLeft : scrollerEl.scrollTop;
75251
+ const max = horizontal ? scrollerEl.scrollWidth - scrollerEl.clientWidth : scrollerEl.scrollHeight - scrollerEl.clientHeight;
75252
+ let to = position;
75253
+ if (to > max) {
75254
+ to = max;
75255
+ }
75256
+ if (to < 0) {
75257
+ to = 0;
75258
+ }
74795
75259
  anchorRef.current = null;
75260
+ const place = startPlaceRef.current;
75261
+ place.way = {
75262
+ from,
75263
+ to
75264
+ };
75265
+ if (to === from) {
75266
+ place.arrived = true;
75267
+ return;
75268
+ }
74796
75269
  if (horizontal) {
74797
- scrollerEl.scrollLeft += delta;
75270
+ scrollerEl.scrollLeft = to;
74798
75271
  } else {
74799
- scrollerEl.scrollTop += delta;
75272
+ scrollerEl.scrollTop = to;
74800
75273
  }
74801
75274
  };
74802
75275
  useLayoutEffect(placeWhereHeld);
@@ -74869,6 +75342,19 @@ const useListScrollSync = ({
74869
75342
  evaluateWindowRef.current("items resized");
74870
75343
  return false;
74871
75344
  };
75345
+ // The box around a held list changing shape is the hold's to answer too: it
75346
+ // places the list again, the way it writes every scroll of its own (see
75347
+ // sendHeldScroll) — written any other way, the scroll would read as someone
75348
+ // else moving the list, and end the hold.
75349
+ const placeIfHeldRef = useRef(null);
75350
+ placeIfHeldRef.current = () => {
75351
+ // Read again: a scroll may have ended the hold since this render.
75352
+ if (!heldSomewhere || startPlaceRef.current.userTookOver) {
75353
+ return false;
75354
+ }
75355
+ placeWhereHeld();
75356
+ return true;
75357
+ };
74872
75358
  useLayoutEffect(() => {
74873
75359
  if (scrolledWanted === "start" || scrolledWanted === undefined || !ref.current) {
74874
75360
  return undefined;
@@ -75013,6 +75499,9 @@ const useListScrollSync = ({
75013
75499
  if (!entries.some(entry => entry.target === scrollerEl)) {
75014
75500
  return;
75015
75501
  }
75502
+ if (placeIfHeldRef.current()) {
75503
+ return;
75504
+ }
75016
75505
  const position = positionRef.current;
75017
75506
  if (!position) {
75018
75507
  return;
@@ -75028,6 +75517,13 @@ const useListScrollSync = ({
75028
75517
  if (delta > -0.5 && delta < 0.5) {
75029
75518
  return;
75030
75519
  }
75520
+ if (wouldInterruptScroll(scrollerEl)) {
75521
+ // As the anchoring's write would (see holdAnchorStill). The document's
75522
+ // box is the scroller of a list scrolling the page, and it resizes with
75523
+ // every item drawn taller than its filler: while the page scrolls, that
75524
+ // is at every window move.
75525
+ return;
75526
+ }
75031
75527
  anchorRef.current = null;
75032
75528
  if (horizontal) {
75033
75529
  scrollerEl.scrollLeft += delta;
@@ -75125,6 +75621,14 @@ const useListScrollSync = ({
75125
75621
  return;
75126
75622
  }
75127
75623
  anchorRef.current = null;
75624
+ if (wouldInterruptScroll(scrollerEl)) {
75625
+ // Nobody is looking at items standing still: they are passing by, on
75626
+ // their way somewhere the browser takes them (a key, a smooth scrollTo,
75627
+ // the status bar, a fling). The write would stop that scroll short of
75628
+ // where it was going. Left alone, the items drawn above push the rest by
75629
+ // `drift` while all of it moves, and the scroll lands.
75630
+ return;
75631
+ }
75128
75632
  scrolledByListRef.current = true;
75129
75633
  if (horizontal) {
75130
75634
  scrollerEl.scrollLeft += drift;
@@ -75461,9 +75965,44 @@ const useListScrollSync = ({
75461
75965
  reportPosition();
75462
75966
  return;
75463
75967
  }
75968
+ letGoUnlessHeldScroll();
75464
75969
  reportPosition();
75465
75970
  evaluateWindowRef.current("scroll");
75466
75971
  };
75972
+ // A scroll the hold did not write is someone else moving the list, which
75973
+ // ends the hold like a wheel does — the ones that come with no input event
75974
+ // at all: the status bar tap on iOS, find in page, a script.
75975
+ // Only once the list has stood where it holds itself, though. Until then
75976
+ // the page is still settling around it, and some of that is written after
75977
+ // the list placed itself: a navigation holding the rendering for its
75978
+ // transition takes the arriving page to its top once the page rendered
75979
+ // (whenRenderingResumes), and the hold puts it back at its next commit.
75980
+ const letGoUnlessHeldScroll = () => {
75981
+ const place = startPlaceRef.current;
75982
+ const way = place.way;
75983
+ if (place.userTookOver || place.wanted === "start" || place.wanted === undefined || !way) {
75984
+ return;
75985
+ }
75986
+ const position = horizontal ? scrollerEl.scrollLeft : scrollerEl.scrollTop;
75987
+ if (position > way.to - 1 && position < way.to + 1) {
75988
+ // From then on only that place is the list's, or a scroll back the
75989
+ // way it came would pass for its own.
75990
+ place.way = {
75991
+ from: way.to,
75992
+ to: way.to
75993
+ };
75994
+ place.arrived = true;
75995
+ return;
75996
+ }
75997
+ if (!place.arrived) {
75998
+ return;
75999
+ }
76000
+ const low = way.from < way.to ? way.from : way.to;
76001
+ const high = way.from < way.to ? way.to : way.from;
76002
+ if (position < low - 1 || position > high + 1) {
76003
+ place.userTookOver = true;
76004
+ }
76005
+ };
75467
76006
  // A page-level scroller does not emit "scroll" on the element itself
75468
76007
  // (document.scrollingElement); the document does.
75469
76008
  const scrollEventTarget = scrollerEl === document.scrollingElement ? document : scrollerEl;
@@ -78949,7 +79488,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
78949
79488
  *
78950
79489
  * In every form the list holds itself there while it is still finding out
78951
79490
  * how many items there are and how tall one is, and lets go the moment the
78952
- * user reaches for the list.
79491
+ * user reaches for the list, or anything else scrolls it (a script, find in
79492
+ * page, the status bar tap on iOS).
78953
79493
  * @param {(scrolled: {id: string, index: number, offset: number, visibleCount: number}) => void} [props.onScrolledChange]
78954
79494
  * Where the list is, as the user scrolls: the item at the top of the view and
78955
79495
  * how far below the place an item lands on its own (see `defaultScrolled`) it