@jsenv/navi 0.29.415 → 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
@@ -24480,7 +25191,31 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
24480
25191
  }
24481
25192
  return result;
24482
25193
  };
24483
- const upsert = (...args) => {
25194
+ // A computed of the row with this id, one per id: what a render reads to
25195
+ // follow that row and no other. An upsert replaces only the items it changes
25196
+ // (see assign below), so a row nobody wrote keeps its object, and its
25197
+ // computed stays still while the rest of the store moves — read through
25198
+ // `select`, a render follows the whole store. Kept as long as the store,
25199
+ // like the rows it holds.
25200
+ const itemSignalById = new Map();
25201
+ const itemSignalForId = (id) => {
25202
+ let itemSignal = itemSignalById.get(id);
25203
+ if (!itemSignal) {
25204
+ itemSignal = computed(() => idMapSignal.value.get(id));
25205
+ itemSignalById.set(id, itemSignal);
25206
+ }
25207
+ return itemSignal;
25208
+ };
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) => {
24484
25219
  const mutationsMap = new Map(); // Map<itemId, propertyMutations>
24485
25220
  const triggerPropertyMutations = () => {
24486
25221
  for (const itemPropertiesObserver of itemPropertiesObserverSet) {
@@ -24544,7 +25279,27 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
24544
25279
  const newValue = props[key];
24545
25280
  if (itemOwnKeys.includes(key)) {
24546
25281
  const oldValue = item[key];
24547
- 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)) {
24548
25303
  hasChanges = true;
24549
25304
  itemWithProps[key] = newValue;
24550
25305
  propertyMutations[key] = {
@@ -24696,6 +25451,9 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
24696
25451
  triggerPropertyMutations();
24697
25452
  return item;
24698
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);
24699
25457
  const drop = (...args) => {
24700
25458
  const removedItemArray = [];
24701
25459
  const triggerRemovedMutations = () => {
@@ -24870,7 +25628,9 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
24870
25628
  arraySignal,
24871
25629
  select,
24872
25630
  selectAll,
25631
+ itemSignalForId,
24873
25632
  upsert,
25633
+ upsertAnswer,
24874
25634
  drop,
24875
25635
 
24876
25636
  observeItemProperties,
@@ -25628,7 +26388,7 @@ const createRangeReader = (
25628
26388
  `${actionName} must return { items, start, count }, received ${describeResult(result)}.`,
25629
26389
  );
25630
26390
  }
25631
- const items = store.upsert(result.items);
26391
+ const items = store.upsertAnswer(result.items);
25632
26392
  let { start, count } = result;
25633
26393
  if (start === undefined) {
25634
26394
  const startAsked = rangeParams.start;
@@ -26035,12 +26795,34 @@ const createResource = (
26035
26795
  idKey,
26036
26796
  uniqueKeys,
26037
26797
 
26038
- // Reactive reads, not hooks: they subscribe the render that calls them to
26039
- // the store's array signal. Nothing hook-shaped inside, so a loop or a
26040
- // condition is fine. `useAllByIds` skips the ids the store does not hold.
26798
+ // Reactive reads, not hooks: nothing hook-shaped inside, so a loop or a
26799
+ // condition is fine. Each subscribes the render that calls it to what it
26800
+ // returns: the rows asked for, each through a computed of that row (see
26801
+ // itemSignalForId in array_signal_store.js), or the whole store for
26802
+ // `useArray` — a card reading its own row stays still while the page next
26803
+ // to it loads rows of its own. `useAllByIds` skips the ids the store does
26804
+ // not hold.
26041
26805
  useArray: () => store.arraySignal.value,
26042
- useById: (id) => store.select(idKey, id),
26043
- useAllByIds: (idArray) => store.selectAll(idArray),
26806
+ useById: (id) => store.itemSignalForId(id).value,
26807
+ useAllByIds: (idArray) => {
26808
+ const items = [];
26809
+ for (const idOrProps of idArray) {
26810
+ let id = idOrProps;
26811
+ if (isProps(idOrProps) && !Object.hasOwn(idOrProps, idKey)) {
26812
+ // Named by a unique key: which row that is can only be read off
26813
+ // the whole store.
26814
+ const item = store.select(idOrProps);
26815
+ id = item ? item[idKey] : undefined;
26816
+ } else if (isProps(idOrProps)) {
26817
+ id = idOrProps[idKey];
26818
+ }
26819
+ const item = store.itemSignalForId(id).value;
26820
+ if (item) {
26821
+ items.push(item);
26822
+ }
26823
+ }
26824
+ return items;
26825
+ },
26044
26826
 
26045
26827
  withParams: undefined,
26046
26828
  one: undefined,
@@ -26267,7 +27049,7 @@ const createResource = (
26267
27049
  : // GET/PUT contract (see .one() JSDoc): the parent object with the
26268
27050
  // relationship nested inside, or null for no relationship.
26269
27051
  (result) => {
26270
- const item = store.upsert(result);
27052
+ const item = store.upsertAnswer(result);
26271
27053
  const childItem = item[propertyName];
26272
27054
  return childItem ? childItem[childIdKey] : undefined;
26273
27055
  };
@@ -26448,8 +27230,8 @@ const createResource = (
26448
27230
  : (childData) => {
26449
27231
  // an array is [property, value, props], used to rename the child id
26450
27232
  const childItem = Array.isArray(childData)
26451
- ? childStore.upsert(...childData)
26452
- : childStore.upsert(childData);
27233
+ ? childStore.upsertAnswer(...childData)
27234
+ : childStore.upsertAnswer(childData);
26453
27235
  return childItem[childIdKey];
26454
27236
  };
26455
27237
  const throwInvalidResult = createInvalidResultThrower(
@@ -26491,7 +27273,7 @@ const createResource = (
26491
27273
  ? (result) => {
26492
27274
  // GET_MANY contract (see .many() JSDoc): the parent object with
26493
27275
  // the child array nested inside; the array replaces the relationship.
26494
- const item = store.upsert(result);
27276
+ const item = store.upsertAnswer(result);
26495
27277
  const childItemArray = item[propertyName];
26496
27278
  return childItemArray.map((childItem) => childItem[childIdKey]);
26497
27279
  }
@@ -26522,7 +27304,7 @@ const createResource = (
26522
27304
  return deletedChildItemIdArray;
26523
27305
  }
26524
27306
  : (childDataArray) => {
26525
- const childItemArray = childStore.upsert(childDataArray);
27307
+ const childItemArray = childStore.upsertAnswer(childDataArray);
26526
27308
  return childItemArray.map((childItem) => childItem[childIdKey]);
26527
27309
  };
26528
27310
  const throwInvalidResult = createInvalidResultThrower(
@@ -26661,18 +27443,37 @@ const createResource = (
26661
27443
  childSetup(childItem);
26662
27444
  }
26663
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.
26664
27450
  const applyProps = (props) => {
26665
27451
  if (!props) {
27452
+ if (childSignal.peek() === null) {
27453
+ return false;
27454
+ }
26666
27455
  childSignal.value = null;
26667
- return;
27456
+ return true;
26668
27457
  }
26669
- // 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.
26670
27461
  for (const [key, value] of Object.entries(props)) {
26671
- 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
+ }
26672
27471
  }
26673
27472
  if (childSignal.peek() !== childItem) {
26674
27473
  childSignal.value = childItem; // first activation: null → childItem
27474
+ changed = true;
26675
27475
  }
27476
+ return changed;
26676
27477
  };
26677
27478
  if (!ownerName) {
26678
27479
  // Declared on a scoped child, an owner id is only unique inside its
@@ -26960,7 +27761,7 @@ const createResource = (
26960
27761
  if (isMany) {
26961
27762
  // GET_MANY, POST_MANY, PUT_MANY etc: rest[0] is the array of items,
26962
27763
  // and it replaces the whole collection.
26963
- const itemArray = childStore.upsert(rest[0]);
27764
+ const itemArray = childStore.upsertAnswer(rest[0]);
26964
27765
  const idArray = itemArray.map((childItem) => childItem[childIdKey]);
26965
27766
  idArraySignal.value = idArray;
26966
27767
  return [ownerId, idArray];
@@ -26969,8 +27770,8 @@ const createResource = (
26969
27770
  // GET, POST, PUT, PATCH: rest may be [props] or [oldId, props] for renames
26970
27771
  const childItem =
26971
27772
  rest.length > 1
26972
- ? childStore.upsert(...rest)
26973
- : childStore.upsert(rest[0]);
27773
+ ? childStore.upsertAnswer(...rest)
27774
+ : childStore.upsertAnswer(rest[0]);
26974
27775
  return [ownerId, childItem[childIdKey]];
26975
27776
  },
26976
27777
  valueToData: (value) => {
@@ -27106,8 +27907,8 @@ const createRestActionFactoryForRoot = (
27106
27907
  // An array result is [property, value, props] — used to rename the
27107
27908
  // idKey of an item: store.upsert("name", "currentName", { name: "newName" })
27108
27909
  const item = Array.isArray(result)
27109
- ? store.upsert(...result)
27110
- : store.upsert(result);
27910
+ ? store.upsertAnswer(...result)
27911
+ : store.upsertAnswer(result);
27111
27912
  return item[idKey];
27112
27913
  };
27113
27914
  const throwInvalidResult = createInvalidResultThrower(
@@ -27171,7 +27972,7 @@ const createRestActionFactoryForRoot = (
27171
27972
  verb === "DELETE"
27172
27973
  ? (idOrMutableIdArray) => store.drop(idOrMutableIdArray)
27173
27974
  : (dataArray) => {
27174
- const itemArray = store.upsert(dataArray);
27975
+ const itemArray = store.upsertAnswer(dataArray);
27175
27976
  return itemArray.map((item) => item[idKey]);
27176
27977
  };
27177
27978
 
@@ -27300,14 +28101,17 @@ ${relationLabel} source location: ${declarationSite}`,
27300
28101
  // array of child props/ids, or anything else meaning "empty") into an array of
27301
28102
  // child ids, upserting each entry into the child store. The id array signal is
27302
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).
27303
28106
  const createChildIdArrayUpdater = (childStore, childIdKey, idArraySignal) => {
27304
28107
  return (valueArray) => {
27305
28108
  const currentIdArray = idArraySignal.peek();
27306
28109
  if (!Array.isArray(valueArray)) {
27307
28110
  if (currentIdArray.length > 0) {
27308
28111
  idArraySignal.value = [];
28112
+ return true;
27309
28113
  }
27310
- return;
28114
+ return false;
27311
28115
  }
27312
28116
  const idArray = [];
27313
28117
  let modified = false;
@@ -27329,7 +28133,9 @@ const createChildIdArrayUpdater = (childStore, childIdKey, idArraySignal) => {
27329
28133
  }
27330
28134
  if (modified || currentIdArray.length !== idArray.length) {
27331
28135
  idArraySignal.value = idArray;
28136
+ return true;
27332
28137
  }
28138
+ return false;
27333
28139
  };
27334
28140
  };
27335
28141
 
@@ -27747,6 +28553,24 @@ const TYPE_CONVERTERS = {
27747
28553
  },
27748
28554
  };
27749
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
+
27750
28574
  const promiseStateWeakMap = new WeakMap();
27751
28575
  const usePromiseAsyncData = (
27752
28576
  promise,
@@ -27882,12 +28706,39 @@ const useActionAsyncData = (action, {
27882
28706
  // has already processed the settlement and the detached DOM is discarded.
27883
28707
  const runningState = action.runningStateSignal.peek();
27884
28708
  const [, setTick] = useState(0);
28709
+ const routePage = useContext(RoutePageContext);
27885
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
+ };
27886
28737
  const unsubscribeFromRunningState = action.runningStateSignal.subscribe(state => {
27887
28738
  if (state === RUNNING) {
27888
28739
  dismissedActionWeakSet.delete(action);
27889
28740
  }
27890
- setTick(n => n + 1);
28741
+ rerender();
27891
28742
  });
27892
28743
  // The data does not come from this action's runs alone: dataSignal is a
27893
28744
  // computed over the resource store, so an other action writing that store
@@ -27901,7 +28752,7 @@ const useActionAsyncData = (action, {
27901
28752
  dataNotificationIsInitial = false;
27902
28753
  return;
27903
28754
  }
27904
- setTick(n => n + 1);
28755
+ rerender();
27905
28756
  });
27906
28757
  // The params say WHICH question this is, and this hook reads them: to know
27907
28758
  // there is nothing to ask for, and to start the run it owns. A binding
@@ -27914,7 +28765,7 @@ const useActionAsyncData = (action, {
27914
28765
  paramsNotificationIsInitial = false;
27915
28766
  return;
27916
28767
  }
27917
- setTick(n => n + 1);
28768
+ rerender();
27918
28769
  });
27919
28770
  // A debounced binding waits before it retargets, so nothing above changes
27920
28771
  // while the delay runs — but what is on screen is already out of date.
@@ -27924,18 +28775,21 @@ const useActionAsyncData = (action, {
27924
28775
  settlingNotificationIsInitial = false;
27925
28776
  return;
27926
28777
  }
27927
- setTick(n => n + 1);
28778
+ rerender();
27928
28779
  });
27929
28780
  return () => {
27930
28781
  unsubscribeFromRunningState();
27931
28782
  unsubscribeFromData();
27932
28783
  unsubscribeFromParams();
27933
28784
  unsubscribeFromParamsSettling();
28785
+ if (stopOwingRender) {
28786
+ stopOwingRender();
28787
+ }
27934
28788
  };
27935
28789
  // Bound to the action, not to the mount: params given as a plain object
27936
28790
  // make another action instance, and the component would otherwise stay
27937
28791
  // subscribed to the state of the one it no longer reads.
27938
- }, [action]);
28792
+ }, [action, routePage]);
27939
28793
 
27940
28794
  // The params moved and the run has not started: under a debounce the action
27941
28795
  // is still the previous one, COMPLETED with the previous answer. There is no
@@ -32095,13 +32949,6 @@ const keepLeavingPages = () => {
32095
32949
  hold.containers.clear();
32096
32950
  };
32097
32951
  };
32098
-
32099
- // What a page can learn about its place from the container rendering it:
32100
- // whether it is a page kept while leaving (see keepLeavingPages). Such a page
32101
- // is still mounted, and whatever it fills OUTSIDE its own nodes — a slot in a
32102
- // bar, say — would otherwise go on showing with the page arriving (see
32103
- // layout/slot.jsx).
32104
- const RoutePageContext = createContext(null);
32105
32952
  const LEAVING_ATTRIBUTE = "data-navi-route-leaving";
32106
32953
  const css$13 = /* css */`[data-navi-route-leaving] {
32107
32954
  display: none !important;
@@ -32226,7 +33073,21 @@ const RouteContainer = ({
32226
33073
  shownBranchRef.current = activeBranch;
32227
33074
  }
32228
33075
  const branch = shownBranchRef.current || activeBranch;
32229
- 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);
32230
33091
 
32231
33092
  // The two things this component knows that nobody outside can find out: the
32232
33093
  // pages a named fallback is the absence of — the children it was written
@@ -32263,7 +33124,7 @@ const RouteContainer = ({
32263
33124
  // page being left must stay the child it was when a page is added beside it,
32264
33125
  // or Preact takes it down and builds it again.
32265
33126
  let leavingKeyCount = 0;
32266
- const useContentKeepingLeavingPages = branch => {
33127
+ const useContentKeepingLeavingPages = (branch, readShownBranch, parentPage) => {
32267
33128
  const [, rerender] = useReducer(countRenders, 0);
32268
33129
  const shownRef = useRef(null);
32269
33130
  const leavingRef = useRef([]);
@@ -32281,6 +33142,7 @@ const useContentKeepingLeavingPages = branch => {
32281
33142
  // of reach — the page arriving could not even take the focus — for as long
32282
33143
  // as it is open, and one in a page kept while leaving stays open for the
32283
33144
  // whole movement. The page is then taken down at once, as without one.
33145
+ shown.page.left = true;
32284
33146
  leavingRef.current.push({
32285
33147
  vnode: shown.vnode,
32286
33148
  page: shown.page,
@@ -32297,10 +33159,11 @@ const useContentKeepingLeavingPages = branch => {
32297
33159
  let vnode = null;
32298
33160
  if (branch) {
32299
33161
  if (!page) {
32300
- page = {
32301
- leavingSignal: signal(false)
32302
- };
33162
+ page = createRoutePage(readShownBranch, parentPage);
32303
33163
  }
33164
+ // A page carried over follows the branch it is now the page of.
33165
+ page.branchId = branch.id;
33166
+ page.element = element;
32304
33167
  vnode = h(RoutePageContext.Provider, {
32305
33168
  key,
32306
33169
  value: page
@@ -32367,6 +33230,33 @@ const useContentKeepingLeavingPages = branch => {
32367
33230
  return children;
32368
33231
  };
32369
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
+ };
32370
33260
  // What Preact compares when a container changes page, below the <Route> it
32371
33261
  // renders either way: the element the page is rendered with — a layout, for a
32372
33262
  // container branch.
@@ -38706,887 +39596,351 @@ registerNaviCommand("--navi-nav-to", (source, event, { argument }) => {
38706
39596
  console.warn(
38707
39597
  `[navi] "--navi-nav-to" needs a destination: --navi-nav-to:/the/url (relative to the current page, or absolute).`,
38708
39598
  );
38709
- return undefined;
38710
- }
38711
- const target = resolveExplicitTarget(source) || source;
38712
- return {
38713
- target,
38714
- // Which way there is worn by the source, as a link wears it (see
38715
- // link_replace.js): `<Button command="--navi-nav-to:/done" replace>`.
38716
- implementation: () =>
38717
- navTo(
38718
- argument,
38719
- linkAsksForReplace(source) ? { replace: true } : undefined,
38720
- ),
38721
- };
38722
- });
38723
-
38724
- // Back to the screen the reader came from, with somewhere to land when there
38725
- // is none of ours behind — a url opened cold (see navBack). The fallback is
38726
- // the argument for the reason --navi-nav-to's destination is: it says WHAT the
38727
- // command does when there is nothing to go back to.
38728
- registerNaviCommand("--navi-nav-back", (source, event, { argument }) => {
38729
- const target = resolveExplicitTarget(source) || source;
38730
- return {
38731
- target,
38732
- implementation: () => navBack({ fallback: argument }),
38733
- };
38734
- });
38735
-
38736
- registerNaviCommand(
38737
- "--navi-toggle",
38738
- (source, event, { anchor, value } = {}) => {
38739
- const target =
38740
- resolveExplicitTarget(source) || resolveClosestExpandable(source);
38741
- if (!target) {
38742
- return undefined;
38743
- }
38744
- return {
38745
- target,
38746
- implementation: () => {
38747
- const isExpanded = target.getAttribute("aria-expanded") === "true";
38748
- const customEventName = isExpanded
38749
- ? "navi_request_close"
38750
- : "navi_request_open";
38751
- return dispatchCustomEvent(target, customEventName, {
38752
- event,
38753
- source: resolveCommandProxySource(source),
38754
- anchor,
38755
- // Same as --navi-open below: the half of the toggle that opens says
38756
- // what it opens ON, and the half that closes carries it too so the
38757
- // popup never has to ask which half it just heard.
38758
- value:
38759
- value === undefined ? resolveCommandValue(source, event) : value,
38760
- });
38761
- },
38762
- };
38763
- },
38764
- );
38765
- // A popup that edits opens ON something, and the press is the only place that
38766
- // knows which one. It says it with `value` — "what this is about", as
38767
- // everywhere else — rather than with an argument, which says what the command
38768
- // DOES: "open" is already a complete instruction, unlike --navi-go-to-slide.
38769
- // <Button value={radar.id} command="--navi-open" commandfor="radar-dialog">
38770
- // The popup is told before it opens — see Dialog/Popover's `onOpen`.
38771
- registerNaviCommand("--navi-open", (source, event, { anchor, value } = {}) => {
38772
- const target =
38773
- resolveExplicitTarget(source) || resolveClosestExpandable(source);
38774
- if (!target) {
38775
- return undefined;
38776
- }
38777
- return {
38778
- target,
38779
- implementation: () => {
38780
- // The popup reads `anchor` first and falls back to the source (see
38781
- // onnavi_request_open in popover.jsx): without an `anchor`, the element
38782
- // asking is the element opened against.
38783
- return dispatchCustomEvent(target, "navi_request_open", {
38784
- event,
38785
- source: resolveCommandProxySource(source),
38786
- anchor,
38787
- value: value === undefined ? resolveCommandValue(source, event) : value,
38788
- });
38789
- },
38790
- };
38791
- });
38792
- /**
38793
- * Tells the popup a source opens that a press on it has started, ahead of the
38794
- * release that runs the command: what the opening will read can be asked for
38795
- * now (see open_controller.js). A press rather than a hover, because what
38796
- * that reads is keyed by the popup — one sheet per card — and a pointer
38797
- * crossing a list of cards has chosen none of them; on a touch screen the
38798
- * press is the first thing the finger says anyway.
38799
- *
38800
- * The target is found the way --navi-open finds it, but silently: nothing
38801
- * runs here, and a source with no target says so at the release.
38802
- */
38803
- const announceOpeningPress = (source, command) => {
38804
- if (command !== "--navi-open" && command !== "--navi-toggle") {
38805
- return;
38806
- }
38807
- const commandFor = source.getAttribute("commandfor");
38808
- const target = commandFor
38809
- ? document.getElementById(commandFor)
38810
- : resolveExplicitTarget(source) || resolveClosestExpandable(source);
38811
- if (!target) {
38812
- return;
38813
- }
38814
- dispatchCustomEvent(target, "navi_open_press");
38815
- };
38816
- // "--navi-close:all" closes every expandable above the source, nearest first —
38817
- // a link leaving from a badge shown over a sheet leaves both. A surface that
38818
- // refuses (a form asking about its changes) keeps what is above it open too:
38819
- // one cannot be out of the sheet while still in the badge.
38820
- registerNaviCommand("--navi-close", (source, event, { argument }) => {
38821
- const target =
38822
- resolveExplicitTarget(source) || resolveClosestExpandable(source);
38823
- if (!target) {
38824
- return undefined;
38825
- }
38826
- return {
38827
- target,
38828
- implementation: () => {
38829
- const detail = { event, source: resolveCommandProxySource(source) };
38830
- if (argument === "all") {
38831
- return requestCloseUpward(target, detail);
38832
- }
38833
- return dispatchCustomEvent(target, "navi_request_close", detail);
38834
- },
38835
- };
38836
- });
38837
- const requestCloseUpward = (target, detail) => {
38838
- let expandable = target;
38839
- while (expandable) {
38840
- const closing = dispatchCustomEvent(
38841
- expandable,
38842
- "navi_request_close",
38843
- detail,
38844
- );
38845
- if (!closing) {
38846
- return false;
38847
- }
38848
- const parent = expandable.parentElement;
38849
- expandable = parent ? parent.closest("[aria-expanded]") : null;
38850
- }
38851
- return true;
38852
- };
38853
- registerNaviCommand("--navi-cancel", (source, event) => {
38854
- const target =
38855
- resolveExplicitTarget(source) || resolveClosestExpandable(source);
38856
- if (!target) {
38857
- return undefined;
38858
- }
38859
- return {
38860
- target,
38861
- implementation: () => {
38862
- return dispatchCustomEvent(target, "navi_request_close", {
38863
- event,
38864
- source: resolveCommandProxySource(source),
38865
- isCancel: true,
38866
- });
38867
- },
38868
- };
38869
- });
38870
- // "Yes, do it" — said inside the popup of a confirm picker (see
38871
- // picker/picker_confirm.jsx), by its own yes button or by whatever a caller put
38872
- // there instead. Its counterpart is --navi-cancel; the difference between the
38873
- // two is this event, since both then close the popup, and a popup that closes
38874
- // without having said this is the answer "no".
38875
- registerNaviCommand("--navi-confirm", (source, event) => {
38876
- const target =
38877
- resolveExplicitTarget(source) || resolveClosestExpandable(source);
38878
- if (!target) {
38879
- return undefined;
38880
- }
38881
- return {
38882
- target,
38883
- implementation: () => {
38884
- const commandSource = resolveCommandProxySource(source);
38885
- dispatchCustomEvent(target, "navi_request_confirm", {
38886
- event,
38887
- source: commandSource,
38888
- });
38889
- return dispatchCustomEvent(target, "navi_request_close", {
38890
- event,
38891
- source: commandSource,
38892
- });
38893
- },
38894
- };
38895
- });
38896
- registerNaviCommand("--navi-define", (source, event) => {
38897
- const target =
38898
- resolveExplicitTarget(source) || resolveClosestExpandable(source);
38899
- if (!target) {
38900
- return undefined;
38901
- }
38902
- return {
38903
- target,
38904
- implementation: () => executeNaviDefine(source, event, target),
38905
- };
38906
- });
38907
- // A send whose target is the popup itself: what is around the source IS the
38908
- // popup's answer, and a picker runs its action with that answer as it closes
38909
- // (see onRequestClose in picker_custom.jsx) — dispatching the action here too
38910
- // would run it twice. `isSend` tells this close from a dismissal (the cross,
38911
- // a press outside, Escape): the person pressed a choice, which a picker may
38912
- // act on even when the choice is the one it already held.
38913
- const executeNaviDefine = (source, event, target) => {
38914
- return dispatchCustomEvent(target, "navi_request_close", {
38915
- event,
38916
- source: resolveCommandProxySource(source),
38917
- isSend: true,
38918
- });
38919
- };
38920
-
38921
- registerNaviCommand("--navi-scroll", (source, event) => {
38922
- const target =
38923
- resolveExplicitTarget(source) || resolveFirstParentControl(source);
38924
- if (!target) {
38925
- return undefined;
38926
- }
38927
- return {
38928
- target,
38929
- implementation: () => {
38930
- return dispatchCustomEvent(target, "navi_request_scroll", {
38931
- event,
38932
- id: resolveCommandValue(source, event),
38933
- });
38934
- },
38935
- };
38936
- });
38937
- // The press a hand is still making, given to something that can be carried: a
38938
- // hold opened a popup, and what the popup holds is to follow that same finger
38939
- // without it lifting and pressing again. `event` is what the press set off (the
38940
- // popup's open event), and the press is read from its chain. Run once the popup
38941
- // has settled, which the caller cannot see: the carry measures its element and
38942
- // puts a copy in the top layer, neither of which holds while a transition is
38943
- // still moving the box (see afterPopupLift). What the carry then is — its
38944
- // outcomes, its refusal — is the element's (see interaction_drag.js).
38945
- registerNaviCommand("--navi-grab", (source, event) => {
38946
- const target =
38947
- resolveExplicitTarget(source) || source.closest("[data-drag-source]");
38948
- if (!target) {
38949
- return undefined;
38950
- }
38951
- return {
38952
- target,
38953
- implementation: () => {
38954
- afterPopupLift(() => {
38955
- // Taken away with the popup before it settled.
38956
- if (!target.isConnected) {
38957
- return;
38958
- }
38959
- dispatchCustomEvent(target, "navi_request_grab", { event });
38960
- });
38961
- return true;
38962
- },
38963
- };
38964
- });
38965
- registerNaviCommand("--navi-check", (source, event) => {
38966
- const target =
38967
- resolveExplicitTarget(source) || resolveFirstParentControl(source);
38968
- if (!target) {
38969
- return undefined;
38970
- }
38971
- return {
38972
- target,
38973
- implementation: () => {
38974
- return dispatchCustomEvent(target, "navi_request_check", {
38975
- event,
38976
- });
38977
- },
38978
- };
38979
- });
38980
- registerNaviCommand("--navi-uncheck", (source, event) => {
38981
- const target =
38982
- resolveExplicitTarget(source) || resolveFirstParentControl(source);
38983
- if (!target) {
38984
- return undefined;
38985
- }
38986
- return {
38987
- target,
38988
- implementation: () => {
38989
- return dispatchCustomEvent(target, "navi_request_uncheck", {
38990
- event,
38991
- });
38992
- },
38993
- };
38994
- });
38995
- registerNaviCommand("--navi-select", (source, event) => {
38996
- const target =
38997
- resolveExplicitTarget(source) || resolveFirstParentControl(source);
38998
- if (!target) {
38999
- return undefined;
39000
- }
39001
- return {
39002
- target,
39003
- implementation: () => {
39004
- return dispatchCustomEvent(target, "navi_request_select", {
39005
- event,
39006
- id: resolveCommandValue(source, event),
39007
- });
39008
- },
39009
- };
39010
- });
39011
- registerNaviCommand("--navi-unselect", (source, event) => {
39012
- const target =
39013
- resolveExplicitTarget(source) || resolveFirstParentControl(source);
39014
- if (!target) {
39015
- return undefined;
39016
- }
39017
- return {
39018
- target,
39019
- implementation: () => {
39020
- return dispatchCustomEvent(target, "navi_request_unselect", {
39021
- event,
39022
- id: resolveCommandValue(source, event),
39023
- });
39024
- },
39025
- };
39026
- });
39027
-
39028
- /**
39029
- * Runs `fn` and commits whatever it re-renders before returning, instead of
39030
- * letting Preact batch it into the next microtask. Layout effects of what gets
39031
- * mounted run inside the call too, exactly as they would on any other commit.
39032
- *
39033
- * For the caller that has to read the DOM it just asked for — measuring an
39034
- * element whose content it mounts in the same breath — and cannot wait a tick
39035
- * to do it, because what comes after is a browser event still in flight
39036
- * (preventDefault, focus placement) that no longer accepts being answered late.
39037
- *
39038
- * `options.debounceRendering` is Preact's own hook for deciding *when* the
39039
- * render queue drains; swapping it for "right now" for the duration of the call
39040
- * is exactly how preact/compat implements React's flushSync. Reserve it for the
39041
- * case above: rendering synchronously in the middle of an event gives up the
39042
- * batching that makes several state changes one commit.
39043
- */
39044
- const flushSyncRendering = (fn) => {
39045
- const debounceRenderingPrevious = options.debounceRendering;
39046
- options.debounceRendering = (drainRenderQueue) => {
39047
- drainRenderQueue();
39048
- };
39049
- try {
39050
- fn();
39051
- } finally {
39052
- options.debounceRendering = debounceRenderingPrevious;
39053
- }
39054
- };
39055
-
39056
- /**
39057
- * Small, renderer-agnostic helpers shared by Popover and Dialog's own custom
39058
- * (non-top-layer) renderers — operate on a plain DOM element, no knowledge
39059
- * of which of the two owns it.
39060
- */
39061
-
39062
-
39063
- /**
39064
- * Whether a visibleRectEffect delivery is one that can have taken height away
39065
- * from a popup, and so pushed whatever holds focus out of sight:
39066
- * - "resize": the window/visual viewport settled — which is also how the
39067
- * on-screen keyboard arrives, overlay or not (window_size.js);
39068
- * - ELEMENT_SIZE_CHANGE: the popup's own box measured different;
39069
- * - "focusin": the focus-settled re-measure, for the room that changes with
39070
- * nothing announcing it (subscribeFocusSettled in window_size.js).
39071
- *
39072
- * Everything else is a scroll of one kind or another, where nothing got
39073
- * smaller and scrolling the focused element back would fight the very gesture
39074
- * that fired it.
39075
- */
39076
- const mayHaveHiddenFocus = (event) => {
39077
- const type = event?.type;
39078
- return (
39079
- type === "resize" || type === ELEMENT_SIZE_CHANGE || type === "focusin"
39080
- );
39081
- };
39082
-
39083
- const scrollportHeightMap = new WeakMap();
39084
- /**
39085
- * Scrolls whatever holds focus inside `popupEl` back into view, if the popup
39086
- * getting shorter has pushed it out.
39087
- *
39088
- * The case this exists for: a field low in the scrolling body of a popup that
39089
- * also has a footer (box.jsx — with a body, the body is the only thing that
39090
- * scrolls and the footer is a sibling sitting right under it). Focusing the
39091
- * field makes the browser scroll it into view, which it does against the
39092
- * popup's height AT THAT MOMENT; the on-screen keyboard then opens and takes
39093
- * that height away. The body shrinks, its scrollTop does not move, so the
39094
- * content slides down relative to the shorter scrollport and the field ends up
39095
- * past its bottom edge — visually, swallowed by the footer. The browser does
39096
- * not redo a scroll-into-view it already answered, so this does.
39097
- *
39098
- * Only what the shrink itself hid, though: with the keyboard up the user reads
39099
- * the rest of the popup by scrolling the field away — to reach the submit under
39100
- * it, typically — and the room keeps changing while they do (a keyboard settling
39101
- * in two steps, a suggestion strip, a browser bar). Answering each of those by
39102
- * scrolling the field back takes the popup away from wherever they had just
39103
- * scrolled it, over and over: what they were reading cannot be reached at all
39104
- * without blurring the field first, and the popup reads as unscrollable. So the
39105
- * field is brought back only when it was in view before the room shrank, which
39106
- * one remembered number answers: a resize moves neither scrollTop nor the
39107
- * element's offset inside the scrolled content, so measuring against the height
39108
- * the scrollport HAD is measuring the state before the change.
39109
- *
39110
- * Scoped to the field's own scroll container (never the page): a popup traps
39111
- * scrolling precisely so the document underneath cannot move, and a plain
39112
- * scrollIntoView walks past a container whose scrollbar isn't visible — see
39113
- * scrollIntoViewScoped's own doc.
39114
- *
39115
- * "nearest": the smallest scroll that makes it visible, and none at all when it
39116
- * already is. Where it lands is the container's own business — a navi scroller
39117
- * keeps a band free at its edges so a field never comes back glued to one (see
39118
- * scroll-padding in box.jsx).
39119
- */
39120
- const keepFocusedElementVisible = (popupEl) => {
39121
- const { activeElement } = document;
39122
- if (!activeElement || activeElement === popupEl) {
39123
- return;
39124
- }
39125
- if (!popupEl.contains(activeElement)) {
39126
- return;
39127
- }
39128
- const scrollContainer = getScrollContainer(activeElement);
39129
- if (!scrollContainer || !popupEl.contains(scrollContainer)) {
39130
- // What scrolls the field is outside the popup, which means the page: a
39131
- // popup holds it still on purpose (trapScrollInside), so there is nothing
39132
- // here to scroll back.
39133
- return;
39134
- }
39135
- const scrollportHeight = scrollContainer.clientHeight;
39136
- const scrollportHeightBefore = scrollportHeightMap.get(scrollContainer);
39137
- scrollportHeightMap.set(scrollContainer, scrollportHeight);
39138
- if (scrollportHeightBefore !== undefined) {
39139
- if (scrollportHeight >= scrollportHeightBefore) {
39140
- // Nothing was taken away, so nothing was hidden by this.
39141
- return;
39142
- }
39143
- if (
39144
- !isVisibleInScrollport(
39145
- activeElement,
39146
- scrollContainer,
39147
- scrollportHeightBefore,
39148
- )
39149
- ) {
39150
- return;
39151
- }
39152
- }
39153
- scrollIntoViewScoped(activeElement, {
39154
- container: scrollContainer,
39155
- block: "nearest",
39156
- });
39157
- };
39158
-
39159
- // Whether any part of `el` was showing in `container` back when its scrollport
39160
- // was `scrollportHeight` tall. Both boxes are read now: the container may have
39161
- // moved as well as shrunk (a centered dialog re-centers itself), and the
39162
- // difference between the two tops is what that move leaves alone.
39163
- const isVisibleInScrollport = (el, container, scrollportHeight) => {
39164
- const elRect = el.getBoundingClientRect();
39165
- const containerRect = container.getBoundingClientRect();
39166
- const elTop = elRect.top - containerRect.top + container.scrollTop;
39167
- const elBottom = elTop + elRect.height;
39168
- const scrollportTop = container.scrollTop;
39169
- const scrollportBottom = scrollportTop + scrollportHeight;
39170
- return elBottom > scrollportTop && elTop < scrollportBottom;
39171
- };
39172
-
39173
- /**
39174
- * Calls `onSettled` once `el`'s current CSS transition is over — via
39175
- * `transitionend`, with a safety `setTimeout` fallback matching the longest
39176
- * `transition-duration`, in case nothing actually transitions or an event is
39177
- * missed.
39178
- *
39179
- * Returns a "cancel" function, so a caller whose instance has been superseded
39180
- * (a fresh open/close about to set its own state) can keep this stale one from
39181
- * firing later. Cancelling only stops `onSettled`: undoing whatever the caller
39182
- * did up front is that fresh call's business, not this one's.
39183
- */
39184
- const whenTransitionSettles = (el, onSettled) => {
39185
- let settled = false;
39186
- const onTransitionEnd = (transitionEvent) => {
39187
- if (transitionEvent.target === el) {
39188
- finish();
39189
- }
39190
- };
39191
- const stopWatching = () => {
39192
- settled = true;
39193
- el.removeEventListener("transitionend", onTransitionEnd);
39194
- clearTimeout(safetyTimeoutId);
39195
- };
39196
- const finish = () => {
39197
- if (settled) {
39198
- return;
39199
- }
39200
- stopWatching();
39201
- onSettled();
39202
- };
39203
- el.addEventListener("transitionend", onTransitionEnd);
39204
- const durationsInSeconds = getComputedStyle(el)
39205
- .transitionDuration.split(",")
39206
- .map((value) => parseFloat(value) || 0);
39207
- const longestDurationMs = Math.max(0, ...durationsInSeconds) * 1000;
39208
- const safetyTimeoutId = setTimeout(finish, longestDurationMs + 50);
39209
- return () => {
39210
- if (settled) {
39211
- return;
39212
- }
39213
- stopWatching();
39214
- };
39215
- };
39216
-
39217
- /**
39218
- * Drops the document's text selection when it lives inside `el`, leaving a
39219
- * selection made elsewhere on the page alone.
39220
- *
39221
- * A popup being closed takes its content with it, and a selection is a claim
39222
- * on content the user can still act on: kept, it would outlive the surface it
39223
- * was made on — painted on the box for the length of its exit transition, with
39224
- * the handles and the copy toolbar a phone draws from the live selection
39225
- * hanging over something that is going away — and reappear with the box on
39226
- * the next opening. Dropping the selection is the only way to remove that
39227
- * chrome: a `user-select: none` on the closing box hides the highlight in some
39228
- * browsers only, and the handles are the browser's own, drawn from the
39229
- * selection object rather than from any style.
39230
- */
39231
- const clearTextSelectionInside = (el) => {
39232
- const selection = window.getSelection();
39233
- if (!selection || selection.rangeCount === 0) {
39234
- return;
39235
- }
39236
- const range = selection.getRangeAt(0);
39237
- if (!el.contains(range.commonAncestorContainer)) {
39238
- return;
39239
- }
39240
- selection.removeAllRanges();
39241
- };
39242
-
39243
- /**
39244
- * Disables pointer-events on `el` until its current CSS transition settles —
39245
- * avoids the cursor changing/something becoming clickable while the popup is
39246
- * still visually moving into or out of place.
39247
- *
39248
- * Returns whenTransitionSettles' own "cancel" function: it doesn't restore
39249
- * pointer-events, since a fresh call for the next open/close is about to set
39250
- * its own state.
39251
- */
39252
- const suppressPointerEventsDuringTransition = (el) => {
39253
- el.style.pointerEvents = "none";
39254
- return whenTransitionSettles(el, () => {
39255
- el.style.pointerEvents = "";
39256
- });
39257
- };
39258
-
39259
- /**
39260
- * Hides the backdrop, deferring until the browser's matching "click" fires
39261
- * when `closeEvent` was triggered by a mousedown (see popover.jsx's top
39262
- * comment for why) — same capture-phase-on-document pattern as
39263
- * armSuppressNextOpenRequest in open_controller.js, which a plain timeout
39264
- * can't safely replace: mouseup (and the click that follows it) can land an
39265
- * arbitrarily long time after mousedown (the user is still holding the
39266
- * button down), so a short timeout can fire first and hide the backdrop
39267
- * before its own click ever arrives. A capture-phase listener on document
39268
- * fires for every click regardless of what any bubble-phase handler does
39269
- * downstream, so no fallback timer is needed.
39270
- *
39271
- * `hide` is the caller's own way to actually hide the backdrop
39272
- * (`hidePopover()` for a top-layer backdrop, a plain `style.display = "none"`
39273
- * for a plain div) — this helper only owns the mousedown/click timing.
39274
- *
39275
- * Returns a disarm function (or undefined if hidden immediately), so a
39276
- * fresh open can cancel a pending hide it's about to make redundant.
39277
- */
39278
- const armPointerDownOutsideClose = (closeEvent, hide) => {
39279
- const mousedownEvent = findEvent(closeEvent, "mousedown");
39280
- if (!mousedownEvent) {
39281
- hide();
39282
- return undefined;
39283
- }
39284
- const onClick = () => {
39285
- document.removeEventListener("click", onClick, { capture: true });
39286
- hide();
39287
- };
39288
- document.addEventListener("click", onClick, { capture: true });
39289
- return () => {
39290
- document.removeEventListener("click", onClick, { capture: true });
39291
- };
39292
- };
39293
-
39294
- /**
39295
- * Maps a positionArea y/x pair to a concrete `navi-animation` value (a
39296
- * `prefix` plus a direction word), or `null` if both axes overlap the anchor
39297
- * (no direction at all — that's `resolvedAnimationKind === "scaling"`
39298
- * territory instead, see resolveAutoAnimationKind below).
39299
- *
39300
- * `prefix: "slide-from"` (used with no real anchor — Dialog always, Popover
39301
- * when docked) keeps the word as the compass direction the popup comes
39302
- * from: placed "top" (a point/corner), it slides in from the top.
39303
- * `prefix: "expand"` (a real anchor, Popover-only) uses the motion/growth
39304
- * direction instead, the opposite compass point: placed "top" of the
39305
- * anchor, it moves/grows up, away from the anchor (which sits below it).
39306
- *
39307
- * "inset-*"/"center" contribute no direction on their axis either way.
39308
- */
39309
- const resolveDirectionValue = (y, x, { prefix }) => {
39310
- const yWord =
39311
- y === "top"
39312
- ? prefix === "expand"
39313
- ? "up"
39314
- : "top"
39315
- : y === "bottom"
39316
- ? prefix === "expand"
39317
- ? "down"
39318
- : "bottom"
39319
- : null;
39320
- const xWord = x === "left" ? "left" : x === "right" ? "right" : null;
39321
- if (!yWord && !xWord) {
39322
- return null;
39323
- }
39324
- return yWord && xWord
39325
- ? `${prefix}-${yWord}-${xWord}`
39326
- : `${prefix}-${yWord || xWord}`;
39327
- };
39328
-
39329
- /**
39330
- * Shared `animation="auto"`/`true` resolution: "scaling" reads best overall
39331
- * — picked for any real anchor, or for a point/corner placed dead-center
39332
- * (both positionArea axes overlapping — there's no sensible direction to
39333
- * slide from in that case). "sliding" otherwise. `anchor` is `undefined`
39334
- * for any no-anchor/docked case (Dialog always, Popover's own custom
39335
- * renderer when there's no real anchor), so this collapses to "scaling"
39336
- * there only for the dead-center case, "sliding" otherwise. The two
39337
- * "overlapping" booleans below describe the *positionArea* itself (a bare
39338
- * word vs. "inset-"/"center"), not anything about the anchor — they'd
39339
- * mean exactly the same thing even with no anchor at all, since it's the
39340
- * position strategy, not the anchor, that decides whether there's a
39341
- * direction to slide from.
39342
- */
39343
- const resolveAutoAnimationKind = (anchor, parsedPositionArea) => {
39344
- const yIsOverlapping =
39345
- parsedPositionArea.y !== "top" && parsedPositionArea.y !== "bottom";
39346
- const xIsOverlapping =
39347
- parsedPositionArea.x !== "left" && parsedPositionArea.x !== "right";
39348
- return anchor || (yIsOverlapping && xIsOverlapping) ? "scaling" : "sliding";
39349
- };
39350
-
39351
- /**
39352
- * An opening that reaches a popup with no element of its own to show.
39353
- *
39354
- * The one cause seen so far, and it is silent: something inside the popup
39355
- * suspended while its content was being built, and the `<Loading>` that caught
39356
- * it sits ABOVE the popup. A boundary suspending on an update keeps the page as
39357
- * it was, as a copy, so what is opened here belongs to a copy nobody owns any
39358
- * more — and once the data arrives the subtree is rebuilt from scratch, closed
39359
- * and empty. Nothing is thrown, nothing is drawn, and the press is lost.
39360
- */
39361
- const warnPopupHasNoElementToOpen = (popupKind) => {
39362
- console.warn(
39363
- `[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 }).`,
39364
- );
39365
- };
39366
-
39367
- /**
39368
- * A popup reads "outside" from its own border box: what a press lands on
39369
- * decides nothing on its own (a genuine backdrop press and a press on the
39370
- * popup's padding both report the popup element as their target, there being
39371
- * no real ::backdrop node to be one), so the rectangle is what tells them
39372
- * apart. That holds as long as the box and what the popup paints are the same
39373
- * thing — which stops being true for a popup with no surface of its own
39374
- * (`backgroundColor="transparent"`, no shadow, no padding): what the eye reads
39375
- * as backdrop is then inside the box, and a press there stays a press on the
39376
- * popup.
39377
- *
39378
- * `data-navi-popup-outside` is how a caller says which of its own boxes are
39379
- * not the surface. It is opt-in because navi cannot infer it — a background
39380
- * can come from anywhere — while the caller who made the popup see-through
39381
- * knows exactly which box is decoration and which is paper.
39382
- *
39383
- * The marker answers for the element it is on, never for its descendants: a
39384
- * box painted inside a marked one is still surface, so a row can be marked and
39385
- * its empty halves read as backdrop while presses on the controls it holds do
39386
- * not dismiss anything. A descendant with `pointer-events: none` never becomes
39387
- * a press target at all, so the marked box answers in its place — while the
39388
- * same declaration on the marked box itself takes it out of hit-testing and
39389
- * makes the marker unreachable (see warnAboutUnreachableOutsideRegions).
39390
- */
39391
- 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
+ });
39392
39613
 
39393
- /**
39394
- * `data-navi-popup-inside` is the other direction: a box of the PAGE the
39395
- * caller says belongs to a popup, so a press on it is not outside that popup
39396
- * even though it lies beyond the border box — a card whose press is what
39397
- * fills the one panel of a board. Its value names the popup by `id`, the way
39398
- * `commandfor` does (several ids, space separated), because the exemption is
39399
- * one popup's: a menu open beside the board still closes on the same press.
39400
- *
39401
- * Unlike the outside marker it answers for its whole subtree: the outside
39402
- * marker carves free space out of a surface whose controls stay surface, this
39403
- * one adds a whole thing — the card, its count, its buttons — to the popup's
39404
- * own ground, and nothing in it is a dismissal.
39405
- */
39406
- 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
+ });
39407
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
+ });
39408
39682
  /**
39409
- * What a press landing on a region the caller declared as not-its-surface
39410
- * 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.
39411
39689
  *
39412
- * Lives on the popup's own content element rather than in the backdrop's
39413
- * handler, for both renderers: the backdrop is a sibling behind the popup
39414
- * (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.
39415
39692
  */
39416
- const handlePressOnOutsideRegion = (
39417
- mouseDownEvent,
39418
- { popupEl, openController, pressOutside },
39419
- ) => {
39420
- if (mouseDownEvent.button !== 0) {
39693
+ const announceOpeningPress = (source, command) => {
39694
+ if (command !== "--navi-open" && command !== "--navi-toggle") {
39421
39695
  return;
39422
39696
  }
39423
- const { target } = mouseDownEvent;
39424
- 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) {
39425
39702
  return;
39426
39703
  }
39427
- if (openedDuringThisPress(openController)) {
39428
- // The release of the press that opened it is not somebody dismissing it
39429
- // (see openedDuringThisPress).
39430
- 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;
39431
39715
  }
39432
- // A popup opens inside its opener's own subtree, so a press on a region
39433
- // belonging to a popup nested in this one bubbles through here too — and it
39434
- // is a press on what is in front, not on this popup's own decoration.
39435
- if (
39436
- target.closest(`[navi-control="dialog"], [navi-control="popover"]`) !==
39437
- popupEl
39438
- ) {
39439
- 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;
39440
39740
  }
39441
- if (pressOutside === "capture") {
39442
- mouseDownEvent.preventDefault();
39443
- 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;
39444
39748
  }
39445
- if (pressOutside === "close" || pressOutside === "cancel") {
39446
- openController.requestClose(mouseDownEvent, {
39447
- isCancel: pressOutside === "cancel",
39448
- });
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;
39449
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
+ });
39450
39809
  };
39451
39810
 
39452
- /**
39453
- * `pointer-events: none` and `inert` are the two things reached for to say
39454
- * "this part of my box is not really there", and neither reaches the popup:
39455
- * inert speaks to the keyboard and to assistive technology, and
39456
- * pointer-events takes the box out of hit-testing so the press is answered by
39457
- * an ancestor that is still the popup's own surface. Writing them next to the
39458
- * marker is the natural mistake, and it makes the marker silently unreachable
39459
- * — so it is said out loud, once per open.
39460
- */
39461
- const warnAboutUnreachableOutsideRegions = (popupEl) => {
39462
- for (const regionEl of popupEl.querySelectorAll(
39463
- `[${OUTSIDE_REGION_ATTRIBUTE}]`,
39464
- )) {
39465
- if (getComputedStyle(regionEl).pointerEvents === "none") {
39466
- console.warn(
39467
- `[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.`,
39468
- regionEl,
39469
- );
39470
- }
39811
+ registerNaviCommand("--navi-scroll", (source, event) => {
39812
+ const target =
39813
+ resolveExplicitTarget(source) || resolveFirstParentControl(source);
39814
+ if (!target) {
39815
+ return undefined;
39471
39816
  }
39472
- };
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
+ });
39473
39917
 
39474
39918
  /**
39475
- * Hears an outside press from the document itself, for a popup with no
39476
- * backdrop of its own to catch one: a modal `<dialog>` (whose native
39477
- * `::backdrop` dispatches nothing a handler can be put on) and any popup the
39478
- * caller asked to leave the page reachable (`backdrop={false}`).
39479
- *
39480
- * The press is read and never taken: no `preventDefault`, no
39481
- * `stopPropagation`, and the listener is on `document` rather than on
39482
- * anything covering the page — so what the press landed on hears it too, in
39483
- * the same gesture, and the popup only takes note. That is the whole point of
39484
- * a popup with no wall: a press outside is a press on the page, and the page
39485
- * answers it. The one popup where that cannot hold is the modal dialog, where
39486
- * the browser has already made everything behind inert before this runs.
39487
- *
39488
- * `pressEventType` says which event IS the press, and the two callers want
39489
- * opposite ones:
39490
- *
39491
- * - `"pointerdown"` for a popup with no wall: a page with no wall over it is
39492
- * a page whose own elements arbitrate their presses, and cancelling a
39493
- * `pointerdown` — what a drag source and a control keeping the focus where
39494
- * it is both do — suppresses every compatibility mouse event that would have
39495
- * followed it. The press happens, reaches its target, and no `mousedown` is
39496
- * ever dispatched for it. Reading the pointer event is reading the press
39497
- * itself, whatever anything does with it afterwards, and it is the same
39498
- * event the drag and surface detectors read.
39499
- * - `"mousedown"` for the modal dialog, whose native `::backdrop` exists to
39500
- * spend the press. The page's controls act on `mousedown`, and on a touch
39501
- * screen that is not the same moment as `pointerdown`: the compatibility
39502
- * mouse events are synthesized at `touchend` and hit-tested again then.
39503
- * Closing on `pointerdown` takes the `::backdrop` away before that second
39504
- * hit-test, so the `mousedown` lands on whatever the page holds under the
39505
- * finger, and one tap both dismisses the dialog and presses the page. Read
39506
- * on `mousedown`, the wall is still up when the press the page acts on is
39507
- * aimed, and it catches it. Nothing behind a modal wall can cancel that
39508
- * 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.
39509
39922
  *
39510
- * Capture phase, so a handler downstream that stops propagation cannot keep
39511
- * 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.
39512
39927
  *
39513
- * 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.
39514
39933
  */
39515
- const armOutsidePressClose = (
39516
- popupEl,
39517
- { openController, pressOutside, pressEventType = "pointerdown" },
39518
- ) => {
39519
- const onDocumentPointerDown = (pointerDownEvent) => {
39520
- if (pointerDownEvent.button !== 0) {
39521
- return;
39522
- }
39523
- if (openedDuringThisPress(openController)) {
39524
- // The release of the press that opened it is not somebody dismissing it
39525
- // (see openedDuringThisPress).
39526
- return;
39527
- }
39528
- // The press landed inside another popup: that is a press on what is in
39529
- // front, not outside. Asking the target where it lives rather than asking
39530
- // this popup whether it was pushed — a popup in front does not have to be
39531
- // one this popup knows about. In front means neither inside nor around: a
39532
- // popup nested in this one falls to the containment check below as the
39533
- // inside press it is, and one this popup opened INSIDE (a bubble rendered
39534
- // in a sheet's content) is around it, so what that popup holds is outside
39535
- // this one exactly like the rest of the page.
39536
- const popupUnderPointer = pointerDownEvent.target.closest?.(
39537
- `[navi-control="dialog"], [navi-control="popover"]`,
39538
- );
39539
- if (
39540
- popupUnderPointer &&
39541
- popupUnderPointer !== popupEl &&
39542
- !popupEl.contains(popupUnderPointer) &&
39543
- !popupUnderPointer.contains(popupEl)
39544
- ) {
39545
- return;
39546
- }
39547
- if (
39548
- popupEl.id &&
39549
- pointerDownEvent.target.closest?.(
39550
- `[${INSIDE_ATTRIBUTE}~="${CSS.escape(popupEl.id)}"]`,
39551
- )
39552
- ) {
39553
- return;
39554
- }
39555
- // Real DOM containment wins over the rectangle below — an element
39556
- // genuinely inside the popup (`overflow: visible`, a negative margin, an
39557
- // absolutely-positioned child) can be painted outside its border box, and
39558
- // a press there is not outside just because its coordinates are. The
39559
- // popup element itself is excluded (contains() answers true for it): a
39560
- // press on a modal dialog's native backdrop reports the dialog as its
39561
- // target, there being no `::backdrop` node to be one, so treating that as
39562
- // contained would keep the rectangle from ever running.
39563
- if (
39564
- pointerDownEvent.target !== popupEl &&
39565
- popupEl.contains(pointerDownEvent.target)
39566
- ) {
39567
- return;
39568
- }
39569
- const rect = popupEl.getBoundingClientRect();
39570
- const isOutside =
39571
- pointerDownEvent.clientX < rect.left ||
39572
- pointerDownEvent.clientX > rect.right ||
39573
- pointerDownEvent.clientY < rect.top ||
39574
- pointerDownEvent.clientY > rect.bottom;
39575
- if (!isOutside) {
39576
- return;
39577
- }
39578
- openController.requestClose(pointerDownEvent, {
39579
- isCancel: pressOutside === "cancel",
39580
- });
39581
- };
39582
- document.addEventListener(pressEventType, onDocumentPointerDown, {
39583
- capture: true,
39584
- });
39585
- return () => {
39586
- document.removeEventListener(pressEventType, onDocumentPointerDown, {
39587
- capture: true,
39588
- });
39934
+ const flushSyncRendering = (fn) => {
39935
+ const debounceRenderingPrevious = options.debounceRendering;
39936
+ options.debounceRendering = (drainRenderQueue) => {
39937
+ drainRenderQueue();
39589
39938
  };
39939
+ try {
39940
+ fn();
39941
+ } finally {
39942
+ options.debounceRendering = debounceRenderingPrevious;
39943
+ }
39590
39944
  };
39591
39945
 
39592
39946
  /**
@@ -66899,6 +67253,13 @@ const css$D = /* css */`
66899
67253
  * is a `Popover`-level answer only for `"close"`/`"cancel"`; `"capture"` has
66900
67254
  * nothing left to absorb without a wall, and `"ignore"` renders no wall anyway.
66901
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.
66902
67263
  * @param {"close"|"cancel"|"capture"|"ignore"} [props.pressOutside="ignore"]
66903
67264
  * - `"ignore"` (default): no backdrop at all, outside presses pass straight
66904
67265
  * through. `"close"` closes the popover on an outside press, `"cancel"`
@@ -67275,6 +67636,7 @@ const usePopoverProps = props => {
67275
67636
  // which is a sibling element (see this file's top comment).
67276
67637
  backdropColor,
67277
67638
  backdropFilter,
67639
+ group,
67278
67640
  scrollCapture,
67279
67641
  focusCapture,
67280
67642
  // "auto" (default) → the popover follows its content. "frozen" → measured
@@ -67927,13 +68289,21 @@ const usePopoverProps = props => {
67927
68289
  }
67928
68290
  if (backdropEl) {
67929
68291
  backdropEl.setAttribute("aria-expanded", "false");
67930
- disarmBackdropHideRef.current = armPointerDownOutsideClose(closeEvent, () => {
68292
+ disarmBackdropHideRef.current = armPointerDownOutsideClose(closeEvent, clickEvent => {
67931
68293
  // Set regardless of isTopLayer — see openEffect's own identical
67932
68294
  // comment for why hidePopover() alone isn't reliably sufficient.
67933
68295
  backdropEl.setAttribute("navi-hidden", "");
67934
68296
  if (isTopLayer) {
67935
68297
  backdropEl.hidePopover();
67936
68298
  }
68299
+ if (group && clickEvent) {
68300
+ handWallClickToPeer(clickEvent, {
68301
+ wallEl: backdropEl,
68302
+ popupEl: popoverEl,
68303
+ anchorEl: anchorElement,
68304
+ group
68305
+ });
68306
+ }
67937
68307
  });
67938
68308
  }
67939
68309
  restoreFocus?.(closeEvent);
@@ -68355,6 +68725,10 @@ const Popup = props => {
68355
68725
  scrollCapture,
68356
68726
  positionAreaFixed,
68357
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,
68358
68732
  // Dialog-only, destructured out for the same reason: forwarded below only
68359
68733
  // in the dialog branch, so they never reach the popover element.
68360
68734
  dockedOnSmallTouchScreen,
@@ -68404,6 +68778,7 @@ const Popup = props => {
68404
68778
  positionAreaWhenAnchorIsInvalid: positionAreaWhenAnchorIsInvalid,
68405
68779
  scrollCapture: scrollCapture === "popover" || scrollCapture,
68406
68780
  positionAreaFixed: positionAreaFixed,
68781
+ group: group,
68407
68782
  className: withPropsClassName("navi_popup", className),
68408
68783
  expand: expand,
68409
68784
  expandX: expandX,
@@ -69321,13 +69696,18 @@ const PickerContentInsidePopup = props => {
69321
69696
  calloutIcon,
69322
69697
  calloutCloseButton,
69323
69698
  calloutCloseByScroll,
69699
+ popupGroup,
69324
69700
  ...rest
69325
69701
  } = props;
69326
69702
  const isPopover = mode === "popover";
69327
69703
  const isCallout = mode === "callout";
69328
69704
  return jsx(Next, {
69329
69705
  "aria-haspopup": isPopover ? "listbox" : "dialog",
69330
- "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,
69331
69711
  ...rest,
69332
69712
  // On popupProps already (see the picker's popup assembly); they mean
69333
69713
  // nothing to the picker element.
@@ -69363,7 +69743,12 @@ const PickerContentInsidePopup = props => {
69363
69743
  status: calloutStatus,
69364
69744
  icon: calloutIcon,
69365
69745
  closeButton: calloutCloseButton,
69366
- 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,
69367
69752
  children: jsx(PopupModeContext.Provider, {
69368
69753
  value: mode,
69369
69754
  children: children
@@ -69400,6 +69785,7 @@ const PickerContentInsidePopup = props => {
69400
69785
  backdropVariant: backdropVariant,
69401
69786
  backdropColor: backdropColor,
69402
69787
  backdropFilter: backdropFilter,
69788
+ group: popupGroup,
69403
69789
  focusCapture: isPopover ? focusCapture : undefined,
69404
69790
  expand: isPopover ? undefined : dialogExpand,
69405
69791
  expandX: isPopover ? undefined : dialogExpandX,
@@ -69459,6 +69845,8 @@ const PickerCalloutPopup = ({
69459
69845
  icon,
69460
69846
  closeButton,
69461
69847
  closeByScroll,
69848
+ backdrop,
69849
+ group,
69462
69850
  onnavi_request_open,
69463
69851
  onnavi_request_close,
69464
69852
  onnavi_request_confirm,
@@ -69500,6 +69888,8 @@ const PickerCalloutPopup = ({
69500
69888
  icon,
69501
69889
  closeButton,
69502
69890
  closeByScroll,
69891
+ backdrop,
69892
+ group,
69503
69893
  anchorElement,
69504
69894
  // The request, chained to the press that made it: the callout reads the
69505
69895
  // mousedown off it to wait for the release before listening for a click
@@ -71666,7 +72056,13 @@ const ListRowContext = createContext(null);
71666
72056
  // row takes its place in the collection by slot: the place is then the list's
71667
72057
  // to move, and the row's to follow — see list_rows.js.
71668
72058
  const ListSlotContext = createContext(null);
71669
- 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 {
71670
72066
  .navi_list_container {
71671
72067
  --list-outline-width: 1px;
71672
72068
  --list-border-radius: 4px;
@@ -72065,6 +72461,7 @@ const css$x = /* css */`@layer navi {
72065
72461
  }
72066
72462
 
72067
72463
  & .navi_list_item_group_list {
72464
+ --list-group-label-height: inherit;
72068
72465
  flex-direction: column;
72069
72466
  width: 100%;
72070
72467
  margin: 0;
@@ -72072,8 +72469,12 @@ const css$x = /* css */`@layer navi {
72072
72469
  list-style: none;
72073
72470
  display: flex;
72074
72471
 
72472
+ & > .navi_list_item {
72473
+ --list-group-label-height: inherit;
72474
+ }
72475
+
72075
72476
  & .navi_list_item {
72076
- 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));
72077
72478
  }
72078
72479
  }
72079
72480
 
@@ -79164,6 +79565,7 @@ const PickerFirstResolver = props => {
79164
79565
  * escapeEffect?: "cancel" | "close",
79165
79566
  * pressOutside?: "close" | "cancel" | "capture",
79166
79567
  * backdrop?: boolean,
79568
+ * popupGroup?: string,
79167
79569
  * backdropVariant?: "auto" | "lift" | "discrete" | "invisible",
79168
79570
  * backdropColor?: string,
79169
79571
  * backdropFilter?: string,
@@ -79563,10 +79965,23 @@ const PickerFirstResolver = props => {
79563
79965
  * and the page at all. `false` lets a press outside both close the popup and
79564
79966
  * reach whatever it landed on, in one gesture — for a picker opened over a
79565
79967
  * page that stays as pressable as it looks (a plan, a map, a canvas), where a
79566
- * 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
79567
79969
  * dialog with no wall is shown through the Popover API rather than
79568
79970
  * `showModal()`, so a picker docked as a bottom sheet on a phone still
79569
- * 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.
79570
79985
  * @param {"auto"|"lift"|"discrete"|"invisible"} [backdropVariant="auto"] How
79571
79986
  * visible the popup's backdrop is, independently of what a click outside
79572
79987
  * does: `"auto"` is the paint `pressOutside` implies,