@jsenv/navi 0.29.431 → 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, 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";
@@ -11669,13 +11669,22 @@ const installScrollRestoration = () => {
11669
11669
  },
11670
11670
  { passive: true },
11671
11671
  );
11672
+ const positionOnLoad = positionByUrl.get(window.location.href);
11673
+ if (!positionOnLoad) {
11674
+ // Where the browser landed the first page, written down for the reason
11675
+ // startAtTop writes its top: no scroll event says so.
11676
+ positionByUrl.set(window.location.href, {
11677
+ x: window.scrollX,
11678
+ y: window.scrollY,
11679
+ });
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(() => {
@@ -11761,6 +11770,11 @@ const startAtTop = (url, { from } = {}) => {
11761
11770
  return;
11762
11771
  }
11763
11772
  arrivalCount++;
11773
+ // Written down as well as done: a document already at its top fires no
11774
+ // scroll event, and a page left without ever being scrolled would have no
11775
+ // position to come back to — the return would keep the offset of the page
11776
+ // being left.
11777
+ positionByUrl.set(new URL(url, window.location.href).href, { x: 0, y: 0 });
11764
11778
  window.scrollTo({ top: 0, left: 0, behavior: "instant" });
11765
11779
  };
11766
11780
  const isArrival = (url, { from }) => {
@@ -16989,7 +17003,7 @@ const renderIntoCallout = (jsx$1, calloutMessageElement, {
16989
17003
  },
16990
17004
  children: jsx$1
16991
17005
  });
16992
- render(calloutJsx, calloutMessageElement);
17006
+ render$1(calloutJsx, calloutMessageElement);
16993
17007
  };
16994
17008
 
16995
17009
  // An HTML message is rendered through preact rather than innerHTML: what a
@@ -17012,7 +17026,7 @@ const renderHtmlIntoCallout = (html, calloutMessageElement, {
17012
17026
  // (a DOM node, an iframe); innerHTML alone would leave preact believing its
17013
17027
  // tree is still there.
17014
17028
  const clearCalloutMessage = calloutMessageElement => {
17015
- render(null, calloutMessageElement);
17029
+ render$1(null, calloutMessageElement);
17016
17030
  calloutMessageElement.innerHTML = "";
17017
17031
  };
17018
17032
  const domToVNodes = node => {
@@ -33210,10 +33224,222 @@ const useUrlSearchParam = (paramName, defaultValue) => {
33210
33224
  return [value, setSearchParamValue];
33211
33225
  };
33212
33226
 
33227
+ /**
33228
+ * The colour the browser paints around the page — Chrome's address bar on
33229
+ * Android, the status bar of an installed app — read from
33230
+ * `<meta name="theme-color">`. It sits right above the page's top edge, so it
33231
+ * reads as part of whatever is drawn there, and two layers decide it:
33232
+ *
33233
+ * - What the page asks for: the theme-color metas a `<Head>` brings. The last
33234
+ * Head to arrive wins, whatever order they leave in — the title's rule, for
33235
+ * the title's reason (see head.jsx).
33236
+ * - What the top layer lays over that edge: an open popup paints its backdrop
33237
+ * over what is under it, and its own surface too when it is flush with the
33238
+ * top edge. Popups sit above every Head, in opening order, and are painted
33239
+ * again from what is under them each time it changes — a page changing
33240
+ * under an open popup is dimmed by it all the same.
33241
+ *
33242
+ * The browser reads the first theme-color meta whose `media` matches, in tree
33243
+ * order: a Head appending its meta after an earlier one would never be read.
33244
+ * So navi writes the result into metas of its own placed before any other, and
33245
+ * a meta written in the HTML is the bottom of the stack. A popup paints over
33246
+ * every meta of the list, `media` kept, so whichever one the browser picks is
33247
+ * the one it would have picked, painted over.
33248
+ */
33249
+
33250
+ const headEntries = [];
33251
+ const popupLayers = [];
33252
+ const ownMetas = [];
33253
+
33254
+ /**
33255
+ * @param {Array<{ content: string, media: string|null }>} themeColors
33256
+ * @returns {() => void} Takes them away.
33257
+ */
33258
+ const pushHeadThemeColors = (themeColors) => {
33259
+ headEntries.push(themeColors);
33260
+ queueRender();
33261
+ return () => {
33262
+ headEntries.splice(headEntries.indexOf(themeColors), 1);
33263
+ queueRender();
33264
+ };
33265
+ };
33266
+
33267
+ /**
33268
+ * @param {object} paint
33269
+ * @param {string} [paint.themeColor] - Forces the colour, nothing is painted.
33270
+ * @param {string|null} [paint.backdropColor] - Laid over the page's colour.
33271
+ * @param {string|null} [paint.surfaceColor] - Laid over that, for a popup
33272
+ * flush with the top edge.
33273
+ * @returns {() => void} Takes the layer away.
33274
+ */
33275
+ const pushPopupThemeColor = ({
33276
+ themeColor,
33277
+ backdropColor,
33278
+ surfaceColor,
33279
+ }) => {
33280
+ let layer;
33281
+ if (themeColor) {
33282
+ layer = { color: themeColor };
33283
+ } else {
33284
+ const paints = [];
33285
+ for (const color of [backdropColor, surfaceColor]) {
33286
+ if (!color) {
33287
+ continue;
33288
+ }
33289
+ const rgba = parseColor(color);
33290
+ if (rgba[3] > 0) {
33291
+ paints.push(rgba);
33292
+ }
33293
+ }
33294
+ // Nothing laid over the edge: the colour under it stays, as it is.
33295
+ if (paints.length === 0) {
33296
+ return () => {};
33297
+ }
33298
+ layer = { paints };
33299
+ }
33300
+ popupLayers.push(layer);
33301
+ queueRender();
33302
+ return () => {
33303
+ popupLayers.splice(popupLayers.indexOf(layer), 1);
33304
+ queueRender();
33305
+ };
33306
+ };
33307
+
33308
+ let renderQueued = false;
33309
+ // Once per commit: a Head re-rendering leaves and arrives again in the same
33310
+ // commit, and the browser must not be handed the colour from in between.
33311
+ const queueRender = () => {
33312
+ if (renderQueued) {
33313
+ return;
33314
+ }
33315
+ renderQueued = true;
33316
+ queueMicrotask(render);
33317
+ };
33318
+
33319
+ const render = () => {
33320
+ renderQueued = false;
33321
+ const htmlMetas = Array.from(
33322
+ document.head.querySelectorAll('meta[name="theme-color"]'),
33323
+ ).filter((meta) => !ownMetas.includes(meta));
33324
+ const htmlColors = htmlMetas.map((meta) => ({
33325
+ content: meta.getAttribute("content"),
33326
+ media: meta.getAttribute("media"),
33327
+ }));
33328
+ let colors = [];
33329
+ for (let i = headEntries.length - 1; i >= 0; i--) {
33330
+ colors.push(...headEntries[i]);
33331
+ }
33332
+ colors.push(...htmlColors);
33333
+ for (const layer of popupLayers) {
33334
+ colors = paintLayer(layer, colors);
33335
+ }
33336
+ // What the HTML metas say reaches the browser without a copy.
33337
+ while (colors.length > 0 && htmlColors.includes(colors[colors.length - 1])) {
33338
+ colors.pop();
33339
+ }
33340
+
33341
+ while (ownMetas.length > colors.length) {
33342
+ ownMetas.pop().remove();
33343
+ }
33344
+ colors.forEach(({ content, media }, index) => {
33345
+ let meta = ownMetas[index];
33346
+ if (!meta) {
33347
+ meta = document.createElement("meta");
33348
+ meta.setAttribute("name", "theme-color");
33349
+ const previousMeta = ownMetas[index - 1];
33350
+ if (previousMeta) {
33351
+ previousMeta.after(meta);
33352
+ } else if (htmlMetas.length > 0) {
33353
+ htmlMetas[0].before(meta);
33354
+ } else {
33355
+ document.head.append(meta);
33356
+ }
33357
+ ownMetas.push(meta);
33358
+ }
33359
+ if (meta.getAttribute("content") !== content) {
33360
+ meta.setAttribute("content", content);
33361
+ }
33362
+ if (meta.getAttribute("media") !== media) {
33363
+ if (media === null) {
33364
+ meta.removeAttribute("media");
33365
+ } else {
33366
+ meta.setAttribute("media", media);
33367
+ }
33368
+ }
33369
+ });
33370
+ };
33371
+
33372
+ const paintLayer = (layer, colors) => {
33373
+ if (layer.color) {
33374
+ return [{ content: layer.color, media: null }];
33375
+ }
33376
+ const underColors =
33377
+ colors.length > 0 ? colors : [{ content: null, media: null }];
33378
+ const painted = [];
33379
+ for (const { content, media } of underColors) {
33380
+ let rgba = content === null ? null : parseUnderColor(content);
33381
+ for (const paint of layer.paints) {
33382
+ rgba = rgba ? composite(paint, rgba) : paint;
33383
+ }
33384
+ // Over no colour at all the browser keeps one of its own, which a
33385
+ // translucent paint cannot be laid over.
33386
+ if (Math.round(rgba[3] * 255) === 255) {
33387
+ painted.push({ content: toHex(rgba), media });
33388
+ }
33389
+ }
33390
+ return painted;
33391
+ };
33392
+
33393
+ // The browser shows a theme-color opaque, whatever alpha it was written with.
33394
+ const parseUnderColor = (color) => {
33395
+ const rgba = parseColor(color);
33396
+ if (rgba[3] === 0) {
33397
+ return null;
33398
+ }
33399
+ return [rgba[0], rgba[1], rgba[2], 1];
33400
+ };
33401
+
33402
+ // Source-over, the way the backdrop is drawn over the page.
33403
+ const composite = (over, under) => {
33404
+ const [r1, g1, b1, a1] = over;
33405
+ const [r2, g2, b2, a2] = under;
33406
+ const alpha = a1 + a2 * (1 - a1);
33407
+ const mix = (c1, c2) => (c1 * a1 + c2 * a2 * (1 - a1)) / alpha;
33408
+ return [mix(r1, r2), mix(g1, g2), mix(b1, b2), alpha];
33409
+ };
33410
+
33411
+ const toHex = ([r, g, b]) =>
33412
+ `#${[r, g, b]
33413
+ .map((channel) => Math.round(channel).toString(16).padStart(2, "0"))
33414
+ .join("")}`;
33415
+
33416
+ // A canvas reads every colour syntax the browser knows — oklch(), color(),
33417
+ // a keyword — and hands back sRGB bytes, which is what a theme-color meta is
33418
+ // sure to be understood in, whatever getComputedStyle answered with.
33419
+ let colorContext = null;
33420
+ const parseColor = (color) => {
33421
+ if (!colorContext) {
33422
+ const canvas = document.createElement("canvas");
33423
+ canvas.width = 1;
33424
+ canvas.height = 1;
33425
+ colorContext = canvas.getContext("2d", { willReadFrequently: true });
33426
+ }
33427
+ colorContext.clearRect(0, 0, 1, 1);
33428
+ // fillStyle ignores a colour it cannot parse: reset first, so that one draws
33429
+ // nothing rather than the previous colour.
33430
+ colorContext.fillStyle = "transparent";
33431
+ colorContext.fillStyle = color;
33432
+ colorContext.fillRect(0, 0, 1, 1);
33433
+ const [r, g, b, a] = colorContext.getImageData(0, 0, 1, 1).data;
33434
+ return [r, g, b, a / 255];
33435
+ };
33436
+
33213
33437
  // The title belongs to the last Head to arrive, whatever order they leave in:
33214
33438
  // the page being left by a route transition is taken down AFTER the page
33215
33439
  // 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.
33440
+ // title it found would put its own page's title over the new one. A
33441
+ // theme-color meta follows the same rule, kept by theme_color.js, where the
33442
+ // popups of the top layer paint over it.
33217
33443
  const titleEntries = [];
33218
33444
  let titleWithoutHead = null;
33219
33445
  const Head = ({
@@ -33225,6 +33451,7 @@ const Head = ({
33225
33451
  }
33226
33452
  const childArray = Array.isArray(children) ? children : [children];
33227
33453
  const appendedElements = [];
33454
+ const themeColors = [];
33228
33455
  let titleEntry = null;
33229
33456
  for (const child of childArray) {
33230
33457
  if (!child) {
@@ -33237,6 +33464,13 @@ const Head = ({
33237
33464
  };
33238
33465
  continue;
33239
33466
  }
33467
+ if (child.type === "meta" && child.props.name === "theme-color") {
33468
+ themeColors.push({
33469
+ content: child.props.content,
33470
+ media: child.props.media ?? null
33471
+ });
33472
+ continue;
33473
+ }
33240
33474
  const el = document.createElement(child.type);
33241
33475
  const props = child.props || {};
33242
33476
  for (const [key, value] of Object.entries(props)) {
@@ -33252,7 +33486,9 @@ const Head = ({
33252
33486
  titleEntries.push(titleEntry);
33253
33487
  document.title = titleEntry.title;
33254
33488
  }
33489
+ const removeThemeColors = themeColors.length > 0 ? pushHeadThemeColors(themeColors) : null;
33255
33490
  return () => {
33491
+ removeThemeColors?.();
33256
33492
  if (titleEntry) {
33257
33493
  titleEntries.splice(titleEntries.indexOf(titleEntry), 1);
33258
33494
  const lastEntry = titleEntries[titleEntries.length - 1];
@@ -65885,9 +66121,17 @@ const css$E = /* css */`
65885
66121
  on its own, sharp, above the wall: any wall on that frame — even a
65886
66122
  half-strength wash and blur — is a flash on the anchor. So the wall
65887
66123
  arrives from nothing, over the movement: on that frame the page is
65888
- untouched, and the anchor with it. */
66124
+ untouched, and the anchor with it.
66125
+ Keyed on the aria-expanded flip, like the local wall: openEffect reads
66126
+ the layout while the transitions are off, which spends @starting-style
66127
+ with nothing armed. @starting-style stays for an opening that reads
66128
+ nothing before the flip. A silent opening flips before the transitions
66129
+ come back, hence the :not(): its wall lands at once. */
65889
66130
  &[data-lifting] {
65890
- &::backdrop {
66131
+ &[aria-expanded="false"]::backdrop {
66132
+ opacity: 0;
66133
+ }
66134
+ &[aria-expanded="true"]:not([navi-transitions-off])::backdrop {
65891
66135
  opacity: 1;
65892
66136
  transition-property: opacity;
65893
66137
  transition-duration: var(--navi-popup-lift-duration, 0.25s);
@@ -66347,6 +66591,16 @@ const css$E = /* css */`
66347
66591
  * `saturate()`/`grayscale()` work too. How far what is behind withdraws is
66348
66592
  * a question of paint, not of what an outside click does — a backdrop that
66349
66593
  * closes can blur.
66594
+ * @param {string|false} [props.themeColor] - The colour the browser paints
66595
+ * above the page (`<meta name="theme-color">`: Chrome's address bar on
66596
+ * Android, an installed app's status bar) while this dialog is open.
66597
+ * Left out, it is what the dialog lays over the top edge of the screen: its
66598
+ * backdrop over the colour the page asked for (`<Head>`), and its own
66599
+ * background over that when it is flush with the top edge
66600
+ * (`marginWithContainer={0}` with `expand`/`expandY` or a top
66601
+ * `positionArea`). A colour forces it, for a surface whose colour cannot be
66602
+ * read (an image, a gradient); `false` leaves the page's colour alone.
66603
+ * `layer="top"` only.
66350
66604
  * @param {boolean} [props.scrollCapture] - Traps scroll gestures inside the
66351
66605
  * dialog so the page/container behind it can't scroll while it's open.
66352
66606
  * A `layer="local"` dialog always locks its own positioned ancestor's
@@ -66870,6 +67124,9 @@ const useDialogProps = props => {
66870
67124
  // so both objects below get them (see this file's CSS).
66871
67125
  backdropColor,
66872
67126
  backdropFilter,
67127
+ // Read at the opening, with the backdrop and the surface it would
67128
+ // otherwise be worked out from (see openEffect).
67129
+ themeColor,
66873
67130
  scrollCapture: scrollCaptureProp,
66874
67131
  // "auto" (default) → the dialog follows its content. "frozen" → measured
66875
67132
  // once, held at that size while open. See this prop's own JSDoc above.
@@ -67510,6 +67767,16 @@ const useDialogProps = props => {
67510
67767
  backdropEl.style.transitionProperty = "";
67511
67768
  }
67512
67769
  }
67770
+ // What the browser paints above the page goes with what is laid over its
67771
+ // top edge, from the start of the opening to the start of the closing: a
67772
+ // local dialog lays nothing there.
67773
+ if (isTopLayer && themeColor !== false) {
67774
+ addCleanup(pushPopupThemeColor({
67775
+ themeColor,
67776
+ backdropColor: backdrop ? getComputedStyle(dialogEl, "::backdrop").backgroundColor : null,
67777
+ surfaceColor: flushEdges.top ? getComputedStyle(dialogEl).backgroundColor : null
67778
+ }));
67779
+ }
67513
67780
  const cancelOpenInteractionSuppression = !silent && hasCssTransitionAnimation ? suppressPointerEventsDuringTransition(dialogEl) : null;
67514
67781
  // Handing the focus to a field is what raises the on-screen keyboard, and
67515
67782
  // the keyboard takes away the very room this dialog was just placed
@@ -68339,6 +68606,12 @@ const css$D = /* css */`
68339
68606
  * `saturate()`/`grayscale()` work too. How far what is behind withdraws is
68340
68607
  * a question of paint, not of what an outside click does — a backdrop that
68341
68608
  * closes can blur.
68609
+ * @param {string|false} [props.themeColor] - The colour the browser paints
68610
+ * above the page (`<meta name="theme-color">`: Chrome's address bar on
68611
+ * Android, an installed app's status bar) while this popover is open.
68612
+ * Left out, it is the backdrop laid over the colour the page asked for
68613
+ * (`<Head>`), and the page's colour when there is no backdrop. A colour
68614
+ * forces it; `false` leaves the page's colour alone. `layer="top"` only.
68342
68615
  * @param {boolean} [props.scrollCapture] - Traps scroll gestures inside the
68343
68616
  * popover so the page/container behind it can't scroll while it's open.
68344
68617
  * @param {boolean} [props.focusCapture] - Traps Tab navigation inside the
@@ -68683,6 +68956,9 @@ const usePopoverProps = props => {
68683
68956
  // which is a sibling element (see this file's top comment).
68684
68957
  backdropColor,
68685
68958
  backdropFilter,
68959
+ // Read at the opening, with the backdrop it would otherwise be worked out
68960
+ // from (see openEffect).
68961
+ themeColor,
68686
68962
  group,
68687
68963
  scrollCapture,
68688
68964
  focusCapture,
@@ -69293,6 +69569,15 @@ const usePopoverProps = props => {
69293
69569
  backdropEl.style.transitionProperty = "";
69294
69570
  }
69295
69571
  }
69572
+ // What the browser paints above the page goes with what is laid over its
69573
+ // top edge, from the start of the opening to the start of the closing: a
69574
+ // local popover's backdrop stops at its container.
69575
+ if (isTopLayer && themeColor !== false) {
69576
+ addCleanup(pushPopupThemeColor({
69577
+ themeColor,
69578
+ backdropColor: backdropEl ? getComputedStyle(backdropEl).backgroundColor : null
69579
+ }));
69580
+ }
69296
69581
  const cancelOpenInteractionSuppression = !silent && hasCssTransitionAnimation ? suppressPointerEventsDuringTransition(popoverEl) : null;
69297
69582
  // autoFocus={false}: opening moves no focus at all — whoever has the
69298
69583
  // keyboard keeps it (combobox-style suggestions under an input being
@@ -69709,6 +69994,10 @@ const css$C = /* css */`@layer navi {
69709
69994
  * identically): the wash the backdrop paints over what is behind.
69710
69995
  * @param {string} [props.backdropFilter] - Forwarded as-is: what that wash
69711
69996
  * does to the picture underneath, `"blur(4px)"` and the like.
69997
+ * @param {string|false} [props.themeColor] - Forwarded as-is: the colour the
69998
+ * browser paints above the page while the popup is open, worked out from
69999
+ * what it lays over the top edge when left out. A colour forces it, `false`
70000
+ * leaves the page's colour alone.
69712
70001
  * @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
70002
  * - Forwarded to whichever renders — see either component's own doc.
69714
70003
  * `"expanding"`/`"expand-*"` are `Popover`'s own; `"lifting"` and
@@ -70720,6 +71009,26 @@ const PickerCustom = props => {
70720
71009
  if (isWithinPickerContent(e.target)) {
70721
71010
  return null;
70722
71011
  }
71012
+ // Still open at the click of a later press: that press brought no
71013
+ // mousedown to close on (a drag source cancelled its pointerdown),
71014
+ // so it closes here, as it opens here in that case. A mousedown
71015
+ // that came has closed it already; the press that opened it is
71016
+ // only ending.
71017
+ if (openController.opened && !openedDuringThisPress(openController)) {
71018
+ return {
71019
+ name: "click to close picker",
71020
+ intent: "read",
71021
+ prevented: () => {
71022
+ e.preventDefault();
71023
+ },
71024
+ allowed: () => {
71025
+ requestClose(e, {
71026
+ isCancel: true
71027
+ });
71028
+ e.preventDefault();
71029
+ }
71030
+ };
71031
+ }
70723
71032
  if (!opensOnPress) {
70724
71033
  // Neither the tap, nor the click a hold leaves behind: the press
70725
71034
  // is not what opens this picker.
@@ -71375,12 +71684,12 @@ const isScrolling = (element) => {
71375
71684
  if (element === undefined) {
71376
71685
  return scrollActivitySignal.peek();
71377
71686
  }
71378
- return asScroller(element).hasAttribute("navi-scrolling");
71687
+ return asScroller$1(element).hasAttribute("navi-scrolling");
71379
71688
  };
71380
71689
 
71381
71690
  // The page scroll is dispatched on the document; the element carrying the
71382
71691
  // attribute is the one CSS can reach, document.scrollingElement.
71383
- const asScroller = (eventTargetOrElement) => {
71692
+ const asScroller$1 = (eventTargetOrElement) => {
71384
71693
  if (eventTargetOrElement === document || eventTargetOrElement === window) {
71385
71694
  return document.scrollingElement;
71386
71695
  }
@@ -71391,7 +71700,7 @@ const idleTimeoutMap = new Map();
71391
71700
  document.addEventListener(
71392
71701
  "scroll",
71393
71702
  (e) => {
71394
- const scroller = asScroller(e.target);
71703
+ const scroller = asScroller$1(e.target);
71395
71704
  const idleTimeout = idleTimeoutMap.get(scroller);
71396
71705
  if (idleTimeout !== undefined) {
71397
71706
  clearTimeout(idleTimeout);
@@ -71413,6 +71722,111 @@ document.addEventListener(
71413
71722
  { capture: true, passive: true },
71414
71723
  );
71415
71724
 
71725
+ /*
71726
+ * Whether a scroller is gliding: moving on its own toward a place it was sent
71727
+ * to, with no hand on it — a key (Home, PageDown, Space), a smooth `scrollTo`
71728
+ * or `scrollIntoView`, the status bar tap on iOS. The other scrolls follow a
71729
+ * hand: a finger on the screen, a wheel turning.
71730
+ *
71731
+ * It matters to whoever writes a scroll position while one plays: the write is
71732
+ * an instant scroll, and the browser abandons the glide for it, short of where
71733
+ * it was going. A hand is not abandoned: its next report moves the scroller on
71734
+ * from wherever the write left it.
71735
+ *
71736
+ * A scroll is followed from its first "scroll" event to its "scrollend". It is
71737
+ * a hand's when a finger was down as it started (the fling a finger leaves
71738
+ * behind is still its own), when one is down now, or when a wheel turned
71739
+ * around the time it started or since. A finger is down from its
71740
+ * touchstart to its touchend, heard on what it touched: a list removes the
71741
+ * items scrolled out of its window while the finger is still on one of them,
71742
+ * and the touchend of a removed element never reaches the document.
71743
+ *
71744
+ * In a browser without "scrollend", nothing is said to glide: a scroll would
71745
+ * never be seen ending.
71746
+ */
71747
+
71748
+ // The scroll a wheel turn causes starts in the frame the turn is read, or the
71749
+ // next one. A turn further from it moved nothing (a wheel at an edge) and is no
71750
+ // hand on a scroll that came from elsewhere.
71751
+ const WHEEL_REACH = 100;
71752
+
71753
+ const flightMap = new WeakMap();
71754
+ let fingersDown = 0;
71755
+ let wheelTimeStamp = -Infinity;
71756
+
71757
+ /**
71758
+ * @param {Element} scrollerEl
71759
+ * @returns {boolean}
71760
+ */
71761
+ const isScrollGliding = (scrollerEl) => {
71762
+ const flight = flightMap.get(scrollerEl);
71763
+ if (!flight) {
71764
+ return false;
71765
+ }
71766
+ if (flight.fingerDown || fingersDown > 0) {
71767
+ return false;
71768
+ }
71769
+ if (wheelTimeStamp > flight.timeStamp - WHEEL_REACH) {
71770
+ return false;
71771
+ }
71772
+ return true;
71773
+ };
71774
+
71775
+ // The page scroll is dispatched on the document; the scroller asked about is
71776
+ // document.scrollingElement.
71777
+ const asScroller = (eventTarget) => {
71778
+ if (eventTarget === document || eventTarget === window) {
71779
+ return document.scrollingElement;
71780
+ }
71781
+ return eventTarget;
71782
+ };
71783
+
71784
+ const onTouchEnd = (e) => {
71785
+ fingersDown = e.touches.length;
71786
+ };
71787
+
71788
+ if ("onscrollend" in window) {
71789
+ // Capturing: "scroll" and "scrollend" do not bubble from an element.
71790
+ document.addEventListener(
71791
+ "scroll",
71792
+ (e) => {
71793
+ const scroller = asScroller(e.target);
71794
+ if (flightMap.has(scroller)) {
71795
+ return;
71796
+ }
71797
+ flightMap.set(scroller, {
71798
+ timeStamp: e.timeStamp,
71799
+ fingerDown: fingersDown > 0,
71800
+ });
71801
+ },
71802
+ { capture: true, passive: true },
71803
+ );
71804
+ document.addEventListener(
71805
+ "scrollend",
71806
+ (e) => {
71807
+ flightMap.delete(asScroller(e.target));
71808
+ },
71809
+ { capture: true, passive: true },
71810
+ );
71811
+ window.addEventListener(
71812
+ "wheel",
71813
+ (e) => {
71814
+ wheelTimeStamp = e.timeStamp;
71815
+ },
71816
+ { capture: true, passive: true },
71817
+ );
71818
+ window.addEventListener(
71819
+ "touchstart",
71820
+ (e) => {
71821
+ fingersDown = e.touches.length;
71822
+ const touchedEl = e.composedPath()[0];
71823
+ touchedEl.addEventListener("touchend", onTouchEnd, { passive: true });
71824
+ touchedEl.addEventListener("touchcancel", onTouchEnd, { passive: true });
71825
+ },
71826
+ { capture: true, passive: true },
71827
+ );
71828
+ }
71829
+
71416
71830
  const LoadingDotsSvg = () => {
71417
71831
  return jsxs("svg", {
71418
71832
  viewBox: "0 0 200 200",
@@ -74498,7 +74912,9 @@ const useListScrollSync = ({
74498
74912
  const scrolledFallback = scrolled === undefined || scrolled === null ? "start" : defaultScrolled ?? "start";
74499
74913
  const startPlaceRef = useRef({
74500
74914
  userTookOver: false,
74501
- wanted: scrolledWanted
74915
+ wanted: scrolledWanted,
74916
+ way: null,
74917
+ arrived: false
74502
74918
  });
74503
74919
  if (startPlaceRef.current.wanted !== scrolledWanted) {
74504
74920
  startPlaceRef.current.wanted = scrolledWanted;
@@ -74709,8 +75125,10 @@ const useListScrollSync = ({
74709
75125
  // keeps moving while the list is still finding out how many items it has and
74710
75126
  // how tall one is, so landing there once would land next to it. What ends the
74711
75127
  // 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.
75128
+ // on the scrollbar — or anything else moving it. A scroll event alone does
75129
+ // not say which: the list provokes as many as the user does, so only one
75130
+ // landing off the way of the list's own placement ends it (see
75131
+ // sendHeldScroll).
74714
75132
  const placeWhereHeld = () => {
74715
75133
  if (scrolledWanted === "start" || scrolledWanted === undefined || startPlaceRef.current.userTookOver || !ref.current) {
74716
75134
  return;
@@ -74759,12 +75177,7 @@ const useListScrollSync = ({
74759
75177
  }
74760
75178
  const scrollerEl = getScroller();
74761
75179
  if (openAt === "end") {
74762
- anchorRef.current = null;
74763
- if (horizontal) {
74764
- scrollerEl.scrollLeft = scrollerEl.scrollWidth;
74765
- } else {
74766
- scrollerEl.scrollTop = scrollerEl.scrollHeight;
74767
- }
75180
+ sendHeldScroll(horizontal ? scrollerEl.scrollWidth : scrollerEl.scrollHeight);
74768
75181
  return;
74769
75182
  }
74770
75183
  if (typeof openAt === "number") {
@@ -74788,15 +75201,45 @@ const useListScrollSync = ({
74788
75201
  const itemRect = itemEl.getBoundingClientRect();
74789
75202
  const offsetWanted = resolveOpenOffset(getItemScrollInset(scrollerEl, itemEl, horizontal) + offset, horizontal ? viewportRect.width : viewportRect.height, horizontal ? itemRect.width : itemRect.height);
74790
75203
  const offsetNow = horizontal ? itemRect.left - viewportRect.left : itemRect.top - viewportRect.top;
75204
+ const scrollNow = horizontal ? scrollerEl.scrollLeft : scrollerEl.scrollTop;
74791
75205
  const delta = offsetNow - offsetWanted;
74792
75206
  if (delta > -0.5 && delta < 0.5) {
75207
+ sendHeldScroll(scrollNow);
74793
75208
  return;
74794
75209
  }
75210
+ sendHeldScroll(scrollNow + delta);
75211
+ };
75212
+ // Every placement goes through here, the ones that find the list in place
75213
+ // too: it keeps the way from where the scroller was to where it is sent,
75214
+ // which is how a scroll event tells the hold's own scrolls from anyone
75215
+ // else's (see letGoUnlessHeldScroll). The whole way, not just its end: under
75216
+ // `scroll-behavior: smooth` the write glides, and every position on the way
75217
+ // is the list's until it gets there.
75218
+ const sendHeldScroll = position => {
75219
+ const scrollerEl = getScroller();
75220
+ const from = horizontal ? scrollerEl.scrollLeft : scrollerEl.scrollTop;
75221
+ const max = horizontal ? scrollerEl.scrollWidth - scrollerEl.clientWidth : scrollerEl.scrollHeight - scrollerEl.clientHeight;
75222
+ let to = position;
75223
+ if (to > max) {
75224
+ to = max;
75225
+ }
75226
+ if (to < 0) {
75227
+ to = 0;
75228
+ }
74795
75229
  anchorRef.current = null;
75230
+ const place = startPlaceRef.current;
75231
+ place.way = {
75232
+ from,
75233
+ to
75234
+ };
75235
+ if (to === from) {
75236
+ place.arrived = true;
75237
+ return;
75238
+ }
74796
75239
  if (horizontal) {
74797
- scrollerEl.scrollLeft += delta;
75240
+ scrollerEl.scrollLeft = to;
74798
75241
  } else {
74799
- scrollerEl.scrollTop += delta;
75242
+ scrollerEl.scrollTop = to;
74800
75243
  }
74801
75244
  };
74802
75245
  useLayoutEffect(placeWhereHeld);
@@ -74869,6 +75312,19 @@ const useListScrollSync = ({
74869
75312
  evaluateWindowRef.current("items resized");
74870
75313
  return false;
74871
75314
  };
75315
+ // The box around a held list changing shape is the hold's to answer too: it
75316
+ // places the list again, the way it writes every scroll of its own (see
75317
+ // sendHeldScroll) — written any other way, the scroll would read as someone
75318
+ // else moving the list, and end the hold.
75319
+ const placeIfHeldRef = useRef(null);
75320
+ placeIfHeldRef.current = () => {
75321
+ // Read again: a scroll may have ended the hold since this render.
75322
+ if (!heldSomewhere || startPlaceRef.current.userTookOver) {
75323
+ return false;
75324
+ }
75325
+ placeWhereHeld();
75326
+ return true;
75327
+ };
74872
75328
  useLayoutEffect(() => {
74873
75329
  if (scrolledWanted === "start" || scrolledWanted === undefined || !ref.current) {
74874
75330
  return undefined;
@@ -75013,6 +75469,9 @@ const useListScrollSync = ({
75013
75469
  if (!entries.some(entry => entry.target === scrollerEl)) {
75014
75470
  return;
75015
75471
  }
75472
+ if (placeIfHeldRef.current()) {
75473
+ return;
75474
+ }
75016
75475
  const position = positionRef.current;
75017
75476
  if (!position) {
75018
75477
  return;
@@ -75125,6 +75584,15 @@ const useListScrollSync = ({
75125
75584
  return;
75126
75585
  }
75127
75586
  anchorRef.current = null;
75587
+ if (isScrollGliding(scrollerEl)) {
75588
+ // Nobody is looking at items standing still: they are passing by, on
75589
+ // their way somewhere the browser was asked to take them (a key, a smooth
75590
+ // scrollTo, the status bar). The write would be an instant scroll, and
75591
+ // the browser abandons the glide for it, short of where it was going.
75592
+ // Left alone, the items drawn above push the rest by `drift` while all
75593
+ // of it moves, and the glide lands.
75594
+ return;
75595
+ }
75128
75596
  scrolledByListRef.current = true;
75129
75597
  if (horizontal) {
75130
75598
  scrollerEl.scrollLeft += drift;
@@ -75461,9 +75929,44 @@ const useListScrollSync = ({
75461
75929
  reportPosition();
75462
75930
  return;
75463
75931
  }
75932
+ letGoUnlessHeldScroll();
75464
75933
  reportPosition();
75465
75934
  evaluateWindowRef.current("scroll");
75466
75935
  };
75936
+ // A scroll the hold did not write is someone else moving the list, which
75937
+ // ends the hold like a wheel does — the ones that come with no input event
75938
+ // at all: the status bar tap on iOS, find in page, a script.
75939
+ // Only once the list has stood where it holds itself, though. Until then
75940
+ // the page is still settling around it, and some of that is written after
75941
+ // the list placed itself: a navigation holding the rendering for its
75942
+ // transition takes the arriving page to its top once the page rendered
75943
+ // (whenRenderingResumes), and the hold puts it back at its next commit.
75944
+ const letGoUnlessHeldScroll = () => {
75945
+ const place = startPlaceRef.current;
75946
+ const way = place.way;
75947
+ if (place.userTookOver || place.wanted === "start" || place.wanted === undefined || !way) {
75948
+ return;
75949
+ }
75950
+ const position = horizontal ? scrollerEl.scrollLeft : scrollerEl.scrollTop;
75951
+ if (position > way.to - 1 && position < way.to + 1) {
75952
+ // From then on only that place is the list's, or a scroll back the
75953
+ // way it came would pass for its own.
75954
+ place.way = {
75955
+ from: way.to,
75956
+ to: way.to
75957
+ };
75958
+ place.arrived = true;
75959
+ return;
75960
+ }
75961
+ if (!place.arrived) {
75962
+ return;
75963
+ }
75964
+ const low = way.from < way.to ? way.from : way.to;
75965
+ const high = way.from < way.to ? way.to : way.from;
75966
+ if (position < low - 1 || position > high + 1) {
75967
+ place.userTookOver = true;
75968
+ }
75969
+ };
75467
75970
  // A page-level scroller does not emit "scroll" on the element itself
75468
75971
  // (document.scrollingElement); the document does.
75469
75972
  const scrollEventTarget = scrollerEl === document.scrollingElement ? document : scrollerEl;
@@ -78949,7 +79452,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
78949
79452
  *
78950
79453
  * In every form the list holds itself there while it is still finding out
78951
79454
  * how many items there are and how tall one is, and lets go the moment the
78952
- * user reaches for the list.
79455
+ * user reaches for the list, or anything else scrolls it (a script, find in
79456
+ * page, the status bar tap on iOS).
78953
79457
  * @param {(scrolled: {id: string, index: number, offset: number, visibleCount: number}) => void} [props.onScrolledChange]
78954
79458
  * Where the list is, as the user scrolls: the item at the top of the view and
78955
79459
  * how far below the place an item lands on its own (see `defaultScrolled`) it