@jsenv/navi 0.29.416 → 0.29.418

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@
3
3
  * using @jsenv/navi as intended.
4
4
  */
5
5
  import { installImportMetaCssBuild, coarsePointerSignal, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
6
- import { createPubSub, elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, createEventGroupLogger, findEvent, mergeOneStyle, findFocusable, getElementSignature, chainEvent, isTouchDrivenEvent, getKeyboardEventDefaultAction, isPressDrivenClick, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, createValueEffect, findFocusDelegateTarget, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, getScrollContainerSet, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, keepTouchRefusable, waitForTap, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, dragSourceThatStoodDown, markDragSource, refuseDragTo, startDragTo, installPanZoom, createInternalCustomEvent, activeElementSignal, normalizeStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, getScrollContainer, watchWheelTravel, scrollRoomTowards, trapScrollInside, scrollIntoViewScoped, 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";
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
9
  import { options, isValidElement, createContext, render, h, toChildArray, createRef, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
@@ -11254,6 +11254,28 @@ const suspendScrollRecording = () => {
11254
11254
  };
11255
11255
  };
11256
11256
 
11257
+ // A list scrolling the document that opens on a row puts the document where
11258
+ // that row is, by measuring it (see placeWhereHeld in list.jsx). The offset kept
11259
+ // for the url is pixels of the page as it was drawn — and a list draws a window
11260
+ // of its rows and holds the room of the others with fillers of an estimated
11261
+ // height, so the same pixels now fall on other rows. Put back after the list
11262
+ // has placed itself, they would undo it, in the very picture a route transition
11263
+ // takes. So the list holds the document while it is placing itself, and the
11264
+ // url's offset is not put back meanwhile. Counted: two lists can share one
11265
+ // document.
11266
+ let documentHoldCount = 0;
11267
+ const holdDocumentScroll = () => {
11268
+ documentHoldCount++;
11269
+ let released = false;
11270
+ return () => {
11271
+ if (released) {
11272
+ return;
11273
+ }
11274
+ released = true;
11275
+ documentHoldCount--;
11276
+ };
11277
+ };
11278
+
11257
11279
  let installed = false;
11258
11280
  const installScrollRestoration = () => {
11259
11281
  if (installed) {
@@ -11288,6 +11310,9 @@ const installScrollRestoration = () => {
11288
11310
  if (positionOnLoad && (positionOnLoad.x || positionOnLoad.y)) {
11289
11311
  const stopListening = observeRouteRender(() => {
11290
11312
  stopListening();
11313
+ if (documentHoldCount) {
11314
+ return;
11315
+ }
11291
11316
  scrollTo(positionOnLoad);
11292
11317
  });
11293
11318
  }
@@ -11302,6 +11327,11 @@ const restoreScrollPosition = (url) => {
11302
11327
  if (!position) {
11303
11328
  return false;
11304
11329
  }
11330
+ if (documentHoldCount) {
11331
+ // There is a place to go back to, and the list holding the document is
11332
+ // putting it back (see holdDocumentScroll).
11333
+ return true;
11334
+ }
11305
11335
  scrollTo(position);
11306
11336
  return true;
11307
11337
  };
@@ -15851,224 +15881,846 @@ const useOpenControllerByProps = (props, name) => {
15851
15881
  };
15852
15882
 
15853
15883
  /**
15854
- * Typography reset shared by the surfaces a control opens: callout, popover,
15855
- * dialog.
15884
+ * Small, renderer-agnostic helpers shared by Popover and Dialog's own custom
15885
+ * (non-top-layer) renderers — operate on a plain DOM element, no knowledge
15886
+ * of which of the two owns it.
15887
+ */
15888
+
15889
+
15890
+ /**
15891
+ * Whether a visibleRectEffect delivery is one that can have taken height away
15892
+ * from a popup, and so pushed whatever holds focus out of sight:
15893
+ * - "resize": the window/visual viewport settled — which is also how the
15894
+ * on-screen keyboard arrives, overlay or not (window_size.js);
15895
+ * - ELEMENT_SIZE_CHANGE: the popup's own box measured different;
15896
+ * - "focusin": the focus-settled re-measure, for the room that changes with
15897
+ * nothing announcing it (subscribeFocusSettled in window_size.js).
15856
15898
  *
15857
- * A surface is painted on top of the page but lives in the DOM subtree of the
15858
- * element it opens from, so every inherited text property of that element
15859
- * reaches it. The ink an element chose for its own background — centered,
15860
- * shadowed, uppercase, letter-spaced, kept on one line — arrives on paper that
15861
- * has none of that background, and the caller discovers it as a symptom
15862
- * (a blurred tooltip, a centered message) with nothing on screen pointing back
15863
- * to the rule three elements up. A surface writes its own text, on its own
15864
- * paper, in the document's terms.
15899
+ * Everything else is a scroll of one kind or another, where nothing got
15900
+ * smaller and scrolling the focused element back would fight the very gesture
15901
+ * that fired it.
15902
+ */
15903
+ const mayHaveHiddenFocus = (event) => {
15904
+ const type = event?.type;
15905
+ return (
15906
+ type === "resize" || type === ELEMENT_SIZE_CHANGE || type === "focusin"
15907
+ );
15908
+ };
15909
+
15910
+ const scrollportHeightMap = new WeakMap();
15911
+ /**
15912
+ * Scrolls whatever holds focus inside `popupEl` back into view, if the popup
15913
+ * getting shorter has pushed it out.
15865
15914
  *
15866
- * Every one of them is set to its initial value rather than `revert`: only
15867
- * `color` and `background-color` are declared by the UA on a `[popover]`/
15868
- * `<dialog>`, so `revert` on any of these would roll back to the inherited
15869
- * value — the very thing to stop. `text-align: initial` is `start`, which
15870
- * still reads the surface's own `direction`, so an RTL document keeps its
15871
- * side.
15915
+ * The case this exists for: a field low in the scrolling body of a popup that
15916
+ * also has a footer (box.jsx — with a body, the body is the only thing that
15917
+ * scrolls and the footer is a sibling sitting right under it). Focusing the
15918
+ * field makes the browser scroll it into view, which it does against the
15919
+ * popup's height AT THAT MOMENT; the on-screen keyboard then opens and takes
15920
+ * that height away. The body shrinks, its scrollTop does not move, so the
15921
+ * content slides down relative to the shorter scrollport and the field ends up
15922
+ * past its bottom edge — visually, swallowed by the footer. The browser does
15923
+ * not redo a scroll-into-view it already answered, so this does.
15872
15924
  *
15873
- * `font-family` is deliberately absent: a surface keeps the face of what
15874
- * opened it, so a section written in a display font gets its tooltips in that
15875
- * font. `color`, `font-size` and `font-weight` are absent too — each surface
15876
- * answers those its own way (a popup writes in --navi-popup-color and follows
15877
- * the size of what opened it; a callout reverts to the document's ink and
15878
- * size).
15925
+ * Only what the shrink itself hid, though: with the keyboard up the user reads
15926
+ * the rest of the popup by scrolling the field away — to reach the submit under
15927
+ * it, typically — and the room keeps changing while they do (a keyboard settling
15928
+ * in two steps, a suggestion strip, a browser bar). Answering each of those by
15929
+ * scrolling the field back takes the popup away from wherever they had just
15930
+ * scrolled it, over and over: what they were reading cannot be reached at all
15931
+ * without blurring the field first, and the popup reads as unscrollable. So the
15932
+ * field is brought back only when it was in view before the room shrank, which
15933
+ * one remembered number answers: a resize moves neither scrollTop nor the
15934
+ * element's offset inside the scrolled content, so measuring against the height
15935
+ * the scrollport HAD is measuring the state before the change.
15879
15936
  *
15880
- * In `@layer navi` so an app that wants one of these back says so on the
15881
- * surface and wins, without having to out-specify anything.
15937
+ * Scoped to the field's own scroll container (never the page): a popup traps
15938
+ * scrolling precisely so the document underneath cannot move, and a plain
15939
+ * scrollIntoView walks past a container whose scrollbar isn't visible — see
15940
+ * scrollIntoViewScoped's own doc.
15941
+ *
15942
+ * "nearest": the smallest scroll that makes it visible, and none at all when it
15943
+ * already is. Where it lands is the container's own business — a navi scroller
15944
+ * keeps a band free at its edges so a field never comes back glued to one (see
15945
+ * scroll-padding in box.jsx).
15882
15946
  */
15883
- const surfaceTextCss = /* css */ `
15884
- @layer navi {
15885
- .navi_callout,
15886
- .navi_popover,
15887
- .navi_dialog {
15888
- font-style: initial;
15889
- text-align: initial;
15890
- text-indent: initial;
15891
- text-transform: initial;
15892
- text-shadow: none;
15893
- white-space: initial;
15894
- word-spacing: initial;
15895
- letter-spacing: initial;
15947
+ const keepFocusedElementVisible = (popupEl) => {
15948
+ const { activeElement } = document;
15949
+ if (!activeElement || activeElement === popupEl) {
15950
+ return;
15951
+ }
15952
+ if (!popupEl.contains(activeElement)) {
15953
+ return;
15954
+ }
15955
+ const scrollContainer = getScrollContainer(activeElement);
15956
+ if (!scrollContainer || !popupEl.contains(scrollContainer)) {
15957
+ // What scrolls the field is outside the popup, which means the page: a
15958
+ // popup holds it still on purpose (trapScrollInside), so there is nothing
15959
+ // here to scroll back.
15960
+ return;
15961
+ }
15962
+ const scrollportHeight = scrollContainer.clientHeight;
15963
+ const scrollportHeightBefore = scrollportHeightMap.get(scrollContainer);
15964
+ scrollportHeightMap.set(scrollContainer, scrollportHeight);
15965
+ if (scrollportHeightBefore !== undefined) {
15966
+ if (scrollportHeight >= scrollportHeightBefore) {
15967
+ // Nothing was taken away, so nothing was hidden by this.
15968
+ return;
15969
+ }
15970
+ if (
15971
+ !isVisibleInScrollport(
15972
+ activeElement,
15973
+ scrollContainer,
15974
+ scrollportHeightBefore,
15975
+ )
15976
+ ) {
15977
+ return;
15896
15978
  }
15897
15979
  }
15898
- `;
15899
-
15900
- const CalloutContext = createContext();
15901
- const useCalloutRequestClose = () => {
15902
- return useContext(CalloutContext)?.requestClose;
15903
- };
15904
- const useCalloutElement = () => {
15905
- return useContext(CalloutContext)?.element;
15906
- };
15907
- const renderIntoCallout = (jsx$1, calloutMessageElement, {
15908
- requestClose,
15909
- element
15910
- }) => {
15911
- const calloutJsx = jsx(CalloutContext.Provider, {
15912
- value: {
15913
- requestClose,
15914
- element
15915
- },
15916
- children: jsx$1
15980
+ scrollIntoViewScoped(activeElement, {
15981
+ container: scrollContainer,
15982
+ block: "nearest",
15917
15983
  });
15918
- render(calloutJsx, calloutMessageElement);
15919
15984
  };
15920
15985
 
15921
- // An HTML message is rendered through preact rather than innerHTML: what a
15922
- // message holds is then a preact tree like any other, so a callout's own JSX
15923
- // (its context, its close button) works the same whether the message arrived
15924
- // as markup or as elements.
15925
- const renderHtmlIntoCallout = (html, calloutMessageElement, {
15926
- requestClose,
15927
- element
15928
- }) => {
15929
- const template = document.createElement("template");
15930
- template.innerHTML = html;
15931
- renderIntoCallout(domToVNodes(template.content), calloutMessageElement, {
15932
- requestClose,
15933
- element
15934
- });
15986
+ // Whether any part of `el` was showing in `container` back when its scrollport
15987
+ // was `scrollportHeight` tall. Both boxes are read now: the container may have
15988
+ // moved as well as shrunk (a centered dialog re-centers itself), and the
15989
+ // difference between the two tops is what that move leaves alone.
15990
+ const isVisibleInScrollport = (el, container, scrollportHeight) => {
15991
+ const elRect = el.getBoundingClientRect();
15992
+ const containerRect = container.getBoundingClientRect();
15993
+ const elTop = elRect.top - containerRect.top + container.scrollTop;
15994
+ const elBottom = elTop + elRect.height;
15995
+ const scrollportTop = container.scrollTop;
15996
+ const scrollportBottom = scrollportTop + scrollportHeight;
15997
+ return elBottom > scrollportTop && elTop < scrollportBottom;
15935
15998
  };
15936
15999
 
15937
- // Unmounts whatever preact rendered before the element is filled by hand
15938
- // (a DOM node, an iframe); innerHTML alone would leave preact believing its
15939
- // tree is still there.
15940
- const clearCalloutMessage = calloutMessageElement => {
15941
- render(null, calloutMessageElement);
15942
- calloutMessageElement.innerHTML = "";
15943
- };
15944
- const domToVNodes = node => {
15945
- const vnodes = [];
15946
- for (const child of node.childNodes) {
15947
- if (child.nodeType === Node.TEXT_NODE) {
15948
- vnodes.push(child.data);
15949
- continue;
16000
+ /**
16001
+ * Calls `onSettled` once `el`'s current CSS transition is over — via
16002
+ * `transitionend`, with a safety `setTimeout` fallback matching the longest
16003
+ * `transition-duration`, in case nothing actually transitions or an event is
16004
+ * missed.
16005
+ *
16006
+ * Returns a "cancel" function, so a caller whose instance has been superseded
16007
+ * (a fresh open/close about to set its own state) can keep this stale one from
16008
+ * firing later. Cancelling only stops `onSettled`: undoing whatever the caller
16009
+ * did up front is that fresh call's business, not this one's.
16010
+ */
16011
+ const whenTransitionSettles = (el, onSettled) => {
16012
+ let settled = false;
16013
+ const onTransitionEnd = (transitionEvent) => {
16014
+ if (transitionEvent.target === el) {
16015
+ finish();
15950
16016
  }
15951
- if (child.nodeType !== Node.ELEMENT_NODE) {
15952
- continue;
16017
+ };
16018
+ const stopWatching = () => {
16019
+ settled = true;
16020
+ el.removeEventListener("transitionend", onTransitionEnd);
16021
+ clearTimeout(safetyTimeoutId);
16022
+ };
16023
+ const finish = () => {
16024
+ if (settled) {
16025
+ return;
15953
16026
  }
15954
- const props = {};
15955
- for (const {
15956
- name,
15957
- value
15958
- } of child.attributes) {
15959
- props[name] = value;
16027
+ stopWatching();
16028
+ onSettled();
16029
+ };
16030
+ el.addEventListener("transitionend", onTransitionEnd);
16031
+ const durationsInSeconds = getComputedStyle(el)
16032
+ .transitionDuration.split(",")
16033
+ .map((value) => parseFloat(value) || 0);
16034
+ const longestDurationMs = Math.max(0, ...durationsInSeconds) * 1000;
16035
+ const safetyTimeoutId = setTimeout(finish, longestDurationMs + 50);
16036
+ return () => {
16037
+ if (settled) {
16038
+ return;
15960
16039
  }
15961
- vnodes.push(h(child.localName, props, ...domToVNodes(child)));
15962
- }
15963
- return vnodes;
16040
+ stopWatching();
16041
+ };
15964
16042
  };
15965
16043
 
15966
- installImportMetaCssBuild(import.meta);
15967
16044
  /**
15968
- * The icon a callout shows beside its message — a square in the status color
15969
- * with the "!" glyph — drawable on its own, where the callout is not: on the
15970
- * trigger that opens one (a picker in callout mode), so what one presses looks
15971
- * like what it opens. The callout's own template draws from the same glyph
15972
- * (see calloutTemplate in callout.js).
16045
+ * Drops the document's text selection when it lives inside `el`, leaving a
16046
+ * selection made elsewhere on the page alone.
16047
+ *
16048
+ * A popup being closed takes its content with it, and a selection is a claim
16049
+ * on content the user can still act on: kept, it would outlive the surface it
16050
+ * was made on — painted on the box for the length of its exit transition, with
16051
+ * the handles and the copy toolbar a phone draws from the live selection
16052
+ * hanging over something that is going away — and reappear with the box on
16053
+ * the next opening. Dropping the selection is the only way to remove that
16054
+ * chrome: a `user-select: none` on the closing box hides the highlight in some
16055
+ * browsers only, and the handles are the browser's own, drawn from the
16056
+ * selection object rather than from any style.
15973
16057
  */
15974
-
15975
- const css$15 = /* css */`.navi_callout_status_icon {
15976
- --x-callout-status-icon-color: var(--navi-callout-neutral-color);
15977
- box-sizing: border-box;
15978
- aspect-ratio: 1;
15979
- color: #fff;
15980
- vertical-align: middle;
15981
- background-color: var(--x-callout-status-icon-color);
15982
- border-radius: 2px;
15983
- flex-shrink: 0;
15984
- justify-content: center;
15985
- align-items: center;
15986
- width: 1em;
15987
- height: 1em;
15988
- display: inline-flex;
15989
-
15990
- &[data-status="success"] {
15991
- --x-callout-status-icon-color: var(--navi-callout-success-color);
15992
- }
15993
-
15994
- &[data-status="info"] {
15995
- --x-callout-status-icon-color: var(--navi-callout-info-color);
16058
+ const clearTextSelectionInside = (el) => {
16059
+ const selection = window.getSelection();
16060
+ if (!selection || selection.rangeCount === 0) {
16061
+ return;
15996
16062
  }
15997
-
15998
- &[data-status="warning"] {
15999
- --x-callout-status-icon-color: var(--navi-callout-warning-color);
16063
+ const range = selection.getRangeAt(0);
16064
+ if (!el.contains(range.commonAncestorContainer)) {
16065
+ return;
16000
16066
  }
16067
+ selection.removeAllRanges();
16068
+ };
16001
16069
 
16002
- &[data-status="error"] {
16003
- --x-callout-status-icon-color: var(--navi-callout-error-color);
16004
- }
16070
+ /**
16071
+ * Disables pointer-events on `el` until its current CSS transition settles —
16072
+ * avoids the cursor changing/something becoming clickable while the popup is
16073
+ * still visually moving into or out of place.
16074
+ *
16075
+ * Returns whenTransitionSettles' own "cancel" function: it doesn't restore
16076
+ * pointer-events, since a fresh call for the next open/close is about to set
16077
+ * its own state.
16078
+ */
16079
+ const suppressPointerEventsDuringTransition = (el) => {
16080
+ el.style.pointerEvents = "none";
16081
+ return whenTransitionSettles(el, () => {
16082
+ el.style.pointerEvents = "";
16083
+ });
16084
+ };
16005
16085
 
16006
- &[data-shape="circle"] {
16007
- border-radius: 50%;
16086
+ /**
16087
+ * Hides the backdrop, deferring until the browser's matching "click" fires
16088
+ * when `closeEvent` was triggered by a mousedown (see popover.jsx's top
16089
+ * comment for why) — same capture-phase-on-document pattern as
16090
+ * armSuppressNextOpenRequest in open_controller.js, which a plain timeout
16091
+ * can't safely replace: mouseup (and the click that follows it) can land an
16092
+ * arbitrarily long time after mousedown (the user is still holding the
16093
+ * button down), so a short timeout can fire first and hide the backdrop
16094
+ * before its own click ever arrives. A capture-phase listener on document
16095
+ * fires for every click regardless of what any bubble-phase handler does
16096
+ * downstream, so no fallback timer is needed.
16097
+ *
16098
+ * `hide` is the caller's own way to actually hide the backdrop
16099
+ * (`hidePopover()` for a top-layer backdrop, a plain `style.display = "none"`
16100
+ * for a plain div) — this helper only owns the mousedown/click timing.
16101
+ *
16102
+ * Returns a disarm function (or undefined if hidden immediately), so a
16103
+ * fresh open can cancel a pending hide it's about to make redundant.
16104
+ */
16105
+ const armPointerDownOutsideClose = (closeEvent, hide) => {
16106
+ const mousedownEvent = findEvent(closeEvent, "mousedown");
16107
+ if (!mousedownEvent) {
16108
+ hide();
16109
+ return undefined;
16008
16110
  }
16111
+ // `hide` is handed the click, for a wall with peers to re-aim it (see
16112
+ // handWallClickToPeer).
16113
+ const onClick = (clickEvent) => {
16114
+ document.removeEventListener("click", onClick, { capture: true });
16115
+ hide(clickEvent);
16116
+ };
16117
+ document.addEventListener("click", onClick, { capture: true });
16118
+ return () => {
16119
+ document.removeEventListener("click", onClick, { capture: true });
16120
+ };
16121
+ };
16009
16122
 
16010
- & svg {
16011
- width: 100%;
16012
- height: 100%;
16123
+ /**
16124
+ * Maps a positionArea y/x pair to a concrete `navi-animation` value (a
16125
+ * `prefix` plus a direction word), or `null` if both axes overlap the anchor
16126
+ * (no direction at all — that's `resolvedAnimationKind === "scaling"`
16127
+ * territory instead, see resolveAutoAnimationKind below).
16128
+ *
16129
+ * `prefix: "slide-from"` (used with no real anchor — Dialog always, Popover
16130
+ * when docked) keeps the word as the compass direction the popup comes
16131
+ * from: placed "top" (a point/corner), it slides in from the top.
16132
+ * `prefix: "expand"` (a real anchor, Popover-only) uses the motion/growth
16133
+ * direction instead, the opposite compass point: placed "top" of the
16134
+ * anchor, it moves/grows up, away from the anchor (which sits below it).
16135
+ *
16136
+ * "inset-*"/"center" contribute no direction on their axis either way.
16137
+ */
16138
+ const resolveDirectionValue = (y, x, { prefix }) => {
16139
+ const yWord =
16140
+ y === "top"
16141
+ ? prefix === "expand"
16142
+ ? "up"
16143
+ : "top"
16144
+ : y === "bottom"
16145
+ ? prefix === "expand"
16146
+ ? "down"
16147
+ : "bottom"
16148
+ : null;
16149
+ const xWord = x === "left" ? "left" : x === "right" ? "right" : null;
16150
+ if (!yWord && !xWord) {
16151
+ return null;
16013
16152
  }
16014
- }
16015
-
16016
- .navi_icon > .navi_callout_status_icon {
16017
- width: 100%;
16018
- height: 100%;
16019
- }
16020
- `;
16153
+ return yWord && xWord
16154
+ ? `${prefix}-${yWord}-${xWord}`
16155
+ : `${prefix}-${yWord || xWord}`;
16156
+ };
16021
16157
 
16022
16158
  /**
16023
- * The "!" on a square, viewBox and path both. The box is square and holds the
16024
- * glyph's margin: the ink is 298 of 540 units tall (55%), centered. Drawn this
16025
- * way rather than tight around the ink so whoever shows it can give the svg the
16026
- * whole square — the glyph is then placed by the vector rasteriser, which has
16027
- * no device pixel grid to miss, at any square size down to a fractional one.
16159
+ * Shared `animation="auto"`/`true` resolution: "scaling" reads best overall
16160
+ * — picked for any real anchor, or for a point/corner placed dead-center
16161
+ * (both positionArea axes overlapping — there's no sensible direction to
16162
+ * slide from in that case). "sliding" otherwise. `anchor` is `undefined`
16163
+ * for any no-anchor/docked case (Dialog always, Popover's own custom
16164
+ * renderer when there's no real anchor), so this collapses to "scaling"
16165
+ * there only for the dead-center case, "sliding" otherwise. The two
16166
+ * "overlapping" booleans below describe the *positionArea* itself (a bare
16167
+ * word vs. "inset-"/"center"), not anything about the anchor — they'd
16168
+ * mean exactly the same thing even with no anchor at all, since it's the
16169
+ * position strategy, not the anchor, that decides whether there's a
16170
+ * direction to slide from.
16028
16171
  */
16029
- const CALLOUT_STATUS_GLYPH_VIEWBOX = "0 0 540 540";
16030
- const CALLOUT_STATUS_GLYPH_PATH = "m232.5,121 8,196h59l8-196zm36.5,224a37,37 0 1,0 2,0z";
16172
+ const resolveAutoAnimationKind = (anchor, parsedPositionArea) => {
16173
+ const yIsOverlapping =
16174
+ parsedPositionArea.y !== "top" && parsedPositionArea.y !== "bottom";
16175
+ const xIsOverlapping =
16176
+ parsedPositionArea.x !== "left" && parsedPositionArea.x !== "right";
16177
+ return anchor || (yIsOverlapping && xIsOverlapping) ? "scaling" : "sliding";
16178
+ };
16031
16179
 
16032
16180
  /**
16033
- * @type {import("ignore:preact").FunctionComponent<{
16034
- * status?: "info" | "warning" | "error" | "success" | "none",
16035
- * shape?: "square" | "circle",
16036
- * }>}
16037
- * @param {"info"|"warning"|"error"|"success"|"none"} [status="info"] The color
16038
- * — the callout's own for that status. `"none"` is the neutral one.
16039
- * @param {"square"|"circle"} [shape="square"] Square like the callout's own
16040
- * icon, or a circle.
16181
+ * An opening that reaches a popup with no element of its own to show.
16182
+ *
16183
+ * The one cause seen so far, and it is silent: something inside the popup
16184
+ * suspended while its content was being built, and the `<Loading>` that caught
16185
+ * it sits ABOVE the popup. A boundary suspending on an update keeps the page as
16186
+ * it was, as a copy, so what is opened here belongs to a copy nobody owns any
16187
+ * more — and once the data arrives the subtree is rebuilt from scratch, closed
16188
+ * and empty. Nothing is thrown, nothing is drawn, and the press is lost.
16041
16189
  */
16042
- const CalloutStatusIcon = ({
16043
- status = "info",
16044
- shape = "square"
16045
- }) => {
16046
- import.meta.css = [css$15, "@jsenv/navi/src/control/rules/callout/callout_status_icon.jsx"];
16047
- return jsx("span", {
16048
- className: "navi_callout_status_icon",
16049
- "data-status": status === "none" ? undefined : status,
16050
- "data-shape": shape === "circle" ? "circle" : undefined,
16051
- "aria-hidden": "true",
16052
- children: jsx("svg", {
16053
- viewBox: CALLOUT_STATUS_GLYPH_VIEWBOX,
16054
- xmlns: "http://www.w3.org/2000/svg",
16055
- children: jsx("path", {
16056
- fill: "currentColor",
16057
- d: CALLOUT_STATUS_GLYPH_PATH
16058
- })
16059
- })
16060
- });
16190
+ const warnPopupHasNoElementToOpen = (popupKind) => {
16191
+ console.warn(
16192
+ `[navi] a "${popupKind}" was asked to open and has no element to open. What usually did it: content inside it suspended, and the <Loading> that caught the wait is above the popup rather than inside it — the popup was set aside with the rest of what that boundary holds, and this opening is lost (the next one works). Put a <Loading> inside the popup, or draw the wait in the component itself with useAsyncData(action, { loading: true }).`,
16193
+ );
16061
16194
  };
16062
16195
 
16063
- installImportMetaCssBuild(import.meta);
16196
+ /**
16197
+ * A popup reads "outside" from its own border box: what a press lands on
16198
+ * decides nothing on its own (a genuine backdrop press and a press on the
16199
+ * popup's padding both report the popup element as their target, there being
16200
+ * no real ::backdrop node to be one), so the rectangle is what tells them
16201
+ * apart. That holds as long as the box and what the popup paints are the same
16202
+ * thing — which stops being true for a popup with no surface of its own
16203
+ * (`backgroundColor="transparent"`, no shadow, no padding): what the eye reads
16204
+ * as backdrop is then inside the box, and a press there stays a press on the
16205
+ * popup.
16206
+ *
16207
+ * `data-navi-popup-outside` is how a caller says which of its own boxes are
16208
+ * not the surface. It is opt-in because navi cannot infer it — a background
16209
+ * can come from anywhere — while the caller who made the popup see-through
16210
+ * knows exactly which box is decoration and which is paper.
16211
+ *
16212
+ * The marker answers for the element it is on, never for its descendants: a
16213
+ * box painted inside a marked one is still surface, so a row can be marked and
16214
+ * its empty halves read as backdrop while presses on the controls it holds do
16215
+ * not dismiss anything. A descendant with `pointer-events: none` never becomes
16216
+ * a press target at all, so the marked box answers in its place — while the
16217
+ * same declaration on the marked box itself takes it out of hit-testing and
16218
+ * makes the marker unreachable (see warnAboutUnreachableOutsideRegions).
16219
+ */
16220
+ const OUTSIDE_REGION_ATTRIBUTE = "data-navi-popup-outside";
16064
16221
 
16065
- // Unique for the page's lifetime: a caller may write the id in a commandfor.
16066
- let calloutCount = 0;
16222
+ /**
16223
+ * `data-navi-popup-inside` is the other direction: a box of the PAGE the
16224
+ * caller says belongs to a popup, so a press on it is not outside that popup
16225
+ * even though it lies beyond the border box — a card whose press is what
16226
+ * fills the one panel of a board. Its value names the popup by `id`, the way
16227
+ * `commandfor` does (several ids, space separated), because the exemption is
16228
+ * one popup's: a menu open beside the board still closes on the same press.
16229
+ *
16230
+ * Unlike the outside marker it answers for its whole subtree: the outside
16231
+ * marker carves free space out of a surface whose controls stay surface, this
16232
+ * one adds a whole thing — the card, its count, its buttons — to the popup's
16233
+ * own ground, and nothing in it is a dismissal.
16234
+ */
16235
+ const INSIDE_ATTRIBUTE = "data-navi-popup-inside";
16067
16236
 
16068
- // How far `closeByScroll: true` lets the page move under the callout before
16069
- // dismissing it: small enough that any deliberate scroll gesture reaches it,
16070
- // large enough to survive the pixel or two a trackpad rests on.
16071
- const CLOSE_BY_SCROLL_DISTANCE = 10;
16237
+ /**
16238
+ * `data-navi-popup-group` names the peers of a popup: the triggers of the other
16239
+ * popups a press goes to from this one — a menu bar, where pressing another
16240
+ * menu while one is open switches to it, in that one press. A trigger joins by
16241
+ * carrying the name (a Picker's `popupGroup` writes it on its own), and a popup
16242
+ * reads it off what the press landed on.
16243
+ *
16244
+ * Returns the peer `target` is in, or null.
16245
+ */
16246
+ const findPopupPeer = (target, group) => {
16247
+ if (!group || !target || typeof target.closest !== "function") {
16248
+ return null;
16249
+ }
16250
+ return target.closest(`[data-navi-popup-group="${CSS.escape(group)}"]`);
16251
+ };
16252
+
16253
+ /**
16254
+ * The click a wall took, handed to the peer (see findPopupPeer) it covered.
16255
+ *
16256
+ * A wall cannot let one press through and not another: it is what the pointer
16257
+ * hits, and that is the whole of how it spends a press. So it goes on taking
16258
+ * every one — the page behind stays as unhoverable and unpressable as it looks
16259
+ * — and once the press is over, its click is re-aimed at what the wall stood
16260
+ * over at that point, when that is a peer. The click alone, for the reason
16261
+ * transition_press.js gives: a pointer stream made up with no pointer behind it
16262
+ * starts gestures (a hold, a drag) that nothing ever ends, and a click is what
16263
+ * opens a popup's trigger.
16264
+ *
16265
+ * Called with the click that takes the wall down after a press closed the
16266
+ * popup (see armPointerDownOutsideClose), so the peer opens as the popup it
16267
+ * replaces goes.
16268
+ */
16269
+ const handWallClickToPeer = (
16270
+ clickEvent,
16271
+ { wallEl, popupEl, anchorEl, group },
16272
+ ) => {
16273
+ if (!clickEvent.isTrusted || clickEvent.target !== wallEl) {
16274
+ // A click dispatched by hand is already aimed, and one that did not land
16275
+ // on the wall (a press on the popup's own outside region) was no wall's.
16276
+ return;
16277
+ }
16278
+ // The topmost element under the wall at that point: what the press would
16279
+ // have landed on with no wall. Asked past the wall rather than after hiding
16280
+ // it, which a fading wall may not be yet.
16281
+ const covered = document
16282
+ .elementsFromPoint(clickEvent.clientX, clickEvent.clientY)
16283
+ .find((el) => el !== wallEl && !popupEl.contains(el));
16284
+ const peer = findPopupPeer(covered, group);
16285
+ if (!peer) {
16286
+ return;
16287
+ }
16288
+ if (peer.contains(popupEl) || (anchorEl && peer.contains(anchorEl))) {
16289
+ // The popup's own trigger — the one holding it (a picker renders its
16290
+ // popup inside itself) or the one it opened against: the press through
16291
+ // the wall closed the popup, and handing the trigger its click would open
16292
+ // it again.
16293
+ return;
16294
+ }
16295
+ // The page sees one click, on the peer, rather than two.
16296
+ clickEvent.stopPropagation();
16297
+ // A PointerEvent where the browser makes clicks of them, a MouseEvent
16298
+ // elsewhere: the same kind, copied field by field.
16299
+ const ClickEvent = clickEvent.constructor;
16300
+ covered.dispatchEvent(new ClickEvent(clickEvent.type, clickEvent));
16301
+ };
16302
+
16303
+ /**
16304
+ * What a press landing on a region the caller declared as not-its-surface
16305
+ * does: exactly what the same press on the backdrop would do.
16306
+ *
16307
+ * Lives on the popup's own content element rather than in the backdrop's
16308
+ * handler, for both renderers: the backdrop is a sibling behind the popup
16309
+ * (never an ancestor), so a press inside the popup's box never reaches it.
16310
+ */
16311
+ const handlePressOnOutsideRegion = (
16312
+ mouseDownEvent,
16313
+ { popupEl, openController, pressOutside },
16314
+ ) => {
16315
+ if (mouseDownEvent.button !== 0) {
16316
+ return;
16317
+ }
16318
+ const { target } = mouseDownEvent;
16319
+ if (!target.hasAttribute(OUTSIDE_REGION_ATTRIBUTE)) {
16320
+ return;
16321
+ }
16322
+ if (openedDuringThisPress(openController)) {
16323
+ // The release of the press that opened it is not somebody dismissing it
16324
+ // (see openedDuringThisPress).
16325
+ return;
16326
+ }
16327
+ // A popup opens inside its opener's own subtree, so a press on a region
16328
+ // belonging to a popup nested in this one bubbles through here too — and it
16329
+ // is a press on what is in front, not on this popup's own decoration.
16330
+ if (
16331
+ target.closest(`[navi-control="dialog"], [navi-control="popover"]`) !==
16332
+ popupEl
16333
+ ) {
16334
+ return;
16335
+ }
16336
+ if (pressOutside === "capture") {
16337
+ mouseDownEvent.preventDefault();
16338
+ return;
16339
+ }
16340
+ if (pressOutside === "close" || pressOutside === "cancel") {
16341
+ openController.requestClose(mouseDownEvent, {
16342
+ isCancel: pressOutside === "cancel",
16343
+ });
16344
+ }
16345
+ };
16346
+
16347
+ /**
16348
+ * `pointer-events: none` and `inert` are the two things reached for to say
16349
+ * "this part of my box is not really there", and neither reaches the popup:
16350
+ * inert speaks to the keyboard and to assistive technology, and
16351
+ * pointer-events takes the box out of hit-testing so the press is answered by
16352
+ * an ancestor that is still the popup's own surface. Writing them next to the
16353
+ * marker is the natural mistake, and it makes the marker silently unreachable
16354
+ * — so it is said out loud, once per open.
16355
+ */
16356
+ const warnAboutUnreachableOutsideRegions = (popupEl) => {
16357
+ for (const regionEl of popupEl.querySelectorAll(
16358
+ `[${OUTSIDE_REGION_ATTRIBUTE}]`,
16359
+ )) {
16360
+ if (getComputedStyle(regionEl).pointerEvents === "none") {
16361
+ console.warn(
16362
+ `[navi] ${OUTSIDE_REGION_ATTRIBUTE} on an element with "pointer-events: none" is never read: the press is answered by the nearest ancestor still in hit-testing, which is the popup itself. Drop pointer-events; the marker alone is what makes a press there count as outside.`,
16363
+ regionEl,
16364
+ );
16365
+ }
16366
+ }
16367
+ };
16368
+
16369
+ /**
16370
+ * Hears an outside press from the document itself, for a popup with no
16371
+ * backdrop of its own to catch one: a modal `<dialog>` (whose native
16372
+ * `::backdrop` dispatches nothing a handler can be put on) and any popup the
16373
+ * caller asked to leave the page reachable (`backdrop={false}`).
16374
+ *
16375
+ * The press is read and never taken: no `preventDefault`, no
16376
+ * `stopPropagation`, and the listener is on `document` rather than on
16377
+ * anything covering the page — so what the press landed on hears it too, in
16378
+ * the same gesture, and the popup only takes note. That is the whole point of
16379
+ * a popup with no wall: a press outside is a press on the page, and the page
16380
+ * answers it. The one popup where that cannot hold is the modal dialog, where
16381
+ * the browser has already made everything behind inert before this runs.
16382
+ *
16383
+ * `pressEventType` says which event IS the press, and the two callers want
16384
+ * opposite ones:
16385
+ *
16386
+ * - `"pointerdown"` for a popup with no wall: a page with no wall over it is
16387
+ * a page whose own elements arbitrate their presses, and cancelling a
16388
+ * `pointerdown` — what a drag source and a control keeping the focus where
16389
+ * it is both do — suppresses every compatibility mouse event that would have
16390
+ * followed it. The press happens, reaches its target, and no `mousedown` is
16391
+ * ever dispatched for it. Reading the pointer event is reading the press
16392
+ * itself, whatever anything does with it afterwards, and it is the same
16393
+ * event the drag and surface detectors read.
16394
+ * - `"mousedown"` for the modal dialog, whose native `::backdrop` exists to
16395
+ * spend the press. The page's controls act on `mousedown`, and on a touch
16396
+ * screen that is not the same moment as `pointerdown`: the compatibility
16397
+ * mouse events are synthesized at `touchend` and hit-tested again then.
16398
+ * Closing on `pointerdown` takes the `::backdrop` away before that second
16399
+ * hit-test, so the `mousedown` lands on whatever the page holds under the
16400
+ * finger, and one tap both dismisses the dialog and presses the page. Read
16401
+ * on `mousedown`, the wall is still up when the press the page acts on is
16402
+ * aimed, and it catches it. Nothing behind a modal wall can cancel that
16403
+ * press, since nothing behind it hears the `pointerdown` at all.
16404
+ *
16405
+ * Capture phase, so a handler downstream that stops propagation cannot keep
16406
+ * the popup open either — the press is still outside whatever is made of it.
16407
+ *
16408
+ * Returns a cleanup that removes the listener.
16409
+ */
16410
+ const armOutsidePressClose = (
16411
+ popupEl,
16412
+ { openController, pressOutside, pressEventType = "pointerdown" },
16413
+ ) => {
16414
+ const onDocumentPointerDown = (pointerDownEvent) => {
16415
+ if (pointerDownEvent.button !== 0) {
16416
+ return;
16417
+ }
16418
+ if (openedDuringThisPress(openController)) {
16419
+ // The release of the press that opened it is not somebody dismissing it
16420
+ // (see openedDuringThisPress).
16421
+ return;
16422
+ }
16423
+ // The press landed inside another popup: that is a press on what is in
16424
+ // front, not outside. Asking the target where it lives rather than asking
16425
+ // this popup whether it was pushed — a popup in front does not have to be
16426
+ // one this popup knows about. In front means neither inside nor around: a
16427
+ // popup nested in this one falls to the containment check below as the
16428
+ // inside press it is, and one this popup opened INSIDE (a bubble rendered
16429
+ // in a sheet's content) is around it, so what that popup holds is outside
16430
+ // this one exactly like the rest of the page.
16431
+ const popupUnderPointer = pointerDownEvent.target.closest?.(
16432
+ `[navi-control="dialog"], [navi-control="popover"]`,
16433
+ );
16434
+ if (
16435
+ popupUnderPointer &&
16436
+ popupUnderPointer !== popupEl &&
16437
+ !popupEl.contains(popupUnderPointer) &&
16438
+ !popupUnderPointer.contains(popupEl)
16439
+ ) {
16440
+ return;
16441
+ }
16442
+ if (
16443
+ popupEl.id &&
16444
+ pointerDownEvent.target.closest?.(
16445
+ `[${INSIDE_ATTRIBUTE}~="${CSS.escape(popupEl.id)}"]`,
16446
+ )
16447
+ ) {
16448
+ return;
16449
+ }
16450
+ // Real DOM containment wins over the rectangle below — an element
16451
+ // genuinely inside the popup (`overflow: visible`, a negative margin, an
16452
+ // absolutely-positioned child) can be painted outside its border box, and
16453
+ // a press there is not outside just because its coordinates are. The
16454
+ // popup element itself is excluded (contains() answers true for it): a
16455
+ // press on a modal dialog's native backdrop reports the dialog as its
16456
+ // target, there being no `::backdrop` node to be one, so treating that as
16457
+ // contained would keep the rectangle from ever running.
16458
+ if (
16459
+ pointerDownEvent.target !== popupEl &&
16460
+ popupEl.contains(pointerDownEvent.target)
16461
+ ) {
16462
+ return;
16463
+ }
16464
+ const rect = popupEl.getBoundingClientRect();
16465
+ const isOutside =
16466
+ pointerDownEvent.clientX < rect.left ||
16467
+ pointerDownEvent.clientX > rect.right ||
16468
+ pointerDownEvent.clientY < rect.top ||
16469
+ pointerDownEvent.clientY > rect.bottom;
16470
+ if (!isOutside) {
16471
+ return;
16472
+ }
16473
+ openController.requestClose(pointerDownEvent, {
16474
+ isCancel: pressOutside === "cancel",
16475
+ });
16476
+ };
16477
+ document.addEventListener(pressEventType, onDocumentPointerDown, {
16478
+ capture: true,
16479
+ });
16480
+ return () => {
16481
+ document.removeEventListener(pressEventType, onDocumentPointerDown, {
16482
+ capture: true,
16483
+ });
16484
+ };
16485
+ };
16486
+
16487
+ /**
16488
+ * Typography reset shared by the surfaces a control opens: callout, popover,
16489
+ * dialog.
16490
+ *
16491
+ * A surface is painted on top of the page but lives in the DOM subtree of the
16492
+ * element it opens from, so every inherited text property of that element
16493
+ * reaches it. The ink an element chose for its own background — centered,
16494
+ * shadowed, uppercase, letter-spaced, kept on one line — arrives on paper that
16495
+ * has none of that background, and the caller discovers it as a symptom
16496
+ * (a blurred tooltip, a centered message) with nothing on screen pointing back
16497
+ * to the rule three elements up. A surface writes its own text, on its own
16498
+ * paper, in the document's terms.
16499
+ *
16500
+ * Every one of them is set to its initial value rather than `revert`: only
16501
+ * `color` and `background-color` are declared by the UA on a `[popover]`/
16502
+ * `<dialog>`, so `revert` on any of these would roll back to the inherited
16503
+ * value — the very thing to stop. `text-align: initial` is `start`, which
16504
+ * still reads the surface's own `direction`, so an RTL document keeps its
16505
+ * side.
16506
+ *
16507
+ * `font-family` is deliberately absent: a surface keeps the face of what
16508
+ * opened it, so a section written in a display font gets its tooltips in that
16509
+ * font. `color`, `font-size` and `font-weight` are absent too — each surface
16510
+ * answers those its own way (a popup writes in --navi-popup-color and follows
16511
+ * the size of what opened it; a callout reverts to the document's ink and
16512
+ * size).
16513
+ *
16514
+ * In `@layer navi` so an app that wants one of these back says so on the
16515
+ * surface and wins, without having to out-specify anything.
16516
+ */
16517
+ const surfaceTextCss = /* css */ `
16518
+ @layer navi {
16519
+ .navi_callout,
16520
+ .navi_popover,
16521
+ .navi_dialog {
16522
+ font-style: initial;
16523
+ text-align: initial;
16524
+ text-indent: initial;
16525
+ text-transform: initial;
16526
+ text-shadow: none;
16527
+ white-space: initial;
16528
+ word-spacing: initial;
16529
+ letter-spacing: initial;
16530
+ }
16531
+ }
16532
+ `;
16533
+
16534
+ const CalloutContext = createContext();
16535
+ const useCalloutRequestClose = () => {
16536
+ return useContext(CalloutContext)?.requestClose;
16537
+ };
16538
+ const useCalloutElement = () => {
16539
+ return useContext(CalloutContext)?.element;
16540
+ };
16541
+ const renderIntoCallout = (jsx$1, calloutMessageElement, {
16542
+ requestClose,
16543
+ element
16544
+ }) => {
16545
+ const calloutJsx = jsx(CalloutContext.Provider, {
16546
+ value: {
16547
+ requestClose,
16548
+ element
16549
+ },
16550
+ children: jsx$1
16551
+ });
16552
+ render(calloutJsx, calloutMessageElement);
16553
+ };
16554
+
16555
+ // An HTML message is rendered through preact rather than innerHTML: what a
16556
+ // message holds is then a preact tree like any other, so a callout's own JSX
16557
+ // (its context, its close button) works the same whether the message arrived
16558
+ // as markup or as elements.
16559
+ const renderHtmlIntoCallout = (html, calloutMessageElement, {
16560
+ requestClose,
16561
+ element
16562
+ }) => {
16563
+ const template = document.createElement("template");
16564
+ template.innerHTML = html;
16565
+ renderIntoCallout(domToVNodes(template.content), calloutMessageElement, {
16566
+ requestClose,
16567
+ element
16568
+ });
16569
+ };
16570
+
16571
+ // Unmounts whatever preact rendered before the element is filled by hand
16572
+ // (a DOM node, an iframe); innerHTML alone would leave preact believing its
16573
+ // tree is still there.
16574
+ const clearCalloutMessage = calloutMessageElement => {
16575
+ render(null, calloutMessageElement);
16576
+ calloutMessageElement.innerHTML = "";
16577
+ };
16578
+ const domToVNodes = node => {
16579
+ const vnodes = [];
16580
+ for (const child of node.childNodes) {
16581
+ if (child.nodeType === Node.TEXT_NODE) {
16582
+ vnodes.push(child.data);
16583
+ continue;
16584
+ }
16585
+ if (child.nodeType !== Node.ELEMENT_NODE) {
16586
+ continue;
16587
+ }
16588
+ const props = {};
16589
+ for (const {
16590
+ name,
16591
+ value
16592
+ } of child.attributes) {
16593
+ props[name] = value;
16594
+ }
16595
+ vnodes.push(h(child.localName, props, ...domToVNodes(child)));
16596
+ }
16597
+ return vnodes;
16598
+ };
16599
+
16600
+ installImportMetaCssBuild(import.meta);
16601
+ /**
16602
+ * The icon a callout shows beside its message — a square in the status color
16603
+ * with the "!" glyph — drawable on its own, where the callout is not: on the
16604
+ * trigger that opens one (a picker in callout mode), so what one presses looks
16605
+ * like what it opens. The callout's own template draws from the same glyph
16606
+ * (see calloutTemplate in callout.js).
16607
+ */
16608
+
16609
+ const css$15 = /* css */`.navi_callout_status_icon {
16610
+ --x-callout-status-icon-color: var(--navi-callout-neutral-color);
16611
+ box-sizing: border-box;
16612
+ aspect-ratio: 1;
16613
+ color: #fff;
16614
+ vertical-align: middle;
16615
+ background-color: var(--x-callout-status-icon-color);
16616
+ border-radius: 2px;
16617
+ flex-shrink: 0;
16618
+ justify-content: center;
16619
+ align-items: center;
16620
+ width: 1em;
16621
+ height: 1em;
16622
+ display: inline-flex;
16623
+
16624
+ &[data-status="success"] {
16625
+ --x-callout-status-icon-color: var(--navi-callout-success-color);
16626
+ }
16627
+
16628
+ &[data-status="info"] {
16629
+ --x-callout-status-icon-color: var(--navi-callout-info-color);
16630
+ }
16631
+
16632
+ &[data-status="warning"] {
16633
+ --x-callout-status-icon-color: var(--navi-callout-warning-color);
16634
+ }
16635
+
16636
+ &[data-status="error"] {
16637
+ --x-callout-status-icon-color: var(--navi-callout-error-color);
16638
+ }
16639
+
16640
+ &[data-shape="circle"] {
16641
+ border-radius: 50%;
16642
+ }
16643
+
16644
+ & svg {
16645
+ width: 100%;
16646
+ height: 100%;
16647
+ }
16648
+ }
16649
+
16650
+ .navi_icon > .navi_callout_status_icon {
16651
+ width: 100%;
16652
+ height: 100%;
16653
+ }
16654
+ `;
16655
+
16656
+ /**
16657
+ * The "!" on a square, viewBox and path both. The box is square and holds the
16658
+ * glyph's margin: the ink is 298 of 540 units tall (55%), centered. Drawn this
16659
+ * way rather than tight around the ink so whoever shows it can give the svg the
16660
+ * whole square — the glyph is then placed by the vector rasteriser, which has
16661
+ * no device pixel grid to miss, at any square size down to a fractional one.
16662
+ */
16663
+ const CALLOUT_STATUS_GLYPH_VIEWBOX = "0 0 540 540";
16664
+ const CALLOUT_STATUS_GLYPH_PATH = "m232.5,121 8,196h59l8-196zm36.5,224a37,37 0 1,0 2,0z";
16665
+
16666
+ /**
16667
+ * @type {import("ignore:preact").FunctionComponent<{
16668
+ * status?: "info" | "warning" | "error" | "success" | "none",
16669
+ * shape?: "square" | "circle",
16670
+ * }>}
16671
+ * @param {"info"|"warning"|"error"|"success"|"none"} [status="info"] The color
16672
+ * — the callout's own for that status. `"none"` is the neutral one.
16673
+ * @param {"square"|"circle"} [shape="square"] Square like the callout's own
16674
+ * icon, or a circle.
16675
+ */
16676
+ const CalloutStatusIcon = ({
16677
+ status = "info",
16678
+ shape = "square"
16679
+ }) => {
16680
+ import.meta.css = [css$15, "@jsenv/navi/src/control/rules/callout/callout_status_icon.jsx"];
16681
+ return jsx("span", {
16682
+ className: "navi_callout_status_icon",
16683
+ "data-status": status === "none" ? undefined : status,
16684
+ "data-shape": shape === "circle" ? "circle" : undefined,
16685
+ "aria-hidden": "true",
16686
+ children: jsx("svg", {
16687
+ viewBox: CALLOUT_STATUS_GLYPH_VIEWBOX,
16688
+ xmlns: "http://www.w3.org/2000/svg",
16689
+ children: jsx("path", {
16690
+ fill: "currentColor",
16691
+ d: CALLOUT_STATUS_GLYPH_PATH
16692
+ })
16693
+ })
16694
+ });
16695
+ };
16696
+
16697
+ installImportMetaCssBuild(import.meta);
16698
+
16699
+ // Unique for the page's lifetime: a caller may write the id in a commandfor.
16700
+ let calloutCount = 0;
16701
+
16702
+ // How far `closeByScroll: true` lets the page move under the callout before
16703
+ // dismissing it: small enough that any deliberate scroll gesture reaches it,
16704
+ // large enough to survive the pixel or two a trackpad rests on.
16705
+ const CLOSE_BY_SCROLL_DISTANCE = 10;
16706
+
16707
+ // The events of a press a `backdrop` callout spends, up to its click (which
16708
+ // closes the callout and is spent there), and `pointercancel`, which says the
16709
+ // browser took the press. No touch events: they are the scroll's, and what
16710
+ // listens to them — a list learning the user took its scroll over — is
16711
+ // watching the finger, not answering the press.
16712
+ const SPENT_PRESS_EVENT_TYPES = [
16713
+ "pointerdown",
16714
+ "pointerup",
16715
+ "pointercancel",
16716
+ "mousedown",
16717
+ "mouseup",
16718
+ "contextmenu",
16719
+ ];
16720
+ // Where a press lands on something drawn over the page rather than on the page
16721
+ // itself: a popup of navi's (either layer) or a native top-layer element.
16722
+ const LAYER_SELECTOR =
16723
+ '[popover], dialog, [navi-control="popover"], [navi-control="dialog"]';
16072
16724
 
16073
16725
  const css$14 = /* css */ `
16074
16726
  /* jsenv-css-opaque: same as dialog.jsx, cascade order. */
@@ -16358,6 +17010,17 @@ const css$14 = /* css */ `
16358
17010
  * the callout opened, so scrolling back cancels nothing but a round trip shorter than the
16359
17011
  * distance leaves it open. Unlike an outside click it also dismisses an "error" callout: the
16360
17012
  * caller asked for it explicitly.
17013
+ * @param {boolean} [options.backdrop=false] - Spend an outside press on closing the callout,
17014
+ * the way a popup's wall spends it: no link followed, no control pressed, no focus moved, no
17015
+ * hold or drag started under it. Without it that press closes the callout and is answered by
17016
+ * what it landed on too. Not a wall, though: the scroll a finger starts beside the callout
17017
+ * still scrolls, a press on a layer drawn over the page (a sheet the callout's content
17018
+ * opened) is that layer's, and a press that does not close the callout (an "error" one) is
17019
+ * not spent either
17020
+ * @param {string} [options.group] - With `backdrop`: the name the callout's peers carry in
17021
+ * `data-navi-popup-group` — the triggers of other popups, callouts or not. A press inside a
17022
+ * peer still closes the callout but is not spent, so the peer it landed on opens its own
17023
+ * popup in the same press — a menu bar, where pressing another menu switches to it
16361
17024
  * @param {Event} [options.openingEvent] - The gesture the callout belongs to. It names the
16362
17025
  * opener — `currentTarget` while the event is still dispatching, `target` once it is over,
16363
17026
  * so awaiting before opening changes nothing — and the opener is the one part of the anchor
@@ -16422,6 +17085,8 @@ const openCallout = (
16422
17085
  closeByPressOutside = status === "info",
16423
17086
  closeOnFocusLeave = closeByPressOutside,
16424
17087
  closeByScroll = false,
17088
+ backdrop = false,
17089
+ group,
16425
17090
  closeButton = true,
16426
17091
  openingEvent,
16427
17092
  reopen = "toggle",
@@ -16507,6 +17172,9 @@ const openCallout = (
16507
17172
  reason,
16508
17173
  });
16509
17174
  };
17175
+ // Asked again at each press rather than once: `update` changes both.
17176
+ const pressOutsideCloses = () =>
17177
+ closeByPressOutside && callout.status !== "error";
16510
17178
  const onRequestClose = (event) => {
16511
17179
  if (!callout.opened) {
16512
17180
  return;
@@ -16515,10 +17183,7 @@ const openCallout = (
16515
17183
  const clickOrSpaceOutside =
16516
17184
  reason === "click_outside" || reason === "space_outside";
16517
17185
  if (clickOrSpaceOutside) {
16518
- if (!closeByPressOutside) {
16519
- return;
16520
- }
16521
- if (callout.status === "error") {
17186
+ if (!pressOutsideCloses()) {
16522
17187
  return;
16523
17188
  }
16524
17189
  } else if (reason === "focus_leave") {
@@ -16793,16 +17458,36 @@ const openCallout = (
16793
17458
  }
16794
17459
  return openerElement === target || openerElement.contains(target);
16795
17460
  };
17461
+ // Whether an outside press on `target` is spent on closing the callout
17462
+ // (`backdrop`). What is not the page behind the callout keeps its press: a
17463
+ // layer the callout is not part of — its own page may well be a sheet, a
17464
+ // sheet its content opened is in front of it — and a peer, which the press
17465
+ // is meant to open.
17466
+ const spendsPressOn = (target) => {
17467
+ if (!backdrop || !pressOutsideCloses()) {
17468
+ return false;
17469
+ }
17470
+ const layer = target.closest?.(LAYER_SELECTOR);
17471
+ if (layer && !layer.contains(calloutElement)) {
17472
+ return false;
17473
+ }
17474
+ if (findPopupPeer(target, group)) {
17475
+ return false;
17476
+ }
17477
+ return true;
17478
+ };
17479
+ const spend = (event) => {
17480
+ debug(event, `outside press spent on closing the callout (backdrop)`);
17481
+ event.preventDefault();
17482
+ event.stopPropagation();
17483
+ };
16796
17484
  const handleClickOutside = (event) => {
16797
17485
  if (event.button !== 0) {
16798
17486
  // right click
16799
17487
  return;
16800
17488
  }
16801
17489
  const clickTarget = event.target;
16802
- if (
16803
- clickTarget === calloutElement ||
16804
- calloutElement.contains(clickTarget)
16805
- ) {
17490
+ if (calloutElement.contains(clickTarget)) {
16806
17491
  return;
16807
17492
  }
16808
17493
  if (isInsideOpener(clickTarget)) {
@@ -16815,14 +17500,20 @@ const openCallout = (
16815
17500
  debug(event, `click on opener, let its handler decide`);
16816
17501
  return;
16817
17502
  }
17503
+ // Read before the close, which takes the callout out of the document —
17504
+ // and out of the layer it stands in, for spendsPressOn to find.
17505
+ const spent = spendsPressOn(clickTarget);
16818
17506
  requestClose(event, "click_outside");
17507
+ if (spent) {
17508
+ spend(event);
17509
+ }
16819
17510
  };
16820
17511
  const handleSpaceOutside = (event) => {
16821
17512
  if (event.key !== " ") {
16822
17513
  return;
16823
17514
  }
16824
17515
  const keyTarget = event.target;
16825
- if (keyTarget === calloutElement || calloutElement.contains(keyTarget)) {
17516
+ if (calloutElement.contains(keyTarget)) {
16826
17517
  return;
16827
17518
  }
16828
17519
  if (isInsideOpener(keyTarget)) {
@@ -16831,7 +17522,41 @@ const openCallout = (
16831
17522
  debug(event, `space on opener, let its handler decide`);
16832
17523
  return;
16833
17524
  }
17525
+ const spent = spendsPressOn(keyTarget);
16834
17526
  requestClose(event, "space_outside");
17527
+ if (spent) {
17528
+ spend(event);
17529
+ }
17530
+ };
17531
+ // The press before its click: a click ends a gesture whose pointerdown
17532
+ // already reached the page, and what acts on the press itself — a hold, a
17533
+ // drag, the focus moving — would otherwise happen under a callout that only
17534
+ // closes on the click. Decided at the pointerdown and kept for the whole
17535
+ // press, wherever its later events land: a release beside the callout ends
17536
+ // a drag started inside it, and must reach it.
17537
+ let pressSpent = false;
17538
+ const handlePressOutside = (event) => {
17539
+ if (event.type === "pointerdown") {
17540
+ const pressTarget = event.target;
17541
+ pressSpent =
17542
+ event.button === 0 &&
17543
+ !calloutElement.contains(pressTarget) &&
17544
+ !isInsideOpener(pressTarget) &&
17545
+ spendsPressOn(pressTarget);
17546
+ } else if (event.type === "pointercancel") {
17547
+ // The browser took the press over — a scroll — and nothing follows it.
17548
+ pressSpent = false;
17549
+ return;
17550
+ }
17551
+ if (!pressSpent) {
17552
+ return;
17553
+ }
17554
+ if (event.type === "mousedown" || event.type === "contextmenu") {
17555
+ // What the press does on its own: the focus moving, a selection
17556
+ // starting, the menu of a held finger.
17557
+ event.preventDefault();
17558
+ }
17559
+ event.stopPropagation();
16835
17560
  };
16836
17561
 
16837
17562
  const registerClickOutsideListener = () => {
@@ -16841,6 +17566,16 @@ const openCallout = (
16841
17566
  document.removeEventListener("click", handleClickOutside, true);
16842
17567
  document.removeEventListener("keydown", handleSpaceOutside, true);
16843
17568
  });
17569
+ if (backdrop) {
17570
+ for (const type of SPENT_PRESS_EVENT_TYPES) {
17571
+ document.addEventListener(type, handlePressOutside, true);
17572
+ }
17573
+ addTeardown(() => {
17574
+ for (const type of SPENT_PRESS_EVENT_TYPES) {
17575
+ document.removeEventListener(type, handlePressOutside, true);
17576
+ }
17577
+ });
17578
+ }
16844
17579
  };
16845
17580
  // A callout opened during a press (mousedown or pointerdown) must wait for the
16846
17581
  // matching release before listening for click-outside, otherwise the same
@@ -18045,7 +18780,7 @@ const warnOnReopenWithinSameEvent = (anchorElement, openingEvent) => {
18045
18780
  *
18046
18781
  * Usage:
18047
18782
  * const myToken = createOpenToken();
18048
- * calloutManager.addOpenToken(myToken, { message, status, testId, closeByScroll, anchorElement, event, skipFocus, onClose });
18783
+ * calloutManager.addOpenToken(myToken, { message, status, testId, closeByScroll, backdrop, group, anchorElement, event, skipFocus, onClose });
18049
18784
  * calloutManager.removeOpenToken(myToken, event);
18050
18785
  * calloutManager.requestCloseCallout(event, debugReason); // force-close all
18051
18786
  * calloutManager.callout // current open callout or null
@@ -18103,6 +18838,8 @@ const createCalloutManager = (
18103
18838
  closeButton: tokenData.closeButton,
18104
18839
  closeByPressOutside: tokenData.status !== "error",
18105
18840
  closeByScroll: tokenData.closeByScroll,
18841
+ backdrop: tokenData.backdrop,
18842
+ group: tokenData.group,
18106
18843
  anchorElement,
18107
18844
  openingEvent: event,
18108
18845
  skipFocus: tokenData.skipFocus,
@@ -18231,6 +18968,8 @@ const createCalloutManager = (
18231
18968
  icon,
18232
18969
  closeButton,
18233
18970
  closeByScroll,
18971
+ backdrop,
18972
+ group,
18234
18973
  anchorElement,
18235
18974
  event,
18236
18975
  skipFocus,
@@ -18248,6 +18987,8 @@ const createCalloutManager = (
18248
18987
  icon,
18249
18988
  closeButton,
18250
18989
  closeByScroll,
18990
+ backdrop,
18991
+ group,
18251
18992
  skipFocus,
18252
18993
  onClose,
18253
18994
  // Resolved as the token is added, and kept with it: a token shown later
@@ -24495,7 +25236,16 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
24495
25236
  }
24496
25237
  return itemSignal;
24497
25238
  };
24498
- const upsert = (...args) => {
25239
+ // A value changes a row when it is another object — the app writing a new
25240
+ // one says something changed, and that is taken as said. An answer says it
25241
+ // with every object nested in its rows: JSON.parse makes them all new, the
25242
+ // ones that did not move included, so a revalidation returning the same
25243
+ // rows would replace every one of them for every reader. What an answer
25244
+ // sends again equal in depth is the value already held (see upsertAnswer).
25245
+ const valueChangedByWrite = (newValue, oldValue) => newValue !== oldValue;
25246
+ const valueChangedByAnswer = (newValue, oldValue) =>
25247
+ newValue !== oldValue && !compareTwoJsValues(newValue, oldValue);
25248
+ const upsertWith = (args, valueChanged) => {
24499
25249
  const mutationsMap = new Map(); // Map<itemId, propertyMutations>
24500
25250
  const triggerPropertyMutations = () => {
24501
25251
  for (const itemPropertiesObserver of itemPropertiesObserverSet) {
@@ -24559,7 +25309,27 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
24559
25309
  const newValue = props[key];
24560
25310
  if (itemOwnKeys.includes(key)) {
24561
25311
  const oldValue = item[key];
24562
- if (newValue !== oldValue) {
25312
+ const { set } = itemOwnPropertyDescriptors[key];
25313
+ if (set) {
25314
+ // A relation (.one(), .many() and their scoped forms in
25315
+ // resource_graph.js): the row holds which children it points to,
25316
+ // the children live in their own store, and whoever reads them
25317
+ // follows them there. Its setter writes them and says whether the
25318
+ // row points elsewhere now. Compared as a value instead, the
25319
+ // children received are never the object the row hands out, and
25320
+ // every write naming the relation would replace the row.
25321
+ if (set.call(itemWithProps, newValue)) {
25322
+ hasChanges = true;
25323
+ propertyMutations[key] = {
25324
+ oldValue,
25325
+ newValue,
25326
+ target: item,
25327
+ newTarget: itemWithProps,
25328
+ };
25329
+ }
25330
+ continue;
25331
+ }
25332
+ if (valueChanged(newValue, oldValue)) {
24563
25333
  hasChanges = true;
24564
25334
  itemWithProps[key] = newValue;
24565
25335
  propertyMutations[key] = {
@@ -24711,6 +25481,9 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
24711
25481
  triggerPropertyMutations();
24712
25482
  return item;
24713
25483
  };
25484
+ const upsert = (...args) => upsertWith(args, valueChangedByWrite);
25485
+ // The same, for the result of a callback — what the server answered.
25486
+ const upsertAnswer = (...args) => upsertWith(args, valueChangedByAnswer);
24714
25487
  const drop = (...args) => {
24715
25488
  const removedItemArray = [];
24716
25489
  const triggerRemovedMutations = () => {
@@ -24887,6 +25660,7 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
24887
25660
  selectAll,
24888
25661
  itemSignalForId,
24889
25662
  upsert,
25663
+ upsertAnswer,
24890
25664
  drop,
24891
25665
 
24892
25666
  observeItemProperties,
@@ -25644,7 +26418,7 @@ const createRangeReader = (
25644
26418
  `${actionName} must return { items, start, count }, received ${describeResult(result)}.`,
25645
26419
  );
25646
26420
  }
25647
- const items = store.upsert(result.items);
26421
+ const items = store.upsertAnswer(result.items);
25648
26422
  let { start, count } = result;
25649
26423
  if (start === undefined) {
25650
26424
  const startAsked = rangeParams.start;
@@ -26305,7 +27079,7 @@ const createResource = (
26305
27079
  : // GET/PUT contract (see .one() JSDoc): the parent object with the
26306
27080
  // relationship nested inside, or null for no relationship.
26307
27081
  (result) => {
26308
- const item = store.upsert(result);
27082
+ const item = store.upsertAnswer(result);
26309
27083
  const childItem = item[propertyName];
26310
27084
  return childItem ? childItem[childIdKey] : undefined;
26311
27085
  };
@@ -26486,8 +27260,8 @@ const createResource = (
26486
27260
  : (childData) => {
26487
27261
  // an array is [property, value, props], used to rename the child id
26488
27262
  const childItem = Array.isArray(childData)
26489
- ? childStore.upsert(...childData)
26490
- : childStore.upsert(childData);
27263
+ ? childStore.upsertAnswer(...childData)
27264
+ : childStore.upsertAnswer(childData);
26491
27265
  return childItem[childIdKey];
26492
27266
  };
26493
27267
  const throwInvalidResult = createInvalidResultThrower(
@@ -26529,7 +27303,7 @@ const createResource = (
26529
27303
  ? (result) => {
26530
27304
  // GET_MANY contract (see .many() JSDoc): the parent object with
26531
27305
  // the child array nested inside; the array replaces the relationship.
26532
- const item = store.upsert(result);
27306
+ const item = store.upsertAnswer(result);
26533
27307
  const childItemArray = item[propertyName];
26534
27308
  return childItemArray.map((childItem) => childItem[childIdKey]);
26535
27309
  }
@@ -26560,7 +27334,7 @@ const createResource = (
26560
27334
  return deletedChildItemIdArray;
26561
27335
  }
26562
27336
  : (childDataArray) => {
26563
- const childItemArray = childStore.upsert(childDataArray);
27337
+ const childItemArray = childStore.upsertAnswer(childDataArray);
26564
27338
  return childItemArray.map((childItem) => childItem[childIdKey]);
26565
27339
  };
26566
27340
  const throwInvalidResult = createInvalidResultThrower(
@@ -26699,18 +27473,37 @@ const createResource = (
26699
27473
  childSetup(childItem);
26700
27474
  }
26701
27475
  const childSignal = signal(null);
27476
+ // Returns whether the child changed — what the store asks a relation
27477
+ // (see assign in array_signal_store.js). Assigned in place, the child
27478
+ // tells nobody itself: a prop it receives changed is a change of the
27479
+ // owner's row, which is how its readers see it.
26702
27480
  const applyProps = (props) => {
26703
27481
  if (!props) {
27482
+ if (childSignal.peek() === null) {
27483
+ return false;
27484
+ }
26704
27485
  childSignal.value = null;
26705
- return;
27486
+ return true;
26706
27487
  }
26707
- // Assign each prop in place. Reactive setters (from chained .one() etc.) will fire.
27488
+ let changed = false;
27489
+ // Assign each prop in place. Reactive setters (from chained .one()
27490
+ // etc.) fire, and say whether their relation changed.
26708
27491
  for (const [key, value] of Object.entries(props)) {
26709
- childItem[key] = value;
27492
+ const descriptor = Object.getOwnPropertyDescriptor(childItem, key);
27493
+ if (descriptor && descriptor.set) {
27494
+ if (descriptor.set.call(childItem, value)) {
27495
+ changed = true;
27496
+ }
27497
+ } else if (childItem[key] !== value) {
27498
+ childItem[key] = value;
27499
+ changed = true;
27500
+ }
26710
27501
  }
26711
27502
  if (childSignal.peek() !== childItem) {
26712
27503
  childSignal.value = childItem; // first activation: null → childItem
27504
+ changed = true;
26713
27505
  }
27506
+ return changed;
26714
27507
  };
26715
27508
  if (!ownerName) {
26716
27509
  // Declared on a scoped child, an owner id is only unique inside its
@@ -26998,7 +27791,7 @@ const createResource = (
26998
27791
  if (isMany) {
26999
27792
  // GET_MANY, POST_MANY, PUT_MANY etc: rest[0] is the array of items,
27000
27793
  // and it replaces the whole collection.
27001
- const itemArray = childStore.upsert(rest[0]);
27794
+ const itemArray = childStore.upsertAnswer(rest[0]);
27002
27795
  const idArray = itemArray.map((childItem) => childItem[childIdKey]);
27003
27796
  idArraySignal.value = idArray;
27004
27797
  return [ownerId, idArray];
@@ -27007,8 +27800,8 @@ const createResource = (
27007
27800
  // GET, POST, PUT, PATCH: rest may be [props] or [oldId, props] for renames
27008
27801
  const childItem =
27009
27802
  rest.length > 1
27010
- ? childStore.upsert(...rest)
27011
- : childStore.upsert(rest[0]);
27803
+ ? childStore.upsertAnswer(...rest)
27804
+ : childStore.upsertAnswer(rest[0]);
27012
27805
  return [ownerId, childItem[childIdKey]];
27013
27806
  },
27014
27807
  valueToData: (value) => {
@@ -27144,8 +27937,8 @@ const createRestActionFactoryForRoot = (
27144
27937
  // An array result is [property, value, props] — used to rename the
27145
27938
  // idKey of an item: store.upsert("name", "currentName", { name: "newName" })
27146
27939
  const item = Array.isArray(result)
27147
- ? store.upsert(...result)
27148
- : store.upsert(result);
27940
+ ? store.upsertAnswer(...result)
27941
+ : store.upsertAnswer(result);
27149
27942
  return item[idKey];
27150
27943
  };
27151
27944
  const throwInvalidResult = createInvalidResultThrower(
@@ -27209,7 +28002,7 @@ const createRestActionFactoryForRoot = (
27209
28002
  verb === "DELETE"
27210
28003
  ? (idOrMutableIdArray) => store.drop(idOrMutableIdArray)
27211
28004
  : (dataArray) => {
27212
- const itemArray = store.upsert(dataArray);
28005
+ const itemArray = store.upsertAnswer(dataArray);
27213
28006
  return itemArray.map((item) => item[idKey]);
27214
28007
  };
27215
28008
 
@@ -27338,14 +28131,17 @@ ${relationLabel} source location: ${declarationSite}`,
27338
28131
  // array of child props/ids, or anything else meaning "empty") into an array of
27339
28132
  // child ids, upserting each entry into the child store. The id array signal is
27340
28133
  // only touched when the resulting ids actually differ.
28134
+ // Returns whether the children the row points to changed — what the store asks
28135
+ // a relation (see assign in array_signal_store.js).
27341
28136
  const createChildIdArrayUpdater = (childStore, childIdKey, idArraySignal) => {
27342
28137
  return (valueArray) => {
27343
28138
  const currentIdArray = idArraySignal.peek();
27344
28139
  if (!Array.isArray(valueArray)) {
27345
28140
  if (currentIdArray.length > 0) {
27346
28141
  idArraySignal.value = [];
28142
+ return true;
27347
28143
  }
27348
- return;
28144
+ return false;
27349
28145
  }
27350
28146
  const idArray = [];
27351
28147
  let modified = false;
@@ -27367,7 +28163,9 @@ const createChildIdArrayUpdater = (childStore, childIdKey, idArraySignal) => {
27367
28163
  }
27368
28164
  if (modified || currentIdArray.length !== idArray.length) {
27369
28165
  idArraySignal.value = idArray;
28166
+ return true;
27370
28167
  }
28168
+ return false;
27371
28169
  };
27372
28170
  };
27373
28171
 
@@ -27785,6 +28583,24 @@ const TYPE_CONVERTERS = {
27785
28583
  },
27786
28584
  };
27787
28585
 
28586
+ /**
28587
+ * What a component can learn about the page it is rendered in, from the route
28588
+ * container rendering that page (see useContentKeepingLeavingPages in
28589
+ * route.jsx). `null` outside of any route container.
28590
+ *
28591
+ * - `isShown()` — whether the container would still show the page. False as
28592
+ * soon as the address leads elsewhere: the page is about to be taken down, or
28593
+ * hidden until the route transition leaving it is over, and nothing it reads
28594
+ * is worth a render any more. False for good once it is kept while leaving: a
28595
+ * return to its route mounts the page anew. Asked outside any render — by a
28596
+ * subscription deciding whether to re-render — and it reads the routes
28597
+ * untracked, so asking subscribes nobody to them.
28598
+ * - `leavingSignal` — true once the page is hidden and kept while its movement
28599
+ * plays: still mounted, so what it fills outside its own nodes (a slot in a
28600
+ * bar) steps out rather than showing with the page arriving.
28601
+ */
28602
+ const RoutePageContext = createContext(null);
28603
+
27788
28604
  const promiseStateWeakMap = new WeakMap();
27789
28605
  const usePromiseAsyncData = (
27790
28606
  promise,
@@ -27920,12 +28736,39 @@ const useActionAsyncData = (action, {
27920
28736
  // has already processed the settlement and the detached DOM is discarded.
27921
28737
  const runningState = action.runningStateSignal.peek();
27922
28738
  const [, setTick] = useState(0);
28739
+ const routePage = useContext(RoutePageContext);
27923
28740
  useEffect(() => {
28741
+ let stopOwingRender = null;
28742
+ const rerender = () => {
28743
+ // Not for a page the address has left: it is about to be taken down,
28744
+ // or hidden until the route transition leaving it is over, and its
28745
+ // route's action moving as that route stops matching would re-render
28746
+ // the whole page — inside the transition's update callback, where
28747
+ // every render delays the movement (see route_page_context.js).
28748
+ if (routePage && !routePage.isShown()) {
28749
+ // Owed rather than dropped: the address can come back before the
28750
+ // container renders (a redirect straight back), and the page then
28751
+ // stays on screen with what it read last.
28752
+ if (!stopOwingRender) {
28753
+ stopOwingRender = observeRouteRender(() => {
28754
+ if (routePage.isShown()) {
28755
+ rerender();
28756
+ }
28757
+ });
28758
+ }
28759
+ return;
28760
+ }
28761
+ if (stopOwingRender) {
28762
+ stopOwingRender();
28763
+ stopOwingRender = null;
28764
+ }
28765
+ setTick(n => n + 1);
28766
+ };
27924
28767
  const unsubscribeFromRunningState = action.runningStateSignal.subscribe(state => {
27925
28768
  if (state === RUNNING) {
27926
28769
  dismissedActionWeakSet.delete(action);
27927
28770
  }
27928
- setTick(n => n + 1);
28771
+ rerender();
27929
28772
  });
27930
28773
  // The data does not come from this action's runs alone: dataSignal is a
27931
28774
  // computed over the resource store, so an other action writing that store
@@ -27939,7 +28782,7 @@ const useActionAsyncData = (action, {
27939
28782
  dataNotificationIsInitial = false;
27940
28783
  return;
27941
28784
  }
27942
- setTick(n => n + 1);
28785
+ rerender();
27943
28786
  });
27944
28787
  // The params say WHICH question this is, and this hook reads them: to know
27945
28788
  // there is nothing to ask for, and to start the run it owns. A binding
@@ -27952,7 +28795,7 @@ const useActionAsyncData = (action, {
27952
28795
  paramsNotificationIsInitial = false;
27953
28796
  return;
27954
28797
  }
27955
- setTick(n => n + 1);
28798
+ rerender();
27956
28799
  });
27957
28800
  // A debounced binding waits before it retargets, so nothing above changes
27958
28801
  // while the delay runs — but what is on screen is already out of date.
@@ -27962,18 +28805,21 @@ const useActionAsyncData = (action, {
27962
28805
  settlingNotificationIsInitial = false;
27963
28806
  return;
27964
28807
  }
27965
- setTick(n => n + 1);
28808
+ rerender();
27966
28809
  });
27967
28810
  return () => {
27968
28811
  unsubscribeFromRunningState();
27969
28812
  unsubscribeFromData();
27970
28813
  unsubscribeFromParams();
27971
28814
  unsubscribeFromParamsSettling();
28815
+ if (stopOwingRender) {
28816
+ stopOwingRender();
28817
+ }
27972
28818
  };
27973
28819
  // Bound to the action, not to the mount: params given as a plain object
27974
28820
  // make another action instance, and the component would otherwise stay
27975
28821
  // subscribed to the state of the one it no longer reads.
27976
- }, [action]);
28822
+ }, [action, routePage]);
27977
28823
 
27978
28824
  // The params moved and the run has not started: under a debounce the action
27979
28825
  // is still the previous one, COMPLETED with the previous answer. There is no
@@ -32133,13 +32979,6 @@ const keepLeavingPages = () => {
32133
32979
  hold.containers.clear();
32134
32980
  };
32135
32981
  };
32136
-
32137
- // What a page can learn about its place from the container rendering it:
32138
- // whether it is a page kept while leaving (see keepLeavingPages). Such a page
32139
- // is still mounted, and whatever it fills OUTSIDE its own nodes — a slot in a
32140
- // bar, say — would otherwise go on showing with the page arriving (see
32141
- // layout/slot.jsx).
32142
- const RoutePageContext = createContext(null);
32143
32982
  const LEAVING_ATTRIBUTE = "data-navi-route-leaving";
32144
32983
  const css$13 = /* css */`[data-navi-route-leaving] {
32145
32984
  display: none !important;
@@ -32264,7 +33103,21 @@ const RouteContainer = ({
32264
33103
  shownBranchRef.current = activeBranch;
32265
33104
  }
32266
33105
  const branch = shownBranchRef.current || activeBranch;
32267
- const content = useContentKeepingLeavingPages(branch);
33106
+ // What this container would show if it rendered now, by the same rule as
33107
+ // the render above — asked from outside any render (see isShown below).
33108
+ const childrenRef = useRef(null);
33109
+ childrenRef.current = children;
33110
+ const readShownBranchRef = useRef(null);
33111
+ if (!readShownBranchRef.current) {
33112
+ readShownBranchRef.current = () => {
33113
+ if (routeRenderFrozen) {
33114
+ return shownBranchRef.current;
33115
+ }
33116
+ return collectBranches(childrenRef.current).activeBranch;
33117
+ };
33118
+ }
33119
+ const parentPage = useContext(RoutePageContext);
33120
+ const content = useContentKeepingLeavingPages(branch, readShownBranchRef.current, parentPage);
32268
33121
 
32269
33122
  // The two things this component knows that nobody outside can find out: the
32270
33123
  // pages a named fallback is the absence of — the children it was written
@@ -32301,7 +33154,7 @@ const RouteContainer = ({
32301
33154
  // page being left must stay the child it was when a page is added beside it,
32302
33155
  // or Preact takes it down and builds it again.
32303
33156
  let leavingKeyCount = 0;
32304
- const useContentKeepingLeavingPages = branch => {
33157
+ const useContentKeepingLeavingPages = (branch, readShownBranch, parentPage) => {
32305
33158
  const [, rerender] = useReducer(countRenders, 0);
32306
33159
  const shownRef = useRef(null);
32307
33160
  const leavingRef = useRef([]);
@@ -32319,6 +33172,7 @@ const useContentKeepingLeavingPages = branch => {
32319
33172
  // of reach — the page arriving could not even take the focus — for as long
32320
33173
  // as it is open, and one in a page kept while leaving stays open for the
32321
33174
  // whole movement. The page is then taken down at once, as without one.
33175
+ shown.page.left = true;
32322
33176
  leavingRef.current.push({
32323
33177
  vnode: shown.vnode,
32324
33178
  page: shown.page,
@@ -32335,10 +33189,11 @@ const useContentKeepingLeavingPages = branch => {
32335
33189
  let vnode = null;
32336
33190
  if (branch) {
32337
33191
  if (!page) {
32338
- page = {
32339
- leavingSignal: signal(false)
32340
- };
33192
+ page = createRoutePage(readShownBranch, parentPage);
32341
33193
  }
33194
+ // A page carried over follows the branch it is now the page of.
33195
+ page.branchId = branch.id;
33196
+ page.element = element;
32342
33197
  vnode = h(RoutePageContext.Provider, {
32343
33198
  key,
32344
33199
  value: page
@@ -32405,6 +33260,33 @@ const useContentKeepingLeavingPages = branch => {
32405
33260
  return children;
32406
33261
  };
32407
33262
  const countRenders = count => count + 1;
33263
+ // The page a container renders, as RoutePageContext hands it over (see
33264
+ // route_page_context.js). Shown while the container would pick its branch, or
33265
+ // a branch rendering the same element (it is then carried over), and while the
33266
+ // page it sits in is shown itself. Never again once kept while leaving: coming
33267
+ // back to its route mounts the page anew.
33268
+ const createRoutePage = (readShownBranch, parentPage) => {
33269
+ const page = {
33270
+ branchId: null,
33271
+ element: undefined,
33272
+ left: false,
33273
+ leavingSignal: signal(false),
33274
+ isShown: () => {
33275
+ if (page.left) {
33276
+ return false;
33277
+ }
33278
+ if (parentPage && !parentPage.isShown()) {
33279
+ return false;
33280
+ }
33281
+ const shownBranch = untracked(readShownBranch);
33282
+ if (!shownBranch) {
33283
+ return false;
33284
+ }
33285
+ return shownBranch.id === page.branchId || branchElement(shownBranch) === page.element;
33286
+ }
33287
+ };
33288
+ return page;
33289
+ };
32408
33290
  // What Preact compares when a container changes page, below the <Route> it
32409
33291
  // renders either way: the element the page is rendered with — a layout, for a
32410
33292
  // container branch.
@@ -38777,854 +39659,318 @@ registerNaviCommand(
38777
39659
  const target =
38778
39660
  resolveExplicitTarget(source) || resolveClosestExpandable(source);
38779
39661
  if (!target) {
38780
- return undefined;
38781
- }
38782
- return {
38783
- target,
38784
- implementation: () => {
38785
- const isExpanded = target.getAttribute("aria-expanded") === "true";
38786
- const customEventName = isExpanded
38787
- ? "navi_request_close"
38788
- : "navi_request_open";
38789
- return dispatchCustomEvent(target, customEventName, {
38790
- event,
38791
- source: resolveCommandProxySource(source),
38792
- anchor,
38793
- // Same as --navi-open below: the half of the toggle that opens says
38794
- // what it opens ON, and the half that closes carries it too so the
38795
- // popup never has to ask which half it just heard.
38796
- value:
38797
- value === undefined ? resolveCommandValue(source, event) : value,
38798
- });
38799
- },
38800
- };
38801
- },
38802
- );
38803
- // A popup that edits opens ON something, and the press is the only place that
38804
- // knows which one. It says it with `value` — "what this is about", as
38805
- // everywhere else — rather than with an argument, which says what the command
38806
- // DOES: "open" is already a complete instruction, unlike --navi-go-to-slide.
38807
- // <Button value={radar.id} command="--navi-open" commandfor="radar-dialog">
38808
- // The popup is told before it opens — see Dialog/Popover's `onOpen`.
38809
- registerNaviCommand("--navi-open", (source, event, { anchor, value } = {}) => {
38810
- const target =
38811
- resolveExplicitTarget(source) || resolveClosestExpandable(source);
38812
- if (!target) {
38813
- return undefined;
38814
- }
38815
- return {
38816
- target,
38817
- implementation: () => {
38818
- // The popup reads `anchor` first and falls back to the source (see
38819
- // onnavi_request_open in popover.jsx): without an `anchor`, the element
38820
- // asking is the element opened against.
38821
- return dispatchCustomEvent(target, "navi_request_open", {
38822
- event,
38823
- source: resolveCommandProxySource(source),
38824
- anchor,
38825
- value: value === undefined ? resolveCommandValue(source, event) : value,
38826
- });
38827
- },
38828
- };
38829
- });
38830
- /**
38831
- * Tells the popup a source opens that a press on it has started, ahead of the
38832
- * release that runs the command: what the opening will read can be asked for
38833
- * now (see open_controller.js). A press rather than a hover, because what
38834
- * that reads is keyed by the popup — one sheet per card — and a pointer
38835
- * crossing a list of cards has chosen none of them; on a touch screen the
38836
- * press is the first thing the finger says anyway.
38837
- *
38838
- * The target is found the way --navi-open finds it, but silently: nothing
38839
- * runs here, and a source with no target says so at the release.
38840
- */
38841
- const announceOpeningPress = (source, command) => {
38842
- if (command !== "--navi-open" && command !== "--navi-toggle") {
38843
- return;
38844
- }
38845
- const commandFor = source.getAttribute("commandfor");
38846
- const target = commandFor
38847
- ? document.getElementById(commandFor)
38848
- : resolveExplicitTarget(source) || resolveClosestExpandable(source);
38849
- if (!target) {
38850
- return;
38851
- }
38852
- dispatchCustomEvent(target, "navi_open_press");
38853
- };
38854
- // "--navi-close:all" closes every expandable above the source, nearest first —
38855
- // a link leaving from a badge shown over a sheet leaves both. A surface that
38856
- // refuses (a form asking about its changes) keeps what is above it open too:
38857
- // one cannot be out of the sheet while still in the badge.
38858
- registerNaviCommand("--navi-close", (source, event, { argument }) => {
38859
- const target =
38860
- resolveExplicitTarget(source) || resolveClosestExpandable(source);
38861
- if (!target) {
38862
- return undefined;
38863
- }
38864
- return {
38865
- target,
38866
- implementation: () => {
38867
- const detail = { event, source: resolveCommandProxySource(source) };
38868
- if (argument === "all") {
38869
- return requestCloseUpward(target, detail);
38870
- }
38871
- return dispatchCustomEvent(target, "navi_request_close", detail);
38872
- },
38873
- };
38874
- });
38875
- const requestCloseUpward = (target, detail) => {
38876
- let expandable = target;
38877
- while (expandable) {
38878
- const closing = dispatchCustomEvent(
38879
- expandable,
38880
- "navi_request_close",
38881
- detail,
38882
- );
38883
- if (!closing) {
38884
- return false;
38885
- }
38886
- const parent = expandable.parentElement;
38887
- expandable = parent ? parent.closest("[aria-expanded]") : null;
38888
- }
38889
- return true;
38890
- };
38891
- registerNaviCommand("--navi-cancel", (source, event) => {
38892
- const target =
38893
- resolveExplicitTarget(source) || resolveClosestExpandable(source);
38894
- if (!target) {
38895
- return undefined;
38896
- }
38897
- return {
38898
- target,
38899
- implementation: () => {
38900
- return dispatchCustomEvent(target, "navi_request_close", {
38901
- event,
38902
- source: resolveCommandProxySource(source),
38903
- isCancel: true,
38904
- });
38905
- },
38906
- };
38907
- });
38908
- // "Yes, do it" — said inside the popup of a confirm picker (see
38909
- // picker/picker_confirm.jsx), by its own yes button or by whatever a caller put
38910
- // there instead. Its counterpart is --navi-cancel; the difference between the
38911
- // two is this event, since both then close the popup, and a popup that closes
38912
- // without having said this is the answer "no".
38913
- registerNaviCommand("--navi-confirm", (source, event) => {
38914
- const target =
38915
- resolveExplicitTarget(source) || resolveClosestExpandable(source);
38916
- if (!target) {
38917
- return undefined;
38918
- }
38919
- return {
38920
- target,
38921
- implementation: () => {
38922
- const commandSource = resolveCommandProxySource(source);
38923
- dispatchCustomEvent(target, "navi_request_confirm", {
38924
- event,
38925
- source: commandSource,
38926
- });
38927
- return dispatchCustomEvent(target, "navi_request_close", {
38928
- event,
38929
- source: commandSource,
38930
- });
38931
- },
38932
- };
38933
- });
38934
- registerNaviCommand("--navi-define", (source, event) => {
38935
- const target =
38936
- resolveExplicitTarget(source) || resolveClosestExpandable(source);
38937
- if (!target) {
38938
- return undefined;
38939
- }
38940
- return {
38941
- target,
38942
- implementation: () => executeNaviDefine(source, event, target),
38943
- };
38944
- });
38945
- // A send whose target is the popup itself: what is around the source IS the
38946
- // popup's answer, and a picker runs its action with that answer as it closes
38947
- // (see onRequestClose in picker_custom.jsx) — dispatching the action here too
38948
- // would run it twice. `isSend` tells this close from a dismissal (the cross,
38949
- // a press outside, Escape): the person pressed a choice, which a picker may
38950
- // act on even when the choice is the one it already held.
38951
- const executeNaviDefine = (source, event, target) => {
38952
- return dispatchCustomEvent(target, "navi_request_close", {
38953
- event,
38954
- source: resolveCommandProxySource(source),
38955
- isSend: true,
38956
- });
38957
- };
38958
-
38959
- registerNaviCommand("--navi-scroll", (source, event) => {
38960
- const target =
38961
- resolveExplicitTarget(source) || resolveFirstParentControl(source);
38962
- if (!target) {
38963
- return undefined;
38964
- }
38965
- return {
38966
- target,
38967
- implementation: () => {
38968
- return dispatchCustomEvent(target, "navi_request_scroll", {
38969
- event,
38970
- id: resolveCommandValue(source, event),
38971
- });
38972
- },
38973
- };
38974
- });
38975
- // The press a hand is still making, given to something that can be carried: a
38976
- // hold opened a popup, and what the popup holds is to follow that same finger
38977
- // without it lifting and pressing again. `event` is what the press set off (the
38978
- // popup's open event), and the press is read from its chain. Run once the popup
38979
- // has settled, which the caller cannot see: the carry measures its element and
38980
- // puts a copy in the top layer, neither of which holds while a transition is
38981
- // still moving the box (see afterPopupLift). What the carry then is — its
38982
- // outcomes, its refusal — is the element's (see interaction_drag.js).
38983
- registerNaviCommand("--navi-grab", (source, event) => {
38984
- const target =
38985
- resolveExplicitTarget(source) || source.closest("[data-drag-source]");
38986
- if (!target) {
38987
- return undefined;
38988
- }
38989
- return {
38990
- target,
38991
- implementation: () => {
38992
- afterPopupLift(() => {
38993
- // Taken away with the popup before it settled.
38994
- if (!target.isConnected) {
38995
- return;
38996
- }
38997
- dispatchCustomEvent(target, "navi_request_grab", { event });
38998
- });
38999
- return true;
39000
- },
39001
- };
39002
- });
39003
- registerNaviCommand("--navi-check", (source, event) => {
39004
- const target =
39005
- resolveExplicitTarget(source) || resolveFirstParentControl(source);
39006
- if (!target) {
39007
- return undefined;
39008
- }
39009
- return {
39010
- target,
39011
- implementation: () => {
39012
- return dispatchCustomEvent(target, "navi_request_check", {
39013
- event,
39014
- });
39015
- },
39016
- };
39017
- });
39018
- registerNaviCommand("--navi-uncheck", (source, event) => {
39019
- const target =
39020
- resolveExplicitTarget(source) || resolveFirstParentControl(source);
39021
- if (!target) {
39022
- return undefined;
39023
- }
39024
- return {
39025
- target,
39026
- implementation: () => {
39027
- return dispatchCustomEvent(target, "navi_request_uncheck", {
39028
- event,
39029
- });
39030
- },
39031
- };
39032
- });
39033
- registerNaviCommand("--navi-select", (source, event) => {
39034
- const target =
39035
- resolveExplicitTarget(source) || resolveFirstParentControl(source);
39036
- if (!target) {
39037
- return undefined;
39038
- }
39039
- return {
39040
- target,
39041
- implementation: () => {
39042
- return dispatchCustomEvent(target, "navi_request_select", {
39043
- event,
39044
- id: resolveCommandValue(source, event),
39045
- });
39046
- },
39047
- };
39048
- });
39049
- registerNaviCommand("--navi-unselect", (source, event) => {
39050
- const target =
39051
- resolveExplicitTarget(source) || resolveFirstParentControl(source);
39052
- if (!target) {
39053
- return undefined;
39054
- }
39055
- return {
39056
- target,
39057
- implementation: () => {
39058
- return dispatchCustomEvent(target, "navi_request_unselect", {
39059
- event,
39060
- id: resolveCommandValue(source, event),
39061
- });
39062
- },
39063
- };
39064
- });
39065
-
39066
- /**
39067
- * Runs `fn` and commits whatever it re-renders before returning, instead of
39068
- * letting Preact batch it into the next microtask. Layout effects of what gets
39069
- * mounted run inside the call too, exactly as they would on any other commit.
39070
- *
39071
- * For the caller that has to read the DOM it just asked for — measuring an
39072
- * element whose content it mounts in the same breath — and cannot wait a tick
39073
- * to do it, because what comes after is a browser event still in flight
39074
- * (preventDefault, focus placement) that no longer accepts being answered late.
39075
- *
39076
- * `options.debounceRendering` is Preact's own hook for deciding *when* the
39077
- * render queue drains; swapping it for "right now" for the duration of the call
39078
- * is exactly how preact/compat implements React's flushSync. Reserve it for the
39079
- * case above: rendering synchronously in the middle of an event gives up the
39080
- * batching that makes several state changes one commit.
39081
- */
39082
- const flushSyncRendering = (fn) => {
39083
- const debounceRenderingPrevious = options.debounceRendering;
39084
- options.debounceRendering = (drainRenderQueue) => {
39085
- drainRenderQueue();
39086
- };
39087
- try {
39088
- fn();
39089
- } finally {
39090
- options.debounceRendering = debounceRenderingPrevious;
39091
- }
39092
- };
39093
-
39094
- /**
39095
- * Small, renderer-agnostic helpers shared by Popover and Dialog's own custom
39096
- * (non-top-layer) renderers — operate on a plain DOM element, no knowledge
39097
- * of which of the two owns it.
39098
- */
39099
-
39100
-
39101
- /**
39102
- * Whether a visibleRectEffect delivery is one that can have taken height away
39103
- * from a popup, and so pushed whatever holds focus out of sight:
39104
- * - "resize": the window/visual viewport settled — which is also how the
39105
- * on-screen keyboard arrives, overlay or not (window_size.js);
39106
- * - ELEMENT_SIZE_CHANGE: the popup's own box measured different;
39107
- * - "focusin": the focus-settled re-measure, for the room that changes with
39108
- * nothing announcing it (subscribeFocusSettled in window_size.js).
39109
- *
39110
- * Everything else is a scroll of one kind or another, where nothing got
39111
- * smaller and scrolling the focused element back would fight the very gesture
39112
- * that fired it.
39113
- */
39114
- const mayHaveHiddenFocus = (event) => {
39115
- const type = event?.type;
39116
- return (
39117
- type === "resize" || type === ELEMENT_SIZE_CHANGE || type === "focusin"
39118
- );
39119
- };
39120
-
39121
- const scrollportHeightMap = new WeakMap();
39122
- /**
39123
- * Scrolls whatever holds focus inside `popupEl` back into view, if the popup
39124
- * getting shorter has pushed it out.
39125
- *
39126
- * The case this exists for: a field low in the scrolling body of a popup that
39127
- * also has a footer (box.jsx — with a body, the body is the only thing that
39128
- * scrolls and the footer is a sibling sitting right under it). Focusing the
39129
- * field makes the browser scroll it into view, which it does against the
39130
- * popup's height AT THAT MOMENT; the on-screen keyboard then opens and takes
39131
- * that height away. The body shrinks, its scrollTop does not move, so the
39132
- * content slides down relative to the shorter scrollport and the field ends up
39133
- * past its bottom edge — visually, swallowed by the footer. The browser does
39134
- * not redo a scroll-into-view it already answered, so this does.
39135
- *
39136
- * Only what the shrink itself hid, though: with the keyboard up the user reads
39137
- * the rest of the popup by scrolling the field away — to reach the submit under
39138
- * it, typically — and the room keeps changing while they do (a keyboard settling
39139
- * in two steps, a suggestion strip, a browser bar). Answering each of those by
39140
- * scrolling the field back takes the popup away from wherever they had just
39141
- * scrolled it, over and over: what they were reading cannot be reached at all
39142
- * without blurring the field first, and the popup reads as unscrollable. So the
39143
- * field is brought back only when it was in view before the room shrank, which
39144
- * one remembered number answers: a resize moves neither scrollTop nor the
39145
- * element's offset inside the scrolled content, so measuring against the height
39146
- * the scrollport HAD is measuring the state before the change.
39147
- *
39148
- * Scoped to the field's own scroll container (never the page): a popup traps
39149
- * scrolling precisely so the document underneath cannot move, and a plain
39150
- * scrollIntoView walks past a container whose scrollbar isn't visible — see
39151
- * scrollIntoViewScoped's own doc.
39152
- *
39153
- * "nearest": the smallest scroll that makes it visible, and none at all when it
39154
- * already is. Where it lands is the container's own business — a navi scroller
39155
- * keeps a band free at its edges so a field never comes back glued to one (see
39156
- * scroll-padding in box.jsx).
39157
- */
39158
- const keepFocusedElementVisible = (popupEl) => {
39159
- const { activeElement } = document;
39160
- if (!activeElement || activeElement === popupEl) {
39161
- return;
39162
- }
39163
- if (!popupEl.contains(activeElement)) {
39164
- return;
39165
- }
39166
- const scrollContainer = getScrollContainer(activeElement);
39167
- if (!scrollContainer || !popupEl.contains(scrollContainer)) {
39168
- // What scrolls the field is outside the popup, which means the page: a
39169
- // popup holds it still on purpose (trapScrollInside), so there is nothing
39170
- // here to scroll back.
39171
- return;
39172
- }
39173
- const scrollportHeight = scrollContainer.clientHeight;
39174
- const scrollportHeightBefore = scrollportHeightMap.get(scrollContainer);
39175
- scrollportHeightMap.set(scrollContainer, scrollportHeight);
39176
- if (scrollportHeightBefore !== undefined) {
39177
- if (scrollportHeight >= scrollportHeightBefore) {
39178
- // Nothing was taken away, so nothing was hidden by this.
39179
- return;
39180
- }
39181
- if (
39182
- !isVisibleInScrollport(
39183
- activeElement,
39184
- scrollContainer,
39185
- scrollportHeightBefore,
39186
- )
39187
- ) {
39188
- return;
39189
- }
39190
- }
39191
- scrollIntoViewScoped(activeElement, {
39192
- container: scrollContainer,
39193
- block: "nearest",
39194
- });
39195
- };
39196
-
39197
- // Whether any part of `el` was showing in `container` back when its scrollport
39198
- // was `scrollportHeight` tall. Both boxes are read now: the container may have
39199
- // moved as well as shrunk (a centered dialog re-centers itself), and the
39200
- // difference between the two tops is what that move leaves alone.
39201
- const isVisibleInScrollport = (el, container, scrollportHeight) => {
39202
- const elRect = el.getBoundingClientRect();
39203
- const containerRect = container.getBoundingClientRect();
39204
- const elTop = elRect.top - containerRect.top + container.scrollTop;
39205
- const elBottom = elTop + elRect.height;
39206
- const scrollportTop = container.scrollTop;
39207
- const scrollportBottom = scrollportTop + scrollportHeight;
39208
- return elBottom > scrollportTop && elTop < scrollportBottom;
39209
- };
39210
-
39211
- /**
39212
- * Calls `onSettled` once `el`'s current CSS transition is over — via
39213
- * `transitionend`, with a safety `setTimeout` fallback matching the longest
39214
- * `transition-duration`, in case nothing actually transitions or an event is
39215
- * missed.
39216
- *
39217
- * Returns a "cancel" function, so a caller whose instance has been superseded
39218
- * (a fresh open/close about to set its own state) can keep this stale one from
39219
- * firing later. Cancelling only stops `onSettled`: undoing whatever the caller
39220
- * did up front is that fresh call's business, not this one's.
39221
- */
39222
- const whenTransitionSettles = (el, onSettled) => {
39223
- let settled = false;
39224
- const onTransitionEnd = (transitionEvent) => {
39225
- if (transitionEvent.target === el) {
39226
- finish();
39227
- }
39228
- };
39229
- const stopWatching = () => {
39230
- settled = true;
39231
- el.removeEventListener("transitionend", onTransitionEnd);
39232
- clearTimeout(safetyTimeoutId);
39233
- };
39234
- const finish = () => {
39235
- if (settled) {
39236
- return;
39237
- }
39238
- stopWatching();
39239
- onSettled();
39240
- };
39241
- el.addEventListener("transitionend", onTransitionEnd);
39242
- const durationsInSeconds = getComputedStyle(el)
39243
- .transitionDuration.split(",")
39244
- .map((value) => parseFloat(value) || 0);
39245
- const longestDurationMs = Math.max(0, ...durationsInSeconds) * 1000;
39246
- const safetyTimeoutId = setTimeout(finish, longestDurationMs + 50);
39247
- return () => {
39248
- if (settled) {
39249
- return;
39250
- }
39251
- stopWatching();
39252
- };
39253
- };
39254
-
39255
- /**
39256
- * Drops the document's text selection when it lives inside `el`, leaving a
39257
- * selection made elsewhere on the page alone.
39258
- *
39259
- * A popup being closed takes its content with it, and a selection is a claim
39260
- * on content the user can still act on: kept, it would outlive the surface it
39261
- * was made on — painted on the box for the length of its exit transition, with
39262
- * the handles and the copy toolbar a phone draws from the live selection
39263
- * hanging over something that is going away — and reappear with the box on
39264
- * the next opening. Dropping the selection is the only way to remove that
39265
- * chrome: a `user-select: none` on the closing box hides the highlight in some
39266
- * browsers only, and the handles are the browser's own, drawn from the
39267
- * selection object rather than from any style.
39268
- */
39269
- const clearTextSelectionInside = (el) => {
39270
- const selection = window.getSelection();
39271
- if (!selection || selection.rangeCount === 0) {
39272
- return;
39273
- }
39274
- const range = selection.getRangeAt(0);
39275
- if (!el.contains(range.commonAncestorContainer)) {
39276
- return;
39277
- }
39278
- selection.removeAllRanges();
39279
- };
39280
-
39281
- /**
39282
- * Disables pointer-events on `el` until its current CSS transition settles —
39283
- * avoids the cursor changing/something becoming clickable while the popup is
39284
- * still visually moving into or out of place.
39285
- *
39286
- * Returns whenTransitionSettles' own "cancel" function: it doesn't restore
39287
- * pointer-events, since a fresh call for the next open/close is about to set
39288
- * its own state.
39289
- */
39290
- const suppressPointerEventsDuringTransition = (el) => {
39291
- el.style.pointerEvents = "none";
39292
- return whenTransitionSettles(el, () => {
39293
- el.style.pointerEvents = "";
39294
- });
39295
- };
39296
-
39297
- /**
39298
- * Hides the backdrop, deferring until the browser's matching "click" fires
39299
- * when `closeEvent` was triggered by a mousedown (see popover.jsx's top
39300
- * comment for why) — same capture-phase-on-document pattern as
39301
- * armSuppressNextOpenRequest in open_controller.js, which a plain timeout
39302
- * can't safely replace: mouseup (and the click that follows it) can land an
39303
- * arbitrarily long time after mousedown (the user is still holding the
39304
- * button down), so a short timeout can fire first and hide the backdrop
39305
- * before its own click ever arrives. A capture-phase listener on document
39306
- * fires for every click regardless of what any bubble-phase handler does
39307
- * downstream, so no fallback timer is needed.
39308
- *
39309
- * `hide` is the caller's own way to actually hide the backdrop
39310
- * (`hidePopover()` for a top-layer backdrop, a plain `style.display = "none"`
39311
- * for a plain div) — this helper only owns the mousedown/click timing.
39312
- *
39313
- * Returns a disarm function (or undefined if hidden immediately), so a
39314
- * fresh open can cancel a pending hide it's about to make redundant.
39315
- */
39316
- const armPointerDownOutsideClose = (closeEvent, hide) => {
39317
- const mousedownEvent = findEvent(closeEvent, "mousedown");
39318
- if (!mousedownEvent) {
39319
- hide();
39320
- return undefined;
39321
- }
39322
- const onClick = () => {
39323
- document.removeEventListener("click", onClick, { capture: true });
39324
- hide();
39325
- };
39326
- document.addEventListener("click", onClick, { capture: true });
39327
- return () => {
39328
- document.removeEventListener("click", onClick, { capture: true });
39329
- };
39330
- };
39331
-
39332
- /**
39333
- * Maps a positionArea y/x pair to a concrete `navi-animation` value (a
39334
- * `prefix` plus a direction word), or `null` if both axes overlap the anchor
39335
- * (no direction at all — that's `resolvedAnimationKind === "scaling"`
39336
- * territory instead, see resolveAutoAnimationKind below).
39337
- *
39338
- * `prefix: "slide-from"` (used with no real anchor — Dialog always, Popover
39339
- * when docked) keeps the word as the compass direction the popup comes
39340
- * from: placed "top" (a point/corner), it slides in from the top.
39341
- * `prefix: "expand"` (a real anchor, Popover-only) uses the motion/growth
39342
- * direction instead, the opposite compass point: placed "top" of the
39343
- * anchor, it moves/grows up, away from the anchor (which sits below it).
39344
- *
39345
- * "inset-*"/"center" contribute no direction on their axis either way.
39346
- */
39347
- const resolveDirectionValue = (y, x, { prefix }) => {
39348
- const yWord =
39349
- y === "top"
39350
- ? prefix === "expand"
39351
- ? "up"
39352
- : "top"
39353
- : y === "bottom"
39354
- ? prefix === "expand"
39355
- ? "down"
39356
- : "bottom"
39357
- : null;
39358
- const xWord = x === "left" ? "left" : x === "right" ? "right" : null;
39359
- if (!yWord && !xWord) {
39360
- return null;
39361
- }
39362
- return yWord && xWord
39363
- ? `${prefix}-${yWord}-${xWord}`
39364
- : `${prefix}-${yWord || xWord}`;
39365
- };
39366
-
39367
- /**
39368
- * Shared `animation="auto"`/`true` resolution: "scaling" reads best overall
39369
- * — picked for any real anchor, or for a point/corner placed dead-center
39370
- * (both positionArea axes overlapping — there's no sensible direction to
39371
- * slide from in that case). "sliding" otherwise. `anchor` is `undefined`
39372
- * for any no-anchor/docked case (Dialog always, Popover's own custom
39373
- * renderer when there's no real anchor), so this collapses to "scaling"
39374
- * there only for the dead-center case, "sliding" otherwise. The two
39375
- * "overlapping" booleans below describe the *positionArea* itself (a bare
39376
- * word vs. "inset-"/"center"), not anything about the anchor — they'd
39377
- * mean exactly the same thing even with no anchor at all, since it's the
39378
- * position strategy, not the anchor, that decides whether there's a
39379
- * direction to slide from.
39380
- */
39381
- const resolveAutoAnimationKind = (anchor, parsedPositionArea) => {
39382
- const yIsOverlapping =
39383
- parsedPositionArea.y !== "top" && parsedPositionArea.y !== "bottom";
39384
- const xIsOverlapping =
39385
- parsedPositionArea.x !== "left" && parsedPositionArea.x !== "right";
39386
- return anchor || (yIsOverlapping && xIsOverlapping) ? "scaling" : "sliding";
39387
- };
39388
-
39389
- /**
39390
- * An opening that reaches a popup with no element of its own to show.
39391
- *
39392
- * The one cause seen so far, and it is silent: something inside the popup
39393
- * suspended while its content was being built, and the `<Loading>` that caught
39394
- * it sits ABOVE the popup. A boundary suspending on an update keeps the page as
39395
- * it was, as a copy, so what is opened here belongs to a copy nobody owns any
39396
- * more — and once the data arrives the subtree is rebuilt from scratch, closed
39397
- * and empty. Nothing is thrown, nothing is drawn, and the press is lost.
39398
- */
39399
- const warnPopupHasNoElementToOpen = (popupKind) => {
39400
- console.warn(
39401
- `[navi] a "${popupKind}" was asked to open and has no element to open. What usually did it: content inside it suspended, and the <Loading> that caught the wait is above the popup rather than inside it — the popup was set aside with the rest of what that boundary holds, and this opening is lost (the next one works). Put a <Loading> inside the popup, or draw the wait in the component itself with useAsyncData(action, { loading: true }).`,
39402
- );
39403
- };
39404
-
39405
- /**
39406
- * A popup reads "outside" from its own border box: what a press lands on
39407
- * decides nothing on its own (a genuine backdrop press and a press on the
39408
- * popup's padding both report the popup element as their target, there being
39409
- * no real ::backdrop node to be one), so the rectangle is what tells them
39410
- * apart. That holds as long as the box and what the popup paints are the same
39411
- * thing — which stops being true for a popup with no surface of its own
39412
- * (`backgroundColor="transparent"`, no shadow, no padding): what the eye reads
39413
- * as backdrop is then inside the box, and a press there stays a press on the
39414
- * popup.
39415
- *
39416
- * `data-navi-popup-outside` is how a caller says which of its own boxes are
39417
- * not the surface. It is opt-in because navi cannot infer it — a background
39418
- * can come from anywhere — while the caller who made the popup see-through
39419
- * knows exactly which box is decoration and which is paper.
39420
- *
39421
- * The marker answers for the element it is on, never for its descendants: a
39422
- * box painted inside a marked one is still surface, so a row can be marked and
39423
- * its empty halves read as backdrop while presses on the controls it holds do
39424
- * not dismiss anything. A descendant with `pointer-events: none` never becomes
39425
- * a press target at all, so the marked box answers in its place — while the
39426
- * same declaration on the marked box itself takes it out of hit-testing and
39427
- * makes the marker unreachable (see warnAboutUnreachableOutsideRegions).
39428
- */
39429
- const OUTSIDE_REGION_ATTRIBUTE = "data-navi-popup-outside";
39430
-
39431
- /**
39432
- * `data-navi-popup-inside` is the other direction: a box of the PAGE the
39433
- * caller says belongs to a popup, so a press on it is not outside that popup
39434
- * even though it lies beyond the border box — a card whose press is what
39435
- * fills the one panel of a board. Its value names the popup by `id`, the way
39436
- * `commandfor` does (several ids, space separated), because the exemption is
39437
- * one popup's: a menu open beside the board still closes on the same press.
39438
- *
39439
- * Unlike the outside marker it answers for its whole subtree: the outside
39440
- * marker carves free space out of a surface whose controls stay surface, this
39441
- * one adds a whole thing — the card, its count, its buttons — to the popup's
39442
- * own ground, and nothing in it is a dismissal.
39443
- */
39444
- const INSIDE_ATTRIBUTE = "data-navi-popup-inside";
39445
-
39662
+ return undefined;
39663
+ }
39664
+ return {
39665
+ target,
39666
+ implementation: () => {
39667
+ const isExpanded = target.getAttribute("aria-expanded") === "true";
39668
+ const customEventName = isExpanded
39669
+ ? "navi_request_close"
39670
+ : "navi_request_open";
39671
+ return dispatchCustomEvent(target, customEventName, {
39672
+ event,
39673
+ source: resolveCommandProxySource(source),
39674
+ anchor,
39675
+ // Same as --navi-open below: the half of the toggle that opens says
39676
+ // what it opens ON, and the half that closes carries it too so the
39677
+ // popup never has to ask which half it just heard.
39678
+ value:
39679
+ value === undefined ? resolveCommandValue(source, event) : value,
39680
+ });
39681
+ },
39682
+ };
39683
+ },
39684
+ );
39685
+ // A popup that edits opens ON something, and the press is the only place that
39686
+ // knows which one. It says it with `value` — "what this is about", as
39687
+ // everywhere else — rather than with an argument, which says what the command
39688
+ // DOES: "open" is already a complete instruction, unlike --navi-go-to-slide.
39689
+ // <Button value={radar.id} command="--navi-open" commandfor="radar-dialog">
39690
+ // The popup is told before it opens — see Dialog/Popover's `onOpen`.
39691
+ registerNaviCommand("--navi-open", (source, event, { anchor, value } = {}) => {
39692
+ const target =
39693
+ resolveExplicitTarget(source) || resolveClosestExpandable(source);
39694
+ if (!target) {
39695
+ return undefined;
39696
+ }
39697
+ return {
39698
+ target,
39699
+ implementation: () => {
39700
+ // The popup reads `anchor` first and falls back to the source (see
39701
+ // onnavi_request_open in popover.jsx): without an `anchor`, the element
39702
+ // asking is the element opened against.
39703
+ return dispatchCustomEvent(target, "navi_request_open", {
39704
+ event,
39705
+ source: resolveCommandProxySource(source),
39706
+ anchor,
39707
+ value: value === undefined ? resolveCommandValue(source, event) : value,
39708
+ });
39709
+ },
39710
+ };
39711
+ });
39446
39712
  /**
39447
- * What a press landing on a region the caller declared as not-its-surface
39448
- * does: exactly what the same press on the backdrop would do.
39713
+ * Tells the popup a source opens that a press on it has started, ahead of the
39714
+ * release that runs the command: what the opening will read can be asked for
39715
+ * now (see open_controller.js). A press rather than a hover, because what
39716
+ * that reads is keyed by the popup — one sheet per card — and a pointer
39717
+ * crossing a list of cards has chosen none of them; on a touch screen the
39718
+ * press is the first thing the finger says anyway.
39449
39719
  *
39450
- * Lives on the popup's own content element rather than in the backdrop's
39451
- * handler, for both renderers: the backdrop is a sibling behind the popup
39452
- * (never an ancestor), so a press inside the popup's box never reaches it.
39720
+ * The target is found the way --navi-open finds it, but silently: nothing
39721
+ * runs here, and a source with no target says so at the release.
39453
39722
  */
39454
- const handlePressOnOutsideRegion = (
39455
- mouseDownEvent,
39456
- { popupEl, openController, pressOutside },
39457
- ) => {
39458
- if (mouseDownEvent.button !== 0) {
39723
+ const announceOpeningPress = (source, command) => {
39724
+ if (command !== "--navi-open" && command !== "--navi-toggle") {
39459
39725
  return;
39460
39726
  }
39461
- const { target } = mouseDownEvent;
39462
- if (!target.hasAttribute(OUTSIDE_REGION_ATTRIBUTE)) {
39727
+ const commandFor = source.getAttribute("commandfor");
39728
+ const target = commandFor
39729
+ ? document.getElementById(commandFor)
39730
+ : resolveExplicitTarget(source) || resolveClosestExpandable(source);
39731
+ if (!target) {
39463
39732
  return;
39464
39733
  }
39465
- if (openedDuringThisPress(openController)) {
39466
- // The release of the press that opened it is not somebody dismissing it
39467
- // (see openedDuringThisPress).
39468
- return;
39734
+ dispatchCustomEvent(target, "navi_open_press");
39735
+ };
39736
+ // "--navi-close:all" closes every expandable above the source, nearest first —
39737
+ // a link leaving from a badge shown over a sheet leaves both. A surface that
39738
+ // refuses (a form asking about its changes) keeps what is above it open too:
39739
+ // one cannot be out of the sheet while still in the badge.
39740
+ registerNaviCommand("--navi-close", (source, event, { argument }) => {
39741
+ const target =
39742
+ resolveExplicitTarget(source) || resolveClosestExpandable(source);
39743
+ if (!target) {
39744
+ return undefined;
39469
39745
  }
39470
- // A popup opens inside its opener's own subtree, so a press on a region
39471
- // belonging to a popup nested in this one bubbles through here too — and it
39472
- // is a press on what is in front, not on this popup's own decoration.
39473
- if (
39474
- target.closest(`[navi-control="dialog"], [navi-control="popover"]`) !==
39475
- popupEl
39476
- ) {
39477
- return;
39746
+ return {
39747
+ target,
39748
+ implementation: () => {
39749
+ const detail = { event, source: resolveCommandProxySource(source) };
39750
+ if (argument === "all") {
39751
+ return requestCloseUpward(target, detail);
39752
+ }
39753
+ return dispatchCustomEvent(target, "navi_request_close", detail);
39754
+ },
39755
+ };
39756
+ });
39757
+ const requestCloseUpward = (target, detail) => {
39758
+ let expandable = target;
39759
+ while (expandable) {
39760
+ const closing = dispatchCustomEvent(
39761
+ expandable,
39762
+ "navi_request_close",
39763
+ detail,
39764
+ );
39765
+ if (!closing) {
39766
+ return false;
39767
+ }
39768
+ const parent = expandable.parentElement;
39769
+ expandable = parent ? parent.closest("[aria-expanded]") : null;
39478
39770
  }
39479
- if (pressOutside === "capture") {
39480
- mouseDownEvent.preventDefault();
39481
- return;
39771
+ return true;
39772
+ };
39773
+ registerNaviCommand("--navi-cancel", (source, event) => {
39774
+ const target =
39775
+ resolveExplicitTarget(source) || resolveClosestExpandable(source);
39776
+ if (!target) {
39777
+ return undefined;
39482
39778
  }
39483
- if (pressOutside === "close" || pressOutside === "cancel") {
39484
- openController.requestClose(mouseDownEvent, {
39485
- isCancel: pressOutside === "cancel",
39486
- });
39779
+ return {
39780
+ target,
39781
+ implementation: () => {
39782
+ return dispatchCustomEvent(target, "navi_request_close", {
39783
+ event,
39784
+ source: resolveCommandProxySource(source),
39785
+ isCancel: true,
39786
+ });
39787
+ },
39788
+ };
39789
+ });
39790
+ // "Yes, do it" — said inside the popup of a confirm picker (see
39791
+ // picker/picker_confirm.jsx), by its own yes button or by whatever a caller put
39792
+ // there instead. Its counterpart is --navi-cancel; the difference between the
39793
+ // two is this event, since both then close the popup, and a popup that closes
39794
+ // without having said this is the answer "no".
39795
+ registerNaviCommand("--navi-confirm", (source, event) => {
39796
+ const target =
39797
+ resolveExplicitTarget(source) || resolveClosestExpandable(source);
39798
+ if (!target) {
39799
+ return undefined;
39800
+ }
39801
+ return {
39802
+ target,
39803
+ implementation: () => {
39804
+ const commandSource = resolveCommandProxySource(source);
39805
+ dispatchCustomEvent(target, "navi_request_confirm", {
39806
+ event,
39807
+ source: commandSource,
39808
+ });
39809
+ return dispatchCustomEvent(target, "navi_request_close", {
39810
+ event,
39811
+ source: commandSource,
39812
+ });
39813
+ },
39814
+ };
39815
+ });
39816
+ registerNaviCommand("--navi-define", (source, event) => {
39817
+ const target =
39818
+ resolveExplicitTarget(source) || resolveClosestExpandable(source);
39819
+ if (!target) {
39820
+ return undefined;
39487
39821
  }
39822
+ return {
39823
+ target,
39824
+ implementation: () => executeNaviDefine(source, event, target),
39825
+ };
39826
+ });
39827
+ // A send whose target is the popup itself: what is around the source IS the
39828
+ // popup's answer, and a picker runs its action with that answer as it closes
39829
+ // (see onRequestClose in picker_custom.jsx) — dispatching the action here too
39830
+ // would run it twice. `isSend` tells this close from a dismissal (the cross,
39831
+ // a press outside, Escape): the person pressed a choice, which a picker may
39832
+ // act on even when the choice is the one it already held.
39833
+ const executeNaviDefine = (source, event, target) => {
39834
+ return dispatchCustomEvent(target, "navi_request_close", {
39835
+ event,
39836
+ source: resolveCommandProxySource(source),
39837
+ isSend: true,
39838
+ });
39488
39839
  };
39489
39840
 
39490
- /**
39491
- * `pointer-events: none` and `inert` are the two things reached for to say
39492
- * "this part of my box is not really there", and neither reaches the popup:
39493
- * inert speaks to the keyboard and to assistive technology, and
39494
- * pointer-events takes the box out of hit-testing so the press is answered by
39495
- * an ancestor that is still the popup's own surface. Writing them next to the
39496
- * marker is the natural mistake, and it makes the marker silently unreachable
39497
- * — so it is said out loud, once per open.
39498
- */
39499
- const warnAboutUnreachableOutsideRegions = (popupEl) => {
39500
- for (const regionEl of popupEl.querySelectorAll(
39501
- `[${OUTSIDE_REGION_ATTRIBUTE}]`,
39502
- )) {
39503
- if (getComputedStyle(regionEl).pointerEvents === "none") {
39504
- console.warn(
39505
- `[navi] ${OUTSIDE_REGION_ATTRIBUTE} on an element with "pointer-events: none" is never read: the press is answered by the nearest ancestor still in hit-testing, which is the popup itself. Drop pointer-events; the marker alone is what makes a press there count as outside.`,
39506
- regionEl,
39507
- );
39508
- }
39841
+ registerNaviCommand("--navi-scroll", (source, event) => {
39842
+ const target =
39843
+ resolveExplicitTarget(source) || resolveFirstParentControl(source);
39844
+ if (!target) {
39845
+ return undefined;
39509
39846
  }
39510
- };
39847
+ return {
39848
+ target,
39849
+ implementation: () => {
39850
+ return dispatchCustomEvent(target, "navi_request_scroll", {
39851
+ event,
39852
+ id: resolveCommandValue(source, event),
39853
+ });
39854
+ },
39855
+ };
39856
+ });
39857
+ // The press a hand is still making, given to something that can be carried: a
39858
+ // hold opened a popup, and what the popup holds is to follow that same finger
39859
+ // without it lifting and pressing again. `event` is what the press set off (the
39860
+ // popup's open event), and the press is read from its chain. Run once the popup
39861
+ // has settled, which the caller cannot see: the carry measures its element and
39862
+ // puts a copy in the top layer, neither of which holds while a transition is
39863
+ // still moving the box (see afterPopupLift). What the carry then is — its
39864
+ // outcomes, its refusal — is the element's (see interaction_drag.js).
39865
+ registerNaviCommand("--navi-grab", (source, event) => {
39866
+ const target =
39867
+ resolveExplicitTarget(source) || source.closest("[data-drag-source]");
39868
+ if (!target) {
39869
+ return undefined;
39870
+ }
39871
+ return {
39872
+ target,
39873
+ implementation: () => {
39874
+ afterPopupLift(() => {
39875
+ // Taken away with the popup before it settled.
39876
+ if (!target.isConnected) {
39877
+ return;
39878
+ }
39879
+ dispatchCustomEvent(target, "navi_request_grab", { event });
39880
+ });
39881
+ return true;
39882
+ },
39883
+ };
39884
+ });
39885
+ registerNaviCommand("--navi-check", (source, event) => {
39886
+ const target =
39887
+ resolveExplicitTarget(source) || resolveFirstParentControl(source);
39888
+ if (!target) {
39889
+ return undefined;
39890
+ }
39891
+ return {
39892
+ target,
39893
+ implementation: () => {
39894
+ return dispatchCustomEvent(target, "navi_request_check", {
39895
+ event,
39896
+ });
39897
+ },
39898
+ };
39899
+ });
39900
+ registerNaviCommand("--navi-uncheck", (source, event) => {
39901
+ const target =
39902
+ resolveExplicitTarget(source) || resolveFirstParentControl(source);
39903
+ if (!target) {
39904
+ return undefined;
39905
+ }
39906
+ return {
39907
+ target,
39908
+ implementation: () => {
39909
+ return dispatchCustomEvent(target, "navi_request_uncheck", {
39910
+ event,
39911
+ });
39912
+ },
39913
+ };
39914
+ });
39915
+ registerNaviCommand("--navi-select", (source, event) => {
39916
+ const target =
39917
+ resolveExplicitTarget(source) || resolveFirstParentControl(source);
39918
+ if (!target) {
39919
+ return undefined;
39920
+ }
39921
+ return {
39922
+ target,
39923
+ implementation: () => {
39924
+ return dispatchCustomEvent(target, "navi_request_select", {
39925
+ event,
39926
+ id: resolveCommandValue(source, event),
39927
+ });
39928
+ },
39929
+ };
39930
+ });
39931
+ registerNaviCommand("--navi-unselect", (source, event) => {
39932
+ const target =
39933
+ resolveExplicitTarget(source) || resolveFirstParentControl(source);
39934
+ if (!target) {
39935
+ return undefined;
39936
+ }
39937
+ return {
39938
+ target,
39939
+ implementation: () => {
39940
+ return dispatchCustomEvent(target, "navi_request_unselect", {
39941
+ event,
39942
+ id: resolveCommandValue(source, event),
39943
+ });
39944
+ },
39945
+ };
39946
+ });
39511
39947
 
39512
39948
  /**
39513
- * Hears an outside press from the document itself, for a popup with no
39514
- * backdrop of its own to catch one: a modal `<dialog>` (whose native
39515
- * `::backdrop` dispatches nothing a handler can be put on) and any popup the
39516
- * caller asked to leave the page reachable (`backdrop={false}`).
39517
- *
39518
- * The press is read and never taken: no `preventDefault`, no
39519
- * `stopPropagation`, and the listener is on `document` rather than on
39520
- * anything covering the page — so what the press landed on hears it too, in
39521
- * the same gesture, and the popup only takes note. That is the whole point of
39522
- * a popup with no wall: a press outside is a press on the page, and the page
39523
- * answers it. The one popup where that cannot hold is the modal dialog, where
39524
- * the browser has already made everything behind inert before this runs.
39525
- *
39526
- * `pressEventType` says which event IS the press, and the two callers want
39527
- * opposite ones:
39528
- *
39529
- * - `"pointerdown"` for a popup with no wall: a page with no wall over it is
39530
- * a page whose own elements arbitrate their presses, and cancelling a
39531
- * `pointerdown` — what a drag source and a control keeping the focus where
39532
- * it is both do — suppresses every compatibility mouse event that would have
39533
- * followed it. The press happens, reaches its target, and no `mousedown` is
39534
- * ever dispatched for it. Reading the pointer event is reading the press
39535
- * itself, whatever anything does with it afterwards, and it is the same
39536
- * event the drag and surface detectors read.
39537
- * - `"mousedown"` for the modal dialog, whose native `::backdrop` exists to
39538
- * spend the press. The page's controls act on `mousedown`, and on a touch
39539
- * screen that is not the same moment as `pointerdown`: the compatibility
39540
- * mouse events are synthesized at `touchend` and hit-tested again then.
39541
- * Closing on `pointerdown` takes the `::backdrop` away before that second
39542
- * hit-test, so the `mousedown` lands on whatever the page holds under the
39543
- * finger, and one tap both dismisses the dialog and presses the page. Read
39544
- * on `mousedown`, the wall is still up when the press the page acts on is
39545
- * aimed, and it catches it. Nothing behind a modal wall can cancel that
39546
- * press, since nothing behind it hears the `pointerdown` at all.
39949
+ * Runs `fn` and commits whatever it re-renders before returning, instead of
39950
+ * letting Preact batch it into the next microtask. Layout effects of what gets
39951
+ * mounted run inside the call too, exactly as they would on any other commit.
39547
39952
  *
39548
- * Capture phase, so a handler downstream that stops propagation cannot keep
39549
- * the popup open either — the press is still outside whatever is made of it.
39953
+ * For the caller that has to read the DOM it just asked for — measuring an
39954
+ * element whose content it mounts in the same breath — and cannot wait a tick
39955
+ * to do it, because what comes after is a browser event still in flight
39956
+ * (preventDefault, focus placement) that no longer accepts being answered late.
39550
39957
  *
39551
- * Returns a cleanup that removes the listener.
39958
+ * `options.debounceRendering` is Preact's own hook for deciding *when* the
39959
+ * render queue drains; swapping it for "right now" for the duration of the call
39960
+ * is exactly how preact/compat implements React's flushSync. Reserve it for the
39961
+ * case above: rendering synchronously in the middle of an event gives up the
39962
+ * batching that makes several state changes one commit.
39552
39963
  */
39553
- const armOutsidePressClose = (
39554
- popupEl,
39555
- { openController, pressOutside, pressEventType = "pointerdown" },
39556
- ) => {
39557
- const onDocumentPointerDown = (pointerDownEvent) => {
39558
- if (pointerDownEvent.button !== 0) {
39559
- return;
39560
- }
39561
- if (openedDuringThisPress(openController)) {
39562
- // The release of the press that opened it is not somebody dismissing it
39563
- // (see openedDuringThisPress).
39564
- return;
39565
- }
39566
- // The press landed inside another popup: that is a press on what is in
39567
- // front, not outside. Asking the target where it lives rather than asking
39568
- // this popup whether it was pushed — a popup in front does not have to be
39569
- // one this popup knows about. In front means neither inside nor around: a
39570
- // popup nested in this one falls to the containment check below as the
39571
- // inside press it is, and one this popup opened INSIDE (a bubble rendered
39572
- // in a sheet's content) is around it, so what that popup holds is outside
39573
- // this one exactly like the rest of the page.
39574
- const popupUnderPointer = pointerDownEvent.target.closest?.(
39575
- `[navi-control="dialog"], [navi-control="popover"]`,
39576
- );
39577
- if (
39578
- popupUnderPointer &&
39579
- popupUnderPointer !== popupEl &&
39580
- !popupEl.contains(popupUnderPointer) &&
39581
- !popupUnderPointer.contains(popupEl)
39582
- ) {
39583
- return;
39584
- }
39585
- if (
39586
- popupEl.id &&
39587
- pointerDownEvent.target.closest?.(
39588
- `[${INSIDE_ATTRIBUTE}~="${CSS.escape(popupEl.id)}"]`,
39589
- )
39590
- ) {
39591
- return;
39592
- }
39593
- // Real DOM containment wins over the rectangle below — an element
39594
- // genuinely inside the popup (`overflow: visible`, a negative margin, an
39595
- // absolutely-positioned child) can be painted outside its border box, and
39596
- // a press there is not outside just because its coordinates are. The
39597
- // popup element itself is excluded (contains() answers true for it): a
39598
- // press on a modal dialog's native backdrop reports the dialog as its
39599
- // target, there being no `::backdrop` node to be one, so treating that as
39600
- // contained would keep the rectangle from ever running.
39601
- if (
39602
- pointerDownEvent.target !== popupEl &&
39603
- popupEl.contains(pointerDownEvent.target)
39604
- ) {
39605
- return;
39606
- }
39607
- const rect = popupEl.getBoundingClientRect();
39608
- const isOutside =
39609
- pointerDownEvent.clientX < rect.left ||
39610
- pointerDownEvent.clientX > rect.right ||
39611
- pointerDownEvent.clientY < rect.top ||
39612
- pointerDownEvent.clientY > rect.bottom;
39613
- if (!isOutside) {
39614
- return;
39615
- }
39616
- openController.requestClose(pointerDownEvent, {
39617
- isCancel: pressOutside === "cancel",
39618
- });
39619
- };
39620
- document.addEventListener(pressEventType, onDocumentPointerDown, {
39621
- capture: true,
39622
- });
39623
- return () => {
39624
- document.removeEventListener(pressEventType, onDocumentPointerDown, {
39625
- capture: true,
39626
- });
39964
+ const flushSyncRendering = (fn) => {
39965
+ const debounceRenderingPrevious = options.debounceRendering;
39966
+ options.debounceRendering = (drainRenderQueue) => {
39967
+ drainRenderQueue();
39627
39968
  };
39969
+ try {
39970
+ fn();
39971
+ } finally {
39972
+ options.debounceRendering = debounceRenderingPrevious;
39973
+ }
39628
39974
  };
39629
39975
 
39630
39976
  /**
@@ -66937,6 +67283,13 @@ const css$D = /* css */`
66937
67283
  * is a `Popover`-level answer only for `"close"`/`"cancel"`; `"capture"` has
66938
67284
  * nothing left to absorb without a wall, and `"ignore"` renders no wall anyway.
66939
67285
  * See docs/popup_backdrop.md.
67286
+ * @param {string} [props.group] - The popover's peers: the triggers carrying
67287
+ * `data-navi-popup-group` with this name. A press on one, while the popover
67288
+ * is open, closes it and opens the peer's popup in that same press — a
67289
+ * menu bar, where pressing another menu switches to it. The wall still
67290
+ * takes the press (the page behind stays out of reach, hover included) and
67291
+ * hands its click to the peer it covered. Without a wall there is nothing
67292
+ * to ask: the press reaches the peer on its own.
66940
67293
  * @param {"close"|"cancel"|"capture"|"ignore"} [props.pressOutside="ignore"]
66941
67294
  * - `"ignore"` (default): no backdrop at all, outside presses pass straight
66942
67295
  * through. `"close"` closes the popover on an outside press, `"cancel"`
@@ -67313,6 +67666,7 @@ const usePopoverProps = props => {
67313
67666
  // which is a sibling element (see this file's top comment).
67314
67667
  backdropColor,
67315
67668
  backdropFilter,
67669
+ group,
67316
67670
  scrollCapture,
67317
67671
  focusCapture,
67318
67672
  // "auto" (default) → the popover follows its content. "frozen" → measured
@@ -67965,13 +68319,21 @@ const usePopoverProps = props => {
67965
68319
  }
67966
68320
  if (backdropEl) {
67967
68321
  backdropEl.setAttribute("aria-expanded", "false");
67968
- disarmBackdropHideRef.current = armPointerDownOutsideClose(closeEvent, () => {
68322
+ disarmBackdropHideRef.current = armPointerDownOutsideClose(closeEvent, clickEvent => {
67969
68323
  // Set regardless of isTopLayer — see openEffect's own identical
67970
68324
  // comment for why hidePopover() alone isn't reliably sufficient.
67971
68325
  backdropEl.setAttribute("navi-hidden", "");
67972
68326
  if (isTopLayer) {
67973
68327
  backdropEl.hidePopover();
67974
68328
  }
68329
+ if (group && clickEvent) {
68330
+ handWallClickToPeer(clickEvent, {
68331
+ wallEl: backdropEl,
68332
+ popupEl: popoverEl,
68333
+ anchorEl: anchorElement,
68334
+ group
68335
+ });
68336
+ }
67975
68337
  });
67976
68338
  }
67977
68339
  restoreFocus?.(closeEvent);
@@ -68393,6 +68755,10 @@ const Popup = props => {
68393
68755
  scrollCapture,
68394
68756
  positionAreaFixed,
68395
68757
  positionAreaWhenAnchorIsInvalid,
68758
+ // A top-layer dialog's wall is its native ::backdrop, which goes with the
68759
+ // dialog on the press that closes it: no click is left on it to hand to a
68760
+ // peer (see handWallClickToPeer).
68761
+ group,
68396
68762
  // Dialog-only, destructured out for the same reason: forwarded below only
68397
68763
  // in the dialog branch, so they never reach the popover element.
68398
68764
  dockedOnSmallTouchScreen,
@@ -68442,6 +68808,7 @@ const Popup = props => {
68442
68808
  positionAreaWhenAnchorIsInvalid: positionAreaWhenAnchorIsInvalid,
68443
68809
  scrollCapture: scrollCapture === "popover" || scrollCapture,
68444
68810
  positionAreaFixed: positionAreaFixed,
68811
+ group: group,
68445
68812
  className: withPropsClassName("navi_popup", className),
68446
68813
  expand: expand,
68447
68814
  expandX: expandX,
@@ -69359,13 +69726,18 @@ const PickerContentInsidePopup = props => {
69359
69726
  calloutIcon,
69360
69727
  calloutCloseButton,
69361
69728
  calloutCloseByScroll,
69729
+ popupGroup,
69362
69730
  ...rest
69363
69731
  } = props;
69364
69732
  const isPopover = mode === "popover";
69365
69733
  const isCallout = mode === "callout";
69366
69734
  return jsx(Next, {
69367
69735
  "aria-haspopup": isPopover ? "listbox" : "dialog",
69368
- "navi-popover-mode": isPopover ? popoverMode : undefined,
69736
+ "navi-popover-mode": isPopover ? popoverMode : undefined
69737
+ // On the trigger, whatever the mode: a peer's popup reads it off the
69738
+ // press that lands here (see `group` in callout.js).
69739
+ ,
69740
+ "data-navi-popup-group": popupGroup,
69369
69741
  ...rest,
69370
69742
  // On popupProps already (see the picker's popup assembly); they mean
69371
69743
  // nothing to the picker element.
@@ -69401,7 +69773,12 @@ const PickerContentInsidePopup = props => {
69401
69773
  status: calloutStatus,
69402
69774
  icon: calloutIcon,
69403
69775
  closeButton: calloutCloseButton,
69404
- closeByScroll: calloutCloseByScroll,
69776
+ closeByScroll: calloutCloseByScroll
69777
+ // Left unsaid, the callout's own default: no backdrop. It dims
69778
+ // nothing, so the page looks as pressable as it did before.
69779
+ ,
69780
+ backdrop: backdrop,
69781
+ group: popupGroup,
69405
69782
  children: jsx(PopupModeContext.Provider, {
69406
69783
  value: mode,
69407
69784
  children: children
@@ -69438,6 +69815,7 @@ const PickerContentInsidePopup = props => {
69438
69815
  backdropVariant: backdropVariant,
69439
69816
  backdropColor: backdropColor,
69440
69817
  backdropFilter: backdropFilter,
69818
+ group: popupGroup,
69441
69819
  focusCapture: isPopover ? focusCapture : undefined,
69442
69820
  expand: isPopover ? undefined : dialogExpand,
69443
69821
  expandX: isPopover ? undefined : dialogExpandX,
@@ -69497,6 +69875,8 @@ const PickerCalloutPopup = ({
69497
69875
  icon,
69498
69876
  closeButton,
69499
69877
  closeByScroll,
69878
+ backdrop,
69879
+ group,
69500
69880
  onnavi_request_open,
69501
69881
  onnavi_request_close,
69502
69882
  onnavi_request_confirm,
@@ -69538,6 +69918,8 @@ const PickerCalloutPopup = ({
69538
69918
  icon,
69539
69919
  closeButton,
69540
69920
  closeByScroll,
69921
+ backdrop,
69922
+ group,
69541
69923
  anchorElement,
69542
69924
  // The request, chained to the press that made it: the callout reads the
69543
69925
  // mousedown off it to wait for the release before listening for a click
@@ -71704,7 +72086,13 @@ const ListRowContext = createContext(null);
71704
72086
  // row takes its place in the collection by slot: the place is then the list's
71705
72087
  // to move, and the row's to follow — see list_rows.js.
71706
72088
  const ListSlotContext = createContext(null);
71707
- const css$x = /* css */`@layer navi {
72089
+ const css$x = /* css */`@property --list-group-label-height {
72090
+ syntax: "<length>";
72091
+ inherits: false;
72092
+ initial-value: 0;
72093
+ }
72094
+
72095
+ @layer navi {
71708
72096
  .navi_list_container {
71709
72097
  --list-outline-width: 1px;
71710
72098
  --list-border-radius: 4px;
@@ -72103,6 +72491,7 @@ const css$x = /* css */`@layer navi {
72103
72491
  }
72104
72492
 
72105
72493
  & .navi_list_item_group_list {
72494
+ --list-group-label-height: inherit;
72106
72495
  flex-direction: column;
72107
72496
  width: 100%;
72108
72497
  margin: 0;
@@ -72110,8 +72499,12 @@ const css$x = /* css */`@layer navi {
72110
72499
  list-style: none;
72111
72500
  display: flex;
72112
72501
 
72502
+ & > .navi_list_item {
72503
+ --list-group-label-height: inherit;
72504
+ }
72505
+
72113
72506
  & .navi_list_item {
72114
- scroll-margin-top: calc(var(--x-list-scroll-spacing-top) + var(--list-group-label-height, 0px));
72507
+ scroll-margin-top: calc(var(--x-list-scroll-spacing-top) + var(--list-group-label-height));
72115
72508
  }
72116
72509
  }
72117
72510
 
@@ -72263,9 +72656,17 @@ const ListUI = props => {
72263
72656
  // why not an effect), and the floor of 30 is its: it protects the scrolling
72264
72657
  // window, not a picture nobody has seen yet.
72265
72658
  const {
72266
- initial: initialRenderBudget,
72659
+ initial: initialRenderBudgetProp,
72267
72660
  after: renderBudgetAfterPaint
72268
72661
  } = resolveRenderBudget(renderBudgetProp);
72662
+ // Opening on a position that says how many rows were on screen from its row
72663
+ // on (the way back to where the list was, see reportPosition): that is what
72664
+ // the first picture shows, whatever `initial` guessed — with the row above
72665
+ // when it stood partly in view. The rest waits for the paint like any
72666
+ // `initial`: a page coming back in a route transition builds it inside the
72667
+ // update callback, and every row below the fold delays the movement.
72668
+ const openingPosition = scrolled ?? defaultScrolled;
72669
+ const initialRenderBudget = openingPosition && typeof openingPosition === "object" && typeof openingPosition.visibleCount === "number" ? openingPosition.visibleCount + (openingPosition.offset > 0 ? 1 : 0) : initialRenderBudgetProp;
72269
72670
  let renderBudget = renderBudgetAfterPaint;
72270
72671
  if (renderBudget < 30 && !renderBudgetSkipCheck) {
72271
72672
  console.warn(`List: renderBudget=${renderBudget} is too low. A renderBudget below 30 is not supported: on large screens or when the list grows, items outside the window would appear as blank space instead of rendered content. Use a value of at least 30, or omit the prop to use the default (${RENDER_BUDGET_DEFAULT}).`);
@@ -72728,11 +73129,23 @@ const useListScrollSync = ({
72728
73129
  horizontal
72729
73130
  });
72730
73131
  };
73132
+
73133
+ // How many rows the window keeps above the row the list is held at. Around it
73134
+ // for the scrolling to come, from the first paint on; before, the window is
73135
+ // the first picture's, and a picture of the list opening on a row shows that
73136
+ // row and what is below it — with the one above when a remembered position
73137
+ // says it stood partly in view.
73138
+ const rowsAboveOpening = (openAt, windowSize) => {
73139
+ if (renderBudget !== renderBudgetSteady) {
73140
+ return openAt && typeof openAt === "object" && openAt.offset > 0 ? 1 : 0;
73141
+ }
73142
+ return Math.floor(windowSize / 2);
73143
+ };
72731
73144
  const [renderWindow, setRenderWindow] = useState(() => {
72732
73145
  // Opening somewhere else than the beginning starts by framing there: the
72733
73146
  // rows the list will draw are the rows it will ask for.
72734
73147
  const openAt = scrolled ?? defaultScrolled;
72735
- const start = typeof openAt === "number" ? openAt - Math.floor(renderBudget / 2) : 0;
73148
+ const start = typeof openAt === "number" ? openAt - rowsAboveOpening(openAt, renderBudget) : 0;
72736
73149
  const startClamped = start < 0 ? 0 : start;
72737
73150
  return {
72738
73151
  start: startClamped,
@@ -72806,8 +73219,12 @@ const useListScrollSync = ({
72806
73219
  // Held somewhere it has not reached yet: what the window frames right now
72807
73220
  // is not what it will frame, so nothing should be fetched for it.
72808
73221
  listRows.holdPending = scrolledWanted !== "start" && scrolledWanted !== undefined;
73222
+ // Zero until the runs have rendered — they count their rows as they do,
73223
+ // after the list — which is not a collection fitting in the window: it is
73224
+ // one whose end is not known yet, and the window then frames the row the
73225
+ // list opens on, already in the first commit.
72809
73226
  const total = listRows.totalSignal.peek();
72810
- if (total <= renderBudget) {
73227
+ if (total > 0 && total <= renderBudget) {
72811
73228
  // The whole collection is what the list draws: wherever in it the list is
72812
73229
  // held, the window is already its place. Nowhere to move to means nothing
72813
73230
  // to wait for — a hold left standing here is a list that never asks for
@@ -72815,21 +73232,21 @@ const useListScrollSync = ({
72815
73232
  listRows.holdPending = false;
72816
73233
  return;
72817
73234
  }
72818
- const half = Math.floor(renderBudget / 2);
73235
+ const above = rowsAboveOpening(scrolledWanted, renderBudget);
72819
73236
  let wantedStart = null;
72820
73237
  if (scrolledWanted === "end") {
72821
73238
  wantedStart = total - renderBudget;
72822
73239
  } else if (typeof scrolledWanted === "number") {
72823
- wantedStart = scrolledWanted - half;
73240
+ wantedStart = scrolledWanted - above;
72824
73241
  } else if (scrolledWanted && scrolledWanted.id !== undefined) {
72825
73242
  const rowIndex = listRows.locateRow(scrolledWanted.id);
72826
73243
  if (rowIndex !== null) {
72827
- wantedStart = rowIndex - half;
73244
+ wantedStart = rowIndex - above;
72828
73245
  } else if (typeof scrolledWanted.index === "number") {
72829
73246
  // The row has not come back yet, but where it stood is known: near
72830
73247
  // enough to frame, and to put the scrollbar roughly where it will end
72831
73248
  // up rather than at the top.
72832
- wantedStart = scrolledWanted.index - half;
73249
+ wantedStart = scrolledWanted.index - above;
72833
73250
  }
72834
73251
  }
72835
73252
  if (wantedStart === null) {
@@ -72838,12 +73255,12 @@ const useListScrollSync = ({
72838
73255
  // back — the run asks for it by name (see useRequestMissing).
72839
73256
  return;
72840
73257
  }
73258
+ if (total > 0 && wantedStart + renderBudget > total) {
73259
+ wantedStart = total - renderBudget;
73260
+ }
72841
73261
  if (wantedStart < 0) {
72842
73262
  wantedStart = 0;
72843
73263
  }
72844
- if (wantedStart + renderBudget > total) {
72845
- wantedStart = total - renderBudget;
72846
- }
72847
73264
  const {
72848
73265
  start,
72849
73266
  end
@@ -73180,8 +73597,8 @@ const useListScrollSync = ({
73180
73597
  end
73181
73598
  } = renderWindowRef.current;
73182
73599
  if (rowIndex < start || rowIndex >= end) {
73183
- const half = Math.floor((end - start) / 2);
73184
- const wantedStart = rowIndex - half < 0 ? 0 : rowIndex - half;
73600
+ const above = rowsAboveOpening(scrolledWanted, end - start);
73601
+ const wantedStart = rowIndex - above < 0 ? 0 : rowIndex - above;
73185
73602
  updateRenderWindow(wantedStart, wantedStart + (end - start), `opening on row ${scrolledWanted.id}`);
73186
73603
  return;
73187
73604
  }
@@ -73227,6 +73644,15 @@ const useListScrollSync = ({
73227
73644
  }
73228
73645
  };
73229
73646
  useLayoutEffect(placeWhereHeld);
73647
+ // Held on a row of a list scrolling the document: where the document goes is
73648
+ // this list's to say, measured on the row, not the url's offset in pixels
73649
+ // (see holdDocumentScroll).
73650
+ useLayoutEffect(() => {
73651
+ if (!heldSomewhere || getScroller() !== document.scrollingElement) {
73652
+ return undefined;
73653
+ }
73654
+ return holdDocumentScroll();
73655
+ }, [heldSomewhere, scrollerElResolved]);
73230
73656
  // What to do when the list's own geometry moves under it, kept fresh for the
73231
73657
  // observer below (which is installed once).
73232
73658
  const onGeometryChangeRef = useRef(null);
@@ -73269,31 +73695,40 @@ const useListScrollSync = ({
73269
73695
  // Where the list was at the last thing that moved it. Kept whether anyone
73270
73696
  // asked for it or not: it is what a resize needs to put things back.
73271
73697
  const positionRef = useRef(null);
73698
+ const captureListAnchor = countVisible => captureScrollAnchor({
73699
+ scrollerEl: getScroller(),
73700
+ listEl: getListEl(),
73701
+ items: listRows.visibleItemsSignal.peek(),
73702
+ horizontal,
73703
+ countVisible
73704
+ });
73705
+ // Said from where the row lands on its own, and with `visibleCount`: the
73706
+ // rows on screen from that one on, which is what a list given this position
73707
+ // back draws first (see ListUI).
73708
+ const toScrolledPosition = position => {
73709
+ const rowEl = findRowElement(getListEl(), position.id);
73710
+ return {
73711
+ id: position.id,
73712
+ index: position.index,
73713
+ offset: position.offset - getRowScrollInset(getScroller(), rowEl, horizontal),
73714
+ visibleCount: position.visibleCount
73715
+ };
73716
+ };
73272
73717
  const reportPosition = () => {
73273
73718
  if (!ref.current) {
73274
73719
  return;
73275
73720
  }
73276
- const position = captureScrollAnchor({
73277
- scrollerEl: getScroller(),
73278
- listEl: getListEl(),
73279
- items: listRows.visibleItemsSignal.peek(),
73280
- horizontal
73281
- });
73721
+ const remember = rememberScrollRef.current;
73722
+ const onScrolledChange = onScrolledChangeRef.current;
73723
+ const position = captureListAnchor(remember || Boolean(onScrolledChange));
73282
73724
  if (!position) {
73283
73725
  return;
73284
73726
  }
73285
73727
  positionRef.current = position;
73286
- const remember = rememberScrollRef.current;
73287
- const onScrolledChange = onScrolledChangeRef.current;
73288
73728
  if (!remember && !onScrolledChange) {
73289
73729
  return;
73290
73730
  }
73291
- const rowEl = findRowElement(getListEl(), position.id);
73292
- const scrolledNow = {
73293
- id: position.id,
73294
- index: position.index,
73295
- offset: position.offset - getRowScrollInset(getScroller(), rowEl, horizontal)
73296
- };
73731
+ const scrolledNow = toScrolledPosition(position);
73297
73732
  if (remember) {
73298
73733
  rememberScrollerPosition(listId, scrolledNow);
73299
73734
  }
@@ -73301,6 +73736,20 @@ const useListScrollSync = ({
73301
73736
  onScrolledChange(scrolledNow);
73302
73737
  }
73303
73738
  };
73739
+ // Nothing scrolled, yet what is on screen changed: the list has just been
73740
+ // laid out, its rows have arrived, one of them grew. A list read where it
73741
+ // opened would otherwise come back with nothing kept — opening again as a
73742
+ // fresh arrival does, all of its window drawn in the first commit instead of
73743
+ // the rows that were on screen. Kept, and told to nobody: nobody scrolled.
73744
+ const rememberPosition = () => {
73745
+ if (!rememberScrollRef.current || !ref.current) {
73746
+ return;
73747
+ }
73748
+ const position = captureListAnchor(true);
73749
+ if (position) {
73750
+ rememberScrollerPosition(listId, toScrolledPosition(position));
73751
+ }
73752
+ };
73304
73753
  // Leaving: with its page, or alone (see forgetScrollerUnlessPageLeft).
73305
73754
  useLayoutEffect(() => {
73306
73755
  return () => {
@@ -73326,6 +73775,7 @@ const useListScrollSync = ({
73326
73775
  // list which has outgrown a bounded ancestor stops holding on to the
73327
73776
  // page.
73328
73777
  resolveScroller();
73778
+ rememberPosition();
73329
73779
  // Two things resize here, and they call for opposite answers. The LIST
73330
73780
  // growing is its own content settling: only a list holding itself
73331
73781
  // somewhere cares (the end it aims at has moved), and a list the user is
@@ -74114,12 +74564,15 @@ const getRowName = rowEl => rowEl.getAttribute("navi-list-item-real");
74114
74564
  // the list is rebuilt around it. Read off the rows' own boxes, not by
74115
74565
  // hit-testing the screen: a scrolling list takes its rows out of hit-testing
74116
74566
  // (see the navi-scrolling rule in the css above), and the scroll event is
74117
- // precisely when this is asked.
74567
+ // precisely when this is asked. `countVisible` adds how many rows are on
74568
+ // screen from that one on: what a list coming back there has to draw before
74569
+ // anything else (see the first paint in ListUI).
74118
74570
  const captureScrollAnchor = ({
74119
74571
  scrollerEl,
74120
74572
  listEl,
74121
74573
  items,
74122
- horizontal
74574
+ horizontal,
74575
+ countVisible
74123
74576
  }) => {
74124
74577
  if (!scrollerEl || !listEl) {
74125
74578
  return null;
@@ -74155,6 +74608,9 @@ const captureScrollAnchor = ({
74155
74608
  offset
74156
74609
  };
74157
74610
  if (offset >= 0) {
74611
+ if (countVisible) {
74612
+ anchor.visibleCount = countRowsStartingBefore(rowEls, i, range.to, horizontal);
74613
+ }
74158
74614
  return anchor;
74159
74615
  }
74160
74616
  // The row under the top of the view starts above it. Good enough to hold
@@ -74163,10 +74619,26 @@ const captureScrollAnchor = ({
74163
74619
  // top" reads, and can be drawn. Keep looking; this one is the fallback.
74164
74620
  if (!fallbackAnchor) {
74165
74621
  fallbackAnchor = anchor;
74622
+ if (countVisible) {
74623
+ anchor.visibleCount = countRowsStartingBefore(rowEls, i, range.to, horizontal);
74624
+ }
74166
74625
  }
74167
74626
  }
74168
74627
  return fallbackAnchor;
74169
74628
  };
74629
+ // How many of the rows from `fromIndex` on start before `to` along the scroll
74630
+ // axis — the ones on screen, when `to` is where the view ends.
74631
+ const countRowsStartingBefore = (rowEls, fromIndex, to, horizontal) => {
74632
+ let index = fromIndex;
74633
+ while (index < rowEls.length) {
74634
+ const rect = rowEls[index].getBoundingClientRect();
74635
+ if ((horizontal ? rect.left : rect.top) >= to) {
74636
+ break;
74637
+ }
74638
+ index++;
74639
+ }
74640
+ return index - fromIndex;
74641
+ };
74170
74642
  // The part of the list that is on screen, along the scrolling axis. Both edges
74171
74643
  // matter: the scroller may be larger than the list (scroller="parent") as well
74172
74644
  // as smaller (the list scrolls inside its own box).
@@ -76534,7 +77006,7 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
76534
77006
  * are skeletons, the others hold their room. `0` says the list is already
76535
77007
  * known to be empty: the empty `fallback` shows right away rather than an
76536
77008
  * empty frame.
76537
- * @param {"start"|"end"|number|{id: string, offset?: number}} [props.defaultScrolled="start"]
77009
+ * @param {"start"|"end"|number|{id: string, index?: number, offset?: number, visibleCount?: number}} [props.defaultScrolled="start"]
76538
77010
  * Where the list opens, after which the user owns the scroll — unless it is
76539
77011
  * being come back to (see `scrollResetOnNavigation`). `"end"` is a
76540
77012
  * thread read backwards — the last rows are the ones to show, and the ones
@@ -76546,8 +77018,10 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
76546
77018
  * the screen it was saved on. `offset: 0` is where a `scrollIntoView()` puts
76547
77019
  * it — in front of the fixed bar the scroller gives room for, below the
76548
77020
  * sticky header and the group label the row lives under — so nothing of that
76549
- * room has to be restated as a number by whoever asks.
76550
- * @param {"start"|"end"|number|{id: string, offset?: number}} [props.scrolled]
77021
+ * room has to be restated as a number by whoever asks. The `index` and
77022
+ * `visibleCount` it also hands out say where to aim before the row is found,
77023
+ * and how many rows to draw before the first paint (see `renderBudget`).
77024
+ * @param {"start"|"end"|number|{id: string, index?: number, offset?: number, visibleCount?: number}} [props.scrolled]
76551
77025
  * The same, but held: the list goes back there every time this changes, even
76552
77026
  * after the user has scrolled — the caller owns where the list is (see
76553
77027
  * `defaultScrolled` for the uncontrolled form, and `open`/`defaultOpen`
@@ -76558,12 +77032,12 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
76558
77032
  * In every form the list holds itself there while it is still finding out
76559
77033
  * how many rows there are and how tall one is, and lets go the moment the
76560
77034
  * user reaches for the list.
76561
- * @param {(scrolled: {id: string, index: number, offset: number}) => void} [props.onScrolledChange]
77035
+ * @param {(scrolled: {id: string, index: number, offset: number, visibleCount: number}) => void} [props.onScrolledChange]
76562
77036
  * Where the list is, as the user scrolls: the row at the top of the view and
76563
77037
  * how far below the place a row lands on its own (see `defaultScrolled`) it
76564
- * starts. Keep it to come back to it
76565
- * later through `scrolled`/`defaultScrolled` — an index would not do, since
76566
- * rows get inserted while a list is being read.
77038
+ * starts, and how many rows are on screen from that one on. Keep it whole to
77039
+ * come back to it later through `scrolled`/`defaultScrolled` — an index alone
77040
+ * would not do, since rows get inserted while a list is being read.
76567
77041
  * @param {number|{initial: number, after: number}} [props.renderBudget=100]
76568
77042
  * How many rows of a `<List.Items>` run are in the DOM at once: the render
76569
77043
  * window, which slides as the user scrolls while fillers hold the room of
@@ -76575,11 +77049,15 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
76575
77049
  * once, with room for that lookahead — the list warns below 30, and when a
76576
77050
  * budget leaves fewer than two rows beyond the screen.
76577
77051
  *
76578
- * `{ initial, after }` for a list drawn in the click that opens it (a popup):
76579
- * `initial` rows in the commit the browser paints first — about what a phone
76580
- * screen shows — and `after` from the paint on, where the floor of 30
76581
- * applies. The runs ask their source for `after` rows from the start, so the
76582
- * first picture costs no second request.
77052
+ * `{ initial, after }` for a list whose first picture is taken as it is
77053
+ * built — drawn in the click that opens a popup, or in the update callback
77054
+ * of a route transition bringing its page back: `initial` rows in the commit
77055
+ * the browser paints first — about what a phone screen shows — counted from
77056
+ * the row the list opens on, and `after` from the paint on, around it, where
77057
+ * the floor of 30 applies. Opening on a position that says how many rows were
77058
+ * on screen (`visibleCount`, see `onScrolledChange`) draws those first,
77059
+ * whatever `initial` says. The runs ask their source for `after` rows from
77060
+ * the start, so the first picture costs no second request.
76583
77061
  * @param {number} [props.virtualItemSize]
76584
77062
  * The size of one row along the scroll axis, in px, when every row has the
76585
77063
  * same: what the fillers are sized with and what a scroll position is
@@ -79202,6 +79680,7 @@ const PickerFirstResolver = props => {
79202
79680
  * escapeEffect?: "cancel" | "close",
79203
79681
  * pressOutside?: "close" | "cancel" | "capture",
79204
79682
  * backdrop?: boolean,
79683
+ * popupGroup?: string,
79205
79684
  * backdropVariant?: "auto" | "lift" | "discrete" | "invisible",
79206
79685
  * backdropColor?: string,
79207
79686
  * backdropFilter?: string,
@@ -79601,10 +80080,23 @@ const PickerFirstResolver = props => {
79601
80080
  * and the page at all. `false` lets a press outside both close the popup and
79602
80081
  * reach whatever it landed on, in one gesture — for a picker opened over a
79603
80082
  * page that stays as pressable as it looks (a plan, a map, a canvas), where a
79604
- * wall would spend the first press on dismissing. Honoured in both modes: a
80083
+ * wall would spend the first press on dismissing. Honoured in every mode: a
79605
80084
  * dialog with no wall is shown through the Popover API rather than
79606
80085
  * `showModal()`, so a picker docked as a bottom sheet on a phone still
79607
- * leaves the plan behind it live.
80086
+ * leaves the plan behind it live. `false` by default under `mode="callout"`,
80087
+ * a bubble that dims nothing; `true` there spends the press outside on
80088
+ * closing it — the link under it not followed, the card under it not held —
80089
+ * without a wall: the scroll a finger starts beside the bubble still
80090
+ * scrolls, and a peer (`popupGroup`) still gets its press.
80091
+ * @param {string} [popupGroup] Pickers sharing the name are peers, whatever
80092
+ * their mode: pressing one while another's popup is open goes from that
80093
+ * popup to this one in a single press — a menu bar, where pressing another
80094
+ * menu switches to it. A popover's wall still takes that press — the page
80095
+ * behind stays out of reach, hover included — and hands its click to the
80096
+ * peer it covered; a callout with `backdrop` closes on it without spending
80097
+ * it. Not read in dialog mode: a dialog's wall goes with it on the press
80098
+ * that closes it. Written on the trigger as `data-navi-popup-group`, which
80099
+ * is also how anything else joins the group.
79608
80100
  * @param {"auto"|"lift"|"discrete"|"invisible"} [backdropVariant="auto"] How
79609
80101
  * visible the popup's backdrop is, independently of what a click outside
79610
80102
  * does: `"auto"` is the paint `pressOutside` implies,