@jsenv/navi 0.29.464 → 0.29.466

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, findEvent, mergeOneStyle, findFocusable, getElementSignature, chainEvent, isTouchDrivenEvent, getKeyboardEventDefaultAction, isPressDrivenClick, ELEMENT_SIZE_CHANGE, getScrollContainer, scrollIntoViewScoped, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, createValueEffect, findFocusDelegateTarget, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, getScrollContainerSet, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, keepTouchRefusable, waitForTap, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, dragSourceThatStoodDown, markDragSource, refuseDragTo, startDragTo, installPanZoom, createInternalCustomEvent, activeElementSignal, normalizeStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, trapScrollInside, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, initFocusGroup, stringifyStyle as stringifyStyle$1, isScrollable, resolveOklchLightness, contrastColor, parsePositionArea, snapToPixel, trapFocusInside, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, setVirtualKeyboardOverlaysContent, subscribeVirtualKeyboardGeometryChange, getVirtualKeyboardOverlayHeight } from "@jsenv/dom";
6
+ import { createPubSub, elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, 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, overflowGoesToViewport, parsePositionArea, snapToPixel, trapFocusInside, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, setVirtualKeyboardOverlaysContent, subscribeVirtualKeyboardGeometryChange, getVirtualKeyboardOverlayHeight } from "@jsenv/dom";
7
7
  export { chainEvent, clickIsSuppressed, contrastColor, createDragGestureController, dragAfterIntent, findEvent, markDragSource, startDragTo } from "@jsenv/dom";
8
8
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
9
9
  import { options, isValidElement, createContext, render as render$1, h, toChildArray, createRef, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
@@ -79,7 +79,7 @@ installImportMetaCssBuild(import.meta);
79
79
  * any of these, and a number is the last resort, not the first tool.
80
80
  */
81
81
 
82
- const css$18 = /* css */ `@layer navi {
82
+ const css$19 = /* css */ `@layer navi {
83
83
  :root {
84
84
  --navi-z-index-control-hovered: 1;
85
85
  --navi-z-index-control-focused: 2;
@@ -92,7 +92,7 @@ const css$18 = /* css */ `@layer navi {
92
92
  }
93
93
  }
94
94
  `;
95
- import.meta.css = [css$18, "@jsenv/navi/src/navi_z_indexes.js"];
95
+ import.meta.css = [css$19, "@jsenv/navi/src/navi_z_indexes.js"];
96
96
 
97
97
  /**
98
98
  * A navigation is ABOUT to be applied — said before its very first write.
@@ -446,7 +446,7 @@ installImportMetaCssBuild(import.meta);
446
446
 
447
447
  const URL_TARGET_ATTRIBUTE = "data-url-target";
448
448
 
449
- const css$17 = /* css */ `@layer navi {
449
+ const css$18 = /* css */ `@layer navi {
450
450
  [data-url-target] {
451
451
  animation: navi_url_target var(--navi-url-target-duration, 2s)
452
452
  ease-out;
@@ -464,7 +464,7 @@ const css$17 = /* css */ `@layer navi {
464
464
  }
465
465
  }
466
466
  `;
467
- import.meta.css = [css$17, "@jsenv/navi/src/nav/url_target/url_target.js"];
467
+ import.meta.css = [css$18, "@jsenv/navi/src/nav/url_target/url_target.js"];
468
468
 
469
469
  let urlTargetOptions = {
470
470
  block: "start",
@@ -2105,7 +2105,7 @@ const useActionStatus = (action) => {
2105
2105
  };
2106
2106
 
2107
2107
  installImportMetaCssBuild(import.meta);
2108
- const css$16 = /* css */`.action_error {
2108
+ const css$17 = /* css */`.action_error {
2109
2109
  background: #fdd;
2110
2110
  border: 1px solid red;
2111
2111
  margin-top: 0;
@@ -2129,7 +2129,7 @@ const ActionRenderer = ({
2129
2129
  children,
2130
2130
  disabled
2131
2131
  }) => {
2132
- import.meta.css = [css$16, "@jsenv/navi/src/action/action_renderer.jsx"];
2132
+ import.meta.css = [css$17, "@jsenv/navi/src/action/action_renderer.jsx"];
2133
2133
  if (action === undefined) {
2134
2134
  throw new Error("ActionRenderer requires an action to render, but none was provided.");
2135
2135
  }
@@ -6554,6 +6554,12 @@ const isAimedAtSelfInteractionsBelow = (
6554
6554
  if (!target || typeof target.closest !== "function") {
6555
6555
  return false;
6556
6556
  }
6557
+ if (event.type === "keydown" || event.type === "keyup") {
6558
+ // A key is no press: the claim is the pointer's. An Escape pressed while
6559
+ // the focus rests on the claimer asks the controls around it — the sheet
6560
+ // it sits in closing.
6561
+ return false;
6562
+ }
6557
6563
  const claimer = target.closest(CLICK_SELECTOR);
6558
6564
  if (!claimer) {
6559
6565
  return false;
@@ -15838,6 +15844,48 @@ const createOpenController = (
15838
15844
  // armSuppressNextOpenRequest below.
15839
15845
  let suppressNextOpenRequest = false;
15840
15846
  let disarmSuppressNextOpenRequest = null;
15847
+ // The press a press-close came from, for as long as that press lasts (see
15848
+ // open() for what it is read for).
15849
+ let pressCountAtPressClose = null;
15850
+ let releaseThisPress = null;
15851
+ const holdReopenForThisPress = (closeEvent) => {
15852
+ releaseThisPress?.();
15853
+ pressCountAtPressClose = pressCount;
15854
+ let timeout = null;
15855
+ const release = () => {
15856
+ clearTimeout(timeout);
15857
+ document.removeEventListener("click", onPressEnd, { capture: true });
15858
+ document.removeEventListener("pointercancel", onPressEnd, {
15859
+ capture: true,
15860
+ });
15861
+ document.removeEventListener("pointerdown", release, { capture: true });
15862
+ pressCountAtPressClose = null;
15863
+ releaseThisPress = null;
15864
+ };
15865
+ // Once the task the press ends in is over: every reaction to the closing
15866
+ // press is synchronous to it, while an open asked for later — a command a
15867
+ // button runs once its action resolves — is somebody asking again.
15868
+ const onPressEnd = () => {
15869
+ document.removeEventListener("click", onPressEnd, { capture: true });
15870
+ document.removeEventListener("pointercancel", onPressEnd, {
15871
+ capture: true,
15872
+ });
15873
+ timeout = setTimeout(release);
15874
+ };
15875
+ if (findEvent(closeEvent, "click")) {
15876
+ // Closed by the press's own click: a listener added during its dispatch
15877
+ // would only hear the next one.
15878
+ timeout = setTimeout(release);
15879
+ } else {
15880
+ document.addEventListener("click", onPressEnd, { capture: true });
15881
+ document.addEventListener("pointercancel", onPressEnd, {
15882
+ capture: true,
15883
+ });
15884
+ }
15885
+ // A press whose click was swallowed (a gesture) ends with the next one.
15886
+ document.addEventListener("pointerdown", release, { capture: true });
15887
+ releaseThisPress = release;
15888
+ };
15841
15889
 
15842
15890
  // When the popup closes because of a press (clicking the backdrop, or a
15843
15891
  // press outside a popup with no backdrop), the browser still dispatches the
@@ -15993,6 +16041,7 @@ const createOpenController = (
15993
16041
  `closed by ${pressEvent.type} -> ignore next click`,
15994
16042
  );
15995
16043
  armSuppressNextOpenRequest();
16044
+ holdReopenForThisPress(closeEvent);
15996
16045
  break prevent_reopen;
15997
16046
  }
15998
16047
 
@@ -16077,6 +16126,17 @@ const createOpenController = (
16077
16126
  { opened: false, event: closeEvent },
16078
16127
  );
16079
16128
  };
16129
+ // A cancel an Escape asked for, read with what Escape means for this popup.
16130
+ const readCloseDetail = (e, detail) => {
16131
+ if (
16132
+ detail?.isCancel &&
16133
+ controller.escapeEffect !== "cancel" &&
16134
+ findEvent(e, isEscapeKeydown)
16135
+ ) {
16136
+ return { ...detail, isCancel: false };
16137
+ }
16138
+ return detail;
16139
+ };
16080
16140
  const controller = {
16081
16141
  opened: false,
16082
16142
  // What the DOM currently shows, as opposed to what the controller has
@@ -16108,6 +16168,11 @@ const createOpenController = (
16108
16168
  // whole point: learning it afterwards means the content mounted on the
16109
16169
  // previous subject first.
16110
16170
  onOpen: null,
16171
+ // What an Escape means for this popup, whoever heard the key — its own
16172
+ // shortcut, its trigger's, the group it stands in, a bubble's: a cancel
16173
+ // (what it holds reverts), unless its owner says otherwise (a Picker's
16174
+ // escapeEffect="close"). Read from the close the key asked for.
16175
+ escapeEffect: "cancel",
16111
16176
  // The counterpart, set only when the popup was told to throw its content
16112
16177
  // away on close (`mount="while-opened"`). Called from performClose above.
16113
16178
  unmountContent: null,
@@ -16127,7 +16192,9 @@ const createOpenController = (
16127
16192
  // called once the open/close has fully happened rather than mid-sequence.
16128
16193
  onOpenedChange: null,
16129
16194
  open: (e, detail) => {
16130
- flushChangeAwaitingTransition();
16195
+ // The refusals below read the decision (`opened`), which is written
16196
+ // before a change waiting for its view transition runs: a refused open
16197
+ // must not force that change through and take its picture away.
16131
16198
  if (controller.opened || !controller.openEffect) {
16132
16199
  return;
16133
16200
  }
@@ -16135,6 +16202,20 @@ const createOpenController = (
16135
16202
  suppressNextOpenRequest = false;
16136
16203
  return;
16137
16204
  }
16205
+ if (
16206
+ pressCountAtPressClose === pressCount &&
16207
+ findEvent(e, isEventOfAPress)
16208
+ ) {
16209
+ // The press that closed it, heard again by what opens it. With nothing
16210
+ // between a popup and its own trigger (no wall, or a PopupGroup), the
16211
+ // press is still on its way when the popup closes, and the trigger's
16212
+ // own reaction to that same press asks to open it again: at its
16213
+ // mousedown, before the click the suppression above waits for — or at
16214
+ // the very click that closed it, whose suppression is armed during its
16215
+ // own dispatch and so only hears the next one.
16216
+ return;
16217
+ }
16218
+ flushChangeAwaitingTransition();
16138
16219
  const requestOpenEvent = new CustomEvent("navi_request_open", {
16139
16220
  detail: { event: e, ...detail },
16140
16221
  cancelable: true,
@@ -16261,11 +16342,20 @@ const createOpenController = (
16261
16342
  return true;
16262
16343
  }
16263
16344
  const requestCloseEvent = new CustomEvent("navi_request_close", {
16264
- detail: { event: e, ...detail },
16345
+ detail: { event: e, ...readCloseDetail(e, detail) },
16265
16346
  cancelable: true,
16266
16347
  });
16267
16348
  chainEvent(requestCloseEvent, e);
16268
16349
  closeHandlers?.onRequestClose?.(requestCloseEvent);
16350
+ // An Escape this popup answered, by closing or by refusing: dispatched
16351
+ // on, it would also be the browser's close request for a modal around
16352
+ // the popup (a PopupGroup, a dialog), closing what this popup kept open,
16353
+ // or what is still closing with it (a lifting dialog closes a frame
16354
+ // later).
16355
+ const escapeKeydown = findEvent(requestCloseEvent, isEscapeKeydown);
16356
+ if (escapeKeydown) {
16357
+ escapeKeydown.preventDefault();
16358
+ }
16269
16359
  if (requestCloseEvent.defaultPrevented) {
16270
16360
  // The native <dialog> "cancel" event (Escape key) closes the dialog
16271
16361
  // by default; prevent that default so denial actually keeps it open.
@@ -16284,7 +16374,7 @@ const createOpenController = (
16284
16374
  return;
16285
16375
  }
16286
16376
  const closeEvent = new CustomEvent("navi_close", {
16287
- detail: { event: e, ...detail },
16377
+ detail: { event: e, ...readCloseDetail(e, detail) },
16288
16378
  });
16289
16379
  chainEvent(closeEvent, e);
16290
16380
  // Skips onRequestClose entirely — there is no choice here.
@@ -16294,13 +16384,27 @@ const createOpenController = (
16294
16384
  return controller;
16295
16385
  };
16296
16386
 
16387
+ // A click a keyboard made (detail 0) is not one: nothing pressed for it.
16388
+ const isEscapeKeydown = (event) =>
16389
+ event.type === "keydown" && event.key === "Escape";
16390
+
16391
+ const isEventOfAPress = (event) =>
16392
+ event.type === "pointerdown" ||
16393
+ event.type === "mousedown" ||
16394
+ (event.type === "click" && event.detail > 0);
16395
+
16297
16396
  // Inside a popup that is open right now — the popup being closed, in practice,
16298
16397
  // since that is the one the key press was delivered to.
16299
16398
  const isInsideOpenPopup = (element) => {
16300
16399
  if (!element || element.nodeType !== 1) {
16301
16400
  return false;
16302
16401
  }
16303
- return Boolean(element.closest("dialog[open], [popover]:popover-open"));
16402
+ // An engaged PopupGroup is open too, around peers that are not the popup.
16403
+ return Boolean(
16404
+ element.closest(
16405
+ "dialog[open]:not([navi-popup-group]), [popover]:popover-open",
16406
+ ),
16407
+ );
16304
16408
  };
16305
16409
 
16306
16410
  // What Enter is about to do when it is about to activate something: press the
@@ -16766,572 +16870,6 @@ const useOpenControllerByProps = (props, name) => {
16766
16870
  return openController;
16767
16871
  };
16768
16872
 
16769
- /**
16770
- * Small, renderer-agnostic helpers shared by Popover and Dialog's own custom
16771
- * (non-top-layer) renderers — operate on a plain DOM element, no knowledge
16772
- * of which of the two owns it.
16773
- */
16774
-
16775
-
16776
- /**
16777
- * Whether a visibleRectEffect delivery is one that can have taken height away
16778
- * from a popup, and so pushed whatever holds focus out of sight:
16779
- * - "resize": the window/visual viewport settled — which is also how the
16780
- * on-screen keyboard arrives, overlay or not (window_size.js);
16781
- * - ELEMENT_SIZE_CHANGE: the popup's own box measured different;
16782
- * - "focusin": the focus-settled re-measure, for the room that changes with
16783
- * nothing announcing it (subscribeFocusSettled in window_size.js).
16784
- *
16785
- * Everything else is a scroll of one kind or another, where nothing got
16786
- * smaller and scrolling the focused element back would fight the very gesture
16787
- * that fired it.
16788
- */
16789
- const mayHaveHiddenFocus = (event) => {
16790
- const type = event?.type;
16791
- return (
16792
- type === "resize" || type === ELEMENT_SIZE_CHANGE || type === "focusin"
16793
- );
16794
- };
16795
-
16796
- const scrollportHeightMap = new WeakMap();
16797
- /**
16798
- * Scrolls whatever holds focus inside `popupEl` back into view, if the popup
16799
- * getting shorter has pushed it out.
16800
- *
16801
- * The case this exists for: a field low in the scrolling body of a popup that
16802
- * also has a footer (box.jsx — with a body, the body is the only thing that
16803
- * scrolls and the footer is a sibling sitting right under it). Focusing the
16804
- * field makes the browser scroll it into view, which it does against the
16805
- * popup's height AT THAT MOMENT; the on-screen keyboard then opens and takes
16806
- * that height away. The body shrinks, its scrollTop does not move, so the
16807
- * content slides down relative to the shorter scrollport and the field ends up
16808
- * past its bottom edge — visually, swallowed by the footer. The browser does
16809
- * not redo a scroll-into-view it already answered, so this does.
16810
- *
16811
- * Only what the shrink itself hid, though: with the keyboard up the user reads
16812
- * the rest of the popup by scrolling the field away — to reach the submit under
16813
- * it, typically — and the room keeps changing while they do (a keyboard settling
16814
- * in two steps, a suggestion strip, a browser bar). Answering each of those by
16815
- * scrolling the field back takes the popup away from wherever they had just
16816
- * scrolled it, over and over: what they were reading cannot be reached at all
16817
- * without blurring the field first, and the popup reads as unscrollable. So the
16818
- * field is brought back only when it was in view before the room shrank, which
16819
- * one remembered number answers: a resize moves neither scrollTop nor the
16820
- * element's offset inside the scrolled content, so measuring against the height
16821
- * the scrollport HAD is measuring the state before the change.
16822
- *
16823
- * Scoped to the field's own scroll container (never the page): a popup traps
16824
- * scrolling precisely so the document underneath cannot move, and a plain
16825
- * scrollIntoView walks past a container whose scrollbar isn't visible — see
16826
- * scrollIntoViewScoped's own doc.
16827
- *
16828
- * "nearest": the smallest scroll that makes it visible, and none at all when it
16829
- * already is. Where it lands is the container's own business — a navi scroller
16830
- * keeps a band free at its edges so a field never comes back glued to one (see
16831
- * scroll-padding in box.jsx).
16832
- */
16833
- const keepFocusedElementVisible = (popupEl) => {
16834
- const { activeElement } = document;
16835
- if (!activeElement || activeElement === popupEl) {
16836
- return;
16837
- }
16838
- if (!popupEl.contains(activeElement)) {
16839
- return;
16840
- }
16841
- const scrollContainer = getScrollContainer(activeElement);
16842
- if (!scrollContainer || !popupEl.contains(scrollContainer)) {
16843
- // What scrolls the field is outside the popup, which means the page: a
16844
- // popup holds it still on purpose (trapScrollInside), so there is nothing
16845
- // here to scroll back.
16846
- return;
16847
- }
16848
- const scrollportHeight = scrollContainer.clientHeight;
16849
- const scrollportHeightBefore = scrollportHeightMap.get(scrollContainer);
16850
- scrollportHeightMap.set(scrollContainer, scrollportHeight);
16851
- if (scrollportHeightBefore !== undefined) {
16852
- if (scrollportHeight >= scrollportHeightBefore) {
16853
- // Nothing was taken away, so nothing was hidden by this.
16854
- return;
16855
- }
16856
- if (
16857
- !isVisibleInScrollport(
16858
- activeElement,
16859
- scrollContainer,
16860
- scrollportHeightBefore,
16861
- )
16862
- ) {
16863
- return;
16864
- }
16865
- }
16866
- scrollIntoViewScoped(activeElement, {
16867
- container: scrollContainer,
16868
- block: "nearest",
16869
- });
16870
- };
16871
-
16872
- // Whether any part of `el` was showing in `container` back when its scrollport
16873
- // was `scrollportHeight` tall. Both boxes are read now: the container may have
16874
- // moved as well as shrunk (a centered dialog re-centers itself), and the
16875
- // difference between the two tops is what that move leaves alone.
16876
- const isVisibleInScrollport = (el, container, scrollportHeight) => {
16877
- const elRect = el.getBoundingClientRect();
16878
- const containerRect = container.getBoundingClientRect();
16879
- const elTop = elRect.top - containerRect.top + container.scrollTop;
16880
- const elBottom = elTop + elRect.height;
16881
- const scrollportTop = container.scrollTop;
16882
- const scrollportBottom = scrollportTop + scrollportHeight;
16883
- return elBottom > scrollportTop && elTop < scrollportBottom;
16884
- };
16885
-
16886
- /**
16887
- * Calls `onSettled` once `el`'s current CSS transition is over — via
16888
- * `transitionend`, with a safety `setTimeout` fallback matching the longest
16889
- * `transition-duration`, in case nothing actually transitions or an event is
16890
- * missed.
16891
- *
16892
- * Returns a "cancel" function, so a caller whose instance has been superseded
16893
- * (a fresh open/close about to set its own state) can keep this stale one from
16894
- * firing later. Cancelling only stops `onSettled`: undoing whatever the caller
16895
- * did up front is that fresh call's business, not this one's.
16896
- */
16897
- const whenTransitionSettles = (el, onSettled) => {
16898
- let settled = false;
16899
- const onTransitionEnd = (transitionEvent) => {
16900
- if (transitionEvent.target === el) {
16901
- finish();
16902
- }
16903
- };
16904
- const stopWatching = () => {
16905
- settled = true;
16906
- el.removeEventListener("transitionend", onTransitionEnd);
16907
- clearTimeout(safetyTimeoutId);
16908
- };
16909
- const finish = () => {
16910
- if (settled) {
16911
- return;
16912
- }
16913
- stopWatching();
16914
- onSettled();
16915
- };
16916
- el.addEventListener("transitionend", onTransitionEnd);
16917
- const durationsInSeconds = getComputedStyle(el)
16918
- .transitionDuration.split(",")
16919
- .map((value) => parseFloat(value) || 0);
16920
- const longestDurationMs = Math.max(0, ...durationsInSeconds) * 1000;
16921
- const safetyTimeoutId = setTimeout(finish, longestDurationMs + 50);
16922
- return () => {
16923
- if (settled) {
16924
- return;
16925
- }
16926
- stopWatching();
16927
- };
16928
- };
16929
-
16930
- /**
16931
- * Drops the document's text selection when it lives inside `el`, leaving a
16932
- * selection made elsewhere on the page alone.
16933
- *
16934
- * A popup being closed takes its content with it, and a selection is a claim
16935
- * on content the user can still act on: kept, it would outlive the surface it
16936
- * was made on — painted on the box for the length of its exit transition, with
16937
- * the handles and the copy toolbar a phone draws from the live selection
16938
- * hanging over something that is going away — and reappear with the box on
16939
- * the next opening. Dropping the selection is the only way to remove that
16940
- * chrome: a `user-select: none` on the closing box hides the highlight in some
16941
- * browsers only, and the handles are the browser's own, drawn from the
16942
- * selection object rather than from any style.
16943
- */
16944
- const clearTextSelectionInside = (el) => {
16945
- const selection = window.getSelection();
16946
- if (!selection || selection.rangeCount === 0) {
16947
- return;
16948
- }
16949
- const range = selection.getRangeAt(0);
16950
- if (!el.contains(range.commonAncestorContainer)) {
16951
- return;
16952
- }
16953
- selection.removeAllRanges();
16954
- };
16955
-
16956
- /**
16957
- * Disables pointer-events on `el` until its current CSS transition settles —
16958
- * avoids the cursor changing/something becoming clickable while the popup is
16959
- * still visually moving into or out of place.
16960
- *
16961
- * Returns whenTransitionSettles' own "cancel" function: it doesn't restore
16962
- * pointer-events, since a fresh call for the next open/close is about to set
16963
- * its own state.
16964
- */
16965
- const suppressPointerEventsDuringTransition = (el) => {
16966
- el.style.pointerEvents = "none";
16967
- return whenTransitionSettles(el, () => {
16968
- el.style.pointerEvents = "";
16969
- });
16970
- };
16971
-
16972
- /**
16973
- * Hides the backdrop, deferring until the browser's matching "click" fires
16974
- * when `closeEvent` was triggered by a mousedown (see popover.jsx's top
16975
- * comment for why) — same capture-phase-on-document pattern as
16976
- * armSuppressNextOpenRequest in open_controller.js, which a plain timeout
16977
- * can't safely replace: mouseup (and the click that follows it) can land an
16978
- * arbitrarily long time after mousedown (the user is still holding the
16979
- * button down), so a short timeout can fire first and hide the backdrop
16980
- * before its own click ever arrives. A capture-phase listener on document
16981
- * fires for every click regardless of what any bubble-phase handler does
16982
- * downstream, so no fallback timer is needed.
16983
- *
16984
- * `hide` is the caller's own way to actually hide the backdrop
16985
- * (`hidePopover()` for a top-layer backdrop, a plain `style.display = "none"`
16986
- * for a plain div) — this helper only owns the mousedown/click timing.
16987
- *
16988
- * Returns a disarm function (or undefined if hidden immediately), so a
16989
- * fresh open can cancel a pending hide it's about to make redundant.
16990
- */
16991
- const armPointerDownOutsideClose = (closeEvent, hide) => {
16992
- const mousedownEvent = findEvent(closeEvent, "mousedown");
16993
- if (!mousedownEvent) {
16994
- hide();
16995
- return undefined;
16996
- }
16997
- // `hide` is handed the click, for a wall with peers to re-aim it (see
16998
- // handWallClickToPeer).
16999
- const onClick = (clickEvent) => {
17000
- document.removeEventListener("click", onClick, { capture: true });
17001
- hide(clickEvent);
17002
- };
17003
- document.addEventListener("click", onClick, { capture: true });
17004
- return () => {
17005
- document.removeEventListener("click", onClick, { capture: true });
17006
- };
17007
- };
17008
-
17009
- /**
17010
- * Maps a positionArea y/x pair to a concrete `navi-animation` value (a
17011
- * `prefix` plus a direction word), or `null` if both axes overlap the anchor
17012
- * (no direction at all — that's `resolvedAnimationKind === "scaling"`
17013
- * territory instead, see resolveAutoAnimationKind below).
17014
- *
17015
- * `prefix: "slide-from"` (used with no real anchor — Dialog always, Popover
17016
- * when docked) keeps the word as the compass direction the popup comes
17017
- * from: placed "top" (a point/corner), it slides in from the top.
17018
- * `prefix: "expand"` (a real anchor, Popover-only) uses the motion/growth
17019
- * direction instead, the opposite compass point: placed "top" of the
17020
- * anchor, it moves/grows up, away from the anchor (which sits below it).
17021
- *
17022
- * "inset-*"/"center" contribute no direction on their axis either way.
17023
- */
17024
- const resolveDirectionValue = (y, x, { prefix }) => {
17025
- const yWord =
17026
- y === "top"
17027
- ? prefix === "expand"
17028
- ? "up"
17029
- : "top"
17030
- : y === "bottom"
17031
- ? prefix === "expand"
17032
- ? "down"
17033
- : "bottom"
17034
- : null;
17035
- const xWord = x === "left" ? "left" : x === "right" ? "right" : null;
17036
- if (!yWord && !xWord) {
17037
- return null;
17038
- }
17039
- return yWord && xWord
17040
- ? `${prefix}-${yWord}-${xWord}`
17041
- : `${prefix}-${yWord || xWord}`;
17042
- };
17043
-
17044
- /**
17045
- * Shared `animation="auto"`/`true` resolution: "scaling" reads best overall
17046
- * — picked for any real anchor, or for a point/corner placed dead-center
17047
- * (both positionArea axes overlapping — there's no sensible direction to
17048
- * slide from in that case). "sliding" otherwise. `anchor` is `undefined`
17049
- * for any no-anchor/docked case (Dialog always, Popover's own custom
17050
- * renderer when there's no real anchor), so this collapses to "scaling"
17051
- * there only for the dead-center case, "sliding" otherwise. The two
17052
- * "overlapping" booleans below describe the *positionArea* itself (a bare
17053
- * word vs. "inset-"/"center"), not anything about the anchor — they'd
17054
- * mean exactly the same thing even with no anchor at all, since it's the
17055
- * position strategy, not the anchor, that decides whether there's a
17056
- * direction to slide from.
17057
- */
17058
- const resolveAutoAnimationKind = (anchor, parsedPositionArea) => {
17059
- const yIsOverlapping =
17060
- parsedPositionArea.y !== "top" && parsedPositionArea.y !== "bottom";
17061
- const xIsOverlapping =
17062
- parsedPositionArea.x !== "left" && parsedPositionArea.x !== "right";
17063
- return anchor || (yIsOverlapping && xIsOverlapping) ? "scaling" : "sliding";
17064
- };
17065
-
17066
- /**
17067
- * A popup reads "outside" from its own border box: what a press lands on
17068
- * decides nothing on its own (a genuine backdrop press and a press on the
17069
- * popup's padding both report the popup element as their target, there being
17070
- * no real ::backdrop node to be one), so the rectangle is what tells them
17071
- * apart. That holds as long as the box and what the popup paints are the same
17072
- * thing — which stops being true for a popup with no surface of its own
17073
- * (`backgroundColor="transparent"`, no shadow, no padding): what the eye reads
17074
- * as backdrop is then inside the box, and a press there stays a press on the
17075
- * popup.
17076
- *
17077
- * `data-navi-popup-outside` is how a caller says which of its own boxes are
17078
- * not the surface. It is opt-in because navi cannot infer it — a background
17079
- * can come from anywhere — while the caller who made the popup see-through
17080
- * knows exactly which box is decoration and which is paper.
17081
- *
17082
- * The marker answers for the element it is on, never for its descendants: a
17083
- * box painted inside a marked one is still surface, so a row can be marked and
17084
- * its empty halves read as backdrop while presses on the controls it holds do
17085
- * not dismiss anything. A descendant with `pointer-events: none` never becomes
17086
- * a press target at all, so the marked box answers in its place — while the
17087
- * same declaration on the marked box itself takes it out of hit-testing and
17088
- * makes the marker unreachable (see warnAboutUnreachableOutsideRegions).
17089
- */
17090
- const OUTSIDE_REGION_ATTRIBUTE = "data-navi-popup-outside";
17091
-
17092
- /**
17093
- * `data-navi-popup-inside` is the other direction: a box of the PAGE the
17094
- * caller says belongs to a popup, so a press on it is not outside that popup
17095
- * even though it lies beyond the border box — a card whose press is what
17096
- * fills the one panel of a board. Its value names the popup by `id`, the way
17097
- * `commandfor` does (several ids, space separated), because the exemption is
17098
- * one popup's: a menu open beside the board still closes on the same press.
17099
- *
17100
- * Unlike the outside marker it answers for its whole subtree: the outside
17101
- * marker carves free space out of a surface whose controls stay surface, this
17102
- * one adds a whole thing — the card, its count, its buttons — to the popup's
17103
- * own ground, and nothing in it is a dismissal.
17104
- */
17105
- const INSIDE_ATTRIBUTE = "data-navi-popup-inside";
17106
-
17107
- /**
17108
- * `data-navi-popup-group` names the peers of a popup: the triggers of the other
17109
- * popups a press goes to from this one — a menu bar, where pressing another
17110
- * menu while one is open switches to it, in that one press. A trigger joins by
17111
- * carrying the name (a Picker's `popupGroup` writes it on its own), and a popup
17112
- * reads it off what the press landed on.
17113
- *
17114
- * Returns the peer `target` is in, or null.
17115
- */
17116
- const findPopupPeer = (target, group) => {
17117
- if (!group || !target || typeof target.closest !== "function") {
17118
- return null;
17119
- }
17120
- return target.closest(`[data-navi-popup-group="${CSS.escape(group)}"]`);
17121
- };
17122
-
17123
- /**
17124
- * The click a wall took, handed to the peer (see findPopupPeer) it covered.
17125
- *
17126
- * A wall cannot let one press through and not another: it is what the pointer
17127
- * hits, and that is the whole of how it spends a press. So it goes on taking
17128
- * every one — the page behind stays as unhoverable and unpressable as it looks
17129
- * — and once the press is over, its click is re-aimed at what the wall stood
17130
- * over at that point, when that is a peer. The click alone, for the reason
17131
- * transition_press.js gives: a pointer stream made up with no pointer behind it
17132
- * starts gestures (a hold, a drag) that nothing ever ends, and a click is what
17133
- * opens a popup's trigger.
17134
- *
17135
- * Called with the click that takes the wall down after a press closed the
17136
- * popup (see armPointerDownOutsideClose), so the peer opens as the popup it
17137
- * replaces goes.
17138
- */
17139
- const handWallClickToPeer = (
17140
- clickEvent,
17141
- { wallEl, popupEl, anchorEl, group },
17142
- ) => {
17143
- if (!clickEvent.isTrusted || clickEvent.target !== wallEl) {
17144
- // A click dispatched by hand is already aimed, and one that did not land
17145
- // on the wall (a press on the popup's own outside region) was no wall's.
17146
- return;
17147
- }
17148
- // The topmost element under the wall at that point: what the press would
17149
- // have landed on with no wall. Asked past the wall rather than after hiding
17150
- // it, which a fading wall may not be yet.
17151
- const covered = document
17152
- .elementsFromPoint(clickEvent.clientX, clickEvent.clientY)
17153
- .find((el) => el !== wallEl && !popupEl.contains(el));
17154
- const peer = findPopupPeer(covered, group);
17155
- if (!peer) {
17156
- return;
17157
- }
17158
- if (peer.contains(popupEl) || (anchorEl && peer.contains(anchorEl))) {
17159
- // The popup's own trigger — the one holding it (a picker renders its
17160
- // popup inside itself) or the one it opened against: the press through
17161
- // the wall closed the popup, and handing the trigger its click would open
17162
- // it again.
17163
- return;
17164
- }
17165
- // The page sees one click, on the peer, rather than two.
17166
- clickEvent.stopPropagation();
17167
- // A PointerEvent where the browser makes clicks of them, a MouseEvent
17168
- // elsewhere: the same kind, copied field by field.
17169
- const ClickEvent = clickEvent.constructor;
17170
- covered.dispatchEvent(new ClickEvent(clickEvent.type, clickEvent));
17171
- };
17172
-
17173
- /**
17174
- * What a press landing on a region the caller declared as not-its-surface
17175
- * does: exactly what the same press on the backdrop would do.
17176
- *
17177
- * Lives on the popup's own content element rather than in the backdrop's
17178
- * handler, for both renderers: the backdrop is a sibling behind the popup
17179
- * (never an ancestor), so a press inside the popup's box never reaches it.
17180
- */
17181
- const handlePressOnOutsideRegion = (
17182
- mouseDownEvent,
17183
- { popupEl, openController, pressOutside },
17184
- ) => {
17185
- if (mouseDownEvent.button !== 0) {
17186
- return;
17187
- }
17188
- const { target } = mouseDownEvent;
17189
- if (!target.hasAttribute(OUTSIDE_REGION_ATTRIBUTE)) {
17190
- return;
17191
- }
17192
- if (openedDuringThisPress(openController)) {
17193
- // The release of the press that opened it is not somebody dismissing it
17194
- // (see openedDuringThisPress).
17195
- return;
17196
- }
17197
- // A popup opens inside its opener's own subtree, so a press on a region
17198
- // belonging to a popup nested in this one bubbles through here too — and it
17199
- // is a press on what is in front, not on this popup's own decoration.
17200
- if (
17201
- target.closest(`[navi-control="dialog"], [navi-control="popover"]`) !==
17202
- popupEl
17203
- ) {
17204
- return;
17205
- }
17206
- if (pressOutside === "capture") {
17207
- mouseDownEvent.preventDefault();
17208
- return;
17209
- }
17210
- if (pressOutside === "close" || pressOutside === "cancel") {
17211
- openController.requestClose(mouseDownEvent, {
17212
- isCancel: pressOutside === "cancel",
17213
- });
17214
- }
17215
- };
17216
-
17217
- /**
17218
- * Hears an outside press from the document itself, for a popup with no
17219
- * backdrop of its own to catch one: a modal `<dialog>` (whose native
17220
- * `::backdrop` dispatches nothing a handler can be put on) and any popup the
17221
- * caller asked to leave the page reachable (`backdrop={false}`).
17222
- *
17223
- * The press is read and never taken: no `preventDefault`, no
17224
- * `stopPropagation`, and the listener is on `document` rather than on
17225
- * anything covering the page — so what the press landed on hears it too, in
17226
- * the same gesture, and the popup only takes note. That is the whole point of
17227
- * a popup with no wall: a press outside is a press on the page, and the page
17228
- * answers it. The one popup where that cannot hold is the modal dialog, where
17229
- * the browser has already made everything behind inert before this runs.
17230
- *
17231
- * `pressEventType` says which event IS the press, and the two callers want
17232
- * opposite ones:
17233
- *
17234
- * - `"pointerdown"` for a popup with no wall: a page with no wall over it is
17235
- * a page whose own elements arbitrate their presses, and cancelling a
17236
- * `pointerdown` — what a drag source and a control keeping the focus where
17237
- * it is both do — suppresses every compatibility mouse event that would have
17238
- * followed it. The press happens, reaches its target, and no `mousedown` is
17239
- * ever dispatched for it. Reading the pointer event is reading the press
17240
- * itself, whatever anything does with it afterwards, and it is the same
17241
- * event the drag and surface detectors read.
17242
- * - `"mousedown"` for the modal dialog, whose native `::backdrop` exists to
17243
- * spend the press. The page's controls act on `mousedown`, and on a touch
17244
- * screen that is not the same moment as `pointerdown`: the compatibility
17245
- * mouse events are synthesized at `touchend` and hit-tested again then.
17246
- * Closing on `pointerdown` takes the `::backdrop` away before that second
17247
- * hit-test, so the `mousedown` lands on whatever the page holds under the
17248
- * finger, and one tap both dismisses the dialog and presses the page. Read
17249
- * on `mousedown`, the wall is still up when the press the page acts on is
17250
- * aimed, and it catches it. Nothing behind a modal wall can cancel that
17251
- * press, since nothing behind it hears the `pointerdown` at all.
17252
- *
17253
- * Capture phase, so a handler downstream that stops propagation cannot keep
17254
- * the popup open either — the press is still outside whatever is made of it.
17255
- *
17256
- * Returns a cleanup that removes the listener.
17257
- */
17258
- const armOutsidePressClose = (
17259
- popupEl,
17260
- { openController, pressOutside, pressEventType = "pointerdown" },
17261
- ) => {
17262
- const onDocumentPointerDown = (pointerDownEvent) => {
17263
- if (pointerDownEvent.button !== 0) {
17264
- return;
17265
- }
17266
- if (openedDuringThisPress(openController)) {
17267
- // The release of the press that opened it is not somebody dismissing it
17268
- // (see openedDuringThisPress).
17269
- return;
17270
- }
17271
- // The press landed inside another popup: that is a press on what is in
17272
- // front, not outside. Asking the target where it lives rather than asking
17273
- // this popup whether it was pushed — a popup in front does not have to be
17274
- // one this popup knows about. In front means neither inside nor around: a
17275
- // popup nested in this one falls to the containment check below as the
17276
- // inside press it is, and one this popup opened INSIDE (a bubble rendered
17277
- // in a sheet's content) is around it, so what that popup holds is outside
17278
- // this one exactly like the rest of the page.
17279
- const popupUnderPointer = pointerDownEvent.target.closest?.(
17280
- `[navi-control="dialog"], [navi-control="popover"]`,
17281
- );
17282
- if (
17283
- popupUnderPointer &&
17284
- popupUnderPointer !== popupEl &&
17285
- !popupEl.contains(popupUnderPointer) &&
17286
- !popupUnderPointer.contains(popupEl)
17287
- ) {
17288
- return;
17289
- }
17290
- if (
17291
- popupEl.id &&
17292
- pointerDownEvent.target.closest?.(
17293
- `[${INSIDE_ATTRIBUTE}~="${CSS.escape(popupEl.id)}"]`,
17294
- )
17295
- ) {
17296
- return;
17297
- }
17298
- // Real DOM containment wins over the rectangle below — an element
17299
- // genuinely inside the popup (`overflow: visible`, a negative margin, an
17300
- // absolutely-positioned child) can be painted outside its border box, and
17301
- // a press there is not outside just because its coordinates are. The
17302
- // popup element itself is excluded (contains() answers true for it): a
17303
- // press on a modal dialog's native backdrop reports the dialog as its
17304
- // target, there being no `::backdrop` node to be one, so treating that as
17305
- // contained would keep the rectangle from ever running.
17306
- if (
17307
- pointerDownEvent.target !== popupEl &&
17308
- popupEl.contains(pointerDownEvent.target)
17309
- ) {
17310
- return;
17311
- }
17312
- const rect = popupEl.getBoundingClientRect();
17313
- const isOutside =
17314
- pointerDownEvent.clientX < rect.left ||
17315
- pointerDownEvent.clientX > rect.right ||
17316
- pointerDownEvent.clientY < rect.top ||
17317
- pointerDownEvent.clientY > rect.bottom;
17318
- if (!isOutside) {
17319
- return;
17320
- }
17321
- openController.requestClose(pointerDownEvent, {
17322
- isCancel: pressOutside === "cancel",
17323
- });
17324
- };
17325
- document.addEventListener(pressEventType, onDocumentPointerDown, {
17326
- capture: true,
17327
- });
17328
- return () => {
17329
- document.removeEventListener(pressEventType, onDocumentPointerDown, {
17330
- capture: true,
17331
- });
17332
- };
17333
- };
17334
-
17335
16873
  /**
17336
16874
  * Typography reset shared by the surfaces a control opens: callout, popover,
17337
16875
  * dialog.
@@ -17454,7 +16992,7 @@ installImportMetaCssBuild(import.meta);
17454
16992
  * (see calloutTemplate in callout.js).
17455
16993
  */
17456
16994
 
17457
- const css$15 = /* css */`.navi_callout_status_icon {
16995
+ const css$16 = /* css */`.navi_callout_status_icon {
17458
16996
  --x-callout-status-icon-color: var(--navi-callout-neutral-color);
17459
16997
  box-sizing: border-box;
17460
16998
  aspect-ratio: 1;
@@ -17525,7 +17063,7 @@ const CalloutStatusIcon = ({
17525
17063
  status = "info",
17526
17064
  shape = "square"
17527
17065
  }) => {
17528
- import.meta.css = [css$15, "@jsenv/navi/src/control/rules/callout/callout_status_icon.jsx"];
17066
+ import.meta.css = [css$16, "@jsenv/navi/src/control/rules/callout/callout_status_icon.jsx"];
17529
17067
  return jsx("span", {
17530
17068
  className: "navi_callout_status_icon",
17531
17069
  "data-status": status === "none" ? undefined : status,
@@ -17566,11 +17104,13 @@ const SPENT_PRESS_EVENT_TYPES = [
17566
17104
  "contextmenu",
17567
17105
  ];
17568
17106
  // Where a press lands on something drawn over the page rather than on the page
17569
- // itself: a popup of navi's (either layer) or a native top-layer element.
17107
+ // itself: a popup of navi's (either layer) or a native top-layer element. An
17108
+ // idle PopupGroup is a closed <dialog> that is part of the page (see
17109
+ // popup_group.jsx).
17570
17110
  const LAYER_SELECTOR =
17571
- '[popover], dialog, [navi-control="popover"], [navi-control="dialog"]';
17111
+ '[popover], dialog:not([navi-popup-group]:not(:modal)), [navi-control="popover"], [navi-control="dialog"]';
17572
17112
 
17573
- const css$14 = /* css */ `
17113
+ const css$15 = /* css */ `
17574
17114
  /* jsenv-css-opaque: same as dialog.jsx, cascade order. */
17575
17115
  @layer navi {
17576
17116
  .navi_callout {
@@ -17843,6 +17383,10 @@ const css$14 = /* css */ `
17843
17383
  * If not provided or too big, callout will be centered in viewport. It says where the
17844
17384
  * callout is drawn, not what dismisses it: see `openingEvent` for what a container anchor
17845
17385
  * costs
17386
+ * @param {HTMLElement} [options.containerElement] - Where the callout element goes in the
17387
+ * DOM, when that is not where the anchor puts it (the anchor itself, or beside it). What
17388
+ * holds a callout decides what it inherits and whether it can be reached at all: outside
17389
+ * an engaged PopupGroup it is inert. The anchor still says where it points
17846
17390
  * @param {string} [options.status=""] - Callout status: "info" | "warning" | "error" | "success"
17847
17391
  * @param {string} [options.testId] - `data-testid` on the callout element. The callout is
17848
17392
  * drawn by navi, so nothing the caller renders can carry the name a test needs — same
@@ -17864,10 +17408,6 @@ const css$14 = /* css */ `
17864
17408
  * still scrolls, a press on a layer drawn over the page (a sheet the callout's content
17865
17409
  * opened) is that layer's, and a press that does not close the callout (an "error" one) is
17866
17410
  * not spent either
17867
- * @param {string} [options.group] - With `backdrop`: the name the callout's peers carry in
17868
- * `data-navi-popup-group` — the triggers of other popups, callouts or not. A press inside a
17869
- * peer still closes the callout but is not spent, so the peer it landed on opens its own
17870
- * popup in the same press — a menu bar, where pressing another menu switches to it
17871
17411
  * @param {Event} [options.openingEvent] - The gesture the callout belongs to. It names the
17872
17412
  * opener — `currentTarget` while the event is still dispatching, `target` once it is over,
17873
17413
  * so awaiting before opening changes nothing — and the opener is the one part of the anchor
@@ -17928,12 +17468,12 @@ const openCallout = (
17928
17468
  // "" - neutral information
17929
17469
  status = "",
17930
17470
  testId,
17471
+ containerElement,
17931
17472
  onClose,
17932
17473
  closeByPressOutside = status === "info",
17933
17474
  closeOnFocusLeave = closeByPressOutside,
17934
17475
  closeByScroll = false,
17935
17476
  backdrop = false,
17936
- group,
17937
17477
  closeButton = true,
17938
17478
  openingEvent,
17939
17479
  reopen = "toggle",
@@ -17943,7 +17483,7 @@ const openCallout = (
17943
17483
  debug = () => {},
17944
17484
  } = {},
17945
17485
  ) => {
17946
- import.meta.css = [css$14, "@jsenv/navi/src/control/rules/callout/callout.js"];
17486
+ import.meta.css = [css$15, "@jsenv/navi/src/control/rules/callout/callout.js"];
17947
17487
  if (debug === true) {
17948
17488
  debug = (e, ...args) => console.debug(`"${e.type}" -> `, ...args);
17949
17489
  }
@@ -18245,10 +17785,11 @@ const openCallout = (
18245
17785
  }
18246
17786
  }
18247
17787
  const calloutContainer = (() => {
18248
- if (!anchorElement || anchorElement === document.body) {
17788
+ const holder = containerElement || anchorElement;
17789
+ if (!holder || holder === document.body) {
18249
17790
  return document.body;
18250
17791
  }
18251
- const closestLabel = anchorElement.closest("label");
17792
+ const closestLabel = holder.closest("label");
18252
17793
  if (closestLabel) {
18253
17794
  // Putting callout inside label would be problematic:
18254
17795
  // - Click on callout would be catched by the browser and re-dispatched to the label
@@ -18260,10 +17801,10 @@ const openCallout = (
18260
17801
  return labelParent;
18261
17802
  }
18262
17803
  // Some elements (e.g. <input>) cannot have children
18263
- if (canContainCallout(anchorElement)) {
18264
- return anchorElement;
17804
+ if (canContainCallout(holder)) {
17805
+ return holder;
18265
17806
  }
18266
- return anchorElement.parentNode || document.body;
17807
+ return holder.parentNode || document.body;
18267
17808
  })();
18268
17809
 
18269
17810
  {
@@ -18310,8 +17851,7 @@ const openCallout = (
18310
17851
  // Whether an outside press on `target` is spent on closing the callout
18311
17852
  // (`backdrop`). What is not the page behind the callout keeps its press: a
18312
17853
  // layer the callout is not part of — its own page may well be a sheet, a
18313
- // sheet its content opened is in front of it — and a peer, which the press
18314
- // is meant to open.
17854
+ // sheet its content opened is in front of it.
18315
17855
  const spendsPressOn = (target) => {
18316
17856
  if (!backdrop || !pressOutsideCloses()) {
18317
17857
  return false;
@@ -18320,9 +17860,6 @@ const openCallout = (
18320
17860
  if (layer && !layer.contains(calloutElement)) {
18321
17861
  return false;
18322
17862
  }
18323
- if (findPopupPeer(target, group)) {
18324
- return false;
18325
- }
18326
17863
  return true;
18327
17864
  };
18328
17865
  const spend = (event) => {
@@ -18491,8 +18028,10 @@ const openCallout = (
18491
18028
  }
18492
18029
  calloutCloseButton.tabIndex = -1;
18493
18030
  calloutCloseButton.setAttribute("navi-focus-delegate", "");
18031
+ // One Escape closes one popup: an Escape already spent by one in front
18032
+ // of the callout — a menu opened from its content — is not this one's.
18494
18033
  const onAnchorKeydown = (e) => {
18495
- if (e.key === "Escape") {
18034
+ if (e.key === "Escape" && !e.defaultPrevented) {
18496
18035
  requestClose(e, "escape_key");
18497
18036
  e.stopPropagation();
18498
18037
  e.preventDefault();
@@ -18507,7 +18046,7 @@ const openCallout = (
18507
18046
  // Usually callout is inside anchor so the anchor would see the escape being pressed
18508
18047
  // but callout can be appended outside anchor when anchor cannot receive children
18509
18048
  const onCalloutKeydown = (e) => {
18510
- if (e.key === "Escape") {
18049
+ if (e.key === "Escape" && !e.defaultPrevented) {
18511
18050
  requestClose(e, "escape_key");
18512
18051
  e.stopPropagation();
18513
18052
  e.preventDefault();
@@ -18553,21 +18092,23 @@ const openCallout = (
18553
18092
  //
18554
18093
  // Only for the ones that cannot be heard otherwise, and only while they are
18555
18094
  // open: a callout the anchor can hear about is left to the anchor, so
18556
- // Escape goes on meaning what it means where the user is.
18095
+ // Escape goes on meaning what it means where the user is. Heard once the
18096
+ // key has been through what it was pressed in: one that answered it (a
18097
+ // popup closing, a field clearing) spent it.
18557
18098
  if (anchorElement && anchorElement.contains(document.activeElement)) {
18558
18099
  break close_on_escape_from_anywhere;
18559
18100
  }
18560
18101
  const onDocumentKeydown = (e) => {
18561
- if (e.key !== "Escape") {
18102
+ if (e.key !== "Escape" || e.defaultPrevented) {
18562
18103
  return;
18563
18104
  }
18564
18105
  requestClose(e, "escape_key");
18565
18106
  e.stopPropagation();
18566
18107
  e.preventDefault();
18567
18108
  };
18568
- document.addEventListener("keydown", onDocumentKeydown, true);
18109
+ document.addEventListener("keydown", onDocumentKeydown);
18569
18110
  addTeardown(() => {
18570
- document.removeEventListener("keydown", onDocumentKeydown, true);
18111
+ document.removeEventListener("keydown", onDocumentKeydown);
18571
18112
  });
18572
18113
  }
18573
18114
  {
@@ -19538,7 +19079,7 @@ const generateSvgWithoutArrow = (width, height) => {
19538
19079
  *
19539
19080
  * Usage:
19540
19081
  * const myToken = createOpenToken();
19541
- * calloutManager.addOpenToken(myToken, { message, status, testId, closeByScroll, backdrop, group, anchorElement, event, skipFocus, onClose });
19082
+ * calloutManager.addOpenToken(myToken, { message, status, testId, closeByScroll, backdrop, closeByPressOutside, containerElement, anchorElement, event, skipFocus, onClose });
19542
19083
  * calloutManager.removeOpenToken(myToken, event);
19543
19084
  * calloutManager.requestCloseCallout(event, debugReason); // force-close all
19544
19085
  * calloutManager.callout // current open callout or null
@@ -19594,10 +19135,15 @@ const createCalloutManager = (
19594
19135
  testId: tokenData.testId,
19595
19136
  icon: tokenData.icon,
19596
19137
  closeButton: tokenData.closeButton,
19597
- closeByPressOutside: tokenData.status !== "error",
19138
+ // Unless the token says otherwise: what a press outside a picker's
19139
+ // callout does inside a PopupGroup is the group's to decide. Focus
19140
+ // leaving stays the callout's own way out, whoever answers the press.
19141
+ closeByPressOutside:
19142
+ tokenData.closeByPressOutside ?? tokenData.status !== "error",
19143
+ closeOnFocusLeave: tokenData.status !== "error",
19144
+ containerElement: tokenData.containerElement,
19598
19145
  closeByScroll: tokenData.closeByScroll,
19599
19146
  backdrop: tokenData.backdrop,
19600
- group: tokenData.group,
19601
19147
  anchorElement,
19602
19148
  openingEvent: event,
19603
19149
  skipFocus: tokenData.skipFocus,
@@ -19727,7 +19273,8 @@ const createCalloutManager = (
19727
19273
  closeButton,
19728
19274
  closeByScroll,
19729
19275
  backdrop,
19730
- group,
19276
+ closeByPressOutside,
19277
+ containerElement,
19731
19278
  anchorElement,
19732
19279
  event,
19733
19280
  skipFocus,
@@ -19746,7 +19293,8 @@ const createCalloutManager = (
19746
19293
  closeButton,
19747
19294
  closeByScroll,
19748
19295
  backdrop,
19749
- group,
19296
+ closeByPressOutside,
19297
+ containerElement,
19750
19298
  skipFocus,
19751
19299
  onClose,
19752
19300
  // Resolved as the token is added, and kept with it: a token shown later
@@ -22066,7 +21614,10 @@ const readNumberFromDom = (element, attribute, defaultValue) => {
22066
21614
  * what a popup outside the top layer says for itself).
22067
21615
  */
22068
21616
 
22069
- const LAYER_OVER_SELECTOR = "[data-drag-ignore],[popover],dialog";
21617
+ // An idle PopupGroup is a closed <dialog> that is part of the page, not a layer
21618
+ // over it (see popup_group.jsx).
21619
+ const LAYER_OVER_SELECTOR =
21620
+ "[data-drag-ignore],[popover],dialog:not([navi-popup-group]:not(:modal))";
22070
21621
 
22071
21622
  /**
22072
21623
  * The nearest word wins: a layer INSIDE the element takes the press away from
@@ -22245,7 +21796,7 @@ import.meta.css = /* css */ [`@property --swipe-progress {
22245
21796
  user-select: none;
22246
21797
  }
22247
21798
 
22248
- :is([data-longpress], [data-swipe], [data-double-click]) :is([data-drag-ignore], [popover], dialog), :is([data-longpress], [data-swipe], [data-double-click]) :is(input:not([data-press-only]), textarea), :is([data-longpress], [data-swipe], [data-double-click]) :is([contenteditable=""], [contenteditable="true"]) {
21799
+ :is([data-longpress], [data-swipe], [data-double-click]) :is([data-drag-ignore], [popover], dialog:not([navi-popup-group]:not(:modal))), :is([data-longpress], [data-swipe], [data-double-click]) :is(input:not([data-press-only]), textarea), :is([data-longpress], [data-swipe], [data-double-click]) :is([contenteditable=""], [contenteditable="true"]) {
22249
21800
  user-select: text;
22250
21801
  }
22251
21802
 
@@ -33184,7 +32735,7 @@ const keepLeavingPages = () => {
33184
32735
  };
33185
32736
  };
33186
32737
  const LEAVING_ATTRIBUTE = "data-navi-route-leaving";
33187
- const css$13 = /* css */`[data-navi-route-leaving] {
32738
+ const css$14 = /* css */`[data-navi-route-leaving] {
33188
32739
  display: none !important;
33189
32740
  }
33190
32741
  `;
@@ -33456,7 +33007,7 @@ const useContentKeepingLeavingPages = (branch, readShownBranch, parentPage) => {
33456
33007
  if (leaving.length === 0) {
33457
33008
  return vnode ? [vnode] : null;
33458
33009
  }
33459
- import.meta.css = [css$13, "@jsenv/navi/src/nav/route.jsx"];
33010
+ import.meta.css = [css$14, "@jsenv/navi/src/nav/route.jsx"];
33460
33011
  const children = vnode ? [vnode] : [];
33461
33012
  for (const entry of leaving) {
33462
33013
  children.push(h("template", {
@@ -33951,20 +33502,21 @@ const TOP_LAYER_SELECTOR = ":modal, :popover-open";
33951
33502
  // stands over the pages in the top layer. A sticky row inside the pages needs
33952
33503
  // none of this — it lives in the area, so it is already part of the pages' own
33953
33504
  // picture, and so is a popup rendered with layer="local".
33954
- const FURNITURE_SELECTOR = `.navi_fixed_bar, .navi_popover:is(${TOP_LAYER_SELECTOR}), .navi_dialog:is(${TOP_LAYER_SELECTOR})`;
33505
+ const FURNITURE_SELECTOR = `.navi_fixed_bar, .navi_popover:is(${TOP_LAYER_SELECTOR}), .navi_dialog:is(${TOP_LAYER_SELECTOR}), .navi_popup_group:is(${TOP_LAYER_SELECTOR})`;
33955
33506
  // Worn by the root for the length of a route transition (route_transition.jsx
33956
33507
  // owns it). Written out rather than imported: importing the module that owns
33957
33508
  // it back into this one would close a cycle.
33958
33509
  const TRANSITION_ATTRIBUTE$1 = "data-navi-route-transition";
33959
33510
  const NAME_PROPERTY$1 = "view-transition-name";
33960
33511
  const FURNITURE_NAME_PREFIX = "navi-transition-furniture-";
33961
- // The walls painted in the top layer: a modal dialog's, which is its
33962
- // ::backdrop, and a popover's, which is a real element shown as a popover
33512
+ // The walls painted in the top layer: a modal dialog's (a PopupGroup's too,
33513
+ // layout/popup_group.jsx), which is its ::backdrop, and a popover's, which is
33514
+ // a real element shown as a popover
33963
33515
  // beside it (layout/popover.jsx) — unnamed, so covered by the pictures for
33964
33516
  // the length of the movement all the same. A top-layer dialog without a wall
33965
33517
  // is shown as a popover and has nothing to stand in for.
33966
33518
  const TOP_LAYER_WALL_SELECTOR =
33967
- ".navi_dialog:modal, .navi_popover_backdrop:popover-open";
33519
+ ".navi_dialog:modal, .navi_popover_backdrop:popover-open, .navi_popup_group:modal";
33968
33520
  const WALL_ATTRIBUTE = "data-navi-transition-wall";
33969
33521
  // Which side of the movement a bar is on, worn by the bar and read by the
33970
33522
  // browser as the class of its group (the CSS below): "leaving" from the first
@@ -34026,7 +33578,7 @@ const TRANSITION_FURNITURE_CSS = /* css */ `.navi_fixed_bar[data-navi-transition
34026
33578
  }
34027
33579
 
34028
33580
  :root[data-navi-route-transition-target="area"] {
34029
- & .navi_dialog::backdrop, & .navi_popover_backdrop {
33581
+ & .navi_dialog::backdrop, & .navi_popup_group::backdrop, & .navi_popover_backdrop {
34030
33582
  backdrop-filter: none;
34031
33583
  background: none;
34032
33584
  }
@@ -34828,7 +34380,7 @@ const readOuterScroll = (element) => {
34828
34380
  };
34829
34381
 
34830
34382
  const isDocumentScroller = (node) => {
34831
- return node === document.documentElement || node === document.body;
34383
+ return node === document.documentElement;
34832
34384
  };
34833
34385
 
34834
34386
  const isPinnedToViewport = (node) => {
@@ -35127,7 +34679,7 @@ const ROUTE_TRAVEL_ATTRIBUTE = "data-navi-route-travel";
35127
34679
  // the root pictures must NOT move (they carry the whole viewport, blank bands
35128
34680
  // included).
35129
34681
 
35130
- const css$12 = /* css */`@property --navi-route-transition-leave {
34682
+ const css$13 = /* css */`@property --navi-route-transition-leave {
35131
34683
  syntax: "*";
35132
34684
  inherits: false
35133
34685
  }
@@ -36709,7 +36261,7 @@ const describePage = ({
36709
36261
  // is the application's element as often as <RouteTransitionArea>, and is
36710
36262
  // only found as a movement starts.
36711
36263
  const installRouteTransitionCss = () => {
36712
- import.meta.css = [css$12, "@jsenv/navi/src/nav/route_transition.jsx"];
36264
+ import.meta.css = [css$13, "@jsenv/navi/src/nav/route_transition.jsx"];
36713
36265
  installTransitionWindowCss();
36714
36266
  installTransitionFurnitureCss();
36715
36267
  };
@@ -36746,7 +36298,7 @@ const DRAGGED_ATTRIBUTE = "data-navi-route-travel-dragged";
36746
36298
  const TURNED_ATTRIBUTE = "data-navi-route-travel-turned";
36747
36299
  // The name the box wears while it travels, and only then (see nameForTravel).
36748
36300
  const TRAVEL_NAME = "navi-route-travel";
36749
- const css$11 = /* css */`.navi_route_travel {
36301
+ const css$12 = /* css */`.navi_route_travel {
36750
36302
  position: relative;
36751
36303
 
36752
36304
  &[data-travel-by-drag="x"] {
@@ -37044,7 +36596,7 @@ const RouteTravel = ({
37044
36596
  children,
37045
36597
  ...rest
37046
36598
  }) => {
37047
- import.meta.css = [css$11, "@jsenv/navi/src/nav/route_travel.jsx"];
36599
+ import.meta.css = [css$12, "@jsenv/navi/src/nav/route_travel.jsx"];
37048
36600
  installTransitionWindowCss();
37049
36601
  const elementRef = useRef();
37050
36602
  const gestureRef = useRef(null);
@@ -38054,12 +37606,12 @@ const whilePageRenders = async (page, change, wait = armRouteRenderWait()) => {
38054
37606
  // (`overflow: clip`) holds nothing and is walked straight through — see
38055
37607
  // is_scrollable.js.
38056
37608
  const pagesScrollTheDocument = element => {
38057
- const scrollContainer = getScrollContainer(element, {
37609
+ // What window.scrollTo moves. A body whose overflow is the viewport's is
37610
+ // walked through to it (see overflowGoesToViewport in @jsenv/dom); a body
37611
+ // the walk stops on scrolls itself, out of the viewport's reach.
37612
+ return getScrollContainer(element, {
38058
37613
  includeHidden: true
38059
- });
38060
- // html and body are one answer: whichever of them the walk stops on, what
38061
- // scrolls is the viewport, which is what window.scrollTo moves.
38062
- return scrollContainer === document.documentElement || scrollContainer === document.body;
37614
+ }) === document.documentElement;
38063
37615
  };
38064
37616
 
38065
37617
  // A page of the row: a route, and the params that say which of its tabs when
@@ -40444,6 +39996,504 @@ const NAVI_COMMANDS = {
40444
39996
  "--navi-unselect": unselectCommand,
40445
39997
  };
40446
39998
 
39999
+ /**
40000
+ * Small, renderer-agnostic helpers shared by Popover and Dialog's own custom
40001
+ * (non-top-layer) renderers — operate on a plain DOM element, no knowledge
40002
+ * of which of the two owns it.
40003
+ */
40004
+
40005
+
40006
+ /**
40007
+ * Whether a visibleRectEffect delivery is one that can have taken height away
40008
+ * from a popup, and so pushed whatever holds focus out of sight:
40009
+ * - "resize": the window/visual viewport settled — which is also how the
40010
+ * on-screen keyboard arrives, overlay or not (window_size.js);
40011
+ * - ELEMENT_SIZE_CHANGE: the popup's own box measured different;
40012
+ * - "focusin": the focus-settled re-measure, for the room that changes with
40013
+ * nothing announcing it (subscribeFocusSettled in window_size.js).
40014
+ *
40015
+ * Everything else is a scroll of one kind or another, where nothing got
40016
+ * smaller and scrolling the focused element back would fight the very gesture
40017
+ * that fired it.
40018
+ */
40019
+ const mayHaveHiddenFocus = (event) => {
40020
+ const type = event?.type;
40021
+ return (
40022
+ type === "resize" || type === ELEMENT_SIZE_CHANGE || type === "focusin"
40023
+ );
40024
+ };
40025
+
40026
+ const scrollportHeightMap = new WeakMap();
40027
+ /**
40028
+ * Scrolls whatever holds focus inside `popupEl` back into view, if the popup
40029
+ * getting shorter has pushed it out.
40030
+ *
40031
+ * The case this exists for: a field low in the scrolling body of a popup that
40032
+ * also has a footer (box.jsx — with a body, the body is the only thing that
40033
+ * scrolls and the footer is a sibling sitting right under it). Focusing the
40034
+ * field makes the browser scroll it into view, which it does against the
40035
+ * popup's height AT THAT MOMENT; the on-screen keyboard then opens and takes
40036
+ * that height away. The body shrinks, its scrollTop does not move, so the
40037
+ * content slides down relative to the shorter scrollport and the field ends up
40038
+ * past its bottom edge — visually, swallowed by the footer. The browser does
40039
+ * not redo a scroll-into-view it already answered, so this does.
40040
+ *
40041
+ * Only what the shrink itself hid, though: with the keyboard up the user reads
40042
+ * the rest of the popup by scrolling the field away — to reach the submit under
40043
+ * it, typically — and the room keeps changing while they do (a keyboard settling
40044
+ * in two steps, a suggestion strip, a browser bar). Answering each of those by
40045
+ * scrolling the field back takes the popup away from wherever they had just
40046
+ * scrolled it, over and over: what they were reading cannot be reached at all
40047
+ * without blurring the field first, and the popup reads as unscrollable. So the
40048
+ * field is brought back only when it was in view before the room shrank, which
40049
+ * one remembered number answers: a resize moves neither scrollTop nor the
40050
+ * element's offset inside the scrolled content, so measuring against the height
40051
+ * the scrollport HAD is measuring the state before the change.
40052
+ *
40053
+ * Scoped to the field's own scroll container (never the page): a popup traps
40054
+ * scrolling precisely so the document underneath cannot move, and a plain
40055
+ * scrollIntoView walks past a container whose scrollbar isn't visible — see
40056
+ * scrollIntoViewScoped's own doc.
40057
+ *
40058
+ * "nearest": the smallest scroll that makes it visible, and none at all when it
40059
+ * already is. Where it lands is the container's own business — a navi scroller
40060
+ * keeps a band free at its edges so a field never comes back glued to one (see
40061
+ * scroll-padding in box.jsx).
40062
+ */
40063
+ const keepFocusedElementVisible = (popupEl) => {
40064
+ const { activeElement } = document;
40065
+ if (!activeElement || activeElement === popupEl) {
40066
+ return;
40067
+ }
40068
+ if (!popupEl.contains(activeElement)) {
40069
+ return;
40070
+ }
40071
+ const scrollContainer = getScrollContainer(activeElement);
40072
+ if (!scrollContainer || !popupEl.contains(scrollContainer)) {
40073
+ // What scrolls the field is outside the popup, which means the page: a
40074
+ // popup holds it still on purpose (trapScrollInside), so there is nothing
40075
+ // here to scroll back.
40076
+ return;
40077
+ }
40078
+ const scrollportHeight = scrollContainer.clientHeight;
40079
+ const scrollportHeightBefore = scrollportHeightMap.get(scrollContainer);
40080
+ scrollportHeightMap.set(scrollContainer, scrollportHeight);
40081
+ if (scrollportHeightBefore !== undefined) {
40082
+ if (scrollportHeight >= scrollportHeightBefore) {
40083
+ // Nothing was taken away, so nothing was hidden by this.
40084
+ return;
40085
+ }
40086
+ if (
40087
+ !isVisibleInScrollport(
40088
+ activeElement,
40089
+ scrollContainer,
40090
+ scrollportHeightBefore,
40091
+ )
40092
+ ) {
40093
+ return;
40094
+ }
40095
+ }
40096
+ scrollIntoViewScoped(activeElement, {
40097
+ container: scrollContainer,
40098
+ block: "nearest",
40099
+ });
40100
+ };
40101
+
40102
+ // Whether any part of `el` was showing in `container` back when its scrollport
40103
+ // was `scrollportHeight` tall. Both boxes are read now: the container may have
40104
+ // moved as well as shrunk (a centered dialog re-centers itself), and the
40105
+ // difference between the two tops is what that move leaves alone.
40106
+ const isVisibleInScrollport = (el, container, scrollportHeight) => {
40107
+ const elRect = el.getBoundingClientRect();
40108
+ const containerRect = container.getBoundingClientRect();
40109
+ const elTop = elRect.top - containerRect.top + container.scrollTop;
40110
+ const elBottom = elTop + elRect.height;
40111
+ const scrollportTop = container.scrollTop;
40112
+ const scrollportBottom = scrollportTop + scrollportHeight;
40113
+ return elBottom > scrollportTop && elTop < scrollportBottom;
40114
+ };
40115
+
40116
+ /**
40117
+ * Calls `onSettled` once `el`'s current CSS transition is over — via
40118
+ * `transitionend`, with a safety `setTimeout` fallback matching the longest
40119
+ * `transition-duration`, in case nothing actually transitions or an event is
40120
+ * missed.
40121
+ *
40122
+ * Returns a "cancel" function, so a caller whose instance has been superseded
40123
+ * (a fresh open/close about to set its own state) can keep this stale one from
40124
+ * firing later. Cancelling only stops `onSettled`: undoing whatever the caller
40125
+ * did up front is that fresh call's business, not this one's.
40126
+ */
40127
+ const whenTransitionSettles = (el, onSettled) => {
40128
+ let settled = false;
40129
+ const onTransitionEnd = (transitionEvent) => {
40130
+ if (transitionEvent.target === el) {
40131
+ finish();
40132
+ }
40133
+ };
40134
+ const stopWatching = () => {
40135
+ settled = true;
40136
+ el.removeEventListener("transitionend", onTransitionEnd);
40137
+ clearTimeout(safetyTimeoutId);
40138
+ };
40139
+ const finish = () => {
40140
+ if (settled) {
40141
+ return;
40142
+ }
40143
+ stopWatching();
40144
+ onSettled();
40145
+ };
40146
+ el.addEventListener("transitionend", onTransitionEnd);
40147
+ const durationsInSeconds = getComputedStyle(el)
40148
+ .transitionDuration.split(",")
40149
+ .map((value) => parseFloat(value) || 0);
40150
+ const longestDurationMs = Math.max(0, ...durationsInSeconds) * 1000;
40151
+ const safetyTimeoutId = setTimeout(finish, longestDurationMs + 50);
40152
+ return () => {
40153
+ if (settled) {
40154
+ return;
40155
+ }
40156
+ stopWatching();
40157
+ };
40158
+ };
40159
+
40160
+ /**
40161
+ * Drops the document's text selection when it lives inside `el`, leaving a
40162
+ * selection made elsewhere on the page alone.
40163
+ *
40164
+ * A popup being closed takes its content with it, and a selection is a claim
40165
+ * on content the user can still act on: kept, it would outlive the surface it
40166
+ * was made on — painted on the box for the length of its exit transition, with
40167
+ * the handles and the copy toolbar a phone draws from the live selection
40168
+ * hanging over something that is going away — and reappear with the box on
40169
+ * the next opening. Dropping the selection is the only way to remove that
40170
+ * chrome: a `user-select: none` on the closing box hides the highlight in some
40171
+ * browsers only, and the handles are the browser's own, drawn from the
40172
+ * selection object rather than from any style.
40173
+ */
40174
+ const clearTextSelectionInside = (el) => {
40175
+ const selection = window.getSelection();
40176
+ if (!selection || selection.rangeCount === 0) {
40177
+ return;
40178
+ }
40179
+ const range = selection.getRangeAt(0);
40180
+ if (!el.contains(range.commonAncestorContainer)) {
40181
+ return;
40182
+ }
40183
+ selection.removeAllRanges();
40184
+ };
40185
+
40186
+ /**
40187
+ * Disables pointer-events on `el` until its current CSS transition settles —
40188
+ * avoids the cursor changing/something becoming clickable while the popup is
40189
+ * still visually moving into or out of place.
40190
+ *
40191
+ * Returns whenTransitionSettles' own "cancel" function: it doesn't restore
40192
+ * pointer-events, since a fresh call for the next open/close is about to set
40193
+ * its own state.
40194
+ */
40195
+ const suppressPointerEventsDuringTransition = (el) => {
40196
+ el.style.pointerEvents = "none";
40197
+ return whenTransitionSettles(el, () => {
40198
+ el.style.pointerEvents = "";
40199
+ });
40200
+ };
40201
+
40202
+ /**
40203
+ * Hides the backdrop, deferring until the browser's matching "click" fires
40204
+ * when `closeEvent` was triggered by a mousedown (see popover.jsx's top
40205
+ * comment for why) — same capture-phase-on-document pattern as
40206
+ * armSuppressNextOpenRequest in open_controller.js, which a plain timeout
40207
+ * can't safely replace: mouseup (and the click that follows it) can land an
40208
+ * arbitrarily long time after mousedown (the user is still holding the
40209
+ * button down), so a short timeout can fire first and hide the backdrop
40210
+ * before its own click ever arrives. A capture-phase listener on document
40211
+ * fires for every click regardless of what any bubble-phase handler does
40212
+ * downstream, so no fallback timer is needed.
40213
+ *
40214
+ * `hide` is the caller's own way to actually hide the backdrop
40215
+ * (`hidePopover()` for a top-layer backdrop, a plain `style.display = "none"`
40216
+ * for a plain div) — this helper only owns the mousedown/click timing.
40217
+ *
40218
+ * Returns a disarm function (or undefined if hidden immediately), so a
40219
+ * fresh open can cancel a pending hide it's about to make redundant.
40220
+ */
40221
+ const armPointerDownOutsideClose = (closeEvent, hide) => {
40222
+ const mousedownEvent = findEvent(closeEvent, "mousedown");
40223
+ if (!mousedownEvent) {
40224
+ hide();
40225
+ return undefined;
40226
+ }
40227
+ const onClick = () => {
40228
+ document.removeEventListener("click", onClick, { capture: true });
40229
+ hide();
40230
+ };
40231
+ document.addEventListener("click", onClick, { capture: true });
40232
+ return () => {
40233
+ document.removeEventListener("click", onClick, { capture: true });
40234
+ };
40235
+ };
40236
+
40237
+ /**
40238
+ * Maps a positionArea y/x pair to a concrete `navi-animation` value (a
40239
+ * `prefix` plus a direction word), or `null` if both axes overlap the anchor
40240
+ * (no direction at all — that's `resolvedAnimationKind === "scaling"`
40241
+ * territory instead, see resolveAutoAnimationKind below).
40242
+ *
40243
+ * `prefix: "slide-from"` (used with no real anchor — Dialog always, Popover
40244
+ * when docked) keeps the word as the compass direction the popup comes
40245
+ * from: placed "top" (a point/corner), it slides in from the top.
40246
+ * `prefix: "expand"` (a real anchor, Popover-only) uses the motion/growth
40247
+ * direction instead, the opposite compass point: placed "top" of the
40248
+ * anchor, it moves/grows up, away from the anchor (which sits below it).
40249
+ *
40250
+ * "inset-*"/"center" contribute no direction on their axis either way.
40251
+ */
40252
+ const resolveDirectionValue = (y, x, { prefix }) => {
40253
+ const yWord =
40254
+ y === "top"
40255
+ ? prefix === "expand"
40256
+ ? "up"
40257
+ : "top"
40258
+ : y === "bottom"
40259
+ ? prefix === "expand"
40260
+ ? "down"
40261
+ : "bottom"
40262
+ : null;
40263
+ const xWord = x === "left" ? "left" : x === "right" ? "right" : null;
40264
+ if (!yWord && !xWord) {
40265
+ return null;
40266
+ }
40267
+ return yWord && xWord
40268
+ ? `${prefix}-${yWord}-${xWord}`
40269
+ : `${prefix}-${yWord || xWord}`;
40270
+ };
40271
+
40272
+ /**
40273
+ * Shared `animation="auto"`/`true` resolution: "scaling" reads best overall
40274
+ * — picked for any real anchor, or for a point/corner placed dead-center
40275
+ * (both positionArea axes overlapping — there's no sensible direction to
40276
+ * slide from in that case). "sliding" otherwise. `anchor` is `undefined`
40277
+ * for any no-anchor/docked case (Dialog always, Popover's own custom
40278
+ * renderer when there's no real anchor), so this collapses to "scaling"
40279
+ * there only for the dead-center case, "sliding" otherwise. The two
40280
+ * "overlapping" booleans below describe the *positionArea* itself (a bare
40281
+ * word vs. "inset-"/"center"), not anything about the anchor — they'd
40282
+ * mean exactly the same thing even with no anchor at all, since it's the
40283
+ * position strategy, not the anchor, that decides whether there's a
40284
+ * direction to slide from.
40285
+ */
40286
+ const resolveAutoAnimationKind = (anchor, parsedPositionArea) => {
40287
+ const yIsOverlapping =
40288
+ parsedPositionArea.y !== "top" && parsedPositionArea.y !== "bottom";
40289
+ const xIsOverlapping =
40290
+ parsedPositionArea.x !== "left" && parsedPositionArea.x !== "right";
40291
+ return anchor || (yIsOverlapping && xIsOverlapping) ? "scaling" : "sliding";
40292
+ };
40293
+
40294
+ /**
40295
+ * A popup reads "outside" from its own border box: what a press lands on
40296
+ * decides nothing on its own (a genuine backdrop press and a press on the
40297
+ * popup's padding both report the popup element as their target, there being
40298
+ * no real ::backdrop node to be one), so the rectangle is what tells them
40299
+ * apart. That holds as long as the box and what the popup paints are the same
40300
+ * thing — which stops being true for a popup with no surface of its own
40301
+ * (`backgroundColor="transparent"`, no shadow, no padding): what the eye reads
40302
+ * as backdrop is then inside the box, and a press there stays a press on the
40303
+ * popup.
40304
+ *
40305
+ * `data-navi-popup-outside` is how a caller says which of its own boxes are
40306
+ * not the surface. It is opt-in because navi cannot infer it — a background
40307
+ * can come from anywhere — while the caller who made the popup see-through
40308
+ * knows exactly which box is decoration and which is paper.
40309
+ *
40310
+ * The marker answers for the element it is on, never for its descendants: a
40311
+ * box painted inside a marked one is still surface, so a row can be marked and
40312
+ * its empty halves read as backdrop while presses on the controls it holds do
40313
+ * not dismiss anything. A descendant with `pointer-events: none` never becomes
40314
+ * a press target at all, so the marked box answers in its place — while the
40315
+ * same declaration on the marked box itself takes it out of hit-testing and
40316
+ * makes the marker unreachable (see warnAboutUnreachableOutsideRegions).
40317
+ */
40318
+ const OUTSIDE_REGION_ATTRIBUTE = "data-navi-popup-outside";
40319
+
40320
+ /**
40321
+ * `data-navi-popup-inside` is the other direction: a box of the PAGE the
40322
+ * caller says belongs to a popup, so a press on it is not outside that popup
40323
+ * even though it lies beyond the border box — a card whose press is what
40324
+ * fills the one panel of a board. Its value names the popup by `id`, the way
40325
+ * `commandfor` does (several ids, space separated), because the exemption is
40326
+ * one popup's: a menu open beside the board still closes on the same press.
40327
+ *
40328
+ * Unlike the outside marker it answers for its whole subtree: the outside
40329
+ * marker carves free space out of a surface whose controls stay surface, this
40330
+ * one adds a whole thing — the card, its count, its buttons — to the popup's
40331
+ * own ground, and nothing in it is a dismissal.
40332
+ */
40333
+ const INSIDE_ATTRIBUTE = "data-navi-popup-inside";
40334
+ // Whether `target` is in a box of the page declared as part of the popup
40335
+ // named `popupId`.
40336
+ const isDeclaredInsidePopup = (target, popupId) =>
40337
+ Boolean(popupId) &&
40338
+ Boolean(target?.closest?.(`[${INSIDE_ATTRIBUTE}~="${CSS.escape(popupId)}"]`));
40339
+
40340
+ /**
40341
+ * What a press landing on a region the caller declared as not-its-surface
40342
+ * does: exactly what the same press on the backdrop would do.
40343
+ *
40344
+ * Lives on the popup's own content element rather than in the backdrop's
40345
+ * handler, for both renderers: the backdrop is a sibling behind the popup
40346
+ * (never an ancestor), so a press inside the popup's box never reaches it.
40347
+ */
40348
+ const handlePressOnOutsideRegion = (
40349
+ mouseDownEvent,
40350
+ { popupEl, openController, pressOutside },
40351
+ ) => {
40352
+ if (mouseDownEvent.button !== 0) {
40353
+ return;
40354
+ }
40355
+ const { target } = mouseDownEvent;
40356
+ if (!target.hasAttribute(OUTSIDE_REGION_ATTRIBUTE)) {
40357
+ return;
40358
+ }
40359
+ if (openedDuringThisPress(openController)) {
40360
+ // The release of the press that opened it is not somebody dismissing it
40361
+ // (see openedDuringThisPress).
40362
+ return;
40363
+ }
40364
+ // A popup opens inside its opener's own subtree, so a press on a region
40365
+ // belonging to a popup nested in this one bubbles through here too — and it
40366
+ // is a press on what is in front, not on this popup's own decoration.
40367
+ if (
40368
+ target.closest(`[navi-control="dialog"], [navi-control="popover"]`) !==
40369
+ popupEl
40370
+ ) {
40371
+ return;
40372
+ }
40373
+ if (pressOutside === "capture") {
40374
+ mouseDownEvent.preventDefault();
40375
+ return;
40376
+ }
40377
+ if (pressOutside === "close" || pressOutside === "cancel") {
40378
+ openController.requestClose(mouseDownEvent, {
40379
+ isCancel: pressOutside === "cancel",
40380
+ });
40381
+ }
40382
+ };
40383
+
40384
+ /**
40385
+ * Hears an outside press from the document itself, for a popup with no
40386
+ * backdrop of its own to catch one: a modal `<dialog>` (whose native
40387
+ * `::backdrop` dispatches nothing a handler can be put on) and any popup the
40388
+ * caller asked to leave the page reachable (`backdrop={false}`).
40389
+ *
40390
+ * The press is read and never taken: no `preventDefault`, no
40391
+ * `stopPropagation`, and the listener is on `document` rather than on
40392
+ * anything covering the page — so what the press landed on hears it too, in
40393
+ * the same gesture, and the popup only takes note. That is the whole point of
40394
+ * a popup with no wall: a press outside is a press on the page, and the page
40395
+ * answers it. The one popup where that cannot hold is the modal dialog, where
40396
+ * the browser has already made everything behind inert before this runs.
40397
+ *
40398
+ * `pressEventType` says which event IS the press, and the two callers want
40399
+ * opposite ones:
40400
+ *
40401
+ * - `"pointerdown"` for a popup with no wall: a page with no wall over it is
40402
+ * a page whose own elements arbitrate their presses, and cancelling a
40403
+ * `pointerdown` — what a drag source and a control keeping the focus where
40404
+ * it is both do — suppresses every compatibility mouse event that would have
40405
+ * followed it. The press happens, reaches its target, and no `mousedown` is
40406
+ * ever dispatched for it. Reading the pointer event is reading the press
40407
+ * itself, whatever anything does with it afterwards, and it is the same
40408
+ * event the drag and surface detectors read.
40409
+ * - `"mousedown"` for the modal dialog, whose native `::backdrop` exists to
40410
+ * spend the press. The page's controls act on `mousedown`, and on a touch
40411
+ * screen that is not the same moment as `pointerdown`: the compatibility
40412
+ * mouse events are synthesized at `touchend` and hit-tested again then.
40413
+ * Closing on `pointerdown` takes the `::backdrop` away before that second
40414
+ * hit-test, so the `mousedown` lands on whatever the page holds under the
40415
+ * finger, and one tap both dismisses the dialog and presses the page. Read
40416
+ * on `mousedown`, the wall is still up when the press the page acts on is
40417
+ * aimed, and it catches it. Nothing behind a modal wall can cancel that
40418
+ * press, since nothing behind it hears the `pointerdown` at all.
40419
+ *
40420
+ * Capture phase, so a handler downstream that stops propagation cannot keep
40421
+ * the popup open either — the press is still outside whatever is made of it.
40422
+ *
40423
+ * Returns a cleanup that removes the listener.
40424
+ */
40425
+ const armOutsidePressClose = (
40426
+ popupEl,
40427
+ { openController, pressOutside, pressEventType = "pointerdown" },
40428
+ ) => {
40429
+ const onDocumentPointerDown = (pointerDownEvent) => {
40430
+ if (pointerDownEvent.button !== 0) {
40431
+ return;
40432
+ }
40433
+ if (openedDuringThisPress(openController)) {
40434
+ // The release of the press that opened it is not somebody dismissing it
40435
+ // (see openedDuringThisPress).
40436
+ return;
40437
+ }
40438
+ // The press landed inside another popup: that is a press on what is in
40439
+ // front, not outside. Asking the target where it lives rather than asking
40440
+ // this popup whether it was pushed — a popup in front does not have to be
40441
+ // one this popup knows about. In front means neither inside nor around: a
40442
+ // popup nested in this one falls to the containment check below as the
40443
+ // inside press it is, and one this popup opened INSIDE (a bubble rendered
40444
+ // in a sheet's content) is around it, so what that popup holds is outside
40445
+ // this one exactly like the rest of the page.
40446
+ const popupUnderPointer = pointerDownEvent.target.closest?.(
40447
+ `[navi-control="dialog"], [navi-control="popover"]`,
40448
+ );
40449
+ if (
40450
+ popupUnderPointer &&
40451
+ popupUnderPointer !== popupEl &&
40452
+ !popupEl.contains(popupUnderPointer) &&
40453
+ !popupUnderPointer.contains(popupEl)
40454
+ ) {
40455
+ return;
40456
+ }
40457
+ if (isDeclaredInsidePopup(pointerDownEvent.target, popupEl.id)) {
40458
+ return;
40459
+ }
40460
+ // Real DOM containment wins over the rectangle below — an element
40461
+ // genuinely inside the popup (`overflow: visible`, a negative margin, an
40462
+ // absolutely-positioned child) can be painted outside its border box, and
40463
+ // a press there is not outside just because its coordinates are. The
40464
+ // popup element itself is excluded (contains() answers true for it): a
40465
+ // press on a modal dialog's native backdrop reports the dialog as its
40466
+ // target, there being no `::backdrop` node to be one, so treating that as
40467
+ // contained would keep the rectangle from ever running.
40468
+ if (
40469
+ pointerDownEvent.target !== popupEl &&
40470
+ popupEl.contains(pointerDownEvent.target)
40471
+ ) {
40472
+ return;
40473
+ }
40474
+ const rect = popupEl.getBoundingClientRect();
40475
+ const isOutside =
40476
+ pointerDownEvent.clientX < rect.left ||
40477
+ pointerDownEvent.clientX > rect.right ||
40478
+ pointerDownEvent.clientY < rect.top ||
40479
+ pointerDownEvent.clientY > rect.bottom;
40480
+ if (!isOutside) {
40481
+ return;
40482
+ }
40483
+ openController.requestClose(pointerDownEvent, {
40484
+ isCancel: pressOutside === "cancel",
40485
+ });
40486
+ };
40487
+ document.addEventListener(pressEventType, onDocumentPointerDown, {
40488
+ capture: true,
40489
+ });
40490
+ return () => {
40491
+ document.removeEventListener(pressEventType, onDocumentPointerDown, {
40492
+ capture: true,
40493
+ });
40494
+ };
40495
+ };
40496
+
40447
40497
  /**
40448
40498
  * When a popup builds what it holds, and when it throws it away.
40449
40499
  *
@@ -49161,7 +49211,7 @@ const startNetworkMonitoring = () => {
49161
49211
  };
49162
49212
 
49163
49213
  installImportMetaCssBuild(import.meta);
49164
- const css$10 = /* css */`.navi_loading_indicator_fluid_container {
49214
+ const css$11 = /* css */`.navi_loading_indicator_fluid_container {
49165
49215
  border-radius: inherit;
49166
49216
  opacity: 1;
49167
49217
  width: 100%;
@@ -49192,7 +49242,7 @@ const LoadingIndicatorFluid = ({
49192
49242
  visuallyHidden,
49193
49243
  ...rest
49194
49244
  }) => {
49195
- import.meta.css = [css$10, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
49245
+ import.meta.css = [css$11, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
49196
49246
  const ref = useRef(null);
49197
49247
  // The container dimensions can be deduced from the ref itself as the indicator is absolute inset 0
49198
49248
  const [containerWidth, setContainerWidth] = useState(0);
@@ -49394,7 +49444,7 @@ const LoadingRectangleSvg = ({
49394
49444
  };
49395
49445
 
49396
49446
  installImportMetaCssBuild(import.meta);
49397
- const css$$ = /* css */`.navi_loading_outline_wrapper {
49447
+ const css$10 = /* css */`.navi_loading_outline_wrapper {
49398
49448
  top: max(var(--loading-outline-min-inset, -100vh),
49399
49449
  var(--loading-rectangle-top, 0px));
49400
49450
  right: max(var(--loading-outline-min-inset, -100vh),
@@ -49415,7 +49465,7 @@ const css$$ = /* css */`.navi_loading_outline_wrapper {
49415
49465
  }
49416
49466
  `;
49417
49467
  const LoadingOutline = props => {
49418
- import.meta.css = [css$$, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
49468
+ import.meta.css = [css$10, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
49419
49469
  if (props.containerRef) {
49420
49470
  const container = props.containerRef.current;
49421
49471
  if (!container) {
@@ -49617,7 +49667,7 @@ const flush = () => {
49617
49667
  };
49618
49668
 
49619
49669
  installImportMetaCssBuild(import.meta);
49620
- const css$_ = /* css */`.navi_text_anchor {
49670
+ const css$$ = /* css */`.navi_text_anchor {
49621
49671
  vertical-align: baseline;
49622
49672
  user-select: none;
49623
49673
  overflow: hidden;
@@ -49655,7 +49705,7 @@ const TextAnchor = ({
49655
49705
  textSize,
49656
49706
  lineLayout
49657
49707
  }) => {
49658
- import.meta.css = [css$_, "@jsenv/navi/src/text/text_anchor.jsx"];
49708
+ import.meta.css = [css$$, "@jsenv/navi/src/text/text_anchor.jsx"];
49659
49709
  const anchorRef = useRef();
49660
49710
  // The correction reads the layout, with the other reads of the commit (see
49661
49711
  // layout_batch.js) — an anchor per icon, each measured between two writes,
@@ -49906,7 +49956,7 @@ const selectByTextStrings = (element, range, startText, endText) => {
49906
49956
  };
49907
49957
 
49908
49958
  installImportMetaCssBuild(import.meta);
49909
- const css$Z = /* css */`@layer navi {
49959
+ const css$_ = /* css */`@layer navi {
49910
49960
  .navi_text {
49911
49961
  &[data-skeleton] {
49912
49962
  border-radius: .2em;
@@ -50498,7 +50548,7 @@ const TextShrinkWrap = props => {
50498
50548
  });
50499
50549
  };
50500
50550
  const TextUI = props => {
50501
- import.meta.css = [css$Z, "@jsenv/navi/src/text/text.jsx"];
50551
+ import.meta.css = [css$_, "@jsenv/navi/src/text/text.jsx"];
50502
50552
  let {
50503
50553
  ref,
50504
50554
  spacing,
@@ -50736,7 +50786,7 @@ const Icon = ({
50736
50786
  fillLine,
50737
50787
  ...props
50738
50788
  }) => {
50739
- import.meta.css = [css$Z, "@jsenv/navi/src/text/text.jsx"];
50789
+ import.meta.css = [css$_, "@jsenv/navi/src/text/text.jsx"];
50740
50790
  const innerChildren = href ? jsx("svg", {
50741
50791
  width: "100%",
50742
50792
  height: "100%",
@@ -51070,7 +51120,7 @@ const useDimColorWhen = (elementRef, shouldDim) => {
51070
51120
  };
51071
51121
 
51072
51122
  installImportMetaCssBuild(import.meta);
51073
- const css$Y = /* css */`@layer navi {
51123
+ const css$Z = /* css */`@layer navi {
51074
51124
  .navi_link {
51075
51125
  --link-border-radius: unset;
51076
51126
  --link-outline-color: var(--navi-focus-outline-color);
@@ -51544,7 +51594,7 @@ const LinkPseudoElements = ["::-navi-loader", "::-navi-indicator"];
51544
51594
  * @param {boolean} [props.readOnly]
51545
51595
  */
51546
51596
  const Link = props => {
51547
- import.meta.css = [css$Y, "@jsenv/navi/src/nav/link/link.jsx"];
51597
+ import.meta.css = [css$Z, "@jsenv/navi/src/nav/link/link.jsx"];
51548
51598
  if (props.route) {
51549
51599
  return jsx(LinkWithRoute, {
51550
51600
  ...props
@@ -52104,7 +52154,7 @@ const rememberProgressAnimation = (navElement, travel) => {
52104
52154
  travel.progressAnimation = animation;
52105
52155
  travel.progressFrom = animation ? parseFloat(animation.effect.getKeyframes()[0]["--slide-travel-progress"]) || 0 : 0;
52106
52156
  };
52107
- const css$X = /* css */`@layer navi {
52157
+ const css$Y = /* css */`@layer navi {
52108
52158
  .navi_nav {
52109
52159
  --nav-border: none;
52110
52160
  --nav-padding: 0px;
@@ -52373,7 +52423,7 @@ const Nav = ({
52373
52423
  slideContainer,
52374
52424
  ...props
52375
52425
  }) => {
52376
- import.meta.css = [css$X, "@jsenv/navi/src/nav/link/nav.jsx"];
52426
+ import.meta.css = [css$Y, "@jsenv/navi/src/nav/link/nav.jsx"];
52377
52427
  const defaultRef = useRef();
52378
52428
  props.ref = props.ref || defaultRef;
52379
52429
  const navRef = props.ref;
@@ -53249,7 +53299,7 @@ const buildBinderCenterlinePathBelow = ({
53249
53299
  };
53250
53300
 
53251
53301
  installImportMetaCssBuild(import.meta);
53252
- const css$W = /* css */`@layer navi {
53302
+ const css$X = /* css */`@layer navi {
53253
53303
  .navi_binder {
53254
53304
  --binder-border-width: var(--navi-control-border-width);
53255
53305
  --binder-border-radius: var(--navi-control-border-radius);
@@ -53536,7 +53586,7 @@ const Binder = ({
53536
53586
  pagePadding,
53537
53587
  ...props
53538
53588
  }) => {
53539
- import.meta.css = [css$W, "@jsenv/navi/src/nav/binder/binder.jsx"];
53589
+ import.meta.css = [css$X, "@jsenv/navi/src/nav/binder/binder.jsx"];
53540
53590
  const items = toChildArray(children).map((child, index) => {
53541
53591
  const {
53542
53592
  value: itemValue,
@@ -53986,7 +54036,7 @@ const writeSpace = (area) => {
53986
54036
  };
53987
54037
 
53988
54038
  installImportMetaCssBuild(import.meta);
53989
- const css$V = /* css */`@layer navi {
54039
+ const css$W = /* css */`@layer navi {
53990
54040
  :root {
53991
54041
  --navi-fixed-bar-width: 56px;
53992
54042
  --navi-fixed-bar-height: 56px;
@@ -54109,7 +54159,7 @@ const FixedBar = ({
54109
54159
  border = true,
54110
54160
  ...props
54111
54161
  }) => {
54112
- import.meta.css = [css$V, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
54162
+ import.meta.css = [css$W, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
54113
54163
  const defaultRef = useRef();
54114
54164
  props.ref = props.ref || defaultRef;
54115
54165
  // Said with the width the border rule reads rather than with an attribute of
@@ -54203,7 +54253,7 @@ const FixedBar = ({
54203
54253
  // Subpixel layout rounds rectangles up on boxes that fit exactly.
54204
54254
  const OVERFLOW_TOLERANCE = 1;
54205
54255
 
54206
- const css$U = /* css */ `
54256
+ const css$V = /* css */ `
54207
54257
  [data-navi-overflow-x] {
54208
54258
  outline: 2px dashed #e74c3c;
54209
54259
  outline-offset: -2px;
@@ -54227,7 +54277,7 @@ const detectHorizontalOverflow = ({
54227
54277
  let styleEl = null;
54228
54278
  if (highlight) {
54229
54279
  styleEl = document.createElement("style");
54230
- styleEl.textContent = css$U;
54280
+ styleEl.textContent = css$V;
54231
54281
  document.head.appendChild(styleEl);
54232
54282
  }
54233
54283
 
@@ -54317,7 +54367,11 @@ const findOverflows = (root) => {
54317
54367
  if (style.display === "none" || style.position === "fixed") {
54318
54368
  continue;
54319
54369
  }
54320
- if (el.hasAttribute("popover") || el.tagName === "DIALOG") {
54370
+ if (
54371
+ el.hasAttribute("popover") ||
54372
+ // An idle PopupGroup is a box of the page (see popup_group.jsx).
54373
+ (el.tagName === "DIALOG" && !el.hasAttribute("navi-popup-group"))
54374
+ ) {
54321
54375
  continue;
54322
54376
  }
54323
54377
  const rect = el.getBoundingClientRect();
@@ -54383,7 +54437,7 @@ const useFocusGroup = (
54383
54437
 
54384
54438
  installImportMetaCssBuild(import.meta);
54385
54439
  const rightArrowPath = "M680-480L360-160l-80-80 240-240-240-240 80-80 320 320z";
54386
- const css$T = /* css */`.navi_summary_marker {
54440
+ const css$U = /* css */`.navi_summary_marker {
54387
54441
  flex-shrink: 0;
54388
54442
  width: 1em;
54389
54443
  height: 1em;
@@ -54475,7 +54529,7 @@ const SummaryMarker = ({
54475
54529
  loading,
54476
54530
  openDirection = "down"
54477
54531
  }) => {
54478
- import.meta.css = [css$T, "@jsenv/navi/src/control/details/summary_marker.jsx"];
54532
+ import.meta.css = [css$U, "@jsenv/navi/src/control/details/summary_marker.jsx"];
54479
54533
  const showLoading = useDebounceTrue(loading, 300);
54480
54534
  return jsx("span", {
54481
54535
  className: "navi_summary_marker",
@@ -54521,7 +54575,7 @@ const SummaryMarker = ({
54521
54575
  };
54522
54576
 
54523
54577
  installImportMetaCssBuild(import.meta);
54524
- const css$S = /* css */`.navi_details {
54578
+ const css$T = /* css */`.navi_details {
54525
54579
  z-index: 1;
54526
54580
  flex-direction: column;
54527
54581
  flex-shrink: 0;
@@ -54566,7 +54620,7 @@ const Details = props => {
54566
54620
  return details;
54567
54621
  };
54568
54622
  const DetailsField = props => {
54569
- import.meta.css = [css$S, "@jsenv/navi/src/control/details/details.jsx"];
54623
+ import.meta.css = [css$T, "@jsenv/navi/src/control/details/details.jsx"];
54570
54624
  const {
54571
54625
  ref,
54572
54626
  persists,
@@ -54732,7 +54786,7 @@ const DetailsFieldContent = ({
54732
54786
  };
54733
54787
 
54734
54788
  installImportMetaCssBuild(import.meta);
54735
- const css$R = /* css */`.navi_expandable {
54789
+ const css$S = /* css */`.navi_expandable {
54736
54790
  flex-direction: column;
54737
54791
  flex-shrink: 0;
54738
54792
  display: flex;
@@ -54976,7 +55030,7 @@ const useExpandableContext = partName => {
54976
55030
  * around them (docs/control_object.md, "A control that is not there").
54977
55031
  */
54978
55032
  const Expandable = props => {
54979
- import.meta.css = [css$R, "@jsenv/navi/src/control/expandable/expandable.jsx"];
55033
+ import.meta.css = [css$S, "@jsenv/navi/src/control/expandable/expandable.jsx"];
54980
55034
  /* The open props are read from `props` by the open controller below; they
54981
55035
  are named here only to keep them out of `rest`, and so out of the DOM. */
54982
55036
  /* eslint-disable no-unused-vars */
@@ -55957,7 +56011,7 @@ const useAccentColorAttributes = (
55957
56011
  };
55958
56012
 
55959
56013
  installImportMetaCssBuild(import.meta);
55960
- const css$Q = /* css */`@layer navi {
56014
+ const css$R = /* css */`@layer navi {
55961
56015
  .navi_button {
55962
56016
  --button-border-radius: var(--navi-control-border-radius);
55963
56017
  --button-border-width: var(--navi-control-border-width);
@@ -56261,7 +56315,7 @@ a.navi_button {
56261
56315
  }
56262
56316
  `;
56263
56317
  const ButtonUI = props => {
56264
- import.meta.css = [css$Q, "@jsenv/navi/src/control/input/button_ui.jsx"];
56318
+ import.meta.css = [css$R, "@jsenv/navi/src/control/input/button_ui.jsx"];
56265
56319
  const {
56266
56320
  ref,
56267
56321
  // href/link
@@ -56476,7 +56530,7 @@ const ButtonShadow = () => {
56476
56530
  markAsOutsideTextFlow(ButtonShadow);
56477
56531
 
56478
56532
  installImportMetaCssBuild(import.meta);
56479
- const css$P = /* css */`.navi_button_confirm {
56533
+ const css$Q = /* css */`.navi_button_confirm {
56480
56534
  align-items: center;
56481
56535
  gap: var(--navi-s);
56482
56536
  flex-wrap: wrap;
@@ -56504,7 +56558,7 @@ const ButtonConfirm = ({
56504
56558
  cancelTestId,
56505
56559
  ...props
56506
56560
  }) => {
56507
- import.meta.css = [css$P, "@jsenv/navi/src/control/input/button_confirm.jsx"];
56561
+ import.meta.css = [css$Q, "@jsenv/navi/src/control/input/button_confirm.jsx"];
56508
56562
  const [asking, setAsking] = useState(false);
56509
56563
  const askingRef = useRef(null);
56510
56564
  const cancelRef = useRef(null);
@@ -56965,7 +57019,7 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
56965
57019
  const Button = /*#__PURE__*/createComponentResolver([ButtonFirstResolver, ButtonRouteResolver, ButtonCommandPropResolver, ButtonConfirmResolver, ButtonHrefResolver, ButtonUI]);
56966
57020
 
56967
57021
  installImportMetaCssBuild(import.meta);
56968
- const css$O = /* css */`.navi_control_swap {
57022
+ const css$P = /* css */`.navi_control_swap {
56969
57023
  font-size: var(--navi-control-font-size);
56970
57024
  --x-control-swap-size: var(--navi-control-swap-size, calc(var(--navi-control-line-height) + 2 * var(--navi-s) + 2 *
56971
57025
  var(--navi-control-border-width)));
@@ -57070,7 +57124,7 @@ const css$O = /* css */`.navi_control_swap {
57070
57124
  * is the space between the boxes.
57071
57125
  */
57072
57126
  const ControlSwap = props => {
57073
- import.meta.css = [css$O, "@jsenv/navi/src/control/control_swap/control_swap.jsx"];
57127
+ import.meta.css = [css$P, "@jsenv/navi/src/control/control_swap/control_swap.jsx"];
57074
57128
  const {
57075
57129
  value,
57076
57130
  defaultValue,
@@ -57446,7 +57500,7 @@ const ControlGroup = props => {
57446
57500
  const CONTROL_GROUP_PSEUDO_CLASSES = [":hover", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
57447
57501
 
57448
57502
  installImportMetaCssBuild(import.meta);
57449
- const css$N = /* css */`@layer navi {
57503
+ const css$O = /* css */`@layer navi {
57450
57504
  .navi_checkbox {
57451
57505
  --switch-margin: 0;
57452
57506
  --switch-padding: 2px;
@@ -57503,7 +57557,7 @@ const css$N = /* css */`@layer navi {
57503
57557
  }
57504
57558
  `;
57505
57559
  const SwitchUI = () => {
57506
- import.meta.css = [css$N, "@jsenv/navi/src/control/input/switch_ui.jsx"];
57560
+ import.meta.css = [css$O, "@jsenv/navi/src/control/input/switch_ui.jsx"];
57507
57561
  return jsx(Box, {
57508
57562
  className: "navi_switch",
57509
57563
  as: "svg",
@@ -57546,7 +57600,7 @@ const useCheckableProps = (props, options) => {
57546
57600
  };
57547
57601
 
57548
57602
  installImportMetaCssBuild(import.meta);
57549
- const css$M = /* css */`@layer navi {
57603
+ const css$N = /* css */`@layer navi {
57550
57604
  .navi_checkbox {
57551
57605
  --border-radius: var(--navi-checkbox-border-radius);
57552
57606
  --border-width: var(--navi-control-border-width);
@@ -57819,7 +57873,7 @@ const InputCheckboxHeadless = props => {
57819
57873
  });
57820
57874
  };
57821
57875
  const InputCheckboxFieldInterface = props => {
57822
- import.meta.css = [css$M, "@jsenv/navi/src/control/input/input_checkbox.jsx"];
57876
+ import.meta.css = [css$N, "@jsenv/navi/src/control/input/input_checkbox.jsx"];
57823
57877
  const [checkboxRootProps, checkboxHostProps] = useCheckableProps(props);
57824
57878
  const {
57825
57879
  icon,
@@ -57941,7 +57995,7 @@ const CheckboxPseudoClasses = [":hover", ":active", ":focus", ":focus-visible",
57941
57995
  const CheckboxPseudoElements = ["::-navi-loader", "::-navi-checkmark"];
57942
57996
 
57943
57997
  installImportMetaCssBuild(import.meta);
57944
- const css$L = /* css */`@layer navi {
57998
+ const css$M = /* css */`@layer navi {
57945
57999
  .navi_label {
57946
58000
  --label-required-indicator-color: var(--navi-color-danger, #b42318);
57947
58001
 
@@ -58019,7 +58073,7 @@ const css$L = /* css */`@layer navi {
58019
58073
  * </Field>
58020
58074
  */
58021
58075
  const Field = props => {
58022
- import.meta.css = [css$L, "@jsenv/navi/src/control/field.jsx"];
58076
+ import.meta.css = [css$M, "@jsenv/navi/src/control/field.jsx"];
58023
58077
  const refDefault = useRef();
58024
58078
  props.ref = props.ref || refDefault;
58025
58079
  const {
@@ -58054,7 +58108,7 @@ const FieldCSSVars = {
58054
58108
  spacingWithControl: ["--spacing-with-control", "padding"]
58055
58109
  };
58056
58110
  const FieldAsContainer = props => {
58057
- import.meta.css = [css$L, "@jsenv/navi/src/control/field.jsx"];
58111
+ import.meta.css = [css$M, "@jsenv/navi/src/control/field.jsx"];
58058
58112
  const {
58059
58113
  children
58060
58114
  } = props;
@@ -58085,7 +58139,7 @@ const FieldAsContainer = props => {
58085
58139
  };
58086
58140
  const FIELD_PSEUDO_CLASSES = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
58087
58141
  const Label = props => {
58088
- import.meta.css = [css$L, "@jsenv/navi/src/control/field.jsx"];
58142
+ import.meta.css = [css$M, "@jsenv/navi/src/control/field.jsx"];
58089
58143
  const {
58090
58144
  children,
58091
58145
  // Marks the label when its control is required. Takes what to show, or
@@ -58253,7 +58307,7 @@ const InputSlot = ({
58253
58307
  };
58254
58308
 
58255
58309
  installImportMetaCssBuild(import.meta);
58256
- const css$K = /* css */`@layer navi {
58310
+ const css$L = /* css */`@layer navi {
58257
58311
  .navi_radio {
58258
58312
  --margin: 3px 3px 3px 5px;
58259
58313
  --outline-offset: 1px;
@@ -58559,7 +58613,7 @@ const InputRadioHeadless = props => {
58559
58613
  };
58560
58614
  const VARIANT_SET = new Set(["icon", "button", "radio"]);
58561
58615
  const InputRadioFieldInterface = props => {
58562
- import.meta.css = [css$K, "@jsenv/navi/src/control/input/input_radio.jsx"];
58616
+ import.meta.css = [css$L, "@jsenv/navi/src/control/input/input_radio.jsx"];
58563
58617
  const [radioRootProps, radioHostProps] = useCheckableProps(props);
58564
58618
  const {
58565
58619
  icon,
@@ -58705,7 +58759,7 @@ const RadioPseudoClasses = [":hover", ":active", ":focus", ":focus-visible", ":r
58705
58759
  const RadioPseudoElements = ["::-navi-loader", "::-navi-radiomark"];
58706
58760
 
58707
58761
  installImportMetaCssBuild(import.meta);
58708
- const css$J = /* css */`@layer navi {
58762
+ const css$K = /* css */`@layer navi {
58709
58763
  .navi_input_range {
58710
58764
  --border-radius: 6px;
58711
58765
  --border-width: var(--navi-control-border-width);
@@ -58944,7 +58998,7 @@ const InputRange = props => {
58944
58998
  });
58945
58999
  };
58946
59000
  const InputRangeFieldInterface = props => {
58947
- import.meta.css = [css$J, "@jsenv/navi/src/control/input/input_range.jsx"];
59001
+ import.meta.css = [css$K, "@jsenv/navi/src/control/input/input_range.jsx"];
58948
59002
  const {
58949
59003
  ref
58950
59004
  } = props;
@@ -60453,7 +60507,7 @@ Input.UI = {
60453
60507
  };
60454
60508
 
60455
60509
  installImportMetaCssBuild(import.meta);
60456
- const css$I = /* css */`.navi_textarea {
60510
+ const css$J = /* css */`.navi_textarea {
60457
60511
  & textarea {
60458
60512
  min-height: calc(var(--textarea-min-rows, 1.5) * 1lh);
60459
60513
  max-height: calc(var(--textarea-max-rows, 99999) * 1lh);
@@ -60540,7 +60594,7 @@ const Textarea = ({
60540
60594
  ...props
60541
60595
  }) => {
60542
60596
  installInputCss();
60543
- import.meta.css = [css$I, "@jsenv/navi/src/control/input/textarea.jsx"];
60597
+ import.meta.css = [css$J, "@jsenv/navi/src/control/input/textarea.jsx"];
60544
60598
  const defaultRef = useRef(null);
60545
60599
  props.ref = props.ref || defaultRef;
60546
60600
  usePlaceholderHeight(props.ref, props.placeholder);
@@ -60622,7 +60676,7 @@ const TextareaCharCount = ({
60622
60676
  maxLength,
60623
60677
  ...rest
60624
60678
  }) => {
60625
- import.meta.css = [css$I, "@jsenv/navi/src/control/input/textarea.jsx"];
60679
+ import.meta.css = [css$J, "@jsenv/navi/src/control/input/textarea.jsx"];
60626
60680
  const resolvedValue = signal ? signal.value : value;
60627
60681
  const length = typeof resolvedValue === "string" ? resolvedValue.length : 0;
60628
60682
  return jsx(Box, {
@@ -60732,7 +60786,7 @@ const RealTextarea = ({
60732
60786
  };
60733
60787
 
60734
60788
  installImportMetaCssBuild(import.meta);
60735
- const css$H = /* css */`.navi_editable_wrapper {
60789
+ const css$I = /* css */`.navi_editable_wrapper {
60736
60790
  --inset-top: 0px;
60737
60791
  --inset-right: 0px;
60738
60792
  --inset-bottom: 0px;
@@ -60902,7 +60956,7 @@ const useEditionController = () => {
60902
60956
  * Every other unknown prop goes to the field itself, styling props included.
60903
60957
  */
60904
60958
  const Editable = props => {
60905
- import.meta.css = [css$H, "@jsenv/navi/src/control/edition/editable.jsx"];
60959
+ import.meta.css = [css$I, "@jsenv/navi/src/control/edition/editable.jsx"];
60906
60960
  let {
60907
60961
  children,
60908
60962
  action,
@@ -61513,7 +61567,7 @@ const useRequestSubmitThroughGates = ref => {
61513
61567
  // };
61514
61568
 
61515
61569
  installImportMetaCssBuild(import.meta);
61516
- const css$G = /* css */`.navi_group {
61570
+ const css$H = /* css */`.navi_group {
61517
61571
  --group-border-width: var(--navi-control-border-width);
61518
61572
 
61519
61573
  & > :not([navi-out-of-flow]) {
@@ -61574,7 +61628,7 @@ const Group = ({
61574
61628
  vertical = row,
61575
61629
  ...props
61576
61630
  }) => {
61577
- import.meta.css = [css$G, "@jsenv/navi/src/control/group.jsx"];
61631
+ import.meta.css = [css$H, "@jsenv/navi/src/control/group.jsx"];
61578
61632
  return jsx(Box, {
61579
61633
  baseClassName: "navi_group",
61580
61634
  "data-vertical": vertical ? "" : undefined
@@ -61706,7 +61760,7 @@ const unfreezeSize = (el) => {
61706
61760
  };
61707
61761
 
61708
61762
  installImportMetaCssBuild(import.meta);
61709
- const css$F = /* css */`@property --slide-travel-progress {
61763
+ const css$G = /* css */`@property --slide-travel-progress {
61710
61764
  syntax: "<number>";
61711
61765
  inherits: true;
61712
61766
  initial-value: 0;
@@ -62193,7 +62247,7 @@ const SlideContainer = ({
62193
62247
  children,
62194
62248
  ...rest
62195
62249
  }) => {
62196
- import.meta.css = [css$F, "@jsenv/navi/src/layout/slide_container.jsx"];
62250
+ import.meta.css = [css$G, "@jsenv/navi/src/layout/slide_container.jsx"];
62197
62251
  const debugFocus = useDebugFocus();
62198
62252
  const trackRef = useRef();
62199
62253
  // The box itself: it is what takes the keyboard when what is on screen holds
@@ -65544,6 +65598,1336 @@ const renderSafe = (value) => {
65544
65598
  return String(value);
65545
65599
  };
65546
65600
 
65601
+ installImportMetaCssBuild(import.meta);
65602
+ const PopupGroupContext = createContext(null);
65603
+
65604
+ /**
65605
+ * What a press outside a popup of the group does, read against a wall that is
65606
+ * always up: "ignore" closes, as "close" does. For a press the group does not
65607
+ * see itself, on a region the popup declared outside
65608
+ * (handlePressOnOutsideRegion).
65609
+ */
65610
+ const readPressOutsideInPopupGroup = pressOutside => pressOutside === "ignore" ? "close" : pressOutside;
65611
+
65612
+ // The groups shown modal right now, for the popup around one of them to close
65613
+ // it first (see closePopupGroupsInside).
65614
+ const engagedGroupSet = new Set();
65615
+
65616
+ /**
65617
+ * Takes the groups engaged inside `popupElement` down, with what they have
65618
+ * open, before that popup gives the focus back: while a group is the topmost
65619
+ * modal everything else is inert, the element the focus goes back to
65620
+ * included. Called by Dialog and Popover as they close.
65621
+ */
65622
+ const closePopupGroupsInside = (popupElement, closeEvent) => {
65623
+ for (const group of engagedGroupSet) {
65624
+ if (popupElement.contains(group.getElement())) {
65625
+ group.closeEverything(closeEvent);
65626
+ }
65627
+ }
65628
+ };
65629
+ const css$F = /* css */`@property --x-popup-group-top {
65630
+ syntax: "*";
65631
+ inherits: false
65632
+ }
65633
+
65634
+ @property --x-popup-group-left {
65635
+ syntax: "*";
65636
+ inherits: false
65637
+ }
65638
+
65639
+ @property --x-popup-group-width {
65640
+ syntax: "*";
65641
+ inherits: false
65642
+ }
65643
+
65644
+ @property --x-popup-group-height {
65645
+ syntax: "*";
65646
+ inherits: false
65647
+ }
65648
+
65649
+ @property --x-popup-group-padding {
65650
+ syntax: "*";
65651
+ inherits: false
65652
+ }
65653
+
65654
+ @property --x-popup-group-clip {
65655
+ syntax: "*";
65656
+ inherits: false
65657
+ }
65658
+
65659
+ @layer navi {
65660
+ .navi_popup_group {
65661
+ --backdrop-background: var(--navi-backdrop-close-background);
65662
+ --backdrop-filter: var(--navi-backdrop-close-backdrop-filter);
65663
+ width: auto;
65664
+ max-width: none;
65665
+ height: auto;
65666
+ max-height: none;
65667
+ color: inherit;
65668
+ background: none;
65669
+ border: none;
65670
+ margin: 0;
65671
+ padding: 0;
65672
+ display: flow-root;
65673
+ position: static;
65674
+ inset: auto;
65675
+ overflow: visible;
65676
+ }
65677
+ }
65678
+
65679
+ .navi_popup_group:modal {
65680
+ inset: auto !important;
65681
+ top: var(--x-popup-group-top) !important;
65682
+ left: var(--x-popup-group-left) !important;
65683
+ box-sizing: border-box !important;
65684
+ width: var(--x-popup-group-width) !important;
65685
+ min-width: 0 !important;
65686
+ max-width: none !important;
65687
+ height: var(--x-popup-group-height) !important;
65688
+ min-height: 0 !important;
65689
+ max-height: none !important;
65690
+ padding: var(--x-popup-group-padding) !important;
65691
+ clip-path: var(--x-popup-group-clip, none) !important;
65692
+ margin: 0 !important;
65693
+ position: fixed !important;
65694
+ }
65695
+
65696
+ .navi_popup_group[data-backdrop-variant="lift"] {
65697
+ --backdrop-background: var(--navi-backdrop-lift-background);
65698
+ --backdrop-filter: var(--navi-backdrop-lift-backdrop-filter);
65699
+ }
65700
+
65701
+ .navi_popup_group[data-backdrop-variant="discrete"] {
65702
+ --backdrop-background: var(--navi-backdrop-discrete-background);
65703
+ --backdrop-filter: var(--navi-backdrop-discrete-backdrop-filter);
65704
+ }
65705
+
65706
+ .navi_popup_group[data-backdrop-variant="invisible"] {
65707
+ --backdrop-background: transparent;
65708
+ --backdrop-filter: none;
65709
+ }
65710
+
65711
+ .navi_popup_group::backdrop {
65712
+ background: var(--backdrop-background);
65713
+ opacity: 1;
65714
+ backdrop-filter: var(--backdrop-filter);
65715
+ transition: opacity var(--popup-animation-duration, .18s) ease;
65716
+
65717
+ @starting-style {
65718
+ opacity: 0;
65719
+ }
65720
+ }
65721
+
65722
+ .navi_popup_group[navi-transitions-off]::backdrop {
65723
+ transition: none;
65724
+ }
65725
+
65726
+ :is(:root[data-navi-route-transition] .navi_popup_group:modal, :root[data-navi-route-travel] .navi_popup_group:modal) {
65727
+ view-transition-class: navi_popup;
65728
+ }
65729
+
65730
+ @supports (view-transition-name: match-element) {
65731
+ :root[data-navi-route-transition]:not([data-navi-route-transition-target="area"]) .navi_popup_group:modal {
65732
+ view-transition-name: match-element;
65733
+ }
65734
+
65735
+ :root[data-navi-route-travel] .navi_popup_group:modal {
65736
+ view-transition-name: match-element;
65737
+ }
65738
+ }
65739
+ `;
65740
+
65741
+ /**
65742
+ * The box around popups that hand over to each other: score cells, a menu bar,
65743
+ * a toolbar. Being inside it is what makes a popup and a button peers: no name
65744
+ * to give.
65745
+ *
65746
+ * While one of its popups is open, the group is shown modal where it stands:
65747
+ * what it holds stays lit and live, the rest of the screen is behind its wall,
65748
+ * and a press on another of its popups' triggers closes the open one and opens
65749
+ * that one in the same press. Its popups draw no wall of their own (their
65750
+ * backdrop props are not read), and a Dialog in it is never modal. The page
65751
+ * behind stays still while one of its Popovers or Dialogs is open; a callout
65752
+ * of it lets the page scroll, as a callout does anywhere.
65753
+ *
65754
+ * Its element is a `<dialog>`, closed at rest and laid out like any box: the
65755
+ * app styles it as it would a `<div>`. While engaged, an invisible placeholder
65756
+ * holds its place in the page, as the element before it, marked
65757
+ * `navi-out-of-flow` like everything navi adds to a tree. See
65758
+ * docs/popup_backdrop.md.
65759
+ *
65760
+ * @type {import("ignore:preact").FunctionComponent<{
65761
+ * backdropVariant?: "auto" | "lift" | "discrete" | "invisible",
65762
+ * backdropColor?: string,
65763
+ * backdropFilter?: string,
65764
+ * themeColor?: string | false,
65765
+ * role?: string,
65766
+ * children?: import("ignore:preact").ComponentChildren,
65767
+ * [key: string]: any,
65768
+ * }>}
65769
+ * @param {"auto"|"lift"|"discrete"|"invisible"} [props.backdropVariant="auto"] -
65770
+ * How visible the wall is: `"auto"` is the wash a popup closing on an
65771
+ * outside press paints, `"lift"` the opaque blurred wall, `"discrete"` a
65772
+ * barely-there dim, `"invisible"` a wall that is not seen and still takes the
65773
+ * press.
65774
+ * @param {string} [props.backdropColor] - The wash itself, over the tokens.
65775
+ * @param {string} [props.backdropFilter] - What that wash does to what is
65776
+ * behind it.
65777
+ * @param {string|false} [props.themeColor] - The browser's colour while the
65778
+ * wall is up (see docs/popup_backdrop.md): worked out from the wash by
65779
+ * default.
65780
+ * @param {string} [props.role="group"] - A box of the page, not a dialog: the
65781
+ * element is a `<dialog>` for its modality alone.
65782
+ */
65783
+ const PopupGroup = props => {
65784
+ import.meta.css = [css$F, "@jsenv/navi/src/layout/popup_group.jsx"];
65785
+ const {
65786
+ backdropVariant = "auto",
65787
+ backdropColor,
65788
+ backdropFilter,
65789
+ themeColor,
65790
+ role = "group",
65791
+ children,
65792
+ ...rest
65793
+ } = props;
65794
+ const group = useMemo(createPopupGroup, []);
65795
+ group.themeColor = themeColor;
65796
+ // Unmounted or parked while engaged: a modal left open with its tree gone
65797
+ // keeps the whole document inert.
65798
+ useLayoutEffect(() => {
65799
+ return () => {
65800
+ group.disengage({
65801
+ leavingTree: true
65802
+ });
65803
+ };
65804
+ }, [group]);
65805
+ return jsx(Box, {
65806
+ as: "dialog",
65807
+ ...rest,
65808
+ // A callback ignoring null: a Suspense park nulls an object ref and
65809
+ // gives it back only when its identity changes.
65810
+ ref: group.captureElement,
65811
+ baseClassName: "navi_popup_group",
65812
+ "navi-popup-group": "",
65813
+ role: role,
65814
+ "data-backdrop-variant": backdropVariant === "auto" ? undefined : backdropVariant,
65815
+ backdropColor: backdropColor,
65816
+ backdropFilter: backdropFilter,
65817
+ styleCSSVars: POPUP_GROUP_STYLE_CSS_VARS,
65818
+ children: jsx(PopupGroupContext.Provider, {
65819
+ value: group,
65820
+ children: children
65821
+ })
65822
+ });
65823
+ };
65824
+ const POPUP_GROUP_STYLE_CSS_VARS = {
65825
+ backdropColor: "--backdrop-background",
65826
+ backdropFilter: "--backdrop-filter"
65827
+ };
65828
+
65829
+ // How long after the release a press that closed a popup may still bring the
65830
+ // click that ends it: the compatibility events of a tap come after touchend,
65831
+ // in a task of their own.
65832
+ const END_OF_PRESS_SAFETY_MS = 1000;
65833
+ const createPopupGroup = () => {
65834
+ let groupEl = null;
65835
+ // Every popup rendered in the group, open or not: "is one of them open" is
65836
+ // their own decision, read before any of them shows (a lifting dialog shows a
65837
+ // frame after it decided).
65838
+ const memberControllerSet = new Set();
65839
+ // The popups of the group that are open, as their openEffect described
65840
+ // them, the last one to open last. One at a time, except when the open one
65841
+ // refuses to close as another opens (a value it cannot note): it is kept
65842
+ // here, so the group can still close it.
65843
+ const openMemberMap = new Map();
65844
+ let engagement = null;
65845
+ const noMemberOpen = () => {
65846
+ for (const openController of memberControllerSet) {
65847
+ if (openController.opened) {
65848
+ return false;
65849
+ }
65850
+ }
65851
+ return true;
65852
+ };
65853
+ const getOpenMembers = () => {
65854
+ const openMembers = [];
65855
+ for (const [openController, member] of openMemberMap) {
65856
+ if (openController.opened) {
65857
+ openMembers.push(member);
65858
+ } else {
65859
+ openMemberMap.delete(openController);
65860
+ }
65861
+ }
65862
+ return openMembers;
65863
+ };
65864
+ // The page behind is held still unless what is open is a callout, which
65865
+ // lets it scroll as it does anywhere (its closeByScroll answers that
65866
+ // scroll). Kept as it is while nothing is open: the wall stays up until the
65867
+ // press that closed the last popup ends.
65868
+ const followScrollLock = () => {
65869
+ const openMembers = getOpenMembers();
65870
+ if (openMembers.length === 0) {
65871
+ return;
65872
+ }
65873
+ engagement.followScrollLock(openMembers.some(openMember => !openMember.pageScrolls));
65874
+ };
65875
+ const group = {
65876
+ themeColor: undefined,
65877
+ captureElement: element => {
65878
+ if (element) {
65879
+ groupEl = element;
65880
+ }
65881
+ },
65882
+ getElement: () => groupEl,
65883
+ register: openController => {
65884
+ memberControllerSet.add(openController);
65885
+ return () => {
65886
+ memberControllerSet.delete(openController);
65887
+ };
65888
+ },
65889
+ /**
65890
+ * Called by a popup of the group as it opens, before it shows itself.
65891
+ * `member`: { openController, popupElement, pressOutside, event,
65892
+ * pageScrolls, popupId }. `popupElement` is read at each press, so a
65893
+ * member whose element is drawn after it engaged (a callout) passes a
65894
+ * getter. `pageScrolls`: the page behind may scroll while it is open.
65895
+ * `popupId`: the id a data-navi-popup-inside names it by, when it is not
65896
+ * the popup element's own.
65897
+ */
65898
+ engage: member => {
65899
+ // One popup of the group at a time. A press on a peer has closed the
65900
+ // open one already (see armPressArbiter): this one opened from the
65901
+ // keyboard or from code.
65902
+ for (const openMember of getOpenMembers()) {
65903
+ if (openMember.openController === member.openController) {
65904
+ continue;
65905
+ }
65906
+ if (openMember.openController.requestClose(member.event)) {
65907
+ openMemberMap.delete(openMember.openController);
65908
+ }
65909
+ }
65910
+ openMemberMap.delete(member.openController);
65911
+ openMemberMap.set(member.openController, member);
65912
+ if (engagement) {
65913
+ engagement.cancelPendingCheck();
65914
+ } else {
65915
+ if (!groupEl || !groupEl.isConnected) {
65916
+ return;
65917
+ }
65918
+ engagement = engageGroup(groupEl, group, {
65919
+ silent: Boolean(member.event?.detail?.silent)
65920
+ });
65921
+ engagedGroupSet.add(group);
65922
+ }
65923
+ followScrollLock();
65924
+ },
65925
+ /**
65926
+ * Called by a popup of the group as it closes. Whether the group goes back
65927
+ * into the page is decided once nothing of the group is open — at the end
65928
+ * of the press that closed it when a press did, since that same press may
65929
+ * be opening a peer.
65930
+ */
65931
+ release: (openController, closeEvent) => {
65932
+ openMemberMap.delete(openController);
65933
+ if (!engagement) {
65934
+ return;
65935
+ }
65936
+ followScrollLock();
65937
+ if (findEvent(closeEvent, ["pointerdown", "mousedown", "pointerup", "mouseup", "click"])) {
65938
+ if (findEvent(closeEvent, "click") || !engagement.pressInFlight) {
65939
+ // Closed from inside the click, or after the press ended (a lifting
65940
+ // Dialog closes in a view transition's update callback, a frame
65941
+ // later): what is left of the press is over once this task is.
65942
+ engagement.checkAfterTask();
65943
+ return;
65944
+ }
65945
+ engagement.pressClosed = true;
65946
+ return;
65947
+ }
65948
+ // Not a press (Escape, a command, code): a peer opening in the same
65949
+ // stack, from the keyboard, is the only thing to wait for.
65950
+ engagement.checkAfterMicrotask();
65951
+ },
65952
+ getOpenMember: () => {
65953
+ const openMembers = getOpenMembers();
65954
+ return openMembers.length ? openMembers[openMembers.length - 1] : null;
65955
+ },
65956
+ disengageIfNothingOpen: () => {
65957
+ if (engagement && noMemberOpen()) {
65958
+ group.disengage();
65959
+ }
65960
+ },
65961
+ // Nothing of the group may stay open over a page that is live again.
65962
+ closeEverything: closeEvent => {
65963
+ for (const openMember of getOpenMembers()) {
65964
+ openMember.openController.close(closeEvent);
65965
+ }
65966
+ openMemberMap.clear();
65967
+ group.disengage();
65968
+ },
65969
+ disengage: ({
65970
+ leavingTree = false
65971
+ } = {}) => {
65972
+ if (!engagement) {
65973
+ return;
65974
+ }
65975
+ const leavingEngagement = engagement;
65976
+ engagement = null;
65977
+ engagedGroupSet.delete(group);
65978
+ leavingEngagement.cleanup({
65979
+ leavingTree
65980
+ });
65981
+ }
65982
+ };
65983
+ return group;
65984
+ };
65985
+ const engageGroup = (groupEl, group, {
65986
+ silent
65987
+ }) => {
65988
+ // Everything read before anything is written.
65989
+ const rect = groupEl.getBoundingClientRect();
65990
+ const groupStyle = getComputedStyle(groupEl);
65991
+ // Copied out: the style is live, and shown modal the group has its margins
65992
+ // and its bounds forced.
65993
+ const {
65994
+ marginLeft,
65995
+ marginRight,
65996
+ minWidth,
65997
+ maxWidth
65998
+ } = groupStyle;
65999
+ const ancestorClip = readAncestorClip(groupEl, groupStyle);
66000
+ const baselineDescent = readBaselineDescent(groupEl, groupStyle, rect);
66001
+ const themeColor = readPopupThemeColor(groupEl, group.themeColor);
66002
+ const focusedBefore = document.activeElement;
66003
+ const focusWasInGroup = Boolean(focusedBefore) && focusedBefore !== groupEl && groupEl.contains(focusedBefore);
66004
+ // What the focus goes back to, when nothing holds it any more at the close.
66005
+ const focusOrigin = !focusWasInGroup && focusedBefore && focusedBefore !== document.body && focusedBefore !== groupEl ? focusedBefore : null;
66006
+ const ringBefore = Boolean(focusedBefore?.matches?.(":focus-visible"));
66007
+ const {
66008
+ scrollTop,
66009
+ scrollLeft
66010
+ } = groupEl;
66011
+ // A gesture on a box fixed in the top layer goes to the viewport: only a
66012
+ // place that scrolls with the viewport moves under it.
66013
+ const placeScrollsWithViewport = getScrollContainer(groupEl) === document.scrollingElement && !findSelfOrAncestorFixedPosition(groupEl);
66014
+ const placeholder = createPlaceholder(groupStyle, rect, baselineDescent);
66015
+ groupEl.before(placeholder);
66016
+ groupEl.style.setProperty("--x-popup-group-padding", `${groupStyle.paddingTop} ${groupStyle.paddingRight} ${groupStyle.paddingBottom} ${groupStyle.paddingLeft}`);
66017
+ const visibleBox = writePlacement(groupEl, rect, ancestorClip);
66018
+ if (silent) {
66019
+ groupEl.setAttribute("navi-transitions-off", "");
66020
+ }
66021
+ // showModal() runs the dialog focusing steps — it focuses the first element
66022
+ // marked autofocus, or else the first focusable the group holds, and
66023
+ // scrolls the group's own scroller to it — and records what held the focus,
66024
+ // for close() to give it back there. A native autofocus is otherwise never
66025
+ // used (see the focus skill): these exist for the length of the call, to
66026
+ // steer the browser's own pick, not to transfer anything.
66027
+ //
66028
+ // From inside the group the pick is what holds the focus already, any other
66029
+ // mark set aside, so nothing moves. From outside, a stop is put first in the
66030
+ // group and focused: it is what showModal() picks and records, and it is
66031
+ // gone by the close, which then moves nothing (see cleanup) — what the focus
66032
+ // came from, a sheet around the group, could not be kept from taking it
66033
+ // back. The focus goes on to the group itself, so no peer is focused on the
66034
+ // way.
66035
+ let focusStop = null;
66036
+ if (!focusWasInGroup) {
66037
+ focusStop = document.createElement("span");
66038
+ focusStop.setAttribute("navi-out-of-flow", "");
66039
+ focusStop.setAttribute("aria-hidden", "true");
66040
+ focusStop.tabIndex = -1;
66041
+ focusStop.style.cssText = "position: absolute; width: 0; height: 0; overflow: hidden";
66042
+ groupEl.prepend(focusStop);
66043
+ moveFocusTo(focusStop, {
66044
+ focusVisible: false
66045
+ });
66046
+ }
66047
+ const autofocusTarget = focusWasInGroup ? focusedBefore : focusStop;
66048
+ const autofocusWasSet = autofocusTarget.hasAttribute("autofocus");
66049
+ const autofocusSetAside = [...groupEl.querySelectorAll("[autofocus]")].filter(element => element !== autofocusTarget);
66050
+ for (const element of autofocusSetAside) {
66051
+ element.removeAttribute("autofocus");
66052
+ }
66053
+ if (!autofocusWasSet) {
66054
+ autofocusTarget.setAttribute("autofocus", "");
66055
+ }
66056
+ // Chromium takes a <dialog> without a tabindex for no focus target. Lent
66057
+ // for the whole engagement: taken back from the group holding the focus,
66058
+ // it would lose it.
66059
+ const tabindexLent = Boolean(focusStop) && !groupEl.hasAttribute("tabindex");
66060
+ try {
66061
+ groupEl.showModal();
66062
+ } catch (error) {
66063
+ focusStop?.remove();
66064
+ placeholder.remove();
66065
+ clearPlacement(groupEl);
66066
+ groupEl.removeAttribute("navi-transitions-off");
66067
+ throw error;
66068
+ } finally {
66069
+ if (!autofocusWasSet) {
66070
+ autofocusTarget.removeAttribute("autofocus");
66071
+ }
66072
+ for (const element of autofocusSetAside) {
66073
+ element.setAttribute("autofocus", "");
66074
+ }
66075
+ }
66076
+ if (focusStop) {
66077
+ if (tabindexLent) {
66078
+ groupEl.tabIndex = -1;
66079
+ }
66080
+ moveFocusTo(groupEl, {
66081
+ focusVisible: false
66082
+ });
66083
+ focusStop.remove();
66084
+ }
66085
+ // Measured once the group has left the flow, where a parent sized by its
66086
+ // content no longer counts it. Only a box given its width by what holds it
66087
+ // goes on following that width (a rotation while engaged); one sized by its
66088
+ // content, bounded by a min or max width the placeholder does not carry, or
66089
+ // placed by margins read here as px (auto ones, percentages), is held at
66090
+ // the width it has.
66091
+ const placeholderWidth = placeholder.getBoundingClientRect().width;
66092
+ if (parseFloat(marginLeft) !== 0 || parseFloat(marginRight) !== 0 || maxWidth !== "none" || minWidth !== "auto" && parseFloat(minWidth) !== 0 || placeholderWidth < rect.width - 0.5 || placeholderWidth > rect.width + 0.5) {
66093
+ placeholder.style.width = `${rect.width}px`;
66094
+ }
66095
+ // Where the engine focused something else anyway.
66096
+ if (groupEl.scrollTop !== scrollTop) {
66097
+ groupEl.scrollTop = scrollTop;
66098
+ }
66099
+ if (groupEl.scrollLeft !== scrollLeft) {
66100
+ groupEl.scrollLeft = scrollLeft;
66101
+ }
66102
+ if (focusWasInGroup && document.activeElement !== focusedBefore) {
66103
+ moveFocusTo(focusedBefore, {
66104
+ focusVisible: ringBefore
66105
+ });
66106
+ }
66107
+ if (silent) {
66108
+ // The wall's starting style is resolved with no transition to play from
66109
+ // it; giving the transition back afterwards changes no value, so nothing
66110
+ // fades.
66111
+ groupEl.getBoundingClientRect();
66112
+ groupEl.removeAttribute("navi-transitions-off");
66113
+ }
66114
+ // Top-layer order is the order things were shown in: whatever was already
66115
+ // open inside the group (a callout on a peer) is now under it, and goes back
66116
+ // on top.
66117
+ for (const topLayerEl of groupEl.querySelectorAll(":popover-open")) {
66118
+ topLayerEl.hidePopover();
66119
+ topLayerEl.showPopover();
66120
+ }
66121
+ const [cleanups, addCleanup] = createCleanupList();
66122
+ const engagement = {
66123
+ // From a press's pointerdown to its click (or its pointercancel).
66124
+ pressInFlight: false,
66125
+ // A popup closed during the press in flight: its end decides.
66126
+ pressClosed: false,
66127
+ visibleBox,
66128
+ cleanup: ({
66129
+ leavingTree
66130
+ }) => {
66131
+ for (const cleanup of cleanups) {
66132
+ cleanup();
66133
+ }
66134
+ clearPlacement(groupEl);
66135
+ placeholder.remove();
66136
+ if (leavingTree) {
66137
+ // Closed once the commit is over, as a Dialog leaving the tree is (see
66138
+ // its close cleanup). A park takes the group out of the top layer and
66139
+ // leaves its `open` attribute, which the reveal would show.
66140
+ queueMicrotask(() => {
66141
+ if (groupEl.open) {
66142
+ groupEl.close();
66143
+ }
66144
+ if (tabindexLent) {
66145
+ groupEl.removeAttribute("tabindex");
66146
+ }
66147
+ });
66148
+ if (focusOrigin) {
66149
+ // The focus left with the group: back where it was, if that stays.
66150
+ giveFocusBackAfterCommit(focusOrigin, () => {
66151
+ moveFocusTo(focusOrigin, {
66152
+ focusVisible: ringBefore
66153
+ });
66154
+ });
66155
+ }
66156
+ return;
66157
+ }
66158
+ const focusedBeforeClose = document.activeElement;
66159
+ if (!focusWasInGroup) {
66160
+ // What showModal() recorded is the stop, gone by now: close() moves
66161
+ // nothing, and the focus stays where a popup of the group, or the
66162
+ // user, put it. Only one still on the group, or lost, goes back to
66163
+ // where it came from.
66164
+ if (groupEl.open) {
66165
+ groupEl.close();
66166
+ }
66167
+ if (focusOrigin && focusOrigin.isConnected && (!focusedBeforeClose || focusedBeforeClose === document.body || focusedBeforeClose === groupEl)) {
66168
+ moveFocusTo(focusOrigin, {
66169
+ focusVisible: ringBefore
66170
+ });
66171
+ }
66172
+ if (tabindexLent) {
66173
+ groupEl.removeAttribute("tabindex");
66174
+ }
66175
+ return;
66176
+ }
66177
+ // Coming from inside the group, the focus was recorded where it was,
66178
+ // and close() gives it back there, undoing where a popup of the group,
66179
+ // or the user, put it since. Unless nothing moved it, that element is
66180
+ // made no focus target for the call: inert — or, holding the focus
66181
+ // inside it, without its tabindex, inert taking the focus with it.
66182
+ const ringBeforeClose = Boolean(focusedBeforeClose?.matches?.(":focus-visible"));
66183
+ const focusMovedInGroup = focusWasInGroup && focusedBeforeClose && focusedBeforeClose !== document.body && focusedBeforeClose !== groupEl && focusedBeforeClose !== focusedBefore && focusedBeforeClose.isConnected && focusedBefore.isConnected;
66184
+ const holdsFocusInside = focusMovedInGroup && focusedBefore.contains(focusedBeforeClose);
66185
+ const madeInert = focusMovedInGroup && !holdsFocusInside && !focusedBefore.inert;
66186
+ const tabindexSetAside = holdsFocusInside ? focusedBefore.getAttribute("tabindex") : null;
66187
+ if (madeInert) {
66188
+ focusedBefore.inert = true;
66189
+ }
66190
+ if (tabindexSetAside !== null) {
66191
+ focusedBefore.removeAttribute("tabindex");
66192
+ }
66193
+ try {
66194
+ if (groupEl.open) {
66195
+ groupEl.close();
66196
+ }
66197
+ } finally {
66198
+ if (madeInert) {
66199
+ focusedBefore.inert = false;
66200
+ }
66201
+ if (tabindexSetAside !== null) {
66202
+ focusedBefore.setAttribute("tabindex", tabindexSetAside);
66203
+ }
66204
+ if (tabindexLent) {
66205
+ groupEl.removeAttribute("tabindex");
66206
+ }
66207
+ }
66208
+ // Where the engine moved it anyway: an element focusable without a
66209
+ // tabindex.
66210
+ if (focusMovedInGroup && document.activeElement !== focusedBeforeClose) {
66211
+ moveFocusTo(focusedBeforeClose, {
66212
+ focusVisible: ringBeforeClose
66213
+ });
66214
+ }
66215
+ }
66216
+ };
66217
+ let pendingCheck = null;
66218
+ engagement.cancelPendingCheck = () => {
66219
+ if (pendingCheck) {
66220
+ pendingCheck.cancel();
66221
+ pendingCheck = null;
66222
+ }
66223
+ };
66224
+ engagement.checkAfterTask = (delay = 0) => {
66225
+ engagement.cancelPendingCheck();
66226
+ const timeout = setTimeout(() => {
66227
+ pendingCheck = null;
66228
+ group.disengageIfNothingOpen();
66229
+ }, delay);
66230
+ pendingCheck = {
66231
+ cancel: () => {
66232
+ clearTimeout(timeout);
66233
+ }
66234
+ };
66235
+ };
66236
+ engagement.checkAfterMicrotask = () => {
66237
+ engagement.cancelPendingCheck();
66238
+ let cancelled = false;
66239
+ queueMicrotask(() => {
66240
+ if (cancelled) {
66241
+ return;
66242
+ }
66243
+ pendingCheck = null;
66244
+ group.disengageIfNothingOpen();
66245
+ });
66246
+ pendingCheck = {
66247
+ cancel: () => {
66248
+ cancelled = true;
66249
+ }
66250
+ };
66251
+ };
66252
+ addCleanup(engagement.cancelPendingCheck);
66253
+ addCleanup(armPressArbiter(groupEl, group, engagement));
66254
+ addCleanup(followPlaceholder(groupEl, placeholder, engagement));
66255
+ // A wheel or a finger on the wall would scroll the page behind, and with it
66256
+ // the place the group is pinned to. On the wall means outside what is shown
66257
+ // of the group, which a clip can make less than its box. Let go for a
66258
+ // callout only where the group's place scrolls with the viewport: in a
66259
+ // sheet, a list scrolling on its own, a fixed bar, the gesture would scroll
66260
+ // the document behind and never the box the group stands in.
66261
+ let releaseScrollLock = null;
66262
+ const letGoOfScroll = () => {
66263
+ if (releaseScrollLock) {
66264
+ releaseScrollLock();
66265
+ releaseScrollLock = null;
66266
+ }
66267
+ };
66268
+ engagement.followScrollLock = openMemberHoldsPage => {
66269
+ if (!openMemberHoldsPage && placeScrollsWithViewport) {
66270
+ letGoOfScroll();
66271
+ return;
66272
+ }
66273
+ if (!releaseScrollLock) {
66274
+ releaseScrollLock = trapScrollInside(groupEl, {
66275
+ backdrop: true,
66276
+ getElementBox: () => engagement.visibleBox,
66277
+ // The group is no scroll container: shown fixed in the top layer, a
66278
+ // gesture over what it holds, or a scroll key pressed in it, would go
66279
+ // straight on to the page.
66280
+ containInside: true
66281
+ });
66282
+ }
66283
+ };
66284
+ addCleanup(letGoOfScroll);
66285
+ if (themeColor !== "none") {
66286
+ addCleanup(pushPopupThemeColor({
66287
+ themeColor,
66288
+ backdropColor: readBackdropColor(groupEl)
66289
+ }));
66290
+ }
66291
+
66292
+ // Escape and the back gesture reach the topmost modal, which is the group:
66293
+ // they are the open popup's to answer, as they would be with focus in it.
66294
+ // Escape is answered at its keydown, before it becomes the browser's close
66295
+ // request: a cancel can be prevented only while the page has a user
66296
+ // activation to spend, and Escape gives none — at the second one the
66297
+ // browser closes the group itself, whatever the open popup said. A keydown
66298
+ // prevented is no close request at all. Left to what the key was pressed in
66299
+ // when that uses it (a search field clearing itself), as the open popup's
66300
+ // own shortcut leaves it.
66301
+ const onKeyDown = keydownEvent => {
66302
+ if (keydownEvent.key !== "Escape" || keydownEvent.defaultPrevented) {
66303
+ // Answered already: by the open popup's own shortcut, when the focus is
66304
+ // in it.
66305
+ return;
66306
+ }
66307
+ if (keydownEvent.target !== groupEl) {
66308
+ const defaultAction = getKeyboardEventDefaultAction(keydownEvent);
66309
+ if (defaultAction && defaultAction !== "dismiss") {
66310
+ return;
66311
+ }
66312
+ }
66313
+ const openMember = group.getOpenMember();
66314
+ if (!openMember) {
66315
+ return;
66316
+ }
66317
+ // requestClose prevents the keydown, whether the popup closes or refuses.
66318
+ openMember.openController.requestClose(keydownEvent, {
66319
+ isCancel: true
66320
+ });
66321
+ };
66322
+ groupEl.addEventListener("keydown", onKeyDown);
66323
+ addCleanup(() => {
66324
+ groupEl.removeEventListener("keydown", onKeyDown);
66325
+ });
66326
+ // What still reaches the group as a close request: the back gesture, an
66327
+ // Escape with nothing of the group open.
66328
+ const onCancel = cancelEvent => {
66329
+ if (cancelEvent.target !== cancelEvent.currentTarget) {
66330
+ // A file input inside the group cancelling its own picker.
66331
+ return;
66332
+ }
66333
+ cancelEvent.preventDefault();
66334
+ const openMember = group.getOpenMember();
66335
+ if (openMember) {
66336
+ openMember.openController.requestClose(cancelEvent, {
66337
+ isCancel: true
66338
+ });
66339
+ return;
66340
+ }
66341
+ group.disengageIfNothingOpen();
66342
+ };
66343
+ groupEl.addEventListener("cancel", onCancel);
66344
+ addCleanup(() => {
66345
+ groupEl.removeEventListener("cancel", onCancel);
66346
+ });
66347
+ // The browser may close the group itself: a close request with no user
66348
+ // activation left to spend (a second back gesture) ignores preventDefault.
66349
+ const onNativeClose = closeEvent => {
66350
+ if (groupEl.open) {
66351
+ // Queued by an earlier engagement's close(): the group has been shown
66352
+ // modal again since, and nothing closed it.
66353
+ return;
66354
+ }
66355
+ group.closeEverything(closeEvent);
66356
+ };
66357
+ groupEl.addEventListener("close", onNativeClose);
66358
+ addCleanup(() => {
66359
+ groupEl.removeEventListener("close", onNativeClose);
66360
+ });
66361
+ // What holds the group closing (the sheet it is in) does not close the
66362
+ // group: a modal left open in a closed dialog keeps the whole document inert
66363
+ // with nothing on screen to dismiss. A navi popup closes the groups inside it
66364
+ // itself, before it gives the focus back (closePopupGroupsInside); this is
66365
+ // for any other.
66366
+ const openableAncestor = closestOpenableAncestor(groupEl);
66367
+ if (openableAncestor) {
66368
+ addCleanup(observeAncestorOpenState(openableAncestor, ({
66369
+ isOpen
66370
+ }) => {
66371
+ if (isOpen) {
66372
+ return;
66373
+ }
66374
+ group.closeEverything(new CustomEvent("popup_group_ancestor_close", {
66375
+ detail: {}
66376
+ }));
66377
+ }));
66378
+ }
66379
+ return engagement;
66380
+ };
66381
+
66382
+ /**
66383
+ * Hears every press while the group is engaged, for the popup of the group
66384
+ * that is open. A press outside that popup closes it, per its pressOutside,
66385
+ * and goes on to what it landed on: another popup's trigger opens it, a button
66386
+ * does its thing. A press on the wall — the ::backdrop reports the group as its
66387
+ * target, outside its box — closes and reaches nothing else, everything behind
66388
+ * being inert.
66389
+ *
66390
+ * Decided on the press's mousedown, the event the page acts on: on a touch
66391
+ * screen it is synthesized at touchend and hit-tested again, with the group
66392
+ * still up, so a finger that scrolled the group (pointercancel, no mousedown)
66393
+ * closes nothing. A pointerdown cancelled downstream (a drag source) brings no
66394
+ * mousedown: its click decides instead, or the start of the drag it turns
66395
+ * into, which brings no click. A popup that captures the outside is
66396
+ * answered at the pointerdown itself: nothing of that press may reach a peer.
66397
+ */
66398
+ const armPressArbiter = (groupEl, group, engagement) => {
66399
+ let press = null;
66400
+ const isOnWall = event => {
66401
+ if (event.target !== groupEl) {
66402
+ return false;
66403
+ }
66404
+ const {
66405
+ left,
66406
+ top,
66407
+ right,
66408
+ bottom
66409
+ } = engagement.visibleBox;
66410
+ return event.clientX < left || event.clientX > right || event.clientY < top || event.clientY > bottom;
66411
+ };
66412
+ const spend = event => {
66413
+ event.preventDefault();
66414
+ event.stopImmediatePropagation();
66415
+ };
66416
+ // The open popup this press is outside of, or null.
66417
+ const findMemberPressedOutside = event => {
66418
+ const openMember = group.getOpenMember();
66419
+ if (!openMember) {
66420
+ return null;
66421
+ }
66422
+ if (openedDuringThisPress(openMember.openController)) {
66423
+ // The release of the press that opened it.
66424
+ return null;
66425
+ }
66426
+ const {
66427
+ target
66428
+ } = event;
66429
+ const {
66430
+ popupElement
66431
+ } = openMember;
66432
+ if (popupElement.contains(target)) {
66433
+ return null;
66434
+ }
66435
+ if (isDeclaredInsidePopup(target, openMember.popupId || popupElement.id)) {
66436
+ return null;
66437
+ }
66438
+ // In front of the open popup: something open inside the group that is
66439
+ // neither that popup nor around it (a callout on a peer).
66440
+ const layerUnderPointer = target.closest?.(':popover-open, dialog[open]:not([navi-popup-group]), [navi-control="popover"][aria-expanded="true"]');
66441
+ if (layerUnderPointer && !layerUnderPointer.contains(popupElement) && !popupElement.contains(layerUnderPointer)) {
66442
+ return null;
66443
+ }
66444
+ return openMember;
66445
+ };
66446
+ const readPressOutside = openMember => readPressOutsideInPopupGroup(openMember.pressOutside);
66447
+ const arbitrate = event => {
66448
+ const openMember = findMemberPressedOutside(event);
66449
+ if (!openMember) {
66450
+ return;
66451
+ }
66452
+ const pressOutside = readPressOutside(openMember);
66453
+ if (pressOutside === "capture") {
66454
+ spend(event);
66455
+ press.spendClick = true;
66456
+ return;
66457
+ }
66458
+ // A press on a peer goes on to it, so the close is told the pointerdown:
66459
+ // told the mousedown, the focus restore cancels that mousedown, and the
66460
+ // peer's own reaction to it with it (a picker opening at mousedown).
66461
+ let closeEvent;
66462
+ if (isOnWall(event)) {
66463
+ closeEvent = event;
66464
+ } else if (event.type === "click" && press.pointerdown) {
66465
+ // Decided at the click (the pointerdown was cancelled): the close is
66466
+ // told both, so what holds a reopen back for this press knows the press
66467
+ // ends with this very click.
66468
+ closeEvent = new CustomEvent("popup_group_press_outside", {
66469
+ detail: {
66470
+ event,
66471
+ eventChain: [press.pointerdown, event]
66472
+ }
66473
+ });
66474
+ } else {
66475
+ closeEvent = press.pointerdown || event;
66476
+ }
66477
+ const closed = openMember.openController.requestClose(closeEvent, {
66478
+ isCancel: pressOutside === "cancel"
66479
+ });
66480
+ if (!closed) {
66481
+ // The popup refuses to be left (a value it cannot note): the press must
66482
+ // not go on to open a peer next to it.
66483
+ spend(event);
66484
+ press.spendClick = true;
66485
+ }
66486
+ };
66487
+ const onPointerDown = pointerDownEvent => {
66488
+ if (engagement.pressClosed) {
66489
+ // The press that closed the last popup ended without a click (a gesture
66490
+ // swallowed it): this one is a new press.
66491
+ engagement.pressClosed = false;
66492
+ group.disengageIfNothingOpen();
66493
+ if (!groupEl.open) {
66494
+ return;
66495
+ }
66496
+ }
66497
+ press = {
66498
+ pointerdown: pointerDownEvent,
66499
+ mousedown: null,
66500
+ spendClick: false
66501
+ };
66502
+ if (pointerDownEvent.button !== 0) {
66503
+ return;
66504
+ }
66505
+ engagement.pressInFlight = true;
66506
+ const openMember = findMemberPressedOutside(pointerDownEvent);
66507
+ if (openMember && readPressOutside(openMember) === "capture") {
66508
+ // Cancelling the pointerdown also cancels the mousedown it would bring.
66509
+ spend(pointerDownEvent);
66510
+ press.spendClick = true;
66511
+ }
66512
+ };
66513
+ const onMouseDown = mouseDownEvent => {
66514
+ if (mouseDownEvent.button !== 0) {
66515
+ return;
66516
+ }
66517
+ if (!press) {
66518
+ press = {
66519
+ pointerdown: null,
66520
+ mousedown: null,
66521
+ spendClick: false
66522
+ };
66523
+ }
66524
+ engagement.pressInFlight = true;
66525
+ press.mousedown = mouseDownEvent;
66526
+ if (press.spendClick) {
66527
+ spend(mouseDownEvent);
66528
+ return;
66529
+ }
66530
+ arbitrate(mouseDownEvent);
66531
+ };
66532
+ const onClick = clickEvent => {
66533
+ if (!isPressDrivenClick(clickEvent)) {
66534
+ // A keyboard activation or a click() from code: not the click of the
66535
+ // press in flight, which the next pointerdown replaces anyway.
66536
+ return;
66537
+ }
66538
+ if (press?.spendClick) {
66539
+ spend(clickEvent);
66540
+ } else if (press && !press.mousedown) {
66541
+ arbitrate(clickEvent);
66542
+ }
66543
+ press = null;
66544
+ engagement.pressInFlight = false;
66545
+ if (engagement.pressClosed) {
66546
+ engagement.pressClosed = false;
66547
+ // After the click's dispatch: a peer it opens counts.
66548
+ engagement.checkAfterTask();
66549
+ }
66550
+ };
66551
+ const onPointerUp = () => {
66552
+ if (engagement.pressClosed) {
66553
+ engagement.checkAfterTask(END_OF_PRESS_SAFETY_MS);
66554
+ }
66555
+ };
66556
+ const onPointerCancel = () => {
66557
+ press = null;
66558
+ engagement.pressInFlight = false;
66559
+ if (engagement.pressClosed) {
66560
+ engagement.pressClosed = false;
66561
+ engagement.checkAfterTask();
66562
+ }
66563
+ };
66564
+ // A press that becomes a drag brings no click: decided at the click, it
66565
+ // would leave the open popup over a card being carried away, its own
66566
+ // trigger maybe. A drag starting outside the open popup closes it, as a
66567
+ // press there does. Nothing is left to hand over to then, so the group goes
66568
+ // back into the page at once — where the drag can land: what is outside the
66569
+ // group is inert while it is engaged.
66570
+ const onDragStart = dragStartEvent => {
66571
+ const openMember = findMemberPressedOutside(dragStartEvent);
66572
+ if (openMember) {
66573
+ const pressOutside = readPressOutside(openMember);
66574
+ if (pressOutside !== "capture") {
66575
+ openMember.openController.requestClose(dragStartEvent, {
66576
+ isCancel: pressOutside === "cancel"
66577
+ });
66578
+ }
66579
+ }
66580
+ if (engagement.pressClosed) {
66581
+ engagement.pressClosed = false;
66582
+ group.disengageIfNothingOpen();
66583
+ }
66584
+ };
66585
+ const listeners = [["pointerdown", onPointerDown], ["mousedown", onMouseDown], ["click", onClick], ["pointerup", onPointerUp], ["pointercancel", onPointerCancel], ["navi_drag_start", onDragStart]];
66586
+ for (const [type, listener] of listeners) {
66587
+ document.addEventListener(type, listener, {
66588
+ capture: true
66589
+ });
66590
+ }
66591
+ return () => {
66592
+ for (const [type, listener] of listeners) {
66593
+ document.removeEventListener(type, listener, {
66594
+ capture: true
66595
+ });
66596
+ }
66597
+ };
66598
+ };
66599
+
66600
+ // The group follows the place it holds in the page: a scroll of what holds it,
66601
+ // the viewport resizing (a keyboard, a rotation), the page reflowing around
66602
+ // it, a popup it is in being placed again. Its own scroll moves nothing.
66603
+ const followPlaceholder = (groupEl, placeholder, engagement) => {
66604
+ const [cleanups, addCleanup] = createCleanupList();
66605
+ // An ancestor's movement may end after this engagement did, and the group a
66606
+ // later one engaged is the same element.
66607
+ let disposed = false;
66608
+ addCleanup(() => {
66609
+ disposed = true;
66610
+ });
66611
+ const update = () => {
66612
+ if (disposed) {
66613
+ return;
66614
+ }
66615
+ const rect = placeholder.getBoundingClientRect();
66616
+ const ancestorClip = readAncestorClip(placeholder, getComputedStyle(placeholder));
66617
+ engagement.visibleBox = writePlacement(groupEl, rect, ancestorClip);
66618
+ };
66619
+ const onScroll = scrollEvent => {
66620
+ if (scrollEvent.target === groupEl || groupEl.contains(scrollEvent.target)) {
66621
+ return;
66622
+ }
66623
+ update();
66624
+ };
66625
+ document.addEventListener("scroll", onScroll, {
66626
+ capture: true,
66627
+ passive: true
66628
+ });
66629
+ window.addEventListener("resize", update);
66630
+ const {
66631
+ visualViewport
66632
+ } = window;
66633
+ visualViewport?.addEventListener("resize", update);
66634
+ addCleanup(() => {
66635
+ document.removeEventListener("scroll", onScroll, {
66636
+ capture: true
66637
+ });
66638
+ window.removeEventListener("resize", update);
66639
+ visualViewport?.removeEventListener("resize", update);
66640
+ });
66641
+ const resizeObserver = new ResizeObserver(update);
66642
+ if (placeholder.parentElement) {
66643
+ resizeObserver.observe(placeholder.parentElement);
66644
+ }
66645
+ addCleanup(() => {
66646
+ resizeObserver.disconnect();
66647
+ });
66648
+ // A popup the group is in moves by a translate, animated, and says so only
66649
+ // to itself (applyNewPosition): followed frame by frame while it moves, as
66650
+ // visibleRectEffect follows it for what is anchored inside it.
66651
+ let ancestor = closestOpenableAncestor(groupEl);
66652
+ while (ancestor) {
66653
+ const openableAncestor = ancestor;
66654
+ let positionTransitionFrame = null;
66655
+ const onPositionChange = () => {
66656
+ update();
66657
+ };
66658
+ const onPositionTransition = positionTransitionEvent => {
66659
+ cancelAnimationFrame(positionTransitionFrame);
66660
+ const loop = () => {
66661
+ update();
66662
+ positionTransitionFrame = requestAnimationFrame(loop);
66663
+ };
66664
+ loop();
66665
+ positionTransitionEvent.detail.onEnd(() => {
66666
+ cancelAnimationFrame(positionTransitionFrame);
66667
+ positionTransitionFrame = null;
66668
+ update();
66669
+ });
66670
+ };
66671
+ openableAncestor.addEventListener("navi_position_change", onPositionChange);
66672
+ openableAncestor.addEventListener("navi_position_transition", onPositionTransition);
66673
+ addCleanup(() => {
66674
+ cancelAnimationFrame(positionTransitionFrame);
66675
+ openableAncestor.removeEventListener("navi_position_change", onPositionChange);
66676
+ openableAncestor.removeEventListener("navi_position_transition", onPositionTransition);
66677
+ });
66678
+ ancestor = closestOpenableAncestor(openableAncestor);
66679
+ }
66680
+ return () => {
66681
+ for (const cleanup of cleanups) {
66682
+ cleanup();
66683
+ }
66684
+ };
66685
+ };
66686
+
66687
+ // What stands in the page for the group while it is in the top layer: the same
66688
+ // outer box at the height it had, invisible, counted by nothing that skips
66689
+ // what navi puts in a tree (css_architecture.md, "Counting children").
66690
+ const createPlaceholder = (groupStyle, rect, baselineDescent) => {
66691
+ const placeholder = document.createElement("div");
66692
+ placeholder.setAttribute("navi-popup-group-placeholder", "");
66693
+ placeholder.setAttribute("navi-out-of-flow", "");
66694
+ placeholder.setAttribute("aria-hidden", "true");
66695
+ const {
66696
+ style
66697
+ } = placeholder;
66698
+ if (groupStyle.display.startsWith("inline")) {
66699
+ style.display = "inline-block";
66700
+ } else if (establishesFormattingContext(groupStyle)) {
66701
+ // Such a box keeps clear of a float beside it, where a plain block slides
66702
+ // under it.
66703
+ style.display = "flow-root";
66704
+ } else {
66705
+ style.display = "block";
66706
+ }
66707
+ style.visibility = "hidden";
66708
+ style.pointerEvents = "none";
66709
+ style.boxSizing = "border-box";
66710
+ style.height = `${rect.height}px`;
66711
+ style.flex = "none";
66712
+ for (const property of PLACEHOLDER_COPIED_PROPERTIES) {
66713
+ style[property] = groupStyle[property];
66714
+ }
66715
+ if (baselineDescent !== null) {
66716
+ // An empty inline box sits on its bottom edge; the group sat on its own
66717
+ // baseline, this far above its bottom.
66718
+ style.verticalAlign = `${-baselineDescent}px`;
66719
+ }
66720
+ return placeholder;
66721
+ };
66722
+ const establishesFormattingContext = style => {
66723
+ const {
66724
+ display,
66725
+ overflowX,
66726
+ overflowY,
66727
+ contain,
66728
+ containerType,
66729
+ contentVisibility,
66730
+ columnCount,
66731
+ columnWidth
66732
+ } = style;
66733
+ if (display === "flow-root" || display === "flex" || display === "grid" || display === "table" || display.includes("flow-root")) {
66734
+ return true;
66735
+ }
66736
+ if (overflowX !== "visible" && overflowX !== "clip" || overflowY !== "visible" && overflowY !== "clip") {
66737
+ return true;
66738
+ }
66739
+ // Containment the computed `contain` does not show: a size container, a box
66740
+ // whose content-visibility skips its content.
66741
+ if (/\b(?:size|inline-size)\b/.test(containerType) || contentVisibility === "auto" || contentVisibility === "hidden") {
66742
+ return true;
66743
+ }
66744
+ // A multi-column box.
66745
+ if (columnCount !== "auto" || columnWidth !== "auto") {
66746
+ return true;
66747
+ }
66748
+ return /\b(?:layout|paint|strict|content)\b/.test(contain);
66749
+ };
66750
+ const PLACEHOLDER_COPIED_PROPERTIES = ["position", "top", "right", "bottom", "left", "marginTop", "marginRight", "marginBottom", "marginLeft", "alignSelf", "justifySelf", "order", "gridRowStart", "gridRowEnd", "gridColumnStart", "gridColumnEnd", "float", "verticalAlign"];
66751
+
66752
+ // How far below the line's baseline an inline-level group on that baseline
66753
+ // reaches: what the placeholder, which has no line of its own, has to sink by.
66754
+ const readBaselineDescent = (groupEl, groupStyle, rect) => {
66755
+ if (!groupStyle.display.startsWith("inline") || groupStyle.verticalAlign !== "baseline") {
66756
+ return null;
66757
+ }
66758
+ const marginTop = parseFloat(groupStyle.marginTop) || 0;
66759
+ const marginBottom = parseFloat(groupStyle.marginBottom) || 0;
66760
+ const readBaselineY = insert => {
66761
+ const probe = document.createElement("span");
66762
+ probe.style.cssText = "display:inline-block;width:0;height:0;vertical-align:baseline";
66763
+ insert(probe);
66764
+ const baselineY = probe.getBoundingClientRect().bottom;
66765
+ probe.remove();
66766
+ return baselineY;
66767
+ };
66768
+ // On the group's own line, between its top and its bottom margin edge: a
66769
+ // probe that takes no room can stay at the end of the line before it.
66770
+ const isOnGroupLine = y => y >= rect.top - marginTop - 0.5 && y <= rect.bottom + marginBottom + 0.5;
66771
+ let baselineY = readBaselineY(probe => groupEl.before(probe));
66772
+ if (!isOnGroupLine(baselineY)) {
66773
+ baselineY = readBaselineY(probe => groupEl.after(probe));
66774
+ if (!isOnGroupLine(baselineY)) {
66775
+ return null;
66776
+ }
66777
+ }
66778
+ // Down to the margin edge: that is what an empty inline box sits on.
66779
+ return rect.bottom + marginBottom - baselineY;
66780
+ };
66781
+
66782
+ // Writes where the group stands and how its ancestors clip it; returns what of
66783
+ // it is on screen.
66784
+ const writePlacement = (groupEl, rect, ancestorClip) => {
66785
+ const {
66786
+ style
66787
+ } = groupEl;
66788
+ style.setProperty("--x-popup-group-top", `${rect.top}px`);
66789
+ style.setProperty("--x-popup-group-left", `${rect.left}px`);
66790
+ style.setProperty("--x-popup-group-width", `${rect.width}px`);
66791
+ style.setProperty("--x-popup-group-height", `${rect.height}px`);
66792
+ if (ancestorClip) {
66793
+ // From the group's border box, reaching past it on a side nothing clips:
66794
+ // what overflows the group was painted up to its ancestors' edge.
66795
+ const toInset = distance => distance === -Infinity ? "-100vmax" : `${distance}px`;
66796
+ style.setProperty("--x-popup-group-clip", `inset(${toInset(ancestorClip.top - rect.top)} ${toInset(rect.right - ancestorClip.right)} ${toInset(rect.bottom - ancestorClip.bottom)} ${toInset(ancestorClip.left - rect.left)})`);
66797
+ } else {
66798
+ style.removeProperty("--x-popup-group-clip");
66799
+ }
66800
+ return intersectBox(rect, ancestorClip);
66801
+ };
66802
+ const clearPlacement = groupEl => {
66803
+ const {
66804
+ style
66805
+ } = groupEl;
66806
+ style.removeProperty("--x-popup-group-top");
66807
+ style.removeProperty("--x-popup-group-left");
66808
+ style.removeProperty("--x-popup-group-width");
66809
+ style.removeProperty("--x-popup-group-height");
66810
+ style.removeProperty("--x-popup-group-padding");
66811
+ style.removeProperty("--x-popup-group-clip");
66812
+ };
66813
+
66814
+ // The clip the ancestors of `element` put on it, up to the first box nothing
66815
+ // around clips (a top-layer or fixed one), -Infinity/Infinity on a side
66816
+ // nothing clips — or null when nothing clips it at all.
66817
+ const readAncestorClip = (element, elementStyle) => {
66818
+ if (elementStyle.position === "fixed") {
66819
+ return null;
66820
+ }
66821
+ let left = -Infinity;
66822
+ let top = -Infinity;
66823
+ let right = Infinity;
66824
+ let bottom = Infinity;
66825
+ let clipped = false;
66826
+ // An absolutely placed box escapes the clip of every ancestor between it
66827
+ // and its containing block: those are skipped.
66828
+ let skipUntilContainingBlock = elementStyle.position === "absolute";
66829
+ let ancestor = element.parentElement;
66830
+ while (ancestor && ancestor !== document.documentElement) {
66831
+ const ancestorStyle = getComputedStyle(ancestor);
66832
+ if (skipUntilContainingBlock && !isContainingBlockForAbsolute(ancestorStyle)) {
66833
+ ancestor = ancestor.parentElement;
66834
+ continue;
66835
+ }
66836
+ skipUntilContainingBlock = false;
66837
+ const {
66838
+ overflowX,
66839
+ overflowY,
66840
+ position
66841
+ } = ancestorStyle;
66842
+ // Read on a body whose overflow is the viewport's, it clips nothing.
66843
+ const clipsNothing = overflowGoesToViewport(ancestor);
66844
+ const clipsX = !clipsNothing && overflowX !== "visible";
66845
+ const clipsY = !clipsNothing && overflowY !== "visible";
66846
+ if (clipsX || clipsY) {
66847
+ clipped = true;
66848
+ const ancestorRect = ancestor.getBoundingClientRect();
66849
+ const innerLeft = ancestorRect.left + ancestor.clientLeft;
66850
+ const innerTop = ancestorRect.top + ancestor.clientTop;
66851
+ if (clipsX) {
66852
+ const innerRight = innerLeft + ancestor.clientWidth;
66853
+ if (innerLeft > left) {
66854
+ left = innerLeft;
66855
+ }
66856
+ if (innerRight < right) {
66857
+ right = innerRight;
66858
+ }
66859
+ }
66860
+ if (clipsY) {
66861
+ const innerBottom = innerTop + ancestor.clientHeight;
66862
+ if (innerTop > top) {
66863
+ top = innerTop;
66864
+ }
66865
+ if (innerBottom < bottom) {
66866
+ bottom = innerBottom;
66867
+ }
66868
+ }
66869
+ }
66870
+ if (position === "fixed" || ancestor.matches(":popover-open, dialog:modal")) {
66871
+ break;
66872
+ }
66873
+ if (position === "absolute") {
66874
+ skipUntilContainingBlock = true;
66875
+ }
66876
+ ancestor = ancestor.parentElement;
66877
+ }
66878
+ return clipped ? {
66879
+ left,
66880
+ top,
66881
+ right,
66882
+ bottom
66883
+ } : null;
66884
+ };
66885
+ const isContainingBlockForAbsolute = style => style.position !== "static" || style.transform !== "none" || style.translate !== "none" || style.rotate !== "none" || style.scale !== "none" || style.perspective !== "none" || style.filter !== "none" ||
66886
+ // Prefixed alone before Safari 18.
66887
+ (style.backdropFilter ?? style.webkitBackdropFilter ?? "none") !== "none" || /\b(?:layout|paint|strict|content)\b/.test(style.contain) ||
66888
+ // Layout containment the computed `contain` does not show.
66889
+ style.contentVisibility === "auto" || /\b(?:size|inline-size)\b/.test(style.containerType) || /\b(?:transform|translate|rotate|scale|perspective|filter|backdrop-filter|contain)\b/.test(style.willChange);
66890
+ const intersectBox = (rect, clip) => {
66891
+ let {
66892
+ left,
66893
+ top,
66894
+ right,
66895
+ bottom
66896
+ } = rect;
66897
+ if (clip) {
66898
+ if (clip.left > left) {
66899
+ left = clip.left;
66900
+ }
66901
+ if (clip.top > top) {
66902
+ top = clip.top;
66903
+ }
66904
+ if (clip.right < right) {
66905
+ right = clip.right;
66906
+ }
66907
+ if (clip.bottom < bottom) {
66908
+ bottom = clip.bottom;
66909
+ }
66910
+ }
66911
+ if (right < left) {
66912
+ right = left;
66913
+ }
66914
+ if (bottom < top) {
66915
+ bottom = top;
66916
+ }
66917
+ return {
66918
+ left,
66919
+ top,
66920
+ right,
66921
+ bottom
66922
+ };
66923
+ };
66924
+ const createCleanupList = () => {
66925
+ const cleanups = [];
66926
+ return [cleanups, cleanup => {
66927
+ cleanups.push(cleanup);
66928
+ }];
66929
+ };
66930
+
65547
66931
  /**
65548
66932
  * Where the "popover or dialog?" answer lives, for both the components that
65549
66933
  * decide it and the content that renders inside one.
@@ -67294,7 +68678,8 @@ const css$E = /* css */`
67294
68678
  * wall is shown through the Popover API rather than `showModal()`, which is
67295
68679
  * what a sheet docked to the screen's bottom edge over a still-readable map
67296
68680
  * needs. What it gives up is what only a modal gets natively: the hardware
67297
- * back button no longer dismisses it. See docs/popup_backdrop.md.
68681
+ * back button no longer dismisses it. Not read inside a `<PopupGroup>`,
68682
+ * whose wall it is. See docs/popup_backdrop.md.
67298
68683
  * @param {"close"|"cancel"|"capture"|"ignore"} [props.pressOutside="close"]
67299
68684
  * - `"close"` closes the dialog on an outside press, `"cancel"` closes it
67300
68685
  * as a cancel (what it holds reverts, as on Escape). `"capture"`/`"ignore"`
@@ -67851,7 +69236,7 @@ const useDialogProps = props => {
67851
69236
  // before how it is painted (backdropVariant below). It also picks the show
67852
69237
  // call for layer="top": a wall is what showModal() is for, and without one
67853
69238
  // the dialog goes to the same top layer through the Popover API instead.
67854
- backdrop = true,
69239
+ backdrop: backdropProp = true,
67855
69240
  // How loudly the backdrop says it is there — independent of what it
67856
69241
  // *does* (that's pressOutside above). "lift" is the
67857
69242
  // opaque wall at one end, "invisible" a wall that is not seen at the
@@ -67919,9 +69304,20 @@ const useDialogProps = props => {
67919
69304
  // Where it is placed, and whether there is a wall — the two questions that
67920
69305
  // pick the show call (see this file's top comment).
67921
69306
  const isTopLayer = layer === "top";
69307
+ // Inside a <PopupGroup> the wall is the group's (see popup_group.jsx): the
69308
+ // dialog takes the wall-less path, never showModal() — a modal dialog makes
69309
+ // everything outside it inert, the group's other popups included.
69310
+ const popupGroup = useContext(PopupGroupContext);
69311
+ useLayoutEffect(() => {
69312
+ if (!popupGroup) {
69313
+ return undefined;
69314
+ }
69315
+ return popupGroup.register(openController);
69316
+ }, [popupGroup, openController]);
69317
+ const backdrop = backdropProp && !popupGroup;
67922
69318
  const isModal = isTopLayer && backdrop;
67923
69319
  const isTopLayerPopover = isTopLayer && !backdrop;
67924
- if (!backdrop && pressOutside === "capture") {
69320
+ if (!popupGroup && !backdrop && pressOutside === "capture") {
67925
69321
  console.warn(`Dialog: pressOutside="capture" needs a backdrop. Absorbing a press is what a wall does, and backdrop={false} takes it away.`);
67926
69322
  }
67927
69323
  const ref = props.ref;
@@ -68158,6 +69554,14 @@ const useDialogProps = props => {
68158
69554
  if (!dialogEl) {
68159
69555
  return undefined;
68160
69556
  }
69557
+ // First, so the group already stands where it stands in the top layer
69558
+ // when this dialog shows itself above it.
69559
+ popupGroup?.engage({
69560
+ openController,
69561
+ popupElement: dialogEl,
69562
+ pressOutside,
69563
+ event: e
69564
+ });
68161
69565
 
68162
69566
  // Set by useOpenControllerByProps for the very first open triggered by
68163
69567
  // `open`/`defaultOpen` already being truthy at mount, and for an open a
@@ -68248,7 +69652,8 @@ const useDialogProps = props => {
68248
69652
  // A modal dialog always has its own ::backdrop; a local one has the
68249
69653
  // backdrop element when it renders one.
68250
69654
  addCleanup(trapScrollInside(dialogEl, {
68251
- backdrop: isModal || backdropEl
69655
+ // A group's ::backdrop covers the page while this dialog is open.
69656
+ backdrop: popupGroup ? true : isModal || backdropEl
68252
69657
  }));
68253
69658
  } else if (!isTopLayer) {
68254
69659
  // A local dialog is confined to its positioned ancestor, and so is its
@@ -68479,7 +69884,9 @@ const useDialogProps = props => {
68479
69884
  // file's top comment), and a local one the caller asked to leave the page
68480
69885
  // reachable. Active for the dialog's entire open lifetime, not just
68481
69886
  // mid-transition.
68482
- if ((isModal || !backdrop) && (pressOutside === "close" || pressOutside === "cancel")) {
69887
+ if ((isModal || !backdrop) &&
69888
+ // The group's wall is up, and the group hears the press itself.
69889
+ !popupGroup && (pressOutside === "close" || pressOutside === "cancel")) {
68483
69890
  addCleanup(armOutsidePressClose(dialogEl, {
68484
69891
  openController,
68485
69892
  pressOutside,
@@ -68518,6 +69925,11 @@ const useDialogProps = props => {
68518
69925
  dialogEl.close();
68519
69926
  }
68520
69927
  };
69928
+ if (!findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
69929
+ // Before its own focus restore: a group engaged inside it is the
69930
+ // topmost modal, and keeps everything else inert until it goes.
69931
+ closePopupGroupsInside(dialogEl, closeEvent);
69932
+ }
68521
69933
  if (findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
68522
69934
  // Leaving the tree: hidden natively once the commit is over. Its
68523
69935
  // removal takes it out of the top layer and hands the focus to
@@ -68547,9 +69959,11 @@ const useDialogProps = props => {
68547
69959
  if (backdropEl) {
68548
69960
  backdropEl.setAttribute("aria-expanded", "false");
68549
69961
  disarmBackdropHideRef.current = armPointerDownOutsideClose(closeEvent, () => {
69962
+ disarmBackdropHideRef.current = null;
68550
69963
  backdropEl.setAttribute("navi-hidden", "");
68551
69964
  });
68552
69965
  }
69966
+ popupGroup?.release(openController, closeEvent);
68553
69967
  restoreFocus(closeEvent);
68554
69968
  cleanup();
68555
69969
  if (silent) {
@@ -68565,9 +69979,13 @@ const useDialogProps = props => {
68565
69979
  };
68566
69980
  const onKeyDownShortcuts = createOnKeyDownForShortcuts({
68567
69981
  escape: e => {
68568
- // Only a modal <dialog> fires "cancel" on Escape natively (handled via
68569
- // onCancel below); neither a .show()n nor a showPopover()n one does.
68570
- if (isModal || !openController.opened) {
69982
+ // Answered at the keydown, modal or not: the innermost popup takes the
69983
+ // key and spends it (requestClose prevents it), so one Escape closes
69984
+ // one popup. A modal's native "cancel" comes only once the keydown has
69985
+ // been dispatched, and the key would reach a popup around this one
69986
+ // first — a picker whose drawing holds this dialog. onCancel below is
69987
+ // left the close requests that are no keydown: the back gesture.
69988
+ if (!openController.opened) {
68571
69989
  return null;
68572
69990
  }
68573
69991
  return {
@@ -68610,8 +70028,9 @@ const useDialogProps = props => {
68610
70028
  // there's no gap for the browser to ever paint this plain-div backdrop
68611
70029
  // visible before anything has actually opened it — see popover.jsx's
68612
70030
  // own identical prop for the full reasoning, and this file's own CSS
68613
- // for the rule it drives.
68614
- "navi-hidden": openController.openedInDom ? undefined : "",
70031
+ // for the rule it drives. Left up while the press that closed the dialog
70032
+ // has its click still to come, as popover.jsx's own wall is.
70033
+ "navi-hidden": openController.openedInDom || disarmBackdropHideRef.current ? undefined : "",
68615
70034
  "styleCSSVars": DIALOG_STYLE_CSS_VARS,
68616
70035
  "animationDuration": rest.animationDuration,
68617
70036
  "data-press-outside": pressOutside,
@@ -68738,7 +70157,8 @@ const useDialogProps = props => {
68738
70157
  handlePressOnOutsideRegion(e, {
68739
70158
  popupEl: ref.current,
68740
70159
  openController,
68741
- pressOutside
70160
+ // In a group, the press the group's wall would get.
70161
+ pressOutside: popupGroup ? readPressOutsideInPopupGroup(pressOutside) : pressOutside
68742
70162
  });
68743
70163
  },
68744
70164
  "onKeyDown": e => {
@@ -68746,9 +70166,9 @@ const useDialogProps = props => {
68746
70166
  onKeyDownShortcuts(e);
68747
70167
  },
68748
70168
  "onCancel": e => {
68749
- // The dialog's own "cancel" (Escape on a modal showModal() dialog —
68750
- // the Escape handling for the other two shows lives in
68751
- // onKeyDownShortcuts above) fires on the dialog element itself. But a child
70169
+ // The dialog's own "cancel" — a close request that is no keydown (the
70170
+ // back gesture), an Escape being answered at its keydown (see
70171
+ // onKeyDownShortcuts above) — fires on the dialog element itself. But a child
68752
70172
  // <input type="file"> also fires a BUBBLING "cancel" when the user
68753
70173
  // dismisses the file chooser (per the HTML spec), and it lands here
68754
70174
  // too; only the dialog's own cancel means "close".
@@ -68766,7 +70186,12 @@ const useDialogProps = props => {
68766
70186
  // prevented it already (see requestClose).
68767
70187
  e.preventDefault();
68768
70188
  },
68769
- children
70189
+ // A popup opened from inside this one is in front of it, not a peer of
70190
+ // it: the group stops here.
70191
+ "children": popupGroup ? jsx(PopupGroupContext.Provider, {
70192
+ value: null,
70193
+ children: children
70194
+ }) : children
68770
70195
  });
68771
70196
 
68772
70197
  // Outside-click handling for the wall we render ourselves. A modal's is a
@@ -69260,13 +70685,6 @@ const css$D = /* css */`
69260
70685
  * is a `Popover`-level answer only for `"close"`/`"cancel"`; `"capture"` has
69261
70686
  * nothing left to absorb without a wall, and `"ignore"` renders no wall anyway.
69262
70687
  * See docs/popup_backdrop.md.
69263
- * @param {string} [props.group] - The popover's peers: the triggers carrying
69264
- * `data-navi-popup-group` with this name. A press on one, while the popover
69265
- * is open, closes it and opens the peer's popup in that same press — a
69266
- * menu bar, where pressing another menu switches to it. The wall still
69267
- * takes the press (the page behind stays out of reach, hover included) and
69268
- * hands its click to the peer it covered. Without a wall there is nothing
69269
- * to ask: the press reaches the peer on its own.
69270
70688
  * @param {"close"|"cancel"|"capture"|"ignore"} [props.pressOutside="ignore"]
69271
70689
  * - `"ignore"` (default): no backdrop at all, outside presses pass straight
69272
70690
  * through. `"close"` closes the popover on an outside press, `"cancel"`
@@ -69654,7 +71072,6 @@ const usePopoverProps = props => {
69654
71072
  // Read at the opening, with the backdrop it would otherwise be worked out
69655
71073
  // from (see openEffect).
69656
71074
  themeColor,
69657
- group,
69658
71075
  scrollCapture,
69659
71076
  focusCapture,
69660
71077
  // "auto" (default) → the popover follows its content. "frozen" → measured
@@ -69716,8 +71133,18 @@ const usePopoverProps = props => {
69716
71133
  // animation={true} or "auto" always resolves to "sliding" or "scaling"
69717
71134
  // (see resolveAutoAnimationKind).
69718
71135
  const isAutoAnimation = animation === true || animation === "auto";
69719
- const hasBackdrop = pressOutside !== "ignore" && backdrop;
69720
- if (!backdrop && pressOutside === "capture") {
71136
+
71137
+ // Inside a <PopupGroup> the wall is the group's (see popup_group.jsx): this
71138
+ // popover draws none, and the group decides what a press outside does.
71139
+ const popupGroup = useContext(PopupGroupContext);
71140
+ useLayoutEffect(() => {
71141
+ if (!popupGroup) {
71142
+ return undefined;
71143
+ }
71144
+ return popupGroup.register(openController);
71145
+ }, [popupGroup, openController]);
71146
+ const hasBackdrop = pressOutside !== "ignore" && backdrop && !popupGroup;
71147
+ if (!popupGroup && !backdrop && pressOutside === "capture") {
69721
71148
  console.warn(`Popover: pressOutside="capture" needs a backdrop. Absorbing a press is what a wall does, and backdrop={false} takes it away — every press outside reaches the page, exactly as with "ignore".`);
69722
71149
  }
69723
71150
  // positionPopover lives in openEffect's closure — created once, when the
@@ -69781,6 +71208,14 @@ const usePopoverProps = props => {
69781
71208
  if (!popoverEl) {
69782
71209
  return undefined;
69783
71210
  }
71211
+ // First, so the group already stands where it stands in the top layer
71212
+ // when this popover reads its anchor and shows itself above it.
71213
+ popupGroup?.engage({
71214
+ openController,
71215
+ popupElement: popoverEl,
71216
+ pressOutside,
71217
+ event: e
71218
+ });
69784
71219
 
69785
71220
  // Set by useOpenControllerByProps for the very first open triggered by
69786
71221
  // `open`/`defaultOpen` already being truthy at mount — there's nothing
@@ -70068,8 +71503,9 @@ const usePopoverProps = props => {
70068
71503
 
70069
71504
  // With no backdrop of its own to catch a press, the popover hears an
70070
71505
  // outside one from the document and takes nothing from it: it closes, and
70071
- // the press goes on to whatever it landed on (armOutsidePressClose).
70072
- if (!hasBackdrop && (pressOutside === "close" || pressOutside === "cancel")) {
71506
+ // the press goes on to whatever it landed on (armOutsidePressClose). Not
71507
+ // inside a group: the group's wall is up, and the group hears the press.
71508
+ if (!hasBackdrop && !popupGroup && (pressOutside === "close" || pressOutside === "cancel")) {
70073
71509
  addCleanup(armOutsidePressClose(popoverEl, {
70074
71510
  openController,
70075
71511
  pressOutside
@@ -70077,7 +71513,8 @@ const usePopoverProps = props => {
70077
71513
  }
70078
71514
  if (scrollCapture) {
70079
71515
  addCleanup(trapScrollInside(popoverEl, {
70080
- backdrop: backdropEl
71516
+ // A group's ::backdrop covers the page while this popover is open.
71517
+ backdrop: popupGroup ? true : backdropEl
70081
71518
  }));
70082
71519
  }
70083
71520
  if (focusCapture) {
@@ -70297,6 +71734,11 @@ const usePopoverProps = props => {
70297
71734
  // comment (openEffect above) for why hidePopover() alone isn't
70298
71735
  // reliably sufficient once a consumer's own CSS also sets display.
70299
71736
  popoverEl.setAttribute("navi-hidden", "");
71737
+ if (!findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
71738
+ // Before its own focus restore: a group engaged inside it is the
71739
+ // topmost modal, and keeps everything else inert until it goes.
71740
+ closePopupGroupsInside(popoverEl, closeEvent);
71741
+ }
70300
71742
  if (isTopLayer) {
70301
71743
  // Left to its removal when the popover leaves the tree: that hides it
70302
71744
  // and hands the focus to nobody, where hidePopover() hands the focus
@@ -70323,23 +71765,17 @@ const usePopoverProps = props => {
70323
71765
  }
70324
71766
  if (backdropEl) {
70325
71767
  backdropEl.setAttribute("aria-expanded", "false");
70326
- disarmBackdropHideRef.current = armPointerDownOutsideClose(closeEvent, clickEvent => {
71768
+ disarmBackdropHideRef.current = armPointerDownOutsideClose(closeEvent, () => {
71769
+ disarmBackdropHideRef.current = null;
70327
71770
  // Set regardless of isTopLayer — see openEffect's own identical
70328
71771
  // comment for why hidePopover() alone isn't reliably sufficient.
70329
71772
  backdropEl.setAttribute("navi-hidden", "");
70330
71773
  if (isTopLayer) {
70331
71774
  backdropEl.hidePopover();
70332
71775
  }
70333
- if (group && clickEvent) {
70334
- handWallClickToPeer(clickEvent, {
70335
- wallEl: backdropEl,
70336
- popupEl: popoverEl,
70337
- anchorEl: anchorElement,
70338
- group
70339
- });
70340
- }
70341
71776
  });
70342
71777
  }
71778
+ popupGroup?.release(openController, closeEvent);
70343
71779
  restoreFocus?.(closeEvent);
70344
71780
 
70345
71781
  // pickPositionRelativeTo (visible_rect.js) writes data-position-y/x-
@@ -70418,8 +71854,11 @@ const usePopoverProps = props => {
70418
71854
  // Read fresh on every render (not frozen at mount), so it stays
70419
71855
  // correct even across a re-render that happens to occur while open —
70420
71856
  // see contentProps' own identical prop just below for the full
70421
- // reasoning (kept once, not repeated here).
70422
- "navi-hidden": openController.openedInDom ? undefined : "",
71857
+ // reasoning (kept once, not repeated here). The wall outlives its popup
71858
+ // by the press that closed it (armPointerDownOutsideClose): a render
71859
+ // before that press's click must leave it up — a popup's owner commonly
71860
+ // renders on its close, in a microtask the release comes after.
71861
+ "navi-hidden": openController.openedInDom || disarmBackdropHideRef.current ? undefined : "",
70423
71862
  "styleCSSVars": POPUP_STYLE_CSS_VARS,
70424
71863
  "animationDuration": rest.animationDuration,
70425
71864
  "data-press-outside": pressOutside,
@@ -70517,14 +71956,20 @@ const usePopoverProps = props => {
70517
71956
  handlePressOnOutsideRegion(e, {
70518
71957
  popupEl: ref.current,
70519
71958
  openController,
70520
- pressOutside
71959
+ // In a group, the press the group's wall would get.
71960
+ pressOutside: popupGroup ? readPressOutsideInPopupGroup(pressOutside) : pressOutside
70521
71961
  });
70522
71962
  },
70523
71963
  "onKeyDown": e => {
70524
71964
  onKeyDown?.(e);
70525
71965
  onKeyDownShortcuts(e);
70526
71966
  },
70527
- children
71967
+ // A popup opened from inside this one is in front of it, not a peer of
71968
+ // it: the group stops here.
71969
+ "children": popupGroup ? jsx(PopupGroupContext.Provider, {
71970
+ value: null,
71971
+ children: children
71972
+ }) : children
70528
71973
  // onnavi_request_open/onnavi_request_close/onnavi_open_press: for the
70529
71974
  // uncontrolled case,
70530
71975
  // already arrive here as plain props via ...rest (wired by
@@ -70774,10 +72219,6 @@ const Popup = props => {
70774
72219
  scrollCapture,
70775
72220
  positionAreaFixed,
70776
72221
  positionAreaWhenAnchorIsInvalid,
70777
- // A top-layer dialog's wall is its native ::backdrop, which goes with the
70778
- // dialog on the press that closes it: no click is left on it to hand to a
70779
- // peer (see handWallClickToPeer).
70780
- group,
70781
72222
  // Dialog-only, destructured out for the same reason: forwarded below only
70782
72223
  // in the dialog branch, so they never reach the popover element.
70783
72224
  dockedOnSmallTouchScreen,
@@ -70827,7 +72268,6 @@ const Popup = props => {
70827
72268
  positionAreaWhenAnchorIsInvalid: positionAreaWhenAnchorIsInvalid,
70828
72269
  scrollCapture: scrollCapture === "popover" || scrollCapture,
70829
72270
  positionAreaFixed: positionAreaFixed,
70830
- group: group,
70831
72271
  className: withPropsClassName("navi_popup", className),
70832
72272
  expand: expand,
70833
72273
  expandX: expandX,
@@ -71357,6 +72797,10 @@ const PickerCustom = props => {
71357
72797
  }
71358
72798
  };
71359
72799
  });
72800
+ // Whoever hears the Escape — this picker's trigger, its popup, the group
72801
+ // it stands in, its bubble — asks for a cancel; the controller reads it
72802
+ // with this (see escapeEffect in open_controller.js).
72803
+ openController.escapeEffect = escapeEffect;
71360
72804
  // scroll <button> of the picker into view when opening it
71361
72805
  // -> would be overriden by dialog.jsx or popover.jsx
71362
72806
  // so ideally openEffect should be either protective or a pubSub to allow multiple callbacks
@@ -71582,13 +73026,13 @@ const PickerCustom = props => {
71582
73026
  if (!openController.opened) {
71583
73027
  return null;
71584
73028
  }
71585
- const isCancel = escapeEffect === "cancel";
71586
73029
  return {
71587
- name: isCancel ? "escape_to_cancel" : "escape_to_close",
73030
+ name: "escape_to_cancel",
71588
73031
  intent: "read",
71589
73032
  allowed: () => {
73033
+ // Read with escapeEffect by the controller (see above).
71590
73034
  requestClose(e, {
71591
- isCancel
73035
+ isCancel: true
71592
73036
  });
71593
73037
  e.preventDefault(); // prevent browser from closing the dialog (if any)
71594
73038
  }
@@ -71854,18 +73298,13 @@ const PickerContentInsidePopup = props => {
71854
73298
  calloutIcon,
71855
73299
  calloutCloseButton,
71856
73300
  calloutCloseByScroll,
71857
- popupGroup,
71858
73301
  ...rest
71859
73302
  } = props;
71860
73303
  const isPopover = mode === "popover";
71861
73304
  const isCallout = mode === "callout";
71862
73305
  return jsx(Next, {
71863
73306
  "aria-haspopup": isPopover ? "listbox" : "dialog",
71864
- "navi-popover-mode": isPopover ? popoverMode : undefined
71865
- // On the trigger, whatever the mode: a peer's popup reads it off the
71866
- // press that lands here (see `group` in callout.js).
71867
- ,
71868
- "data-navi-popup-group": popupGroup,
73307
+ "navi-popover-mode": isPopover ? popoverMode : undefined,
71869
73308
  ...rest,
71870
73309
  // On popupProps already (see the picker's popup assembly); they mean
71871
73310
  // nothing to the picker element.
@@ -71906,7 +73345,7 @@ const PickerContentInsidePopup = props => {
71906
73345
  // nothing, so the page looks as pressable as it did before.
71907
73346
  ,
71908
73347
  backdrop: backdrop,
71909
- group: popupGroup,
73348
+ pressOutside: pressOutside,
71910
73349
  children: jsx(PopupModeContext.Provider, {
71911
73350
  value: mode,
71912
73351
  children: children
@@ -71943,7 +73382,6 @@ const PickerContentInsidePopup = props => {
71943
73382
  backdropVariant: backdropVariant,
71944
73383
  backdropColor: backdropColor,
71945
73384
  backdropFilter: backdropFilter,
71946
- group: popupGroup,
71947
73385
  focusCapture: isPopover ? focusCapture : undefined,
71948
73386
  expand: isPopover ? undefined : dialogExpand,
71949
73387
  expandX: isPopover ? undefined : dialogExpandX,
@@ -72004,7 +73442,7 @@ const PickerCalloutPopup = ({
72004
73442
  closeButton,
72005
73443
  closeByScroll,
72006
73444
  backdrop,
72007
- group,
73445
+ pressOutside,
72008
73446
  onnavi_request_open,
72009
73447
  onnavi_request_close,
72010
73448
  onnavi_request_confirm,
@@ -72021,6 +73459,16 @@ const PickerCalloutPopup = ({
72021
73459
  anchor
72022
73460
  });
72023
73461
  const children = contentMounted ? childrenProp : null;
73462
+ // Inside a <PopupGroup> the wall is the group's (see popup_group.jsx): it
73463
+ // decides what a press outside the callout does, and the callout spends and
73464
+ // closes on none of its own.
73465
+ const popupGroup = useContext(PopupGroupContext);
73466
+ useLayoutEffect(() => {
73467
+ if (!popupGroup) {
73468
+ return undefined;
73469
+ }
73470
+ return popupGroup.register(openController);
73471
+ }, [popupGroup, openController]);
72024
73472
  // Reassigned on every render, like Popover's own, so it closes over the
72025
73473
  // latest props.
72026
73474
  openController.getElement = () => pickerRef.current;
@@ -72035,6 +73483,23 @@ const PickerCalloutPopup = ({
72035
73483
  // picker's own input — which is where the data-callout-* attributes a
72036
73484
  // caller puts on the picker land, and where the callout reads them.
72037
73485
  const anchorElement = anchor === pickerRef ? undefined : anchor && "current" in anchor ? anchor.current : anchor;
73486
+ // First, so the group already stands where it stands in the top layer
73487
+ // when the callout reads its anchor and shows itself above it.
73488
+ popupGroup?.engage({
73489
+ openController,
73490
+ // Drawn by the manager below, once the group stands.
73491
+ get popupElement() {
73492
+ return calloutManager.callout.element;
73493
+ },
73494
+ // What a data-navi-popup-inside names this popup by (aria-controls
73495
+ // names it too): the dock, not the callout drawn for it.
73496
+ popupId: id,
73497
+ pressOutside,
73498
+ event: openEvent,
73499
+ // A callout never holds the page still: a scroll is its own
73500
+ // closeByScroll's to answer.
73501
+ pageScrolls: true
73502
+ });
72038
73503
  calloutManager.addOpenToken(PICKER_CALLOUT_CONTENT_TOKEN, {
72039
73504
  message: host,
72040
73505
  // The popup a `popupTestId` names is this callout: it is the surface the
@@ -72046,8 +73511,11 @@ const PickerCalloutPopup = ({
72046
73511
  icon,
72047
73512
  closeButton,
72048
73513
  closeByScroll,
72049
- backdrop,
72050
- group,
73514
+ backdrop: popupGroup ? false : backdrop,
73515
+ closeByPressOutside: popupGroup ? false : undefined,
73516
+ // Inside the group whatever it points at: what is not a descendant of
73517
+ // an engaged group is inert, a bubble drawn there included.
73518
+ containerElement: popupGroup ? pickerEl : undefined,
72051
73519
  anchorElement,
72052
73520
  // The request, chained to the press that made it: the callout reads the
72053
73521
  // mousedown off it to wait for the release before listening for a click
@@ -72056,10 +73524,15 @@ const PickerCalloutPopup = ({
72056
73524
  // Not skipped: the callout moves the focus into the picker when it is
72057
73525
  // elsewhere, which is what lets Escape find the callout right away.
72058
73526
  skipFocus: false,
73527
+ // An Escape the callout answered itself (the focus in the bubble) is
73528
+ // the picker's Escape (see escapeEffect in open_controller.js).
72059
73529
  onClose: ({
72060
- event
73530
+ event,
73531
+ reason
72061
73532
  }) => {
72062
- openController.close(event);
73533
+ openController.close(event, {
73534
+ isCancel: reason === "escape_key"
73535
+ });
72063
73536
  }
72064
73537
  });
72065
73538
  // A --navi-confirm said inside the callout is aimed at the callout (its
@@ -72077,9 +73550,15 @@ const PickerCalloutPopup = ({
72077
73550
  dock.setAttribute("aria-expanded", "true");
72078
73551
  return closeEvent => {
72079
73552
  calloutElement.removeEventListener("navi_request_confirm", forwardConfirm);
73553
+ if (!findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
73554
+ // Before the content goes back to the dock: a group engaged in there
73555
+ // would leave the top layer with it, its arbiter still listening.
73556
+ closePopupGroupsInside(calloutElement, closeEvent);
73557
+ }
72080
73558
  calloutManager.removeOpenToken(PICKER_CALLOUT_CONTENT_TOKEN, closeEvent);
72081
73559
  dock.appendChild(host);
72082
73560
  dock.setAttribute("aria-expanded", "false");
73561
+ popupGroup?.release(openController, closeEvent);
72083
73562
  };
72084
73563
  };
72085
73564
  return (
@@ -72107,7 +73586,10 @@ const PickerCalloutPopup = ({
72107
73586
  children: jsx("div", {
72108
73587
  ref: hostRef,
72109
73588
  "data-picker-content": "",
72110
- children: children
73589
+ children: popupGroup ? jsx(PopupGroupContext.Provider, {
73590
+ value: null,
73591
+ children: children
73592
+ }) : children
72111
73593
  })
72112
73594
  })
72113
73595
  );
@@ -73631,7 +75113,11 @@ const css$y = /* css */`@layer navi {
73631
75113
  }
73632
75114
  }
73633
75115
 
73634
- & dialog, & [popover] {
75116
+ & [popover] {
75117
+ pointer-events: auto;
75118
+ }
75119
+
75120
+ & dialog:not([navi-popup-group]:not(:modal)) {
73635
75121
  pointer-events: auto;
73636
75122
  }
73637
75123
  }
@@ -74429,7 +75915,7 @@ const css$x = /* css */`@property --list-group-label-height {
74429
75915
  background: var(--list-background-color);
74430
75916
  }
74431
75917
 
74432
- :where([popover], dialog) > .navi_list_container, .navi_list_container[popover] {
75918
+ :where([popover], dialog:not([navi-popup-group])) > .navi_list_container, .navi_list_container[popover] {
74433
75919
  --list-border-width-default: 0px;
74434
75920
  }
74435
75921
 
@@ -95830,8 +97316,9 @@ const css = /* css */`.navi_side_panel {
95830
97316
  * interaction only makes sense paired with not letting focus silently
95831
97317
  * leave the panel first. A box of the page whose press must not close the
95832
97318
  * panel (a card that fills it) names the panel:
95833
- * `data-navi-popup-inside={id}`, read when the panel has no wall
95834
- * (`backdrop={false}`) — see docs/popup_backdrop.md.
97319
+ * `data-navi-popup-inside={id}`, read when the panel has no wall of its own
97320
+ * (`backdrop={false}`, or inside a `<PopupGroup>`) — see
97321
+ * docs/popup_backdrop.md.
95835
97322
  * @param {boolean} [props.closeByDrag=true] - Pushing the panel back
95836
97323
  * towards the edge it is docked to closes it: the panel follows the
95837
97324
  * pointer and finishes leaving (or comes back to rest) when it is
@@ -96277,5 +97764,5 @@ const UserSvg = () => jsx("svg", {
96277
97764
  })
96278
97765
  });
96279
97766
 
96280
- export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, CalloutStatusIcon, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, ControlSwap, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, Expandable, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, InfoSvg, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NetworkPolicyError, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RouteTransitionArea, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, SplitButton, StarSvg, Step, StepList, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRange, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, activeRouteActionsSignal, activeRouteReadsSignal, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, constraintFromValidityRule, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, dispatchRequestSetUIState, enableDebugActions, enableDebugOnDocumentLoading, enableVirtualKeyboardOverlay, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, getNowHours, getNowHoursRoundedToStep, isCellSelected, isColumnSelected, isNetworkPolicyError, isRowSelected, isScrolling, isToday, keepPageOnScreen, languagesSignal, localStorageSignal, markAsOutsideTextFlow, markErrorAsDisplayedBy, moveArrayItemByIndex, moveFocusTo, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, preloadUrl, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, routeFallback, scrollActivitySignal, setBaseUrl, setNetworkPolicy, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutElement, useCalloutRequestClose, useCanNavBack, useCanNavForward, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useHoldScrollRestoration, useInputGroup, useKeyboardShortcuts, useNavState, useNetworkPolicyReason, useOrderedColumns, usePickerState, usePopupMode, useRouteStatus, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideContainer, useSlideValue, useStateArray, useTitleLevel, useTransitionCover, useUrlSearchParam, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
97767
+ export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, CalloutStatusIcon, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, ControlSwap, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, Expandable, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, InfoSvg, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NetworkPolicyError, NumberSpin, Paragraph, Picker, Popover, Popup, PopupGroup, Quantity, RadioGroup, Route, RouteTransitionArea, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, SplitButton, StarSvg, Step, StepList, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRange, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, activeRouteActionsSignal, activeRouteReadsSignal, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, constraintFromValidityRule, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, dispatchRequestSetUIState, enableDebugActions, enableDebugOnDocumentLoading, enableVirtualKeyboardOverlay, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, getNowHours, getNowHoursRoundedToStep, isCellSelected, isColumnSelected, isNetworkPolicyError, isRowSelected, isScrolling, isToday, keepPageOnScreen, languagesSignal, localStorageSignal, markAsOutsideTextFlow, markErrorAsDisplayedBy, moveArrayItemByIndex, moveFocusTo, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, preloadUrl, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, routeFallback, scrollActivitySignal, setBaseUrl, setNetworkPolicy, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutElement, useCalloutRequestClose, useCanNavBack, useCanNavForward, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useHoldScrollRestoration, useInputGroup, useKeyboardShortcuts, useNavState, useNetworkPolicyReason, useOrderedColumns, usePickerState, usePopupMode, useRouteStatus, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideContainer, useSlideValue, useStateArray, useTitleLevel, useTransitionCover, useUrlSearchParam, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
96281
97768
  //# sourceMappingURL=jsenv_navi.js.map