@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, 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";
@@ -11241,13 +11241,22 @@ const installScrollRestoration = () => {
11241
11241
  },
11242
11242
  { passive: true },
11243
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
+ }
11244
11254
  // What a reload asks for, now that the browser has been told not to do it.
11245
11255
  // Once, and at the first render of a route: the position is only meaningful
11246
11256
  // once there is a page under it.
11247
11257
  // Written once the commit that rendered it has ended: said from inside it,
11248
11258
  // and what the offset shows may still have to be built (see scrollTo).
11249
- const positionOnLoad = positionByUrl.get(window.location.href);
11250
- if (positionOnLoad && (positionOnLoad.x || positionOnLoad.y)) {
11259
+ if (positionOnLoad.x || positionOnLoad.y) {
11251
11260
  const stopListening = observeRouteRender(() => {
11252
11261
  stopListening();
11253
11262
  queueMicrotask(() => {
@@ -11333,6 +11342,11 @@ const startAtTop = (url, { from } = {}) => {
11333
11342
  return;
11334
11343
  }
11335
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 });
11336
11350
  window.scrollTo({ top: 0, left: 0, behavior: "instant" });
11337
11351
  };
11338
11352
  const isArrival = (url, { from }) => {
@@ -16346,7 +16360,7 @@ const renderIntoCallout = (jsx$1, calloutMessageElement, {
16346
16360
  },
16347
16361
  children: jsx$1
16348
16362
  });
16349
- render(calloutJsx, calloutMessageElement);
16363
+ render$1(calloutJsx, calloutMessageElement);
16350
16364
  };
16351
16365
 
16352
16366
  // An HTML message is rendered through preact rather than innerHTML: what a
@@ -16369,7 +16383,7 @@ const renderHtmlIntoCallout = (html, calloutMessageElement, {
16369
16383
  // (a DOM node, an iframe); innerHTML alone would leave preact believing its
16370
16384
  // tree is still there.
16371
16385
  const clearCalloutMessage = calloutMessageElement => {
16372
- render(null, calloutMessageElement);
16386
+ render$1(null, calloutMessageElement);
16373
16387
  calloutMessageElement.innerHTML = "";
16374
16388
  };
16375
16389
  const domToVNodes = node => {
@@ -32262,10 +32276,222 @@ const useUrlSearchParam = (paramName, defaultValue) => {
32262
32276
  return [value, setSearchParamValue];
32263
32277
  };
32264
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
+
32265
32489
  // The title belongs to the last Head to arrive, whatever order they leave in:
32266
32490
  // the page being left by a route transition is taken down AFTER the page
32267
32491
  // arriving is up (see keepLeavingPages in route.jsx), and putting back the
32268
- // 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.
32269
32495
  const titleEntries = [];
32270
32496
  let titleWithoutHead = null;
32271
32497
  const Head = ({
@@ -32277,6 +32503,7 @@ const Head = ({
32277
32503
  }
32278
32504
  const childArray = Array.isArray(children) ? children : [children];
32279
32505
  const appendedElements = [];
32506
+ const themeColors = [];
32280
32507
  let titleEntry = null;
32281
32508
  for (const child of childArray) {
32282
32509
  if (!child) {
@@ -32289,6 +32516,13 @@ const Head = ({
32289
32516
  };
32290
32517
  continue;
32291
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
+ }
32292
32526
  const el = document.createElement(child.type);
32293
32527
  const props = child.props || {};
32294
32528
  for (const [key, value] of Object.entries(props)) {
@@ -32304,7 +32538,9 @@ const Head = ({
32304
32538
  titleEntries.push(titleEntry);
32305
32539
  document.title = titleEntry.title;
32306
32540
  }
32541
+ const removeThemeColors = themeColors.length > 0 ? pushHeadThemeColors(themeColors) : null;
32307
32542
  return () => {
32543
+ removeThemeColors?.();
32308
32544
  if (titleEntry) {
32309
32545
  titleEntries.splice(titleEntries.indexOf(titleEntry), 1);
32310
32546
  const lastEntry = titleEntries[titleEntries.length - 1];
@@ -64607,9 +64843,17 @@ const css$E = /* css */`
64607
64843
  on its own, sharp, above the wall: any wall on that frame — even a
64608
64844
  half-strength wash and blur — is a flash on the anchor. So the wall
64609
64845
  arrives from nothing, over the movement: on that frame the page is
64610
- 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. */
64611
64852
  &[data-lifting] {
64612
- &::backdrop {
64853
+ &[aria-expanded="false"]::backdrop {
64854
+ opacity: 0;
64855
+ }
64856
+ &[aria-expanded="true"]:not([navi-transitions-off])::backdrop {
64613
64857
  opacity: 1;
64614
64858
  transition-property: opacity;
64615
64859
  transition-duration: var(--navi-popup-lift-duration, 0.25s);
@@ -65069,6 +65313,16 @@ const css$E = /* css */`
65069
65313
  * `saturate()`/`grayscale()` work too. How far what is behind withdraws is
65070
65314
  * a question of paint, not of what an outside click does — a backdrop that
65071
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.
65072
65326
  * @param {boolean} [props.scrollCapture] - Traps scroll gestures inside the
65073
65327
  * dialog so the page/container behind it can't scroll while it's open.
65074
65328
  * A `layer="local"` dialog always locks its own positioned ancestor's
@@ -65590,6 +65844,9 @@ const useDialogProps = props => {
65590
65844
  // so both objects below get them (see this file's CSS).
65591
65845
  backdropColor,
65592
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,
65593
65850
  scrollCapture: scrollCaptureProp,
65594
65851
  // "auto" (default) → the dialog follows its content. "frozen" → measured
65595
65852
  // once, held at that size while open. See this prop's own JSDoc above.
@@ -66173,6 +66430,16 @@ const useDialogProps = props => {
66173
66430
  backdropEl.style.transitionProperty = "";
66174
66431
  }
66175
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
+ }
66176
66443
  const cancelOpenInteractionSuppression = !silent && hasCssTransitionAnimation ? suppressPointerEventsDuringTransition(dialogEl) : null;
66177
66444
  // Handing the focus to a field is what raises the on-screen keyboard, and
66178
66445
  // the keyboard takes away the very room this dialog was just placed
@@ -66975,6 +67242,12 @@ const css$D = /* css */`
66975
67242
  * `saturate()`/`grayscale()` work too. How far what is behind withdraws is
66976
67243
  * a question of paint, not of what an outside click does — a backdrop that
66977
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.
66978
67251
  * @param {boolean} [props.scrollCapture] - Traps scroll gestures inside the
66979
67252
  * popover so the page/container behind it can't scroll while it's open.
66980
67253
  * @param {boolean} [props.focusCapture] - Traps Tab navigation inside the
@@ -67317,6 +67590,9 @@ const usePopoverProps = props => {
67317
67590
  // which is a sibling element (see this file's top comment).
67318
67591
  backdropColor,
67319
67592
  backdropFilter,
67593
+ // Read at the opening, with the backdrop it would otherwise be worked out
67594
+ // from (see openEffect).
67595
+ themeColor,
67320
67596
  group,
67321
67597
  scrollCapture,
67322
67598
  focusCapture,
@@ -67921,6 +68197,15 @@ const usePopoverProps = props => {
67921
68197
  backdropEl.style.transitionProperty = "";
67922
68198
  }
67923
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
+ }
67924
68209
  const cancelOpenInteractionSuppression = !silent && hasCssTransitionAnimation ? suppressPointerEventsDuringTransition(popoverEl) : null;
67925
68210
  // autoFocus={false}: opening moves no focus at all — whoever has the
67926
68211
  // keyboard keeps it (combobox-style suggestions under an input being
@@ -68336,6 +68621,10 @@ const css$C = /* css */`@layer navi {
68336
68621
  * identically): the wash the backdrop paints over what is behind.
68337
68622
  * @param {string} [props.backdropFilter] - Forwarded as-is: what that wash
68338
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.
68339
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]
68340
68629
  * - Forwarded to whichever renders — see either component's own doc.
68341
68630
  * `"expanding"`/`"expand-*"` are `Popover`'s own; `"lifting"` and
@@ -69333,6 +69622,26 @@ const PickerCustom = props => {
69333
69622
  if (isWithinPickerContent(e.target)) {
69334
69623
  return null;
69335
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
+ }
69336
69645
  if (!opensOnPress) {
69337
69646
  // Neither the tap, nor the click a hold leaves behind: the press
69338
69647
  // is not what opens this picker.
@@ -69988,12 +70297,12 @@ const isScrolling = (element) => {
69988
70297
  if (element === undefined) {
69989
70298
  return scrollActivitySignal.peek();
69990
70299
  }
69991
- return asScroller(element).hasAttribute("navi-scrolling");
70300
+ return asScroller$1(element).hasAttribute("navi-scrolling");
69992
70301
  };
69993
70302
 
69994
70303
  // The page scroll is dispatched on the document; the element carrying the
69995
70304
  // attribute is the one CSS can reach, document.scrollingElement.
69996
- const asScroller = (eventTargetOrElement) => {
70305
+ const asScroller$1 = (eventTargetOrElement) => {
69997
70306
  if (eventTargetOrElement === document || eventTargetOrElement === window) {
69998
70307
  return document.scrollingElement;
69999
70308
  }
@@ -70004,7 +70313,7 @@ const idleTimeoutMap = new Map();
70004
70313
  document.addEventListener(
70005
70314
  "scroll",
70006
70315
  (e) => {
70007
- const scroller = asScroller(e.target);
70316
+ const scroller = asScroller$1(e.target);
70008
70317
  const idleTimeout = idleTimeoutMap.get(scroller);
70009
70318
  if (idleTimeout !== undefined) {
70010
70319
  clearTimeout(idleTimeout);
@@ -70026,6 +70335,111 @@ document.addEventListener(
70026
70335
  { capture: true, passive: true },
70027
70336
  );
70028
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
+
70029
70443
  const LoadingDotsSvg = () => {
70030
70444
  return jsxs("svg", {
70031
70445
  viewBox: "0 0 200 200",
@@ -73109,7 +73523,9 @@ const useListScrollSync = ({
73109
73523
  const scrolledFallback = scrolled === undefined || scrolled === null ? "start" : defaultScrolled ?? "start";
73110
73524
  const startPlaceRef = useRef({
73111
73525
  userTookOver: false,
73112
- wanted: scrolledWanted
73526
+ wanted: scrolledWanted,
73527
+ way: null,
73528
+ arrived: false
73113
73529
  });
73114
73530
  if (startPlaceRef.current.wanted !== scrolledWanted) {
73115
73531
  startPlaceRef.current.wanted = scrolledWanted;
@@ -73319,8 +73735,10 @@ const useListScrollSync = ({
73319
73735
  // keeps moving while the list is still finding out how many items it has and
73320
73736
  // how tall one is, so landing there once would land next to it. What ends the
73321
73737
  // hold is the user reaching for the list — a wheel, a finger, a key, a hand
73322
- // on the scrollbar — and not the scroll event itself, which the list provokes
73323
- // 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).
73324
73742
  const placeWhereHeld = () => {
73325
73743
  if (scrolledWanted === "start" || scrolledWanted === undefined || startPlaceRef.current.userTookOver || !ref.current) {
73326
73744
  return;
@@ -73369,12 +73787,7 @@ const useListScrollSync = ({
73369
73787
  }
73370
73788
  const scrollerEl = getScroller();
73371
73789
  if (openAt === "end") {
73372
- anchorRef.current = null;
73373
- if (horizontal) {
73374
- scrollerEl.scrollLeft = scrollerEl.scrollWidth;
73375
- } else {
73376
- scrollerEl.scrollTop = scrollerEl.scrollHeight;
73377
- }
73790
+ sendHeldScroll(horizontal ? scrollerEl.scrollWidth : scrollerEl.scrollHeight);
73378
73791
  return;
73379
73792
  }
73380
73793
  if (typeof openAt === "number") {
@@ -73398,15 +73811,45 @@ const useListScrollSync = ({
73398
73811
  const itemRect = itemEl.getBoundingClientRect();
73399
73812
  const offsetWanted = resolveOpenOffset(getItemScrollInset(scrollerEl, itemEl, horizontal) + offset, horizontal ? viewportRect.width : viewportRect.height, horizontal ? itemRect.width : itemRect.height);
73400
73813
  const offsetNow = horizontal ? itemRect.left - viewportRect.left : itemRect.top - viewportRect.top;
73814
+ const scrollNow = horizontal ? scrollerEl.scrollLeft : scrollerEl.scrollTop;
73401
73815
  const delta = offsetNow - offsetWanted;
73402
73816
  if (delta > -0.5 && delta < 0.5) {
73817
+ sendHeldScroll(scrollNow);
73403
73818
  return;
73404
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
+ }
73405
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
+ }
73406
73849
  if (horizontal) {
73407
- scrollerEl.scrollLeft += delta;
73850
+ scrollerEl.scrollLeft = to;
73408
73851
  } else {
73409
- scrollerEl.scrollTop += delta;
73852
+ scrollerEl.scrollTop = to;
73410
73853
  }
73411
73854
  };
73412
73855
  useLayoutEffect(placeWhereHeld);
@@ -73479,6 +73922,19 @@ const useListScrollSync = ({
73479
73922
  evaluateWindowRef.current("items resized");
73480
73923
  return false;
73481
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
+ };
73482
73938
  useLayoutEffect(() => {
73483
73939
  if (scrolledWanted === "start" || scrolledWanted === undefined || !ref.current) {
73484
73940
  return undefined;
@@ -73623,6 +74079,9 @@ const useListScrollSync = ({
73623
74079
  if (!entries.some(entry => entry.target === scrollerEl)) {
73624
74080
  return;
73625
74081
  }
74082
+ if (placeIfHeldRef.current()) {
74083
+ return;
74084
+ }
73626
74085
  const position = positionRef.current;
73627
74086
  if (!position) {
73628
74087
  return;
@@ -73735,6 +74194,15 @@ const useListScrollSync = ({
73735
74194
  return;
73736
74195
  }
73737
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
+ }
73738
74206
  scrolledByListRef.current = true;
73739
74207
  if (horizontal) {
73740
74208
  scrollerEl.scrollLeft += drift;
@@ -74062,9 +74530,44 @@ const useListScrollSync = ({
74062
74530
  reportPosition();
74063
74531
  return;
74064
74532
  }
74533
+ letGoUnlessHeldScroll();
74065
74534
  reportPosition();
74066
74535
  evaluateWindowRef.current("scroll");
74067
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
+ };
74068
74571
  // A page-level scroller does not emit "scroll" on the element itself
74069
74572
  // (document.scrollingElement); the document does.
74070
74573
  const scrollEventTarget = scrollerEl === document.scrollingElement ? document : scrollerEl;
@@ -77453,7 +77956,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
77453
77956
  *
77454
77957
  * In every form the list holds itself there while it is still finding out
77455
77958
  * how many items there are and how tall one is, and lets go the moment the
77456
- * 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).
77457
77961
  * @param {(scrolled: {id: string, index: number, offset: number, visibleCount: number}) => void} [props.onScrolledChange]
77458
77962
  * Where the list is, as the user scrolls: the item at the top of the view and
77459
77963
  * how far below the place an item lands on its own (see `defaultScrolled`) it