@jsenv/navi 0.29.416 → 0.29.417

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";
@@ -15851,235 +15851,857 @@ const useOpenControllerByProps = (props, name) => {
15851
15851
  };
15852
15852
 
15853
15853
  /**
15854
- * Typography reset shared by the surfaces a control opens: callout, popover,
15855
- * dialog.
15854
+ * Small, renderer-agnostic helpers shared by Popover and Dialog's own custom
15855
+ * (non-top-layer) renderers — operate on a plain DOM element, no knowledge
15856
+ * of which of the two owns it.
15857
+ */
15858
+
15859
+
15860
+ /**
15861
+ * Whether a visibleRectEffect delivery is one that can have taken height away
15862
+ * from a popup, and so pushed whatever holds focus out of sight:
15863
+ * - "resize": the window/visual viewport settled — which is also how the
15864
+ * on-screen keyboard arrives, overlay or not (window_size.js);
15865
+ * - ELEMENT_SIZE_CHANGE: the popup's own box measured different;
15866
+ * - "focusin": the focus-settled re-measure, for the room that changes with
15867
+ * nothing announcing it (subscribeFocusSettled in window_size.js).
15856
15868
  *
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.
15869
+ * Everything else is a scroll of one kind or another, where nothing got
15870
+ * smaller and scrolling the focused element back would fight the very gesture
15871
+ * that fired it.
15872
+ */
15873
+ const mayHaveHiddenFocus = (event) => {
15874
+ const type = event?.type;
15875
+ return (
15876
+ type === "resize" || type === ELEMENT_SIZE_CHANGE || type === "focusin"
15877
+ );
15878
+ };
15879
+
15880
+ const scrollportHeightMap = new WeakMap();
15881
+ /**
15882
+ * Scrolls whatever holds focus inside `popupEl` back into view, if the popup
15883
+ * getting shorter has pushed it out.
15865
15884
  *
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.
15885
+ * The case this exists for: a field low in the scrolling body of a popup that
15886
+ * also has a footer (box.jsx — with a body, the body is the only thing that
15887
+ * scrolls and the footer is a sibling sitting right under it). Focusing the
15888
+ * field makes the browser scroll it into view, which it does against the
15889
+ * popup's height AT THAT MOMENT; the on-screen keyboard then opens and takes
15890
+ * that height away. The body shrinks, its scrollTop does not move, so the
15891
+ * content slides down relative to the shorter scrollport and the field ends up
15892
+ * past its bottom edge — visually, swallowed by the footer. The browser does
15893
+ * not redo a scroll-into-view it already answered, so this does.
15872
15894
  *
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).
15895
+ * Only what the shrink itself hid, though: with the keyboard up the user reads
15896
+ * the rest of the popup by scrolling the field away — to reach the submit under
15897
+ * it, typically — and the room keeps changing while they do (a keyboard settling
15898
+ * in two steps, a suggestion strip, a browser bar). Answering each of those by
15899
+ * scrolling the field back takes the popup away from wherever they had just
15900
+ * scrolled it, over and over: what they were reading cannot be reached at all
15901
+ * without blurring the field first, and the popup reads as unscrollable. So the
15902
+ * field is brought back only when it was in view before the room shrank, which
15903
+ * one remembered number answers: a resize moves neither scrollTop nor the
15904
+ * element's offset inside the scrolled content, so measuring against the height
15905
+ * the scrollport HAD is measuring the state before the change.
15879
15906
  *
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.
15907
+ * Scoped to the field's own scroll container (never the page): a popup traps
15908
+ * scrolling precisely so the document underneath cannot move, and a plain
15909
+ * scrollIntoView walks past a container whose scrollbar isn't visible — see
15910
+ * scrollIntoViewScoped's own doc.
15911
+ *
15912
+ * "nearest": the smallest scroll that makes it visible, and none at all when it
15913
+ * already is. Where it lands is the container's own business — a navi scroller
15914
+ * keeps a band free at its edges so a field never comes back glued to one (see
15915
+ * scroll-padding in box.jsx).
15882
15916
  */
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;
15917
+ const keepFocusedElementVisible = (popupEl) => {
15918
+ const { activeElement } = document;
15919
+ if (!activeElement || activeElement === popupEl) {
15920
+ return;
15921
+ }
15922
+ if (!popupEl.contains(activeElement)) {
15923
+ return;
15924
+ }
15925
+ const scrollContainer = getScrollContainer(activeElement);
15926
+ if (!scrollContainer || !popupEl.contains(scrollContainer)) {
15927
+ // What scrolls the field is outside the popup, which means the page: a
15928
+ // popup holds it still on purpose (trapScrollInside), so there is nothing
15929
+ // here to scroll back.
15930
+ return;
15931
+ }
15932
+ const scrollportHeight = scrollContainer.clientHeight;
15933
+ const scrollportHeightBefore = scrollportHeightMap.get(scrollContainer);
15934
+ scrollportHeightMap.set(scrollContainer, scrollportHeight);
15935
+ if (scrollportHeightBefore !== undefined) {
15936
+ if (scrollportHeight >= scrollportHeightBefore) {
15937
+ // Nothing was taken away, so nothing was hidden by this.
15938
+ return;
15939
+ }
15940
+ if (
15941
+ !isVisibleInScrollport(
15942
+ activeElement,
15943
+ scrollContainer,
15944
+ scrollportHeightBefore,
15945
+ )
15946
+ ) {
15947
+ return;
15896
15948
  }
15897
15949
  }
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
15950
+ scrollIntoViewScoped(activeElement, {
15951
+ container: scrollContainer,
15952
+ block: "nearest",
15917
15953
  });
15918
- render(calloutJsx, calloutMessageElement);
15919
15954
  };
15920
15955
 
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
- });
15956
+ // Whether any part of `el` was showing in `container` back when its scrollport
15957
+ // was `scrollportHeight` tall. Both boxes are read now: the container may have
15958
+ // moved as well as shrunk (a centered dialog re-centers itself), and the
15959
+ // difference between the two tops is what that move leaves alone.
15960
+ const isVisibleInScrollport = (el, container, scrollportHeight) => {
15961
+ const elRect = el.getBoundingClientRect();
15962
+ const containerRect = container.getBoundingClientRect();
15963
+ const elTop = elRect.top - containerRect.top + container.scrollTop;
15964
+ const elBottom = elTop + elRect.height;
15965
+ const scrollportTop = container.scrollTop;
15966
+ const scrollportBottom = scrollportTop + scrollportHeight;
15967
+ return elBottom > scrollportTop && elTop < scrollportBottom;
15935
15968
  };
15936
15969
 
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;
15970
+ /**
15971
+ * Calls `onSettled` once `el`'s current CSS transition is over — via
15972
+ * `transitionend`, with a safety `setTimeout` fallback matching the longest
15973
+ * `transition-duration`, in case nothing actually transitions or an event is
15974
+ * missed.
15975
+ *
15976
+ * Returns a "cancel" function, so a caller whose instance has been superseded
15977
+ * (a fresh open/close about to set its own state) can keep this stale one from
15978
+ * firing later. Cancelling only stops `onSettled`: undoing whatever the caller
15979
+ * did up front is that fresh call's business, not this one's.
15980
+ */
15981
+ const whenTransitionSettles = (el, onSettled) => {
15982
+ let settled = false;
15983
+ const onTransitionEnd = (transitionEvent) => {
15984
+ if (transitionEvent.target === el) {
15985
+ finish();
15950
15986
  }
15951
- if (child.nodeType !== Node.ELEMENT_NODE) {
15952
- continue;
15987
+ };
15988
+ const stopWatching = () => {
15989
+ settled = true;
15990
+ el.removeEventListener("transitionend", onTransitionEnd);
15991
+ clearTimeout(safetyTimeoutId);
15992
+ };
15993
+ const finish = () => {
15994
+ if (settled) {
15995
+ return;
15953
15996
  }
15954
- const props = {};
15955
- for (const {
15956
- name,
15957
- value
15958
- } of child.attributes) {
15959
- props[name] = value;
15997
+ stopWatching();
15998
+ onSettled();
15999
+ };
16000
+ el.addEventListener("transitionend", onTransitionEnd);
16001
+ const durationsInSeconds = getComputedStyle(el)
16002
+ .transitionDuration.split(",")
16003
+ .map((value) => parseFloat(value) || 0);
16004
+ const longestDurationMs = Math.max(0, ...durationsInSeconds) * 1000;
16005
+ const safetyTimeoutId = setTimeout(finish, longestDurationMs + 50);
16006
+ return () => {
16007
+ if (settled) {
16008
+ return;
15960
16009
  }
15961
- vnodes.push(h(child.localName, props, ...domToVNodes(child)));
15962
- }
15963
- return vnodes;
16010
+ stopWatching();
16011
+ };
15964
16012
  };
15965
16013
 
15966
- installImportMetaCssBuild(import.meta);
15967
16014
  /**
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).
16015
+ * Drops the document's text selection when it lives inside `el`, leaving a
16016
+ * selection made elsewhere on the page alone.
16017
+ *
16018
+ * A popup being closed takes its content with it, and a selection is a claim
16019
+ * on content the user can still act on: kept, it would outlive the surface it
16020
+ * was made on — painted on the box for the length of its exit transition, with
16021
+ * the handles and the copy toolbar a phone draws from the live selection
16022
+ * hanging over something that is going away — and reappear with the box on
16023
+ * the next opening. Dropping the selection is the only way to remove that
16024
+ * chrome: a `user-select: none` on the closing box hides the highlight in some
16025
+ * browsers only, and the handles are the browser's own, drawn from the
16026
+ * selection object rather than from any style.
15973
16027
  */
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);
16028
+ const clearTextSelectionInside = (el) => {
16029
+ const selection = window.getSelection();
16030
+ if (!selection || selection.rangeCount === 0) {
16031
+ return;
15996
16032
  }
15997
-
15998
- &[data-status="warning"] {
15999
- --x-callout-status-icon-color: var(--navi-callout-warning-color);
16033
+ const range = selection.getRangeAt(0);
16034
+ if (!el.contains(range.commonAncestorContainer)) {
16035
+ return;
16000
16036
  }
16037
+ selection.removeAllRanges();
16038
+ };
16001
16039
 
16002
- &[data-status="error"] {
16003
- --x-callout-status-icon-color: var(--navi-callout-error-color);
16004
- }
16040
+ /**
16041
+ * Disables pointer-events on `el` until its current CSS transition settles —
16042
+ * avoids the cursor changing/something becoming clickable while the popup is
16043
+ * still visually moving into or out of place.
16044
+ *
16045
+ * Returns whenTransitionSettles' own "cancel" function: it doesn't restore
16046
+ * pointer-events, since a fresh call for the next open/close is about to set
16047
+ * its own state.
16048
+ */
16049
+ const suppressPointerEventsDuringTransition = (el) => {
16050
+ el.style.pointerEvents = "none";
16051
+ return whenTransitionSettles(el, () => {
16052
+ el.style.pointerEvents = "";
16053
+ });
16054
+ };
16005
16055
 
16006
- &[data-shape="circle"] {
16007
- border-radius: 50%;
16056
+ /**
16057
+ * Hides the backdrop, deferring until the browser's matching "click" fires
16058
+ * when `closeEvent` was triggered by a mousedown (see popover.jsx's top
16059
+ * comment for why) — same capture-phase-on-document pattern as
16060
+ * armSuppressNextOpenRequest in open_controller.js, which a plain timeout
16061
+ * can't safely replace: mouseup (and the click that follows it) can land an
16062
+ * arbitrarily long time after mousedown (the user is still holding the
16063
+ * button down), so a short timeout can fire first and hide the backdrop
16064
+ * before its own click ever arrives. A capture-phase listener on document
16065
+ * fires for every click regardless of what any bubble-phase handler does
16066
+ * downstream, so no fallback timer is needed.
16067
+ *
16068
+ * `hide` is the caller's own way to actually hide the backdrop
16069
+ * (`hidePopover()` for a top-layer backdrop, a plain `style.display = "none"`
16070
+ * for a plain div) — this helper only owns the mousedown/click timing.
16071
+ *
16072
+ * Returns a disarm function (or undefined if hidden immediately), so a
16073
+ * fresh open can cancel a pending hide it's about to make redundant.
16074
+ */
16075
+ const armPointerDownOutsideClose = (closeEvent, hide) => {
16076
+ const mousedownEvent = findEvent(closeEvent, "mousedown");
16077
+ if (!mousedownEvent) {
16078
+ hide();
16079
+ return undefined;
16008
16080
  }
16081
+ // `hide` is handed the click, for a wall with peers to re-aim it (see
16082
+ // handWallClickToPeer).
16083
+ const onClick = (clickEvent) => {
16084
+ document.removeEventListener("click", onClick, { capture: true });
16085
+ hide(clickEvent);
16086
+ };
16087
+ document.addEventListener("click", onClick, { capture: true });
16088
+ return () => {
16089
+ document.removeEventListener("click", onClick, { capture: true });
16090
+ };
16091
+ };
16009
16092
 
16010
- & svg {
16011
- width: 100%;
16012
- height: 100%;
16093
+ /**
16094
+ * Maps a positionArea y/x pair to a concrete `navi-animation` value (a
16095
+ * `prefix` plus a direction word), or `null` if both axes overlap the anchor
16096
+ * (no direction at all — that's `resolvedAnimationKind === "scaling"`
16097
+ * territory instead, see resolveAutoAnimationKind below).
16098
+ *
16099
+ * `prefix: "slide-from"` (used with no real anchor — Dialog always, Popover
16100
+ * when docked) keeps the word as the compass direction the popup comes
16101
+ * from: placed "top" (a point/corner), it slides in from the top.
16102
+ * `prefix: "expand"` (a real anchor, Popover-only) uses the motion/growth
16103
+ * direction instead, the opposite compass point: placed "top" of the
16104
+ * anchor, it moves/grows up, away from the anchor (which sits below it).
16105
+ *
16106
+ * "inset-*"/"center" contribute no direction on their axis either way.
16107
+ */
16108
+ const resolveDirectionValue = (y, x, { prefix }) => {
16109
+ const yWord =
16110
+ y === "top"
16111
+ ? prefix === "expand"
16112
+ ? "up"
16113
+ : "top"
16114
+ : y === "bottom"
16115
+ ? prefix === "expand"
16116
+ ? "down"
16117
+ : "bottom"
16118
+ : null;
16119
+ const xWord = x === "left" ? "left" : x === "right" ? "right" : null;
16120
+ if (!yWord && !xWord) {
16121
+ return null;
16013
16122
  }
16014
- }
16015
-
16016
- .navi_icon > .navi_callout_status_icon {
16017
- width: 100%;
16018
- height: 100%;
16019
- }
16020
- `;
16123
+ return yWord && xWord
16124
+ ? `${prefix}-${yWord}-${xWord}`
16125
+ : `${prefix}-${yWord || xWord}`;
16126
+ };
16021
16127
 
16022
16128
  /**
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.
16129
+ * Shared `animation="auto"`/`true` resolution: "scaling" reads best overall
16130
+ * — picked for any real anchor, or for a point/corner placed dead-center
16131
+ * (both positionArea axes overlapping — there's no sensible direction to
16132
+ * slide from in that case). "sliding" otherwise. `anchor` is `undefined`
16133
+ * for any no-anchor/docked case (Dialog always, Popover's own custom
16134
+ * renderer when there's no real anchor), so this collapses to "scaling"
16135
+ * there only for the dead-center case, "sliding" otherwise. The two
16136
+ * "overlapping" booleans below describe the *positionArea* itself (a bare
16137
+ * word vs. "inset-"/"center"), not anything about the anchor — they'd
16138
+ * mean exactly the same thing even with no anchor at all, since it's the
16139
+ * position strategy, not the anchor, that decides whether there's a
16140
+ * direction to slide from.
16028
16141
  */
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";
16142
+ const resolveAutoAnimationKind = (anchor, parsedPositionArea) => {
16143
+ const yIsOverlapping =
16144
+ parsedPositionArea.y !== "top" && parsedPositionArea.y !== "bottom";
16145
+ const xIsOverlapping =
16146
+ parsedPositionArea.x !== "left" && parsedPositionArea.x !== "right";
16147
+ return anchor || (yIsOverlapping && xIsOverlapping) ? "scaling" : "sliding";
16148
+ };
16031
16149
 
16032
16150
  /**
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.
16151
+ * An opening that reaches a popup with no element of its own to show.
16152
+ *
16153
+ * The one cause seen so far, and it is silent: something inside the popup
16154
+ * suspended while its content was being built, and the `<Loading>` that caught
16155
+ * it sits ABOVE the popup. A boundary suspending on an update keeps the page as
16156
+ * it was, as a copy, so what is opened here belongs to a copy nobody owns any
16157
+ * more — and once the data arrives the subtree is rebuilt from scratch, closed
16158
+ * and empty. Nothing is thrown, nothing is drawn, and the press is lost.
16041
16159
  */
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
- });
16160
+ const warnPopupHasNoElementToOpen = (popupKind) => {
16161
+ console.warn(
16162
+ `[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 }).`,
16163
+ );
16061
16164
  };
16062
16165
 
16063
- installImportMetaCssBuild(import.meta);
16166
+ /**
16167
+ * A popup reads "outside" from its own border box: what a press lands on
16168
+ * decides nothing on its own (a genuine backdrop press and a press on the
16169
+ * popup's padding both report the popup element as their target, there being
16170
+ * no real ::backdrop node to be one), so the rectangle is what tells them
16171
+ * apart. That holds as long as the box and what the popup paints are the same
16172
+ * thing — which stops being true for a popup with no surface of its own
16173
+ * (`backgroundColor="transparent"`, no shadow, no padding): what the eye reads
16174
+ * as backdrop is then inside the box, and a press there stays a press on the
16175
+ * popup.
16176
+ *
16177
+ * `data-navi-popup-outside` is how a caller says which of its own boxes are
16178
+ * not the surface. It is opt-in because navi cannot infer it — a background
16179
+ * can come from anywhere — while the caller who made the popup see-through
16180
+ * knows exactly which box is decoration and which is paper.
16181
+ *
16182
+ * The marker answers for the element it is on, never for its descendants: a
16183
+ * box painted inside a marked one is still surface, so a row can be marked and
16184
+ * its empty halves read as backdrop while presses on the controls it holds do
16185
+ * not dismiss anything. A descendant with `pointer-events: none` never becomes
16186
+ * a press target at all, so the marked box answers in its place — while the
16187
+ * same declaration on the marked box itself takes it out of hit-testing and
16188
+ * makes the marker unreachable (see warnAboutUnreachableOutsideRegions).
16189
+ */
16190
+ const OUTSIDE_REGION_ATTRIBUTE = "data-navi-popup-outside";
16064
16191
 
16065
- // Unique for the page's lifetime: a caller may write the id in a commandfor.
16066
- let calloutCount = 0;
16192
+ /**
16193
+ * `data-navi-popup-inside` is the other direction: a box of the PAGE the
16194
+ * caller says belongs to a popup, so a press on it is not outside that popup
16195
+ * even though it lies beyond the border box — a card whose press is what
16196
+ * fills the one panel of a board. Its value names the popup by `id`, the way
16197
+ * `commandfor` does (several ids, space separated), because the exemption is
16198
+ * one popup's: a menu open beside the board still closes on the same press.
16199
+ *
16200
+ * Unlike the outside marker it answers for its whole subtree: the outside
16201
+ * marker carves free space out of a surface whose controls stay surface, this
16202
+ * one adds a whole thing — the card, its count, its buttons — to the popup's
16203
+ * own ground, and nothing in it is a dismissal.
16204
+ */
16205
+ const INSIDE_ATTRIBUTE = "data-navi-popup-inside";
16067
16206
 
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;
16207
+ /**
16208
+ * `data-navi-popup-group` names the peers of a popup: the triggers of the other
16209
+ * popups a press goes to from this one — a menu bar, where pressing another
16210
+ * menu while one is open switches to it, in that one press. A trigger joins by
16211
+ * carrying the name (a Picker's `popupGroup` writes it on its own), and a popup
16212
+ * reads it off what the press landed on.
16213
+ *
16214
+ * Returns the peer `target` is in, or null.
16215
+ */
16216
+ const findPopupPeer = (target, group) => {
16217
+ if (!group || !target || typeof target.closest !== "function") {
16218
+ return null;
16219
+ }
16220
+ return target.closest(`[data-navi-popup-group="${CSS.escape(group)}"]`);
16221
+ };
16072
16222
 
16073
- const css$14 = /* css */ `
16074
- /* jsenv-css-opaque: same as dialog.jsx, cascade order. */
16075
- @layer navi {
16076
- .navi_callout {
16077
- /* A callout is parented to what it explains, so it inherits from it — and
16078
- an element that suppressed text selection (a list row, a drag source)
16079
- would make its own explanation unselectable. The message is text one
16080
- copies. Layered: an app that has a reason to lock its own callouts down
16081
- says so and wins. */
16082
- user-select: text;
16223
+ /**
16224
+ * The click a wall took, handed to the peer (see findPopupPeer) it covered.
16225
+ *
16226
+ * A wall cannot let one press through and not another: it is what the pointer
16227
+ * hits, and that is the whole of how it spends a press. So it goes on taking
16228
+ * every one — the page behind stays as unhoverable and unpressable as it looks
16229
+ * — and once the press is over, its click is re-aimed at what the wall stood
16230
+ * over at that point, when that is a peer. The click alone, for the reason
16231
+ * transition_press.js gives: a pointer stream made up with no pointer behind it
16232
+ * starts gestures (a hold, a drag) that nothing ever ends, and a click is what
16233
+ * opens a popup's trigger.
16234
+ *
16235
+ * Called with the click that takes the wall down after a press closed the
16236
+ * popup (see armPointerDownOutsideClose), so the peer opens as the popup it
16237
+ * replaces goes.
16238
+ */
16239
+ const handWallClickToPeer = (
16240
+ clickEvent,
16241
+ { wallEl, popupEl, anchorEl, group },
16242
+ ) => {
16243
+ if (!clickEvent.isTrusted || clickEvent.target !== wallEl) {
16244
+ // A click dispatched by hand is already aimed, and one that did not land
16245
+ // on the wall (a press on the popup's own outside region) was no wall's.
16246
+ return;
16247
+ }
16248
+ // The topmost element under the wall at that point: what the press would
16249
+ // have landed on with no wall. Asked past the wall rather than after hiding
16250
+ // it, which a fading wall may not be yet.
16251
+ const covered = document
16252
+ .elementsFromPoint(clickEvent.clientX, clickEvent.clientY)
16253
+ .find((el) => el !== wallEl && !popupEl.contains(el));
16254
+ const peer = findPopupPeer(covered, group);
16255
+ if (!peer) {
16256
+ return;
16257
+ }
16258
+ if (peer.contains(popupEl) || (anchorEl && peer.contains(anchorEl))) {
16259
+ // The popup's own trigger — the one holding it (a picker renders its
16260
+ // popup inside itself) or the one it opened against: the press through
16261
+ // the wall closed the popup, and handing the trigger its click would open
16262
+ // it again.
16263
+ return;
16264
+ }
16265
+ // The page sees one click, on the peer, rather than two.
16266
+ clickEvent.stopPropagation();
16267
+ // A PointerEvent where the browser makes clicks of them, a MouseEvent
16268
+ // elsewhere: the same kind, copied field by field.
16269
+ const ClickEvent = clickEvent.constructor;
16270
+ covered.dispatchEvent(new ClickEvent(clickEvent.type, clickEvent));
16271
+ };
16272
+
16273
+ /**
16274
+ * What a press landing on a region the caller declared as not-its-surface
16275
+ * does: exactly what the same press on the backdrop would do.
16276
+ *
16277
+ * Lives on the popup's own content element rather than in the backdrop's
16278
+ * handler, for both renderers: the backdrop is a sibling behind the popup
16279
+ * (never an ancestor), so a press inside the popup's box never reaches it.
16280
+ */
16281
+ const handlePressOnOutsideRegion = (
16282
+ mouseDownEvent,
16283
+ { popupEl, openController, pressOutside },
16284
+ ) => {
16285
+ if (mouseDownEvent.button !== 0) {
16286
+ return;
16287
+ }
16288
+ const { target } = mouseDownEvent;
16289
+ if (!target.hasAttribute(OUTSIDE_REGION_ATTRIBUTE)) {
16290
+ return;
16291
+ }
16292
+ if (openedDuringThisPress(openController)) {
16293
+ // The release of the press that opened it is not somebody dismissing it
16294
+ // (see openedDuringThisPress).
16295
+ return;
16296
+ }
16297
+ // A popup opens inside its opener's own subtree, so a press on a region
16298
+ // belonging to a popup nested in this one bubbles through here too — and it
16299
+ // is a press on what is in front, not on this popup's own decoration.
16300
+ if (
16301
+ target.closest(`[navi-control="dialog"], [navi-control="popover"]`) !==
16302
+ popupEl
16303
+ ) {
16304
+ return;
16305
+ }
16306
+ if (pressOutside === "capture") {
16307
+ mouseDownEvent.preventDefault();
16308
+ return;
16309
+ }
16310
+ if (pressOutside === "close" || pressOutside === "cancel") {
16311
+ openController.requestClose(mouseDownEvent, {
16312
+ isCancel: pressOutside === "cancel",
16313
+ });
16314
+ }
16315
+ };
16316
+
16317
+ /**
16318
+ * `pointer-events: none` and `inert` are the two things reached for to say
16319
+ * "this part of my box is not really there", and neither reaches the popup:
16320
+ * inert speaks to the keyboard and to assistive technology, and
16321
+ * pointer-events takes the box out of hit-testing so the press is answered by
16322
+ * an ancestor that is still the popup's own surface. Writing them next to the
16323
+ * marker is the natural mistake, and it makes the marker silently unreachable
16324
+ * — so it is said out loud, once per open.
16325
+ */
16326
+ const warnAboutUnreachableOutsideRegions = (popupEl) => {
16327
+ for (const regionEl of popupEl.querySelectorAll(
16328
+ `[${OUTSIDE_REGION_ATTRIBUTE}]`,
16329
+ )) {
16330
+ if (getComputedStyle(regionEl).pointerEvents === "none") {
16331
+ console.warn(
16332
+ `[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.`,
16333
+ regionEl,
16334
+ );
16335
+ }
16336
+ }
16337
+ };
16338
+
16339
+ /**
16340
+ * Hears an outside press from the document itself, for a popup with no
16341
+ * backdrop of its own to catch one: a modal `<dialog>` (whose native
16342
+ * `::backdrop` dispatches nothing a handler can be put on) and any popup the
16343
+ * caller asked to leave the page reachable (`backdrop={false}`).
16344
+ *
16345
+ * The press is read and never taken: no `preventDefault`, no
16346
+ * `stopPropagation`, and the listener is on `document` rather than on
16347
+ * anything covering the page — so what the press landed on hears it too, in
16348
+ * the same gesture, and the popup only takes note. That is the whole point of
16349
+ * a popup with no wall: a press outside is a press on the page, and the page
16350
+ * answers it. The one popup where that cannot hold is the modal dialog, where
16351
+ * the browser has already made everything behind inert before this runs.
16352
+ *
16353
+ * `pressEventType` says which event IS the press, and the two callers want
16354
+ * opposite ones:
16355
+ *
16356
+ * - `"pointerdown"` for a popup with no wall: a page with no wall over it is
16357
+ * a page whose own elements arbitrate their presses, and cancelling a
16358
+ * `pointerdown` — what a drag source and a control keeping the focus where
16359
+ * it is both do — suppresses every compatibility mouse event that would have
16360
+ * followed it. The press happens, reaches its target, and no `mousedown` is
16361
+ * ever dispatched for it. Reading the pointer event is reading the press
16362
+ * itself, whatever anything does with it afterwards, and it is the same
16363
+ * event the drag and surface detectors read.
16364
+ * - `"mousedown"` for the modal dialog, whose native `::backdrop` exists to
16365
+ * spend the press. The page's controls act on `mousedown`, and on a touch
16366
+ * screen that is not the same moment as `pointerdown`: the compatibility
16367
+ * mouse events are synthesized at `touchend` and hit-tested again then.
16368
+ * Closing on `pointerdown` takes the `::backdrop` away before that second
16369
+ * hit-test, so the `mousedown` lands on whatever the page holds under the
16370
+ * finger, and one tap both dismisses the dialog and presses the page. Read
16371
+ * on `mousedown`, the wall is still up when the press the page acts on is
16372
+ * aimed, and it catches it. Nothing behind a modal wall can cancel that
16373
+ * press, since nothing behind it hears the `pointerdown` at all.
16374
+ *
16375
+ * Capture phase, so a handler downstream that stops propagation cannot keep
16376
+ * the popup open either — the press is still outside whatever is made of it.
16377
+ *
16378
+ * Returns a cleanup that removes the listener.
16379
+ */
16380
+ const armOutsidePressClose = (
16381
+ popupEl,
16382
+ { openController, pressOutside, pressEventType = "pointerdown" },
16383
+ ) => {
16384
+ const onDocumentPointerDown = (pointerDownEvent) => {
16385
+ if (pointerDownEvent.button !== 0) {
16386
+ return;
16387
+ }
16388
+ if (openedDuringThisPress(openController)) {
16389
+ // The release of the press that opened it is not somebody dismissing it
16390
+ // (see openedDuringThisPress).
16391
+ return;
16392
+ }
16393
+ // The press landed inside another popup: that is a press on what is in
16394
+ // front, not outside. Asking the target where it lives rather than asking
16395
+ // this popup whether it was pushed — a popup in front does not have to be
16396
+ // one this popup knows about. In front means neither inside nor around: a
16397
+ // popup nested in this one falls to the containment check below as the
16398
+ // inside press it is, and one this popup opened INSIDE (a bubble rendered
16399
+ // in a sheet's content) is around it, so what that popup holds is outside
16400
+ // this one exactly like the rest of the page.
16401
+ const popupUnderPointer = pointerDownEvent.target.closest?.(
16402
+ `[navi-control="dialog"], [navi-control="popover"]`,
16403
+ );
16404
+ if (
16405
+ popupUnderPointer &&
16406
+ popupUnderPointer !== popupEl &&
16407
+ !popupEl.contains(popupUnderPointer) &&
16408
+ !popupUnderPointer.contains(popupEl)
16409
+ ) {
16410
+ return;
16411
+ }
16412
+ if (
16413
+ popupEl.id &&
16414
+ pointerDownEvent.target.closest?.(
16415
+ `[${INSIDE_ATTRIBUTE}~="${CSS.escape(popupEl.id)}"]`,
16416
+ )
16417
+ ) {
16418
+ return;
16419
+ }
16420
+ // Real DOM containment wins over the rectangle below — an element
16421
+ // genuinely inside the popup (`overflow: visible`, a negative margin, an
16422
+ // absolutely-positioned child) can be painted outside its border box, and
16423
+ // a press there is not outside just because its coordinates are. The
16424
+ // popup element itself is excluded (contains() answers true for it): a
16425
+ // press on a modal dialog's native backdrop reports the dialog as its
16426
+ // target, there being no `::backdrop` node to be one, so treating that as
16427
+ // contained would keep the rectangle from ever running.
16428
+ if (
16429
+ pointerDownEvent.target !== popupEl &&
16430
+ popupEl.contains(pointerDownEvent.target)
16431
+ ) {
16432
+ return;
16433
+ }
16434
+ const rect = popupEl.getBoundingClientRect();
16435
+ const isOutside =
16436
+ pointerDownEvent.clientX < rect.left ||
16437
+ pointerDownEvent.clientX > rect.right ||
16438
+ pointerDownEvent.clientY < rect.top ||
16439
+ pointerDownEvent.clientY > rect.bottom;
16440
+ if (!isOutside) {
16441
+ return;
16442
+ }
16443
+ openController.requestClose(pointerDownEvent, {
16444
+ isCancel: pressOutside === "cancel",
16445
+ });
16446
+ };
16447
+ document.addEventListener(pressEventType, onDocumentPointerDown, {
16448
+ capture: true,
16449
+ });
16450
+ return () => {
16451
+ document.removeEventListener(pressEventType, onDocumentPointerDown, {
16452
+ capture: true,
16453
+ });
16454
+ };
16455
+ };
16456
+
16457
+ /**
16458
+ * Typography reset shared by the surfaces a control opens: callout, popover,
16459
+ * dialog.
16460
+ *
16461
+ * A surface is painted on top of the page but lives in the DOM subtree of the
16462
+ * element it opens from, so every inherited text property of that element
16463
+ * reaches it. The ink an element chose for its own background — centered,
16464
+ * shadowed, uppercase, letter-spaced, kept on one line — arrives on paper that
16465
+ * has none of that background, and the caller discovers it as a symptom
16466
+ * (a blurred tooltip, a centered message) with nothing on screen pointing back
16467
+ * to the rule three elements up. A surface writes its own text, on its own
16468
+ * paper, in the document's terms.
16469
+ *
16470
+ * Every one of them is set to its initial value rather than `revert`: only
16471
+ * `color` and `background-color` are declared by the UA on a `[popover]`/
16472
+ * `<dialog>`, so `revert` on any of these would roll back to the inherited
16473
+ * value — the very thing to stop. `text-align: initial` is `start`, which
16474
+ * still reads the surface's own `direction`, so an RTL document keeps its
16475
+ * side.
16476
+ *
16477
+ * `font-family` is deliberately absent: a surface keeps the face of what
16478
+ * opened it, so a section written in a display font gets its tooltips in that
16479
+ * font. `color`, `font-size` and `font-weight` are absent too — each surface
16480
+ * answers those its own way (a popup writes in --navi-popup-color and follows
16481
+ * the size of what opened it; a callout reverts to the document's ink and
16482
+ * size).
16483
+ *
16484
+ * In `@layer navi` so an app that wants one of these back says so on the
16485
+ * surface and wins, without having to out-specify anything.
16486
+ */
16487
+ const surfaceTextCss = /* css */ `
16488
+ @layer navi {
16489
+ .navi_callout,
16490
+ .navi_popover,
16491
+ .navi_dialog {
16492
+ font-style: initial;
16493
+ text-align: initial;
16494
+ text-indent: initial;
16495
+ text-transform: initial;
16496
+ text-shadow: none;
16497
+ white-space: initial;
16498
+ word-spacing: initial;
16499
+ letter-spacing: initial;
16500
+ }
16501
+ }
16502
+ `;
16503
+
16504
+ const CalloutContext = createContext();
16505
+ const useCalloutRequestClose = () => {
16506
+ return useContext(CalloutContext)?.requestClose;
16507
+ };
16508
+ const useCalloutElement = () => {
16509
+ return useContext(CalloutContext)?.element;
16510
+ };
16511
+ const renderIntoCallout = (jsx$1, calloutMessageElement, {
16512
+ requestClose,
16513
+ element
16514
+ }) => {
16515
+ const calloutJsx = jsx(CalloutContext.Provider, {
16516
+ value: {
16517
+ requestClose,
16518
+ element
16519
+ },
16520
+ children: jsx$1
16521
+ });
16522
+ render(calloutJsx, calloutMessageElement);
16523
+ };
16524
+
16525
+ // An HTML message is rendered through preact rather than innerHTML: what a
16526
+ // message holds is then a preact tree like any other, so a callout's own JSX
16527
+ // (its context, its close button) works the same whether the message arrived
16528
+ // as markup or as elements.
16529
+ const renderHtmlIntoCallout = (html, calloutMessageElement, {
16530
+ requestClose,
16531
+ element
16532
+ }) => {
16533
+ const template = document.createElement("template");
16534
+ template.innerHTML = html;
16535
+ renderIntoCallout(domToVNodes(template.content), calloutMessageElement, {
16536
+ requestClose,
16537
+ element
16538
+ });
16539
+ };
16540
+
16541
+ // Unmounts whatever preact rendered before the element is filled by hand
16542
+ // (a DOM node, an iframe); innerHTML alone would leave preact believing its
16543
+ // tree is still there.
16544
+ const clearCalloutMessage = calloutMessageElement => {
16545
+ render(null, calloutMessageElement);
16546
+ calloutMessageElement.innerHTML = "";
16547
+ };
16548
+ const domToVNodes = node => {
16549
+ const vnodes = [];
16550
+ for (const child of node.childNodes) {
16551
+ if (child.nodeType === Node.TEXT_NODE) {
16552
+ vnodes.push(child.data);
16553
+ continue;
16554
+ }
16555
+ if (child.nodeType !== Node.ELEMENT_NODE) {
16556
+ continue;
16557
+ }
16558
+ const props = {};
16559
+ for (const {
16560
+ name,
16561
+ value
16562
+ } of child.attributes) {
16563
+ props[name] = value;
16564
+ }
16565
+ vnodes.push(h(child.localName, props, ...domToVNodes(child)));
16566
+ }
16567
+ return vnodes;
16568
+ };
16569
+
16570
+ installImportMetaCssBuild(import.meta);
16571
+ /**
16572
+ * The icon a callout shows beside its message — a square in the status color
16573
+ * with the "!" glyph — drawable on its own, where the callout is not: on the
16574
+ * trigger that opens one (a picker in callout mode), so what one presses looks
16575
+ * like what it opens. The callout's own template draws from the same glyph
16576
+ * (see calloutTemplate in callout.js).
16577
+ */
16578
+
16579
+ const css$15 = /* css */`.navi_callout_status_icon {
16580
+ --x-callout-status-icon-color: var(--navi-callout-neutral-color);
16581
+ box-sizing: border-box;
16582
+ aspect-ratio: 1;
16583
+ color: #fff;
16584
+ vertical-align: middle;
16585
+ background-color: var(--x-callout-status-icon-color);
16586
+ border-radius: 2px;
16587
+ flex-shrink: 0;
16588
+ justify-content: center;
16589
+ align-items: center;
16590
+ width: 1em;
16591
+ height: 1em;
16592
+ display: inline-flex;
16593
+
16594
+ &[data-status="success"] {
16595
+ --x-callout-status-icon-color: var(--navi-callout-success-color);
16596
+ }
16597
+
16598
+ &[data-status="info"] {
16599
+ --x-callout-status-icon-color: var(--navi-callout-info-color);
16600
+ }
16601
+
16602
+ &[data-status="warning"] {
16603
+ --x-callout-status-icon-color: var(--navi-callout-warning-color);
16604
+ }
16605
+
16606
+ &[data-status="error"] {
16607
+ --x-callout-status-icon-color: var(--navi-callout-error-color);
16608
+ }
16609
+
16610
+ &[data-shape="circle"] {
16611
+ border-radius: 50%;
16612
+ }
16613
+
16614
+ & svg {
16615
+ width: 100%;
16616
+ height: 100%;
16617
+ }
16618
+ }
16619
+
16620
+ .navi_icon > .navi_callout_status_icon {
16621
+ width: 100%;
16622
+ height: 100%;
16623
+ }
16624
+ `;
16625
+
16626
+ /**
16627
+ * The "!" on a square, viewBox and path both. The box is square and holds the
16628
+ * glyph's margin: the ink is 298 of 540 units tall (55%), centered. Drawn this
16629
+ * way rather than tight around the ink so whoever shows it can give the svg the
16630
+ * whole square — the glyph is then placed by the vector rasteriser, which has
16631
+ * no device pixel grid to miss, at any square size down to a fractional one.
16632
+ */
16633
+ const CALLOUT_STATUS_GLYPH_VIEWBOX = "0 0 540 540";
16634
+ const CALLOUT_STATUS_GLYPH_PATH = "m232.5,121 8,196h59l8-196zm36.5,224a37,37 0 1,0 2,0z";
16635
+
16636
+ /**
16637
+ * @type {import("ignore:preact").FunctionComponent<{
16638
+ * status?: "info" | "warning" | "error" | "success" | "none",
16639
+ * shape?: "square" | "circle",
16640
+ * }>}
16641
+ * @param {"info"|"warning"|"error"|"success"|"none"} [status="info"] The color
16642
+ * — the callout's own for that status. `"none"` is the neutral one.
16643
+ * @param {"square"|"circle"} [shape="square"] Square like the callout's own
16644
+ * icon, or a circle.
16645
+ */
16646
+ const CalloutStatusIcon = ({
16647
+ status = "info",
16648
+ shape = "square"
16649
+ }) => {
16650
+ import.meta.css = [css$15, "@jsenv/navi/src/control/rules/callout/callout_status_icon.jsx"];
16651
+ return jsx("span", {
16652
+ className: "navi_callout_status_icon",
16653
+ "data-status": status === "none" ? undefined : status,
16654
+ "data-shape": shape === "circle" ? "circle" : undefined,
16655
+ "aria-hidden": "true",
16656
+ children: jsx("svg", {
16657
+ viewBox: CALLOUT_STATUS_GLYPH_VIEWBOX,
16658
+ xmlns: "http://www.w3.org/2000/svg",
16659
+ children: jsx("path", {
16660
+ fill: "currentColor",
16661
+ d: CALLOUT_STATUS_GLYPH_PATH
16662
+ })
16663
+ })
16664
+ });
16665
+ };
16666
+
16667
+ installImportMetaCssBuild(import.meta);
16668
+
16669
+ // Unique for the page's lifetime: a caller may write the id in a commandfor.
16670
+ let calloutCount = 0;
16671
+
16672
+ // How far `closeByScroll: true` lets the page move under the callout before
16673
+ // dismissing it: small enough that any deliberate scroll gesture reaches it,
16674
+ // large enough to survive the pixel or two a trackpad rests on.
16675
+ const CLOSE_BY_SCROLL_DISTANCE = 10;
16676
+
16677
+ // The events of a press a `backdrop` callout spends, up to its click (which
16678
+ // closes the callout and is spent there), and `pointercancel`, which says the
16679
+ // browser took the press. No touch events: they are the scroll's, and what
16680
+ // listens to them — a list learning the user took its scroll over — is
16681
+ // watching the finger, not answering the press.
16682
+ const SPENT_PRESS_EVENT_TYPES = [
16683
+ "pointerdown",
16684
+ "pointerup",
16685
+ "pointercancel",
16686
+ "mousedown",
16687
+ "mouseup",
16688
+ "contextmenu",
16689
+ ];
16690
+ // Where a press lands on something drawn over the page rather than on the page
16691
+ // itself: a popup of navi's (either layer) or a native top-layer element.
16692
+ const LAYER_SELECTOR =
16693
+ '[popover], dialog, [navi-control="popover"], [navi-control="dialog"]';
16694
+
16695
+ const css$14 = /* css */ `
16696
+ /* jsenv-css-opaque: same as dialog.jsx, cascade order. */
16697
+ @layer navi {
16698
+ .navi_callout {
16699
+ /* A callout is parented to what it explains, so it inherits from it — and
16700
+ an element that suppressed text selection (a list row, a drag source)
16701
+ would make its own explanation unselectable. The message is text one
16702
+ copies. Layered: an app that has a reason to lock its own callouts down
16703
+ says so and wins. */
16704
+ user-select: text;
16083
16705
 
16084
16706
  --callout-success-color: var(--navi-callout-success-color);
16085
16707
  --callout-info-color: var(--navi-callout-info-color);
@@ -16358,6 +16980,17 @@ const css$14 = /* css */ `
16358
16980
  * the callout opened, so scrolling back cancels nothing but a round trip shorter than the
16359
16981
  * distance leaves it open. Unlike an outside click it also dismisses an "error" callout: the
16360
16982
  * caller asked for it explicitly.
16983
+ * @param {boolean} [options.backdrop=false] - Spend an outside press on closing the callout,
16984
+ * the way a popup's wall spends it: no link followed, no control pressed, no focus moved, no
16985
+ * hold or drag started under it. Without it that press closes the callout and is answered by
16986
+ * what it landed on too. Not a wall, though: the scroll a finger starts beside the callout
16987
+ * still scrolls, a press on a layer drawn over the page (a sheet the callout's content
16988
+ * opened) is that layer's, and a press that does not close the callout (an "error" one) is
16989
+ * not spent either
16990
+ * @param {string} [options.group] - With `backdrop`: the name the callout's peers carry in
16991
+ * `data-navi-popup-group` — the triggers of other popups, callouts or not. A press inside a
16992
+ * peer still closes the callout but is not spent, so the peer it landed on opens its own
16993
+ * popup in the same press — a menu bar, where pressing another menu switches to it
16361
16994
  * @param {Event} [options.openingEvent] - The gesture the callout belongs to. It names the
16362
16995
  * opener — `currentTarget` while the event is still dispatching, `target` once it is over,
16363
16996
  * so awaiting before opening changes nothing — and the opener is the one part of the anchor
@@ -16422,6 +17055,8 @@ const openCallout = (
16422
17055
  closeByPressOutside = status === "info",
16423
17056
  closeOnFocusLeave = closeByPressOutside,
16424
17057
  closeByScroll = false,
17058
+ backdrop = false,
17059
+ group,
16425
17060
  closeButton = true,
16426
17061
  openingEvent,
16427
17062
  reopen = "toggle",
@@ -16507,6 +17142,9 @@ const openCallout = (
16507
17142
  reason,
16508
17143
  });
16509
17144
  };
17145
+ // Asked again at each press rather than once: `update` changes both.
17146
+ const pressOutsideCloses = () =>
17147
+ closeByPressOutside && callout.status !== "error";
16510
17148
  const onRequestClose = (event) => {
16511
17149
  if (!callout.opened) {
16512
17150
  return;
@@ -16515,10 +17153,7 @@ const openCallout = (
16515
17153
  const clickOrSpaceOutside =
16516
17154
  reason === "click_outside" || reason === "space_outside";
16517
17155
  if (clickOrSpaceOutside) {
16518
- if (!closeByPressOutside) {
16519
- return;
16520
- }
16521
- if (callout.status === "error") {
17156
+ if (!pressOutsideCloses()) {
16522
17157
  return;
16523
17158
  }
16524
17159
  } else if (reason === "focus_leave") {
@@ -16793,16 +17428,36 @@ const openCallout = (
16793
17428
  }
16794
17429
  return openerElement === target || openerElement.contains(target);
16795
17430
  };
17431
+ // Whether an outside press on `target` is spent on closing the callout
17432
+ // (`backdrop`). What is not the page behind the callout keeps its press: a
17433
+ // layer the callout is not part of — its own page may well be a sheet, a
17434
+ // sheet its content opened is in front of it — and a peer, which the press
17435
+ // is meant to open.
17436
+ const spendsPressOn = (target) => {
17437
+ if (!backdrop || !pressOutsideCloses()) {
17438
+ return false;
17439
+ }
17440
+ const layer = target.closest?.(LAYER_SELECTOR);
17441
+ if (layer && !layer.contains(calloutElement)) {
17442
+ return false;
17443
+ }
17444
+ if (findPopupPeer(target, group)) {
17445
+ return false;
17446
+ }
17447
+ return true;
17448
+ };
17449
+ const spend = (event) => {
17450
+ debug(event, `outside press spent on closing the callout (backdrop)`);
17451
+ event.preventDefault();
17452
+ event.stopPropagation();
17453
+ };
16796
17454
  const handleClickOutside = (event) => {
16797
17455
  if (event.button !== 0) {
16798
17456
  // right click
16799
17457
  return;
16800
17458
  }
16801
17459
  const clickTarget = event.target;
16802
- if (
16803
- clickTarget === calloutElement ||
16804
- calloutElement.contains(clickTarget)
16805
- ) {
17460
+ if (calloutElement.contains(clickTarget)) {
16806
17461
  return;
16807
17462
  }
16808
17463
  if (isInsideOpener(clickTarget)) {
@@ -16815,14 +17470,20 @@ const openCallout = (
16815
17470
  debug(event, `click on opener, let its handler decide`);
16816
17471
  return;
16817
17472
  }
17473
+ // Read before the close, which takes the callout out of the document —
17474
+ // and out of the layer it stands in, for spendsPressOn to find.
17475
+ const spent = spendsPressOn(clickTarget);
16818
17476
  requestClose(event, "click_outside");
17477
+ if (spent) {
17478
+ spend(event);
17479
+ }
16819
17480
  };
16820
17481
  const handleSpaceOutside = (event) => {
16821
17482
  if (event.key !== " ") {
16822
17483
  return;
16823
17484
  }
16824
17485
  const keyTarget = event.target;
16825
- if (keyTarget === calloutElement || calloutElement.contains(keyTarget)) {
17486
+ if (calloutElement.contains(keyTarget)) {
16826
17487
  return;
16827
17488
  }
16828
17489
  if (isInsideOpener(keyTarget)) {
@@ -16831,7 +17492,41 @@ const openCallout = (
16831
17492
  debug(event, `space on opener, let its handler decide`);
16832
17493
  return;
16833
17494
  }
17495
+ const spent = spendsPressOn(keyTarget);
16834
17496
  requestClose(event, "space_outside");
17497
+ if (spent) {
17498
+ spend(event);
17499
+ }
17500
+ };
17501
+ // The press before its click: a click ends a gesture whose pointerdown
17502
+ // already reached the page, and what acts on the press itself — a hold, a
17503
+ // drag, the focus moving — would otherwise happen under a callout that only
17504
+ // closes on the click. Decided at the pointerdown and kept for the whole
17505
+ // press, wherever its later events land: a release beside the callout ends
17506
+ // a drag started inside it, and must reach it.
17507
+ let pressSpent = false;
17508
+ const handlePressOutside = (event) => {
17509
+ if (event.type === "pointerdown") {
17510
+ const pressTarget = event.target;
17511
+ pressSpent =
17512
+ event.button === 0 &&
17513
+ !calloutElement.contains(pressTarget) &&
17514
+ !isInsideOpener(pressTarget) &&
17515
+ spendsPressOn(pressTarget);
17516
+ } else if (event.type === "pointercancel") {
17517
+ // The browser took the press over — a scroll — and nothing follows it.
17518
+ pressSpent = false;
17519
+ return;
17520
+ }
17521
+ if (!pressSpent) {
17522
+ return;
17523
+ }
17524
+ if (event.type === "mousedown" || event.type === "contextmenu") {
17525
+ // What the press does on its own: the focus moving, a selection
17526
+ // starting, the menu of a held finger.
17527
+ event.preventDefault();
17528
+ }
17529
+ event.stopPropagation();
16835
17530
  };
16836
17531
 
16837
17532
  const registerClickOutsideListener = () => {
@@ -16841,6 +17536,16 @@ const openCallout = (
16841
17536
  document.removeEventListener("click", handleClickOutside, true);
16842
17537
  document.removeEventListener("keydown", handleSpaceOutside, true);
16843
17538
  });
17539
+ if (backdrop) {
17540
+ for (const type of SPENT_PRESS_EVENT_TYPES) {
17541
+ document.addEventListener(type, handlePressOutside, true);
17542
+ }
17543
+ addTeardown(() => {
17544
+ for (const type of SPENT_PRESS_EVENT_TYPES) {
17545
+ document.removeEventListener(type, handlePressOutside, true);
17546
+ }
17547
+ });
17548
+ }
16844
17549
  };
16845
17550
  // A callout opened during a press (mousedown or pointerdown) must wait for the
16846
17551
  // matching release before listening for click-outside, otherwise the same
@@ -18045,7 +18750,7 @@ const warnOnReopenWithinSameEvent = (anchorElement, openingEvent) => {
18045
18750
  *
18046
18751
  * Usage:
18047
18752
  * const myToken = createOpenToken();
18048
- * calloutManager.addOpenToken(myToken, { message, status, testId, closeByScroll, anchorElement, event, skipFocus, onClose });
18753
+ * calloutManager.addOpenToken(myToken, { message, status, testId, closeByScroll, backdrop, group, anchorElement, event, skipFocus, onClose });
18049
18754
  * calloutManager.removeOpenToken(myToken, event);
18050
18755
  * calloutManager.requestCloseCallout(event, debugReason); // force-close all
18051
18756
  * calloutManager.callout // current open callout or null
@@ -18103,6 +18808,8 @@ const createCalloutManager = (
18103
18808
  closeButton: tokenData.closeButton,
18104
18809
  closeByPressOutside: tokenData.status !== "error",
18105
18810
  closeByScroll: tokenData.closeByScroll,
18811
+ backdrop: tokenData.backdrop,
18812
+ group: tokenData.group,
18106
18813
  anchorElement,
18107
18814
  openingEvent: event,
18108
18815
  skipFocus: tokenData.skipFocus,
@@ -18231,6 +18938,8 @@ const createCalloutManager = (
18231
18938
  icon,
18232
18939
  closeButton,
18233
18940
  closeByScroll,
18941
+ backdrop,
18942
+ group,
18234
18943
  anchorElement,
18235
18944
  event,
18236
18945
  skipFocus,
@@ -18248,6 +18957,8 @@ const createCalloutManager = (
18248
18957
  icon,
18249
18958
  closeButton,
18250
18959
  closeByScroll,
18960
+ backdrop,
18961
+ group,
18251
18962
  skipFocus,
18252
18963
  onClose,
18253
18964
  // Resolved as the token is added, and kept with it: a token shown later
@@ -24495,7 +25206,16 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
24495
25206
  }
24496
25207
  return itemSignal;
24497
25208
  };
24498
- const upsert = (...args) => {
25209
+ // A value changes a row when it is another object — the app writing a new
25210
+ // one says something changed, and that is taken as said. An answer says it
25211
+ // with every object nested in its rows: JSON.parse makes them all new, the
25212
+ // ones that did not move included, so a revalidation returning the same
25213
+ // rows would replace every one of them for every reader. What an answer
25214
+ // sends again equal in depth is the value already held (see upsertAnswer).
25215
+ const valueChangedByWrite = (newValue, oldValue) => newValue !== oldValue;
25216
+ const valueChangedByAnswer = (newValue, oldValue) =>
25217
+ newValue !== oldValue && !compareTwoJsValues(newValue, oldValue);
25218
+ const upsertWith = (args, valueChanged) => {
24499
25219
  const mutationsMap = new Map(); // Map<itemId, propertyMutations>
24500
25220
  const triggerPropertyMutations = () => {
24501
25221
  for (const itemPropertiesObserver of itemPropertiesObserverSet) {
@@ -24559,7 +25279,27 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
24559
25279
  const newValue = props[key];
24560
25280
  if (itemOwnKeys.includes(key)) {
24561
25281
  const oldValue = item[key];
24562
- if (newValue !== oldValue) {
25282
+ const { set } = itemOwnPropertyDescriptors[key];
25283
+ if (set) {
25284
+ // A relation (.one(), .many() and their scoped forms in
25285
+ // resource_graph.js): the row holds which children it points to,
25286
+ // the children live in their own store, and whoever reads them
25287
+ // follows them there. Its setter writes them and says whether the
25288
+ // row points elsewhere now. Compared as a value instead, the
25289
+ // children received are never the object the row hands out, and
25290
+ // every write naming the relation would replace the row.
25291
+ if (set.call(itemWithProps, newValue)) {
25292
+ hasChanges = true;
25293
+ propertyMutations[key] = {
25294
+ oldValue,
25295
+ newValue,
25296
+ target: item,
25297
+ newTarget: itemWithProps,
25298
+ };
25299
+ }
25300
+ continue;
25301
+ }
25302
+ if (valueChanged(newValue, oldValue)) {
24563
25303
  hasChanges = true;
24564
25304
  itemWithProps[key] = newValue;
24565
25305
  propertyMutations[key] = {
@@ -24711,6 +25451,9 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
24711
25451
  triggerPropertyMutations();
24712
25452
  return item;
24713
25453
  };
25454
+ const upsert = (...args) => upsertWith(args, valueChangedByWrite);
25455
+ // The same, for the result of a callback — what the server answered.
25456
+ const upsertAnswer = (...args) => upsertWith(args, valueChangedByAnswer);
24714
25457
  const drop = (...args) => {
24715
25458
  const removedItemArray = [];
24716
25459
  const triggerRemovedMutations = () => {
@@ -24887,6 +25630,7 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
24887
25630
  selectAll,
24888
25631
  itemSignalForId,
24889
25632
  upsert,
25633
+ upsertAnswer,
24890
25634
  drop,
24891
25635
 
24892
25636
  observeItemProperties,
@@ -25644,7 +26388,7 @@ const createRangeReader = (
25644
26388
  `${actionName} must return { items, start, count }, received ${describeResult(result)}.`,
25645
26389
  );
25646
26390
  }
25647
- const items = store.upsert(result.items);
26391
+ const items = store.upsertAnswer(result.items);
25648
26392
  let { start, count } = result;
25649
26393
  if (start === undefined) {
25650
26394
  const startAsked = rangeParams.start;
@@ -26305,7 +27049,7 @@ const createResource = (
26305
27049
  : // GET/PUT contract (see .one() JSDoc): the parent object with the
26306
27050
  // relationship nested inside, or null for no relationship.
26307
27051
  (result) => {
26308
- const item = store.upsert(result);
27052
+ const item = store.upsertAnswer(result);
26309
27053
  const childItem = item[propertyName];
26310
27054
  return childItem ? childItem[childIdKey] : undefined;
26311
27055
  };
@@ -26486,8 +27230,8 @@ const createResource = (
26486
27230
  : (childData) => {
26487
27231
  // an array is [property, value, props], used to rename the child id
26488
27232
  const childItem = Array.isArray(childData)
26489
- ? childStore.upsert(...childData)
26490
- : childStore.upsert(childData);
27233
+ ? childStore.upsertAnswer(...childData)
27234
+ : childStore.upsertAnswer(childData);
26491
27235
  return childItem[childIdKey];
26492
27236
  };
26493
27237
  const throwInvalidResult = createInvalidResultThrower(
@@ -26529,7 +27273,7 @@ const createResource = (
26529
27273
  ? (result) => {
26530
27274
  // GET_MANY contract (see .many() JSDoc): the parent object with
26531
27275
  // the child array nested inside; the array replaces the relationship.
26532
- const item = store.upsert(result);
27276
+ const item = store.upsertAnswer(result);
26533
27277
  const childItemArray = item[propertyName];
26534
27278
  return childItemArray.map((childItem) => childItem[childIdKey]);
26535
27279
  }
@@ -26560,7 +27304,7 @@ const createResource = (
26560
27304
  return deletedChildItemIdArray;
26561
27305
  }
26562
27306
  : (childDataArray) => {
26563
- const childItemArray = childStore.upsert(childDataArray);
27307
+ const childItemArray = childStore.upsertAnswer(childDataArray);
26564
27308
  return childItemArray.map((childItem) => childItem[childIdKey]);
26565
27309
  };
26566
27310
  const throwInvalidResult = createInvalidResultThrower(
@@ -26699,18 +27443,37 @@ const createResource = (
26699
27443
  childSetup(childItem);
26700
27444
  }
26701
27445
  const childSignal = signal(null);
27446
+ // Returns whether the child changed — what the store asks a relation
27447
+ // (see assign in array_signal_store.js). Assigned in place, the child
27448
+ // tells nobody itself: a prop it receives changed is a change of the
27449
+ // owner's row, which is how its readers see it.
26702
27450
  const applyProps = (props) => {
26703
27451
  if (!props) {
27452
+ if (childSignal.peek() === null) {
27453
+ return false;
27454
+ }
26704
27455
  childSignal.value = null;
26705
- return;
27456
+ return true;
26706
27457
  }
26707
- // Assign each prop in place. Reactive setters (from chained .one() etc.) will fire.
27458
+ let changed = false;
27459
+ // Assign each prop in place. Reactive setters (from chained .one()
27460
+ // etc.) fire, and say whether their relation changed.
26708
27461
  for (const [key, value] of Object.entries(props)) {
26709
- childItem[key] = value;
27462
+ const descriptor = Object.getOwnPropertyDescriptor(childItem, key);
27463
+ if (descriptor && descriptor.set) {
27464
+ if (descriptor.set.call(childItem, value)) {
27465
+ changed = true;
27466
+ }
27467
+ } else if (childItem[key] !== value) {
27468
+ childItem[key] = value;
27469
+ changed = true;
27470
+ }
26710
27471
  }
26711
27472
  if (childSignal.peek() !== childItem) {
26712
27473
  childSignal.value = childItem; // first activation: null → childItem
27474
+ changed = true;
26713
27475
  }
27476
+ return changed;
26714
27477
  };
26715
27478
  if (!ownerName) {
26716
27479
  // Declared on a scoped child, an owner id is only unique inside its
@@ -26998,7 +27761,7 @@ const createResource = (
26998
27761
  if (isMany) {
26999
27762
  // GET_MANY, POST_MANY, PUT_MANY etc: rest[0] is the array of items,
27000
27763
  // and it replaces the whole collection.
27001
- const itemArray = childStore.upsert(rest[0]);
27764
+ const itemArray = childStore.upsertAnswer(rest[0]);
27002
27765
  const idArray = itemArray.map((childItem) => childItem[childIdKey]);
27003
27766
  idArraySignal.value = idArray;
27004
27767
  return [ownerId, idArray];
@@ -27007,8 +27770,8 @@ const createResource = (
27007
27770
  // GET, POST, PUT, PATCH: rest may be [props] or [oldId, props] for renames
27008
27771
  const childItem =
27009
27772
  rest.length > 1
27010
- ? childStore.upsert(...rest)
27011
- : childStore.upsert(rest[0]);
27773
+ ? childStore.upsertAnswer(...rest)
27774
+ : childStore.upsertAnswer(rest[0]);
27012
27775
  return [ownerId, childItem[childIdKey]];
27013
27776
  },
27014
27777
  valueToData: (value) => {
@@ -27144,8 +27907,8 @@ const createRestActionFactoryForRoot = (
27144
27907
  // An array result is [property, value, props] — used to rename the
27145
27908
  // idKey of an item: store.upsert("name", "currentName", { name: "newName" })
27146
27909
  const item = Array.isArray(result)
27147
- ? store.upsert(...result)
27148
- : store.upsert(result);
27910
+ ? store.upsertAnswer(...result)
27911
+ : store.upsertAnswer(result);
27149
27912
  return item[idKey];
27150
27913
  };
27151
27914
  const throwInvalidResult = createInvalidResultThrower(
@@ -27209,7 +27972,7 @@ const createRestActionFactoryForRoot = (
27209
27972
  verb === "DELETE"
27210
27973
  ? (idOrMutableIdArray) => store.drop(idOrMutableIdArray)
27211
27974
  : (dataArray) => {
27212
- const itemArray = store.upsert(dataArray);
27975
+ const itemArray = store.upsertAnswer(dataArray);
27213
27976
  return itemArray.map((item) => item[idKey]);
27214
27977
  };
27215
27978
 
@@ -27338,14 +28101,17 @@ ${relationLabel} source location: ${declarationSite}`,
27338
28101
  // array of child props/ids, or anything else meaning "empty") into an array of
27339
28102
  // child ids, upserting each entry into the child store. The id array signal is
27340
28103
  // only touched when the resulting ids actually differ.
28104
+ // Returns whether the children the row points to changed — what the store asks
28105
+ // a relation (see assign in array_signal_store.js).
27341
28106
  const createChildIdArrayUpdater = (childStore, childIdKey, idArraySignal) => {
27342
28107
  return (valueArray) => {
27343
28108
  const currentIdArray = idArraySignal.peek();
27344
28109
  if (!Array.isArray(valueArray)) {
27345
28110
  if (currentIdArray.length > 0) {
27346
28111
  idArraySignal.value = [];
28112
+ return true;
27347
28113
  }
27348
- return;
28114
+ return false;
27349
28115
  }
27350
28116
  const idArray = [];
27351
28117
  let modified = false;
@@ -27367,7 +28133,9 @@ const createChildIdArrayUpdater = (childStore, childIdKey, idArraySignal) => {
27367
28133
  }
27368
28134
  if (modified || currentIdArray.length !== idArray.length) {
27369
28135
  idArraySignal.value = idArray;
28136
+ return true;
27370
28137
  }
28138
+ return false;
27371
28139
  };
27372
28140
  };
27373
28141
 
@@ -27785,6 +28553,24 @@ const TYPE_CONVERTERS = {
27785
28553
  },
27786
28554
  };
27787
28555
 
28556
+ /**
28557
+ * What a component can learn about the page it is rendered in, from the route
28558
+ * container rendering that page (see useContentKeepingLeavingPages in
28559
+ * route.jsx). `null` outside of any route container.
28560
+ *
28561
+ * - `isShown()` — whether the container would still show the page. False as
28562
+ * soon as the address leads elsewhere: the page is about to be taken down, or
28563
+ * hidden until the route transition leaving it is over, and nothing it reads
28564
+ * is worth a render any more. False for good once it is kept while leaving: a
28565
+ * return to its route mounts the page anew. Asked outside any render — by a
28566
+ * subscription deciding whether to re-render — and it reads the routes
28567
+ * untracked, so asking subscribes nobody to them.
28568
+ * - `leavingSignal` — true once the page is hidden and kept while its movement
28569
+ * plays: still mounted, so what it fills outside its own nodes (a slot in a
28570
+ * bar) steps out rather than showing with the page arriving.
28571
+ */
28572
+ const RoutePageContext = createContext(null);
28573
+
27788
28574
  const promiseStateWeakMap = new WeakMap();
27789
28575
  const usePromiseAsyncData = (
27790
28576
  promise,
@@ -27920,12 +28706,39 @@ const useActionAsyncData = (action, {
27920
28706
  // has already processed the settlement and the detached DOM is discarded.
27921
28707
  const runningState = action.runningStateSignal.peek();
27922
28708
  const [, setTick] = useState(0);
28709
+ const routePage = useContext(RoutePageContext);
27923
28710
  useEffect(() => {
28711
+ let stopOwingRender = null;
28712
+ const rerender = () => {
28713
+ // Not for a page the address has left: it is about to be taken down,
28714
+ // or hidden until the route transition leaving it is over, and its
28715
+ // route's action moving as that route stops matching would re-render
28716
+ // the whole page — inside the transition's update callback, where
28717
+ // every render delays the movement (see route_page_context.js).
28718
+ if (routePage && !routePage.isShown()) {
28719
+ // Owed rather than dropped: the address can come back before the
28720
+ // container renders (a redirect straight back), and the page then
28721
+ // stays on screen with what it read last.
28722
+ if (!stopOwingRender) {
28723
+ stopOwingRender = observeRouteRender(() => {
28724
+ if (routePage.isShown()) {
28725
+ rerender();
28726
+ }
28727
+ });
28728
+ }
28729
+ return;
28730
+ }
28731
+ if (stopOwingRender) {
28732
+ stopOwingRender();
28733
+ stopOwingRender = null;
28734
+ }
28735
+ setTick(n => n + 1);
28736
+ };
27924
28737
  const unsubscribeFromRunningState = action.runningStateSignal.subscribe(state => {
27925
28738
  if (state === RUNNING) {
27926
28739
  dismissedActionWeakSet.delete(action);
27927
28740
  }
27928
- setTick(n => n + 1);
28741
+ rerender();
27929
28742
  });
27930
28743
  // The data does not come from this action's runs alone: dataSignal is a
27931
28744
  // computed over the resource store, so an other action writing that store
@@ -27939,7 +28752,7 @@ const useActionAsyncData = (action, {
27939
28752
  dataNotificationIsInitial = false;
27940
28753
  return;
27941
28754
  }
27942
- setTick(n => n + 1);
28755
+ rerender();
27943
28756
  });
27944
28757
  // The params say WHICH question this is, and this hook reads them: to know
27945
28758
  // there is nothing to ask for, and to start the run it owns. A binding
@@ -27952,7 +28765,7 @@ const useActionAsyncData = (action, {
27952
28765
  paramsNotificationIsInitial = false;
27953
28766
  return;
27954
28767
  }
27955
- setTick(n => n + 1);
28768
+ rerender();
27956
28769
  });
27957
28770
  // A debounced binding waits before it retargets, so nothing above changes
27958
28771
  // while the delay runs — but what is on screen is already out of date.
@@ -27962,18 +28775,21 @@ const useActionAsyncData = (action, {
27962
28775
  settlingNotificationIsInitial = false;
27963
28776
  return;
27964
28777
  }
27965
- setTick(n => n + 1);
28778
+ rerender();
27966
28779
  });
27967
28780
  return () => {
27968
28781
  unsubscribeFromRunningState();
27969
28782
  unsubscribeFromData();
27970
28783
  unsubscribeFromParams();
27971
28784
  unsubscribeFromParamsSettling();
28785
+ if (stopOwingRender) {
28786
+ stopOwingRender();
28787
+ }
27972
28788
  };
27973
28789
  // Bound to the action, not to the mount: params given as a plain object
27974
28790
  // make another action instance, and the component would otherwise stay
27975
28791
  // subscribed to the state of the one it no longer reads.
27976
- }, [action]);
28792
+ }, [action, routePage]);
27977
28793
 
27978
28794
  // The params moved and the run has not started: under a debounce the action
27979
28795
  // is still the previous one, COMPLETED with the previous answer. There is no
@@ -32133,13 +32949,6 @@ const keepLeavingPages = () => {
32133
32949
  hold.containers.clear();
32134
32950
  };
32135
32951
  };
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
32952
  const LEAVING_ATTRIBUTE = "data-navi-route-leaving";
32144
32953
  const css$13 = /* css */`[data-navi-route-leaving] {
32145
32954
  display: none !important;
@@ -32264,7 +33073,21 @@ const RouteContainer = ({
32264
33073
  shownBranchRef.current = activeBranch;
32265
33074
  }
32266
33075
  const branch = shownBranchRef.current || activeBranch;
32267
- const content = useContentKeepingLeavingPages(branch);
33076
+ // What this container would show if it rendered now, by the same rule as
33077
+ // the render above — asked from outside any render (see isShown below).
33078
+ const childrenRef = useRef(null);
33079
+ childrenRef.current = children;
33080
+ const readShownBranchRef = useRef(null);
33081
+ if (!readShownBranchRef.current) {
33082
+ readShownBranchRef.current = () => {
33083
+ if (routeRenderFrozen) {
33084
+ return shownBranchRef.current;
33085
+ }
33086
+ return collectBranches(childrenRef.current).activeBranch;
33087
+ };
33088
+ }
33089
+ const parentPage = useContext(RoutePageContext);
33090
+ const content = useContentKeepingLeavingPages(branch, readShownBranchRef.current, parentPage);
32268
33091
 
32269
33092
  // The two things this component knows that nobody outside can find out: the
32270
33093
  // pages a named fallback is the absence of — the children it was written
@@ -32301,7 +33124,7 @@ const RouteContainer = ({
32301
33124
  // page being left must stay the child it was when a page is added beside it,
32302
33125
  // or Preact takes it down and builds it again.
32303
33126
  let leavingKeyCount = 0;
32304
- const useContentKeepingLeavingPages = branch => {
33127
+ const useContentKeepingLeavingPages = (branch, readShownBranch, parentPage) => {
32305
33128
  const [, rerender] = useReducer(countRenders, 0);
32306
33129
  const shownRef = useRef(null);
32307
33130
  const leavingRef = useRef([]);
@@ -32319,6 +33142,7 @@ const useContentKeepingLeavingPages = branch => {
32319
33142
  // of reach — the page arriving could not even take the focus — for as long
32320
33143
  // as it is open, and one in a page kept while leaving stays open for the
32321
33144
  // whole movement. The page is then taken down at once, as without one.
33145
+ shown.page.left = true;
32322
33146
  leavingRef.current.push({
32323
33147
  vnode: shown.vnode,
32324
33148
  page: shown.page,
@@ -32335,10 +33159,11 @@ const useContentKeepingLeavingPages = branch => {
32335
33159
  let vnode = null;
32336
33160
  if (branch) {
32337
33161
  if (!page) {
32338
- page = {
32339
- leavingSignal: signal(false)
32340
- };
33162
+ page = createRoutePage(readShownBranch, parentPage);
32341
33163
  }
33164
+ // A page carried over follows the branch it is now the page of.
33165
+ page.branchId = branch.id;
33166
+ page.element = element;
32342
33167
  vnode = h(RoutePageContext.Provider, {
32343
33168
  key,
32344
33169
  value: page
@@ -32405,6 +33230,33 @@ const useContentKeepingLeavingPages = branch => {
32405
33230
  return children;
32406
33231
  };
32407
33232
  const countRenders = count => count + 1;
33233
+ // The page a container renders, as RoutePageContext hands it over (see
33234
+ // route_page_context.js). Shown while the container would pick its branch, or
33235
+ // a branch rendering the same element (it is then carried over), and while the
33236
+ // page it sits in is shown itself. Never again once kept while leaving: coming
33237
+ // back to its route mounts the page anew.
33238
+ const createRoutePage = (readShownBranch, parentPage) => {
33239
+ const page = {
33240
+ branchId: null,
33241
+ element: undefined,
33242
+ left: false,
33243
+ leavingSignal: signal(false),
33244
+ isShown: () => {
33245
+ if (page.left) {
33246
+ return false;
33247
+ }
33248
+ if (parentPage && !parentPage.isShown()) {
33249
+ return false;
33250
+ }
33251
+ const shownBranch = untracked(readShownBranch);
33252
+ if (!shownBranch) {
33253
+ return false;
33254
+ }
33255
+ return shownBranch.id === page.branchId || branchElement(shownBranch) === page.element;
33256
+ }
33257
+ };
33258
+ return page;
33259
+ };
32408
33260
  // What Preact compares when a container changes page, below the <Route> it
32409
33261
  // renders either way: the element the page is rendered with — a layout, for a
32410
33262
  // container branch.
@@ -38744,887 +39596,351 @@ registerNaviCommand("--navi-nav-to", (source, event, { argument }) => {
38744
39596
  console.warn(
38745
39597
  `[navi] "--navi-nav-to" needs a destination: --navi-nav-to:/the/url (relative to the current page, or absolute).`,
38746
39598
  );
38747
- return undefined;
38748
- }
38749
- const target = resolveExplicitTarget(source) || source;
38750
- return {
38751
- target,
38752
- // Which way there is worn by the source, as a link wears it (see
38753
- // link_replace.js): `<Button command="--navi-nav-to:/done" replace>`.
38754
- implementation: () =>
38755
- navTo(
38756
- argument,
38757
- linkAsksForReplace(source) ? { replace: true } : undefined,
38758
- ),
38759
- };
38760
- });
38761
-
38762
- // Back to the screen the reader came from, with somewhere to land when there
38763
- // is none of ours behind — a url opened cold (see navBack). The fallback is
38764
- // the argument for the reason --navi-nav-to's destination is: it says WHAT the
38765
- // command does when there is nothing to go back to.
38766
- registerNaviCommand("--navi-nav-back", (source, event, { argument }) => {
38767
- const target = resolveExplicitTarget(source) || source;
38768
- return {
38769
- target,
38770
- implementation: () => navBack({ fallback: argument }),
38771
- };
38772
- });
38773
-
38774
- registerNaviCommand(
38775
- "--navi-toggle",
38776
- (source, event, { anchor, value } = {}) => {
38777
- const target =
38778
- resolveExplicitTarget(source) || resolveClosestExpandable(source);
38779
- 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";
39599
+ return undefined;
39600
+ }
39601
+ const target = resolveExplicitTarget(source) || source;
39602
+ return {
39603
+ target,
39604
+ // Which way there is worn by the source, as a link wears it (see
39605
+ // link_replace.js): `<Button command="--navi-nav-to:/done" replace>`.
39606
+ implementation: () =>
39607
+ navTo(
39608
+ argument,
39609
+ linkAsksForReplace(source) ? { replace: true } : undefined,
39610
+ ),
39611
+ };
39612
+ });
39430
39613
 
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";
39614
+ // Back to the screen the reader came from, with somewhere to land when there
39615
+ // is none of ours behind — a url opened cold (see navBack). The fallback is
39616
+ // the argument for the reason --navi-nav-to's destination is: it says WHAT the
39617
+ // command does when there is nothing to go back to.
39618
+ registerNaviCommand("--navi-nav-back", (source, event, { argument }) => {
39619
+ const target = resolveExplicitTarget(source) || source;
39620
+ return {
39621
+ target,
39622
+ implementation: () => navBack({ fallback: argument }),
39623
+ };
39624
+ });
39445
39625
 
39626
+ registerNaviCommand(
39627
+ "--navi-toggle",
39628
+ (source, event, { anchor, value } = {}) => {
39629
+ const target =
39630
+ resolveExplicitTarget(source) || resolveClosestExpandable(source);
39631
+ if (!target) {
39632
+ return undefined;
39633
+ }
39634
+ return {
39635
+ target,
39636
+ implementation: () => {
39637
+ const isExpanded = target.getAttribute("aria-expanded") === "true";
39638
+ const customEventName = isExpanded
39639
+ ? "navi_request_close"
39640
+ : "navi_request_open";
39641
+ return dispatchCustomEvent(target, customEventName, {
39642
+ event,
39643
+ source: resolveCommandProxySource(source),
39644
+ anchor,
39645
+ // Same as --navi-open below: the half of the toggle that opens says
39646
+ // what it opens ON, and the half that closes carries it too so the
39647
+ // popup never has to ask which half it just heard.
39648
+ value:
39649
+ value === undefined ? resolveCommandValue(source, event) : value,
39650
+ });
39651
+ },
39652
+ };
39653
+ },
39654
+ );
39655
+ // A popup that edits opens ON something, and the press is the only place that
39656
+ // knows which one. It says it with `value` — "what this is about", as
39657
+ // everywhere else — rather than with an argument, which says what the command
39658
+ // DOES: "open" is already a complete instruction, unlike --navi-go-to-slide.
39659
+ // <Button value={radar.id} command="--navi-open" commandfor="radar-dialog">
39660
+ // The popup is told before it opens — see Dialog/Popover's `onOpen`.
39661
+ registerNaviCommand("--navi-open", (source, event, { anchor, value } = {}) => {
39662
+ const target =
39663
+ resolveExplicitTarget(source) || resolveClosestExpandable(source);
39664
+ if (!target) {
39665
+ return undefined;
39666
+ }
39667
+ return {
39668
+ target,
39669
+ implementation: () => {
39670
+ // The popup reads `anchor` first and falls back to the source (see
39671
+ // onnavi_request_open in popover.jsx): without an `anchor`, the element
39672
+ // asking is the element opened against.
39673
+ return dispatchCustomEvent(target, "navi_request_open", {
39674
+ event,
39675
+ source: resolveCommandProxySource(source),
39676
+ anchor,
39677
+ value: value === undefined ? resolveCommandValue(source, event) : value,
39678
+ });
39679
+ },
39680
+ };
39681
+ });
39446
39682
  /**
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.
39683
+ * Tells the popup a source opens that a press on it has started, ahead of the
39684
+ * release that runs the command: what the opening will read can be asked for
39685
+ * now (see open_controller.js). A press rather than a hover, because what
39686
+ * that reads is keyed by the popup — one sheet per card — and a pointer
39687
+ * crossing a list of cards has chosen none of them; on a touch screen the
39688
+ * press is the first thing the finger says anyway.
39449
39689
  *
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.
39690
+ * The target is found the way --navi-open finds it, but silently: nothing
39691
+ * runs here, and a source with no target says so at the release.
39453
39692
  */
39454
- const handlePressOnOutsideRegion = (
39455
- mouseDownEvent,
39456
- { popupEl, openController, pressOutside },
39457
- ) => {
39458
- if (mouseDownEvent.button !== 0) {
39693
+ const announceOpeningPress = (source, command) => {
39694
+ if (command !== "--navi-open" && command !== "--navi-toggle") {
39459
39695
  return;
39460
39696
  }
39461
- const { target } = mouseDownEvent;
39462
- if (!target.hasAttribute(OUTSIDE_REGION_ATTRIBUTE)) {
39697
+ const commandFor = source.getAttribute("commandfor");
39698
+ const target = commandFor
39699
+ ? document.getElementById(commandFor)
39700
+ : resolveExplicitTarget(source) || resolveClosestExpandable(source);
39701
+ if (!target) {
39463
39702
  return;
39464
39703
  }
39465
- if (openedDuringThisPress(openController)) {
39466
- // The release of the press that opened it is not somebody dismissing it
39467
- // (see openedDuringThisPress).
39468
- return;
39704
+ dispatchCustomEvent(target, "navi_open_press");
39705
+ };
39706
+ // "--navi-close:all" closes every expandable above the source, nearest first —
39707
+ // a link leaving from a badge shown over a sheet leaves both. A surface that
39708
+ // refuses (a form asking about its changes) keeps what is above it open too:
39709
+ // one cannot be out of the sheet while still in the badge.
39710
+ registerNaviCommand("--navi-close", (source, event, { argument }) => {
39711
+ const target =
39712
+ resolveExplicitTarget(source) || resolveClosestExpandable(source);
39713
+ if (!target) {
39714
+ return undefined;
39469
39715
  }
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;
39716
+ return {
39717
+ target,
39718
+ implementation: () => {
39719
+ const detail = { event, source: resolveCommandProxySource(source) };
39720
+ if (argument === "all") {
39721
+ return requestCloseUpward(target, detail);
39722
+ }
39723
+ return dispatchCustomEvent(target, "navi_request_close", detail);
39724
+ },
39725
+ };
39726
+ });
39727
+ const requestCloseUpward = (target, detail) => {
39728
+ let expandable = target;
39729
+ while (expandable) {
39730
+ const closing = dispatchCustomEvent(
39731
+ expandable,
39732
+ "navi_request_close",
39733
+ detail,
39734
+ );
39735
+ if (!closing) {
39736
+ return false;
39737
+ }
39738
+ const parent = expandable.parentElement;
39739
+ expandable = parent ? parent.closest("[aria-expanded]") : null;
39478
39740
  }
39479
- if (pressOutside === "capture") {
39480
- mouseDownEvent.preventDefault();
39481
- return;
39741
+ return true;
39742
+ };
39743
+ registerNaviCommand("--navi-cancel", (source, event) => {
39744
+ const target =
39745
+ resolveExplicitTarget(source) || resolveClosestExpandable(source);
39746
+ if (!target) {
39747
+ return undefined;
39482
39748
  }
39483
- if (pressOutside === "close" || pressOutside === "cancel") {
39484
- openController.requestClose(mouseDownEvent, {
39485
- isCancel: pressOutside === "cancel",
39486
- });
39749
+ return {
39750
+ target,
39751
+ implementation: () => {
39752
+ return dispatchCustomEvent(target, "navi_request_close", {
39753
+ event,
39754
+ source: resolveCommandProxySource(source),
39755
+ isCancel: true,
39756
+ });
39757
+ },
39758
+ };
39759
+ });
39760
+ // "Yes, do it" — said inside the popup of a confirm picker (see
39761
+ // picker/picker_confirm.jsx), by its own yes button or by whatever a caller put
39762
+ // there instead. Its counterpart is --navi-cancel; the difference between the
39763
+ // two is this event, since both then close the popup, and a popup that closes
39764
+ // without having said this is the answer "no".
39765
+ registerNaviCommand("--navi-confirm", (source, event) => {
39766
+ const target =
39767
+ resolveExplicitTarget(source) || resolveClosestExpandable(source);
39768
+ if (!target) {
39769
+ return undefined;
39770
+ }
39771
+ return {
39772
+ target,
39773
+ implementation: () => {
39774
+ const commandSource = resolveCommandProxySource(source);
39775
+ dispatchCustomEvent(target, "navi_request_confirm", {
39776
+ event,
39777
+ source: commandSource,
39778
+ });
39779
+ return dispatchCustomEvent(target, "navi_request_close", {
39780
+ event,
39781
+ source: commandSource,
39782
+ });
39783
+ },
39784
+ };
39785
+ });
39786
+ registerNaviCommand("--navi-define", (source, event) => {
39787
+ const target =
39788
+ resolveExplicitTarget(source) || resolveClosestExpandable(source);
39789
+ if (!target) {
39790
+ return undefined;
39487
39791
  }
39792
+ return {
39793
+ target,
39794
+ implementation: () => executeNaviDefine(source, event, target),
39795
+ };
39796
+ });
39797
+ // A send whose target is the popup itself: what is around the source IS the
39798
+ // popup's answer, and a picker runs its action with that answer as it closes
39799
+ // (see onRequestClose in picker_custom.jsx) — dispatching the action here too
39800
+ // would run it twice. `isSend` tells this close from a dismissal (the cross,
39801
+ // a press outside, Escape): the person pressed a choice, which a picker may
39802
+ // act on even when the choice is the one it already held.
39803
+ const executeNaviDefine = (source, event, target) => {
39804
+ return dispatchCustomEvent(target, "navi_request_close", {
39805
+ event,
39806
+ source: resolveCommandProxySource(source),
39807
+ isSend: true,
39808
+ });
39488
39809
  };
39489
39810
 
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
- }
39811
+ registerNaviCommand("--navi-scroll", (source, event) => {
39812
+ const target =
39813
+ resolveExplicitTarget(source) || resolveFirstParentControl(source);
39814
+ if (!target) {
39815
+ return undefined;
39509
39816
  }
39510
- };
39817
+ return {
39818
+ target,
39819
+ implementation: () => {
39820
+ return dispatchCustomEvent(target, "navi_request_scroll", {
39821
+ event,
39822
+ id: resolveCommandValue(source, event),
39823
+ });
39824
+ },
39825
+ };
39826
+ });
39827
+ // The press a hand is still making, given to something that can be carried: a
39828
+ // hold opened a popup, and what the popup holds is to follow that same finger
39829
+ // without it lifting and pressing again. `event` is what the press set off (the
39830
+ // popup's open event), and the press is read from its chain. Run once the popup
39831
+ // has settled, which the caller cannot see: the carry measures its element and
39832
+ // puts a copy in the top layer, neither of which holds while a transition is
39833
+ // still moving the box (see afterPopupLift). What the carry then is — its
39834
+ // outcomes, its refusal — is the element's (see interaction_drag.js).
39835
+ registerNaviCommand("--navi-grab", (source, event) => {
39836
+ const target =
39837
+ resolveExplicitTarget(source) || source.closest("[data-drag-source]");
39838
+ if (!target) {
39839
+ return undefined;
39840
+ }
39841
+ return {
39842
+ target,
39843
+ implementation: () => {
39844
+ afterPopupLift(() => {
39845
+ // Taken away with the popup before it settled.
39846
+ if (!target.isConnected) {
39847
+ return;
39848
+ }
39849
+ dispatchCustomEvent(target, "navi_request_grab", { event });
39850
+ });
39851
+ return true;
39852
+ },
39853
+ };
39854
+ });
39855
+ registerNaviCommand("--navi-check", (source, event) => {
39856
+ const target =
39857
+ resolveExplicitTarget(source) || resolveFirstParentControl(source);
39858
+ if (!target) {
39859
+ return undefined;
39860
+ }
39861
+ return {
39862
+ target,
39863
+ implementation: () => {
39864
+ return dispatchCustomEvent(target, "navi_request_check", {
39865
+ event,
39866
+ });
39867
+ },
39868
+ };
39869
+ });
39870
+ registerNaviCommand("--navi-uncheck", (source, event) => {
39871
+ const target =
39872
+ resolveExplicitTarget(source) || resolveFirstParentControl(source);
39873
+ if (!target) {
39874
+ return undefined;
39875
+ }
39876
+ return {
39877
+ target,
39878
+ implementation: () => {
39879
+ return dispatchCustomEvent(target, "navi_request_uncheck", {
39880
+ event,
39881
+ });
39882
+ },
39883
+ };
39884
+ });
39885
+ registerNaviCommand("--navi-select", (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_select", {
39895
+ event,
39896
+ id: resolveCommandValue(source, event),
39897
+ });
39898
+ },
39899
+ };
39900
+ });
39901
+ registerNaviCommand("--navi-unselect", (source, event) => {
39902
+ const target =
39903
+ resolveExplicitTarget(source) || resolveFirstParentControl(source);
39904
+ if (!target) {
39905
+ return undefined;
39906
+ }
39907
+ return {
39908
+ target,
39909
+ implementation: () => {
39910
+ return dispatchCustomEvent(target, "navi_request_unselect", {
39911
+ event,
39912
+ id: resolveCommandValue(source, event),
39913
+ });
39914
+ },
39915
+ };
39916
+ });
39511
39917
 
39512
39918
  /**
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.
39919
+ * Runs `fn` and commits whatever it re-renders before returning, instead of
39920
+ * letting Preact batch it into the next microtask. Layout effects of what gets
39921
+ * mounted run inside the call too, exactly as they would on any other commit.
39547
39922
  *
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.
39923
+ * For the caller that has to read the DOM it just asked for — measuring an
39924
+ * element whose content it mounts in the same breath — and cannot wait a tick
39925
+ * to do it, because what comes after is a browser event still in flight
39926
+ * (preventDefault, focus placement) that no longer accepts being answered late.
39550
39927
  *
39551
- * Returns a cleanup that removes the listener.
39928
+ * `options.debounceRendering` is Preact's own hook for deciding *when* the
39929
+ * render queue drains; swapping it for "right now" for the duration of the call
39930
+ * is exactly how preact/compat implements React's flushSync. Reserve it for the
39931
+ * case above: rendering synchronously in the middle of an event gives up the
39932
+ * batching that makes several state changes one commit.
39552
39933
  */
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
- });
39934
+ const flushSyncRendering = (fn) => {
39935
+ const debounceRenderingPrevious = options.debounceRendering;
39936
+ options.debounceRendering = (drainRenderQueue) => {
39937
+ drainRenderQueue();
39627
39938
  };
39939
+ try {
39940
+ fn();
39941
+ } finally {
39942
+ options.debounceRendering = debounceRenderingPrevious;
39943
+ }
39628
39944
  };
39629
39945
 
39630
39946
  /**
@@ -66937,6 +67253,13 @@ const css$D = /* css */`
66937
67253
  * is a `Popover`-level answer only for `"close"`/`"cancel"`; `"capture"` has
66938
67254
  * nothing left to absorb without a wall, and `"ignore"` renders no wall anyway.
66939
67255
  * See docs/popup_backdrop.md.
67256
+ * @param {string} [props.group] - The popover's peers: the triggers carrying
67257
+ * `data-navi-popup-group` with this name. A press on one, while the popover
67258
+ * is open, closes it and opens the peer's popup in that same press — a
67259
+ * menu bar, where pressing another menu switches to it. The wall still
67260
+ * takes the press (the page behind stays out of reach, hover included) and
67261
+ * hands its click to the peer it covered. Without a wall there is nothing
67262
+ * to ask: the press reaches the peer on its own.
66940
67263
  * @param {"close"|"cancel"|"capture"|"ignore"} [props.pressOutside="ignore"]
66941
67264
  * - `"ignore"` (default): no backdrop at all, outside presses pass straight
66942
67265
  * through. `"close"` closes the popover on an outside press, `"cancel"`
@@ -67313,6 +67636,7 @@ const usePopoverProps = props => {
67313
67636
  // which is a sibling element (see this file's top comment).
67314
67637
  backdropColor,
67315
67638
  backdropFilter,
67639
+ group,
67316
67640
  scrollCapture,
67317
67641
  focusCapture,
67318
67642
  // "auto" (default) → the popover follows its content. "frozen" → measured
@@ -67965,13 +68289,21 @@ const usePopoverProps = props => {
67965
68289
  }
67966
68290
  if (backdropEl) {
67967
68291
  backdropEl.setAttribute("aria-expanded", "false");
67968
- disarmBackdropHideRef.current = armPointerDownOutsideClose(closeEvent, () => {
68292
+ disarmBackdropHideRef.current = armPointerDownOutsideClose(closeEvent, clickEvent => {
67969
68293
  // Set regardless of isTopLayer — see openEffect's own identical
67970
68294
  // comment for why hidePopover() alone isn't reliably sufficient.
67971
68295
  backdropEl.setAttribute("navi-hidden", "");
67972
68296
  if (isTopLayer) {
67973
68297
  backdropEl.hidePopover();
67974
68298
  }
68299
+ if (group && clickEvent) {
68300
+ handWallClickToPeer(clickEvent, {
68301
+ wallEl: backdropEl,
68302
+ popupEl: popoverEl,
68303
+ anchorEl: anchorElement,
68304
+ group
68305
+ });
68306
+ }
67975
68307
  });
67976
68308
  }
67977
68309
  restoreFocus?.(closeEvent);
@@ -68393,6 +68725,10 @@ const Popup = props => {
68393
68725
  scrollCapture,
68394
68726
  positionAreaFixed,
68395
68727
  positionAreaWhenAnchorIsInvalid,
68728
+ // A top-layer dialog's wall is its native ::backdrop, which goes with the
68729
+ // dialog on the press that closes it: no click is left on it to hand to a
68730
+ // peer (see handWallClickToPeer).
68731
+ group,
68396
68732
  // Dialog-only, destructured out for the same reason: forwarded below only
68397
68733
  // in the dialog branch, so they never reach the popover element.
68398
68734
  dockedOnSmallTouchScreen,
@@ -68442,6 +68778,7 @@ const Popup = props => {
68442
68778
  positionAreaWhenAnchorIsInvalid: positionAreaWhenAnchorIsInvalid,
68443
68779
  scrollCapture: scrollCapture === "popover" || scrollCapture,
68444
68780
  positionAreaFixed: positionAreaFixed,
68781
+ group: group,
68445
68782
  className: withPropsClassName("navi_popup", className),
68446
68783
  expand: expand,
68447
68784
  expandX: expandX,
@@ -69359,13 +69696,18 @@ const PickerContentInsidePopup = props => {
69359
69696
  calloutIcon,
69360
69697
  calloutCloseButton,
69361
69698
  calloutCloseByScroll,
69699
+ popupGroup,
69362
69700
  ...rest
69363
69701
  } = props;
69364
69702
  const isPopover = mode === "popover";
69365
69703
  const isCallout = mode === "callout";
69366
69704
  return jsx(Next, {
69367
69705
  "aria-haspopup": isPopover ? "listbox" : "dialog",
69368
- "navi-popover-mode": isPopover ? popoverMode : undefined,
69706
+ "navi-popover-mode": isPopover ? popoverMode : undefined
69707
+ // On the trigger, whatever the mode: a peer's popup reads it off the
69708
+ // press that lands here (see `group` in callout.js).
69709
+ ,
69710
+ "data-navi-popup-group": popupGroup,
69369
69711
  ...rest,
69370
69712
  // On popupProps already (see the picker's popup assembly); they mean
69371
69713
  // nothing to the picker element.
@@ -69401,7 +69743,12 @@ const PickerContentInsidePopup = props => {
69401
69743
  status: calloutStatus,
69402
69744
  icon: calloutIcon,
69403
69745
  closeButton: calloutCloseButton,
69404
- closeByScroll: calloutCloseByScroll,
69746
+ closeByScroll: calloutCloseByScroll
69747
+ // Left unsaid, the callout's own default: no backdrop. It dims
69748
+ // nothing, so the page looks as pressable as it did before.
69749
+ ,
69750
+ backdrop: backdrop,
69751
+ group: popupGroup,
69405
69752
  children: jsx(PopupModeContext.Provider, {
69406
69753
  value: mode,
69407
69754
  children: children
@@ -69438,6 +69785,7 @@ const PickerContentInsidePopup = props => {
69438
69785
  backdropVariant: backdropVariant,
69439
69786
  backdropColor: backdropColor,
69440
69787
  backdropFilter: backdropFilter,
69788
+ group: popupGroup,
69441
69789
  focusCapture: isPopover ? focusCapture : undefined,
69442
69790
  expand: isPopover ? undefined : dialogExpand,
69443
69791
  expandX: isPopover ? undefined : dialogExpandX,
@@ -69497,6 +69845,8 @@ const PickerCalloutPopup = ({
69497
69845
  icon,
69498
69846
  closeButton,
69499
69847
  closeByScroll,
69848
+ backdrop,
69849
+ group,
69500
69850
  onnavi_request_open,
69501
69851
  onnavi_request_close,
69502
69852
  onnavi_request_confirm,
@@ -69538,6 +69888,8 @@ const PickerCalloutPopup = ({
69538
69888
  icon,
69539
69889
  closeButton,
69540
69890
  closeByScroll,
69891
+ backdrop,
69892
+ group,
69541
69893
  anchorElement,
69542
69894
  // The request, chained to the press that made it: the callout reads the
69543
69895
  // mousedown off it to wait for the release before listening for a click
@@ -71704,7 +72056,13 @@ const ListRowContext = createContext(null);
71704
72056
  // row takes its place in the collection by slot: the place is then the list's
71705
72057
  // to move, and the row's to follow — see list_rows.js.
71706
72058
  const ListSlotContext = createContext(null);
71707
- const css$x = /* css */`@layer navi {
72059
+ const css$x = /* css */`@property --list-group-label-height {
72060
+ syntax: "<length>";
72061
+ inherits: false;
72062
+ initial-value: 0;
72063
+ }
72064
+
72065
+ @layer navi {
71708
72066
  .navi_list_container {
71709
72067
  --list-outline-width: 1px;
71710
72068
  --list-border-radius: 4px;
@@ -72103,6 +72461,7 @@ const css$x = /* css */`@layer navi {
72103
72461
  }
72104
72462
 
72105
72463
  & .navi_list_item_group_list {
72464
+ --list-group-label-height: inherit;
72106
72465
  flex-direction: column;
72107
72466
  width: 100%;
72108
72467
  margin: 0;
@@ -72110,8 +72469,12 @@ const css$x = /* css */`@layer navi {
72110
72469
  list-style: none;
72111
72470
  display: flex;
72112
72471
 
72472
+ & > .navi_list_item {
72473
+ --list-group-label-height: inherit;
72474
+ }
72475
+
72113
72476
  & .navi_list_item {
72114
- scroll-margin-top: calc(var(--x-list-scroll-spacing-top) + var(--list-group-label-height, 0px));
72477
+ scroll-margin-top: calc(var(--x-list-scroll-spacing-top) + var(--list-group-label-height));
72115
72478
  }
72116
72479
  }
72117
72480
 
@@ -79202,6 +79565,7 @@ const PickerFirstResolver = props => {
79202
79565
  * escapeEffect?: "cancel" | "close",
79203
79566
  * pressOutside?: "close" | "cancel" | "capture",
79204
79567
  * backdrop?: boolean,
79568
+ * popupGroup?: string,
79205
79569
  * backdropVariant?: "auto" | "lift" | "discrete" | "invisible",
79206
79570
  * backdropColor?: string,
79207
79571
  * backdropFilter?: string,
@@ -79601,10 +79965,23 @@ const PickerFirstResolver = props => {
79601
79965
  * and the page at all. `false` lets a press outside both close the popup and
79602
79966
  * reach whatever it landed on, in one gesture — for a picker opened over a
79603
79967
  * 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
79968
+ * wall would spend the first press on dismissing. Honoured in every mode: a
79605
79969
  * dialog with no wall is shown through the Popover API rather than
79606
79970
  * `showModal()`, so a picker docked as a bottom sheet on a phone still
79607
- * leaves the plan behind it live.
79971
+ * leaves the plan behind it live. `false` by default under `mode="callout"`,
79972
+ * a bubble that dims nothing; `true` there spends the press outside on
79973
+ * closing it — the link under it not followed, the card under it not held —
79974
+ * without a wall: the scroll a finger starts beside the bubble still
79975
+ * scrolls, and a peer (`popupGroup`) still gets its press.
79976
+ * @param {string} [popupGroup] Pickers sharing the name are peers, whatever
79977
+ * their mode: pressing one while another's popup is open goes from that
79978
+ * popup to this one in a single press — a menu bar, where pressing another
79979
+ * menu switches to it. A popover's wall still takes that press — the page
79980
+ * behind stays out of reach, hover included — and hands its click to the
79981
+ * peer it covered; a callout with `backdrop` closes on it without spending
79982
+ * it. Not read in dialog mode: a dialog's wall goes with it on the press
79983
+ * that closes it. Written on the trigger as `data-navi-popup-group`, which
79984
+ * is also how anything else joins the group.
79608
79985
  * @param {"auto"|"lift"|"discrete"|"invisible"} [backdropVariant="auto"] How
79609
79986
  * visible the popup's backdrop is, independently of what a click outside
79610
79987
  * does: `"auto"` is the paint `pressOutside` implies,