@jsenv/navi 0.29.463 → 0.29.465

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, 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
  }
@@ -11409,6 +11409,8 @@ const keptNameWeakMap = new WeakMap();
11409
11409
  const getRouteActionKeptName = (routeAction) => {
11410
11410
  return keptNameWeakMap.get(routeAction);
11411
11411
  };
11412
+ // Shared by every route action without a `when`.
11413
+ const NOT_GATED_SIGNAL = signal(false);
11412
11414
  let activeRouteActionsPrevious = new Set();
11413
11415
  /**
11414
11416
  * The route actions asking something for the page on screen: their route
@@ -11435,19 +11437,17 @@ const activeRouteActionsSignal = computed(() => {
11435
11437
  });
11436
11438
 
11437
11439
  /**
11438
- * Whether a route action of the page on screen asks nothing yet: its route
11439
- * matches and its params (or `when`) say no. Gated on app state not there yet,
11440
- * or never meant to ask on this page — nothing tells one from the other before
11441
- * it asks. Read by the scroll restoration, which stays ready for it.
11440
+ * Whether a route action of the page on screen waits for app state to ask: its
11441
+ * route matches and its `when` says no. Params that say no are not waiting:
11442
+ * they read the address, and this one asks nothing of the action — a later ask
11443
+ * is the page changing under the reader (a search typed), not arriving. Read
11444
+ * by the scroll restoration, which stays ready for it.
11442
11445
  *
11443
11446
  * @type {import("@preact/signals").ReadonlySignal<boolean>}
11444
11447
  */
11445
11448
  const routeActionYetToAskSignal = computed(() => {
11446
- for (const {
11447
- routeMatchingSignal,
11448
- askingSignal,
11449
- } of routeActionEntriesSignal.value) {
11450
- if (routeMatchingSignal.value && !askingSignal.value) {
11449
+ for (const { gatedSignal } of routeActionEntriesSignal.value) {
11450
+ if (gatedSignal.value) {
11451
11451
  return true;
11452
11452
  }
11453
11453
  }
@@ -11520,7 +11520,9 @@ const activeRouteReadsSignal = computed(() => {
11520
11520
  * params when the condition holds at that moment — a page's code only some
11521
11521
  * readers may download (`() => meIsAdminSignal.value`). A condition reading
11522
11522
  * the address belongs in the params: a link reads `when` as it is, not as
11523
- * the address it leads to will make it.
11523
+ * the address it leads to will make it. It is also what a reload's scroll
11524
+ * position waits for: the page is not whole while a `when` says no, whereas
11525
+ * params that say no ask nothing on this page (see scroll_restoration.js).
11524
11526
  * @param {boolean} [options.prefetch=true] - `false` keeps the action from
11525
11527
  * being prerun ahead of what asks for it, when the read is not worth a
11526
11528
  * hover or a press that may not end in an opening.
@@ -11567,8 +11569,10 @@ const routeAction = (
11567
11569
  ...routeActionEntriesSignal.peek(),
11568
11570
  {
11569
11571
  routeAction: actionBoundToRoute,
11570
- routeMatchingSignal,
11571
11572
  askingSignal: computed(() => Boolean(readParamsAsked())),
11573
+ gatedSignal: when
11574
+ ? computed(() => Boolean(routeMatchingSignal.value) && !when())
11575
+ : NOT_GATED_SIGNAL,
11572
11576
  },
11573
11577
  ];
11574
11578
  for (const route of routes) {
@@ -11726,8 +11730,8 @@ const haveSameMembers = (setA, setB) => {
11726
11730
  * mount="after-paint">` is built before the offset that shows it is written
11727
11731
  * (see mount_after_paint.jsx), and a reload or a return waits for the route
11728
11732
  * actions the page has nothing to draw for — its code among them — including
11729
- * one that starts asking after the position is written (see
11730
- * putBackOnceAnswered). Anything else only the page knows, and it says so
11733
+ * one whose `when` lets it ask after the position is written (see
11734
+ * stayForLateAsks). Anything else only the page knows, and it says so
11731
11735
  * (useHoldScrollRestoration). A wait can be long, so the reader reaching for
11732
11736
  * the page during it cancels the position: moved afterwards, they would lose
11733
11737
  * whatever they had started on.
@@ -11932,7 +11936,7 @@ const holdScrollRestoration = () => {
11932
11936
  * meantime (a finger, a click, a wheel, a key), or gone elsewhere.
11933
11937
  *
11934
11938
  * The page's route actions are waited for without this — its code, a read
11935
- * with no answer yet, one gated on app state that asks later. This is for
11939
+ * with no answer yet, one whose `when` holds later. This is for
11936
11940
  * what navi cannot see: a read the page starts itself, content whose height
11937
11941
  * arrives late.
11938
11942
  *
@@ -12073,14 +12077,15 @@ const readUnansweredRouteActions = () => {
12073
12077
  return unanswered;
12074
12078
  };
12075
12079
 
12076
- // A page can start asking after the position is written: an action gated on
12077
- // app state not there yet (`when`, or params reading an app-wide read), and
12078
- // nothing tells "not yet" from "never" before it asks (see
12079
- // routeActionYetToAskSignal). So a written position stays pending while a
12080
- // route action of the page has not asked: the next one that starts without an
12081
- // answer is waited for like the first ones, and the position put back once it
12082
- // is drawn. Not told when the restoration is dropped (pendingRestoration is no
12083
- // signal): it notices at its next run.
12080
+ // A page can still be arriving after the position is written: an action whose
12081
+ // `when` waits for app state not there yet (see routeActionYetToAskSignal). So
12082
+ // a written position stays pending while one does: the next one that starts
12083
+ // without an answer is waited for like the first ones, and the position put
12084
+ // back once it is drawn. Params that say no are not waited for: the address
12085
+ // asks nothing of the action, and one that asks later — a search typed — is
12086
+ // the page moving on under the reader, not arriving. Not told when the
12087
+ // restoration is dropped (pendingRestoration is no signal): it notices at its
12088
+ // next run.
12084
12089
  const stayForLateAsks = (restoration) => {
12085
12090
  effect(function () {
12086
12091
  let running = false;
@@ -15833,6 +15838,48 @@ const createOpenController = (
15833
15838
  // armSuppressNextOpenRequest below.
15834
15839
  let suppressNextOpenRequest = false;
15835
15840
  let disarmSuppressNextOpenRequest = null;
15841
+ // The press a press-close came from, for as long as that press lasts (see
15842
+ // open() for what it is read for).
15843
+ let pressCountAtPressClose = null;
15844
+ let releaseThisPress = null;
15845
+ const holdReopenForThisPress = (closeEvent) => {
15846
+ releaseThisPress?.();
15847
+ pressCountAtPressClose = pressCount;
15848
+ let timeout = null;
15849
+ const release = () => {
15850
+ clearTimeout(timeout);
15851
+ document.removeEventListener("click", onPressEnd, { capture: true });
15852
+ document.removeEventListener("pointercancel", onPressEnd, {
15853
+ capture: true,
15854
+ });
15855
+ document.removeEventListener("pointerdown", release, { capture: true });
15856
+ pressCountAtPressClose = null;
15857
+ releaseThisPress = null;
15858
+ };
15859
+ // Once the task the press ends in is over: every reaction to the closing
15860
+ // press is synchronous to it, while an open asked for later — a command a
15861
+ // button runs once its action resolves — is somebody asking again.
15862
+ const onPressEnd = () => {
15863
+ document.removeEventListener("click", onPressEnd, { capture: true });
15864
+ document.removeEventListener("pointercancel", onPressEnd, {
15865
+ capture: true,
15866
+ });
15867
+ timeout = setTimeout(release);
15868
+ };
15869
+ if (findEvent(closeEvent, "click")) {
15870
+ // Closed by the press's own click: a listener added during its dispatch
15871
+ // would only hear the next one.
15872
+ timeout = setTimeout(release);
15873
+ } else {
15874
+ document.addEventListener("click", onPressEnd, { capture: true });
15875
+ document.addEventListener("pointercancel", onPressEnd, {
15876
+ capture: true,
15877
+ });
15878
+ }
15879
+ // A press whose click was swallowed (a gesture) ends with the next one.
15880
+ document.addEventListener("pointerdown", release, { capture: true });
15881
+ releaseThisPress = release;
15882
+ };
15836
15883
 
15837
15884
  // When the popup closes because of a press (clicking the backdrop, or a
15838
15885
  // press outside a popup with no backdrop), the browser still dispatches the
@@ -15988,6 +16035,7 @@ const createOpenController = (
15988
16035
  `closed by ${pressEvent.type} -> ignore next click`,
15989
16036
  );
15990
16037
  armSuppressNextOpenRequest();
16038
+ holdReopenForThisPress(closeEvent);
15991
16039
  break prevent_reopen;
15992
16040
  }
15993
16041
 
@@ -16122,7 +16170,9 @@ const createOpenController = (
16122
16170
  // called once the open/close has fully happened rather than mid-sequence.
16123
16171
  onOpenedChange: null,
16124
16172
  open: (e, detail) => {
16125
- flushChangeAwaitingTransition();
16173
+ // The refusals below read the decision (`opened`), which is written
16174
+ // before a change waiting for its view transition runs: a refused open
16175
+ // must not force that change through and take its picture away.
16126
16176
  if (controller.opened || !controller.openEffect) {
16127
16177
  return;
16128
16178
  }
@@ -16130,6 +16180,20 @@ const createOpenController = (
16130
16180
  suppressNextOpenRequest = false;
16131
16181
  return;
16132
16182
  }
16183
+ if (
16184
+ pressCountAtPressClose === pressCount &&
16185
+ findEvent(e, isEventOfAPress)
16186
+ ) {
16187
+ // The press that closed it, heard again by what opens it. With nothing
16188
+ // between a popup and its own trigger (no wall, or a PopupGroup), the
16189
+ // press is still on its way when the popup closes, and the trigger's
16190
+ // own reaction to that same press asks to open it again: at its
16191
+ // mousedown, before the click the suppression above waits for — or at
16192
+ // the very click that closed it, whose suppression is armed during its
16193
+ // own dispatch and so only hears the next one.
16194
+ return;
16195
+ }
16196
+ flushChangeAwaitingTransition();
16133
16197
  const requestOpenEvent = new CustomEvent("navi_request_open", {
16134
16198
  detail: { event: e, ...detail },
16135
16199
  cancelable: true,
@@ -16261,6 +16325,18 @@ const createOpenController = (
16261
16325
  });
16262
16326
  chainEvent(requestCloseEvent, e);
16263
16327
  closeHandlers?.onRequestClose?.(requestCloseEvent);
16328
+ // An Escape this popup answered, by closing or by refusing: dispatched
16329
+ // on, it would also be the browser's close request for a modal around
16330
+ // the popup (a PopupGroup, a dialog), closing what this popup kept open,
16331
+ // or what is still closing with it (a lifting dialog closes a frame
16332
+ // later).
16333
+ const escapeKeydown = findEvent(
16334
+ requestCloseEvent,
16335
+ (event) => event.type === "keydown" && event.key === "Escape",
16336
+ );
16337
+ if (escapeKeydown) {
16338
+ escapeKeydown.preventDefault();
16339
+ }
16264
16340
  if (requestCloseEvent.defaultPrevented) {
16265
16341
  // The native <dialog> "cancel" event (Escape key) closes the dialog
16266
16342
  // by default; prevent that default so denial actually keeps it open.
@@ -16289,13 +16365,24 @@ const createOpenController = (
16289
16365
  return controller;
16290
16366
  };
16291
16367
 
16368
+ // A click a keyboard made (detail 0) is not one: nothing pressed for it.
16369
+ const isEventOfAPress = (event) =>
16370
+ event.type === "pointerdown" ||
16371
+ event.type === "mousedown" ||
16372
+ (event.type === "click" && event.detail > 0);
16373
+
16292
16374
  // Inside a popup that is open right now — the popup being closed, in practice,
16293
16375
  // since that is the one the key press was delivered to.
16294
16376
  const isInsideOpenPopup = (element) => {
16295
16377
  if (!element || element.nodeType !== 1) {
16296
16378
  return false;
16297
16379
  }
16298
- return Boolean(element.closest("dialog[open], [popover]:popover-open"));
16380
+ // An engaged PopupGroup is open too, around peers that are not the popup.
16381
+ return Boolean(
16382
+ element.closest(
16383
+ "dialog[open]:not([navi-popup-group]), [popover]:popover-open",
16384
+ ),
16385
+ );
16299
16386
  };
16300
16387
 
16301
16388
  // What Enter is about to do when it is about to activate something: press the
@@ -16761,572 +16848,6 @@ const useOpenControllerByProps = (props, name) => {
16761
16848
  return openController;
16762
16849
  };
16763
16850
 
16764
- /**
16765
- * Small, renderer-agnostic helpers shared by Popover and Dialog's own custom
16766
- * (non-top-layer) renderers — operate on a plain DOM element, no knowledge
16767
- * of which of the two owns it.
16768
- */
16769
-
16770
-
16771
- /**
16772
- * Whether a visibleRectEffect delivery is one that can have taken height away
16773
- * from a popup, and so pushed whatever holds focus out of sight:
16774
- * - "resize": the window/visual viewport settled — which is also how the
16775
- * on-screen keyboard arrives, overlay or not (window_size.js);
16776
- * - ELEMENT_SIZE_CHANGE: the popup's own box measured different;
16777
- * - "focusin": the focus-settled re-measure, for the room that changes with
16778
- * nothing announcing it (subscribeFocusSettled in window_size.js).
16779
- *
16780
- * Everything else is a scroll of one kind or another, where nothing got
16781
- * smaller and scrolling the focused element back would fight the very gesture
16782
- * that fired it.
16783
- */
16784
- const mayHaveHiddenFocus = (event) => {
16785
- const type = event?.type;
16786
- return (
16787
- type === "resize" || type === ELEMENT_SIZE_CHANGE || type === "focusin"
16788
- );
16789
- };
16790
-
16791
- const scrollportHeightMap = new WeakMap();
16792
- /**
16793
- * Scrolls whatever holds focus inside `popupEl` back into view, if the popup
16794
- * getting shorter has pushed it out.
16795
- *
16796
- * The case this exists for: a field low in the scrolling body of a popup that
16797
- * also has a footer (box.jsx — with a body, the body is the only thing that
16798
- * scrolls and the footer is a sibling sitting right under it). Focusing the
16799
- * field makes the browser scroll it into view, which it does against the
16800
- * popup's height AT THAT MOMENT; the on-screen keyboard then opens and takes
16801
- * that height away. The body shrinks, its scrollTop does not move, so the
16802
- * content slides down relative to the shorter scrollport and the field ends up
16803
- * past its bottom edge — visually, swallowed by the footer. The browser does
16804
- * not redo a scroll-into-view it already answered, so this does.
16805
- *
16806
- * Only what the shrink itself hid, though: with the keyboard up the user reads
16807
- * the rest of the popup by scrolling the field away — to reach the submit under
16808
- * it, typically — and the room keeps changing while they do (a keyboard settling
16809
- * in two steps, a suggestion strip, a browser bar). Answering each of those by
16810
- * scrolling the field back takes the popup away from wherever they had just
16811
- * scrolled it, over and over: what they were reading cannot be reached at all
16812
- * without blurring the field first, and the popup reads as unscrollable. So the
16813
- * field is brought back only when it was in view before the room shrank, which
16814
- * one remembered number answers: a resize moves neither scrollTop nor the
16815
- * element's offset inside the scrolled content, so measuring against the height
16816
- * the scrollport HAD is measuring the state before the change.
16817
- *
16818
- * Scoped to the field's own scroll container (never the page): a popup traps
16819
- * scrolling precisely so the document underneath cannot move, and a plain
16820
- * scrollIntoView walks past a container whose scrollbar isn't visible — see
16821
- * scrollIntoViewScoped's own doc.
16822
- *
16823
- * "nearest": the smallest scroll that makes it visible, and none at all when it
16824
- * already is. Where it lands is the container's own business — a navi scroller
16825
- * keeps a band free at its edges so a field never comes back glued to one (see
16826
- * scroll-padding in box.jsx).
16827
- */
16828
- const keepFocusedElementVisible = (popupEl) => {
16829
- const { activeElement } = document;
16830
- if (!activeElement || activeElement === popupEl) {
16831
- return;
16832
- }
16833
- if (!popupEl.contains(activeElement)) {
16834
- return;
16835
- }
16836
- const scrollContainer = getScrollContainer(activeElement);
16837
- if (!scrollContainer || !popupEl.contains(scrollContainer)) {
16838
- // What scrolls the field is outside the popup, which means the page: a
16839
- // popup holds it still on purpose (trapScrollInside), so there is nothing
16840
- // here to scroll back.
16841
- return;
16842
- }
16843
- const scrollportHeight = scrollContainer.clientHeight;
16844
- const scrollportHeightBefore = scrollportHeightMap.get(scrollContainer);
16845
- scrollportHeightMap.set(scrollContainer, scrollportHeight);
16846
- if (scrollportHeightBefore !== undefined) {
16847
- if (scrollportHeight >= scrollportHeightBefore) {
16848
- // Nothing was taken away, so nothing was hidden by this.
16849
- return;
16850
- }
16851
- if (
16852
- !isVisibleInScrollport(
16853
- activeElement,
16854
- scrollContainer,
16855
- scrollportHeightBefore,
16856
- )
16857
- ) {
16858
- return;
16859
- }
16860
- }
16861
- scrollIntoViewScoped(activeElement, {
16862
- container: scrollContainer,
16863
- block: "nearest",
16864
- });
16865
- };
16866
-
16867
- // Whether any part of `el` was showing in `container` back when its scrollport
16868
- // was `scrollportHeight` tall. Both boxes are read now: the container may have
16869
- // moved as well as shrunk (a centered dialog re-centers itself), and the
16870
- // difference between the two tops is what that move leaves alone.
16871
- const isVisibleInScrollport = (el, container, scrollportHeight) => {
16872
- const elRect = el.getBoundingClientRect();
16873
- const containerRect = container.getBoundingClientRect();
16874
- const elTop = elRect.top - containerRect.top + container.scrollTop;
16875
- const elBottom = elTop + elRect.height;
16876
- const scrollportTop = container.scrollTop;
16877
- const scrollportBottom = scrollportTop + scrollportHeight;
16878
- return elBottom > scrollportTop && elTop < scrollportBottom;
16879
- };
16880
-
16881
- /**
16882
- * Calls `onSettled` once `el`'s current CSS transition is over — via
16883
- * `transitionend`, with a safety `setTimeout` fallback matching the longest
16884
- * `transition-duration`, in case nothing actually transitions or an event is
16885
- * missed.
16886
- *
16887
- * Returns a "cancel" function, so a caller whose instance has been superseded
16888
- * (a fresh open/close about to set its own state) can keep this stale one from
16889
- * firing later. Cancelling only stops `onSettled`: undoing whatever the caller
16890
- * did up front is that fresh call's business, not this one's.
16891
- */
16892
- const whenTransitionSettles = (el, onSettled) => {
16893
- let settled = false;
16894
- const onTransitionEnd = (transitionEvent) => {
16895
- if (transitionEvent.target === el) {
16896
- finish();
16897
- }
16898
- };
16899
- const stopWatching = () => {
16900
- settled = true;
16901
- el.removeEventListener("transitionend", onTransitionEnd);
16902
- clearTimeout(safetyTimeoutId);
16903
- };
16904
- const finish = () => {
16905
- if (settled) {
16906
- return;
16907
- }
16908
- stopWatching();
16909
- onSettled();
16910
- };
16911
- el.addEventListener("transitionend", onTransitionEnd);
16912
- const durationsInSeconds = getComputedStyle(el)
16913
- .transitionDuration.split(",")
16914
- .map((value) => parseFloat(value) || 0);
16915
- const longestDurationMs = Math.max(0, ...durationsInSeconds) * 1000;
16916
- const safetyTimeoutId = setTimeout(finish, longestDurationMs + 50);
16917
- return () => {
16918
- if (settled) {
16919
- return;
16920
- }
16921
- stopWatching();
16922
- };
16923
- };
16924
-
16925
- /**
16926
- * Drops the document's text selection when it lives inside `el`, leaving a
16927
- * selection made elsewhere on the page alone.
16928
- *
16929
- * A popup being closed takes its content with it, and a selection is a claim
16930
- * on content the user can still act on: kept, it would outlive the surface it
16931
- * was made on — painted on the box for the length of its exit transition, with
16932
- * the handles and the copy toolbar a phone draws from the live selection
16933
- * hanging over something that is going away — and reappear with the box on
16934
- * the next opening. Dropping the selection is the only way to remove that
16935
- * chrome: a `user-select: none` on the closing box hides the highlight in some
16936
- * browsers only, and the handles are the browser's own, drawn from the
16937
- * selection object rather than from any style.
16938
- */
16939
- const clearTextSelectionInside = (el) => {
16940
- const selection = window.getSelection();
16941
- if (!selection || selection.rangeCount === 0) {
16942
- return;
16943
- }
16944
- const range = selection.getRangeAt(0);
16945
- if (!el.contains(range.commonAncestorContainer)) {
16946
- return;
16947
- }
16948
- selection.removeAllRanges();
16949
- };
16950
-
16951
- /**
16952
- * Disables pointer-events on `el` until its current CSS transition settles —
16953
- * avoids the cursor changing/something becoming clickable while the popup is
16954
- * still visually moving into or out of place.
16955
- *
16956
- * Returns whenTransitionSettles' own "cancel" function: it doesn't restore
16957
- * pointer-events, since a fresh call for the next open/close is about to set
16958
- * its own state.
16959
- */
16960
- const suppressPointerEventsDuringTransition = (el) => {
16961
- el.style.pointerEvents = "none";
16962
- return whenTransitionSettles(el, () => {
16963
- el.style.pointerEvents = "";
16964
- });
16965
- };
16966
-
16967
- /**
16968
- * Hides the backdrop, deferring until the browser's matching "click" fires
16969
- * when `closeEvent` was triggered by a mousedown (see popover.jsx's top
16970
- * comment for why) — same capture-phase-on-document pattern as
16971
- * armSuppressNextOpenRequest in open_controller.js, which a plain timeout
16972
- * can't safely replace: mouseup (and the click that follows it) can land an
16973
- * arbitrarily long time after mousedown (the user is still holding the
16974
- * button down), so a short timeout can fire first and hide the backdrop
16975
- * before its own click ever arrives. A capture-phase listener on document
16976
- * fires for every click regardless of what any bubble-phase handler does
16977
- * downstream, so no fallback timer is needed.
16978
- *
16979
- * `hide` is the caller's own way to actually hide the backdrop
16980
- * (`hidePopover()` for a top-layer backdrop, a plain `style.display = "none"`
16981
- * for a plain div) — this helper only owns the mousedown/click timing.
16982
- *
16983
- * Returns a disarm function (or undefined if hidden immediately), so a
16984
- * fresh open can cancel a pending hide it's about to make redundant.
16985
- */
16986
- const armPointerDownOutsideClose = (closeEvent, hide) => {
16987
- const mousedownEvent = findEvent(closeEvent, "mousedown");
16988
- if (!mousedownEvent) {
16989
- hide();
16990
- return undefined;
16991
- }
16992
- // `hide` is handed the click, for a wall with peers to re-aim it (see
16993
- // handWallClickToPeer).
16994
- const onClick = (clickEvent) => {
16995
- document.removeEventListener("click", onClick, { capture: true });
16996
- hide(clickEvent);
16997
- };
16998
- document.addEventListener("click", onClick, { capture: true });
16999
- return () => {
17000
- document.removeEventListener("click", onClick, { capture: true });
17001
- };
17002
- };
17003
-
17004
- /**
17005
- * Maps a positionArea y/x pair to a concrete `navi-animation` value (a
17006
- * `prefix` plus a direction word), or `null` if both axes overlap the anchor
17007
- * (no direction at all — that's `resolvedAnimationKind === "scaling"`
17008
- * territory instead, see resolveAutoAnimationKind below).
17009
- *
17010
- * `prefix: "slide-from"` (used with no real anchor — Dialog always, Popover
17011
- * when docked) keeps the word as the compass direction the popup comes
17012
- * from: placed "top" (a point/corner), it slides in from the top.
17013
- * `prefix: "expand"` (a real anchor, Popover-only) uses the motion/growth
17014
- * direction instead, the opposite compass point: placed "top" of the
17015
- * anchor, it moves/grows up, away from the anchor (which sits below it).
17016
- *
17017
- * "inset-*"/"center" contribute no direction on their axis either way.
17018
- */
17019
- const resolveDirectionValue = (y, x, { prefix }) => {
17020
- const yWord =
17021
- y === "top"
17022
- ? prefix === "expand"
17023
- ? "up"
17024
- : "top"
17025
- : y === "bottom"
17026
- ? prefix === "expand"
17027
- ? "down"
17028
- : "bottom"
17029
- : null;
17030
- const xWord = x === "left" ? "left" : x === "right" ? "right" : null;
17031
- if (!yWord && !xWord) {
17032
- return null;
17033
- }
17034
- return yWord && xWord
17035
- ? `${prefix}-${yWord}-${xWord}`
17036
- : `${prefix}-${yWord || xWord}`;
17037
- };
17038
-
17039
- /**
17040
- * Shared `animation="auto"`/`true` resolution: "scaling" reads best overall
17041
- * — picked for any real anchor, or for a point/corner placed dead-center
17042
- * (both positionArea axes overlapping — there's no sensible direction to
17043
- * slide from in that case). "sliding" otherwise. `anchor` is `undefined`
17044
- * for any no-anchor/docked case (Dialog always, Popover's own custom
17045
- * renderer when there's no real anchor), so this collapses to "scaling"
17046
- * there only for the dead-center case, "sliding" otherwise. The two
17047
- * "overlapping" booleans below describe the *positionArea* itself (a bare
17048
- * word vs. "inset-"/"center"), not anything about the anchor — they'd
17049
- * mean exactly the same thing even with no anchor at all, since it's the
17050
- * position strategy, not the anchor, that decides whether there's a
17051
- * direction to slide from.
17052
- */
17053
- const resolveAutoAnimationKind = (anchor, parsedPositionArea) => {
17054
- const yIsOverlapping =
17055
- parsedPositionArea.y !== "top" && parsedPositionArea.y !== "bottom";
17056
- const xIsOverlapping =
17057
- parsedPositionArea.x !== "left" && parsedPositionArea.x !== "right";
17058
- return anchor || (yIsOverlapping && xIsOverlapping) ? "scaling" : "sliding";
17059
- };
17060
-
17061
- /**
17062
- * A popup reads "outside" from its own border box: what a press lands on
17063
- * decides nothing on its own (a genuine backdrop press and a press on the
17064
- * popup's padding both report the popup element as their target, there being
17065
- * no real ::backdrop node to be one), so the rectangle is what tells them
17066
- * apart. That holds as long as the box and what the popup paints are the same
17067
- * thing — which stops being true for a popup with no surface of its own
17068
- * (`backgroundColor="transparent"`, no shadow, no padding): what the eye reads
17069
- * as backdrop is then inside the box, and a press there stays a press on the
17070
- * popup.
17071
- *
17072
- * `data-navi-popup-outside` is how a caller says which of its own boxes are
17073
- * not the surface. It is opt-in because navi cannot infer it — a background
17074
- * can come from anywhere — while the caller who made the popup see-through
17075
- * knows exactly which box is decoration and which is paper.
17076
- *
17077
- * The marker answers for the element it is on, never for its descendants: a
17078
- * box painted inside a marked one is still surface, so a row can be marked and
17079
- * its empty halves read as backdrop while presses on the controls it holds do
17080
- * not dismiss anything. A descendant with `pointer-events: none` never becomes
17081
- * a press target at all, so the marked box answers in its place — while the
17082
- * same declaration on the marked box itself takes it out of hit-testing and
17083
- * makes the marker unreachable (see warnAboutUnreachableOutsideRegions).
17084
- */
17085
- const OUTSIDE_REGION_ATTRIBUTE = "data-navi-popup-outside";
17086
-
17087
- /**
17088
- * `data-navi-popup-inside` is the other direction: a box of the PAGE the
17089
- * caller says belongs to a popup, so a press on it is not outside that popup
17090
- * even though it lies beyond the border box — a card whose press is what
17091
- * fills the one panel of a board. Its value names the popup by `id`, the way
17092
- * `commandfor` does (several ids, space separated), because the exemption is
17093
- * one popup's: a menu open beside the board still closes on the same press.
17094
- *
17095
- * Unlike the outside marker it answers for its whole subtree: the outside
17096
- * marker carves free space out of a surface whose controls stay surface, this
17097
- * one adds a whole thing — the card, its count, its buttons — to the popup's
17098
- * own ground, and nothing in it is a dismissal.
17099
- */
17100
- const INSIDE_ATTRIBUTE = "data-navi-popup-inside";
17101
-
17102
- /**
17103
- * `data-navi-popup-group` names the peers of a popup: the triggers of the other
17104
- * popups a press goes to from this one — a menu bar, where pressing another
17105
- * menu while one is open switches to it, in that one press. A trigger joins by
17106
- * carrying the name (a Picker's `popupGroup` writes it on its own), and a popup
17107
- * reads it off what the press landed on.
17108
- *
17109
- * Returns the peer `target` is in, or null.
17110
- */
17111
- const findPopupPeer = (target, group) => {
17112
- if (!group || !target || typeof target.closest !== "function") {
17113
- return null;
17114
- }
17115
- return target.closest(`[data-navi-popup-group="${CSS.escape(group)}"]`);
17116
- };
17117
-
17118
- /**
17119
- * The click a wall took, handed to the peer (see findPopupPeer) it covered.
17120
- *
17121
- * A wall cannot let one press through and not another: it is what the pointer
17122
- * hits, and that is the whole of how it spends a press. So it goes on taking
17123
- * every one — the page behind stays as unhoverable and unpressable as it looks
17124
- * — and once the press is over, its click is re-aimed at what the wall stood
17125
- * over at that point, when that is a peer. The click alone, for the reason
17126
- * transition_press.js gives: a pointer stream made up with no pointer behind it
17127
- * starts gestures (a hold, a drag) that nothing ever ends, and a click is what
17128
- * opens a popup's trigger.
17129
- *
17130
- * Called with the click that takes the wall down after a press closed the
17131
- * popup (see armPointerDownOutsideClose), so the peer opens as the popup it
17132
- * replaces goes.
17133
- */
17134
- const handWallClickToPeer = (
17135
- clickEvent,
17136
- { wallEl, popupEl, anchorEl, group },
17137
- ) => {
17138
- if (!clickEvent.isTrusted || clickEvent.target !== wallEl) {
17139
- // A click dispatched by hand is already aimed, and one that did not land
17140
- // on the wall (a press on the popup's own outside region) was no wall's.
17141
- return;
17142
- }
17143
- // The topmost element under the wall at that point: what the press would
17144
- // have landed on with no wall. Asked past the wall rather than after hiding
17145
- // it, which a fading wall may not be yet.
17146
- const covered = document
17147
- .elementsFromPoint(clickEvent.clientX, clickEvent.clientY)
17148
- .find((el) => el !== wallEl && !popupEl.contains(el));
17149
- const peer = findPopupPeer(covered, group);
17150
- if (!peer) {
17151
- return;
17152
- }
17153
- if (peer.contains(popupEl) || (anchorEl && peer.contains(anchorEl))) {
17154
- // The popup's own trigger — the one holding it (a picker renders its
17155
- // popup inside itself) or the one it opened against: the press through
17156
- // the wall closed the popup, and handing the trigger its click would open
17157
- // it again.
17158
- return;
17159
- }
17160
- // The page sees one click, on the peer, rather than two.
17161
- clickEvent.stopPropagation();
17162
- // A PointerEvent where the browser makes clicks of them, a MouseEvent
17163
- // elsewhere: the same kind, copied field by field.
17164
- const ClickEvent = clickEvent.constructor;
17165
- covered.dispatchEvent(new ClickEvent(clickEvent.type, clickEvent));
17166
- };
17167
-
17168
- /**
17169
- * What a press landing on a region the caller declared as not-its-surface
17170
- * does: exactly what the same press on the backdrop would do.
17171
- *
17172
- * Lives on the popup's own content element rather than in the backdrop's
17173
- * handler, for both renderers: the backdrop is a sibling behind the popup
17174
- * (never an ancestor), so a press inside the popup's box never reaches it.
17175
- */
17176
- const handlePressOnOutsideRegion = (
17177
- mouseDownEvent,
17178
- { popupEl, openController, pressOutside },
17179
- ) => {
17180
- if (mouseDownEvent.button !== 0) {
17181
- return;
17182
- }
17183
- const { target } = mouseDownEvent;
17184
- if (!target.hasAttribute(OUTSIDE_REGION_ATTRIBUTE)) {
17185
- return;
17186
- }
17187
- if (openedDuringThisPress(openController)) {
17188
- // The release of the press that opened it is not somebody dismissing it
17189
- // (see openedDuringThisPress).
17190
- return;
17191
- }
17192
- // A popup opens inside its opener's own subtree, so a press on a region
17193
- // belonging to a popup nested in this one bubbles through here too — and it
17194
- // is a press on what is in front, not on this popup's own decoration.
17195
- if (
17196
- target.closest(`[navi-control="dialog"], [navi-control="popover"]`) !==
17197
- popupEl
17198
- ) {
17199
- return;
17200
- }
17201
- if (pressOutside === "capture") {
17202
- mouseDownEvent.preventDefault();
17203
- return;
17204
- }
17205
- if (pressOutside === "close" || pressOutside === "cancel") {
17206
- openController.requestClose(mouseDownEvent, {
17207
- isCancel: pressOutside === "cancel",
17208
- });
17209
- }
17210
- };
17211
-
17212
- /**
17213
- * Hears an outside press from the document itself, for a popup with no
17214
- * backdrop of its own to catch one: a modal `<dialog>` (whose native
17215
- * `::backdrop` dispatches nothing a handler can be put on) and any popup the
17216
- * caller asked to leave the page reachable (`backdrop={false}`).
17217
- *
17218
- * The press is read and never taken: no `preventDefault`, no
17219
- * `stopPropagation`, and the listener is on `document` rather than on
17220
- * anything covering the page — so what the press landed on hears it too, in
17221
- * the same gesture, and the popup only takes note. That is the whole point of
17222
- * a popup with no wall: a press outside is a press on the page, and the page
17223
- * answers it. The one popup where that cannot hold is the modal dialog, where
17224
- * the browser has already made everything behind inert before this runs.
17225
- *
17226
- * `pressEventType` says which event IS the press, and the two callers want
17227
- * opposite ones:
17228
- *
17229
- * - `"pointerdown"` for a popup with no wall: a page with no wall over it is
17230
- * a page whose own elements arbitrate their presses, and cancelling a
17231
- * `pointerdown` — what a drag source and a control keeping the focus where
17232
- * it is both do — suppresses every compatibility mouse event that would have
17233
- * followed it. The press happens, reaches its target, and no `mousedown` is
17234
- * ever dispatched for it. Reading the pointer event is reading the press
17235
- * itself, whatever anything does with it afterwards, and it is the same
17236
- * event the drag and surface detectors read.
17237
- * - `"mousedown"` for the modal dialog, whose native `::backdrop` exists to
17238
- * spend the press. The page's controls act on `mousedown`, and on a touch
17239
- * screen that is not the same moment as `pointerdown`: the compatibility
17240
- * mouse events are synthesized at `touchend` and hit-tested again then.
17241
- * Closing on `pointerdown` takes the `::backdrop` away before that second
17242
- * hit-test, so the `mousedown` lands on whatever the page holds under the
17243
- * finger, and one tap both dismisses the dialog and presses the page. Read
17244
- * on `mousedown`, the wall is still up when the press the page acts on is
17245
- * aimed, and it catches it. Nothing behind a modal wall can cancel that
17246
- * press, since nothing behind it hears the `pointerdown` at all.
17247
- *
17248
- * Capture phase, so a handler downstream that stops propagation cannot keep
17249
- * the popup open either — the press is still outside whatever is made of it.
17250
- *
17251
- * Returns a cleanup that removes the listener.
17252
- */
17253
- const armOutsidePressClose = (
17254
- popupEl,
17255
- { openController, pressOutside, pressEventType = "pointerdown" },
17256
- ) => {
17257
- const onDocumentPointerDown = (pointerDownEvent) => {
17258
- if (pointerDownEvent.button !== 0) {
17259
- return;
17260
- }
17261
- if (openedDuringThisPress(openController)) {
17262
- // The release of the press that opened it is not somebody dismissing it
17263
- // (see openedDuringThisPress).
17264
- return;
17265
- }
17266
- // The press landed inside another popup: that is a press on what is in
17267
- // front, not outside. Asking the target where it lives rather than asking
17268
- // this popup whether it was pushed — a popup in front does not have to be
17269
- // one this popup knows about. In front means neither inside nor around: a
17270
- // popup nested in this one falls to the containment check below as the
17271
- // inside press it is, and one this popup opened INSIDE (a bubble rendered
17272
- // in a sheet's content) is around it, so what that popup holds is outside
17273
- // this one exactly like the rest of the page.
17274
- const popupUnderPointer = pointerDownEvent.target.closest?.(
17275
- `[navi-control="dialog"], [navi-control="popover"]`,
17276
- );
17277
- if (
17278
- popupUnderPointer &&
17279
- popupUnderPointer !== popupEl &&
17280
- !popupEl.contains(popupUnderPointer) &&
17281
- !popupUnderPointer.contains(popupEl)
17282
- ) {
17283
- return;
17284
- }
17285
- if (
17286
- popupEl.id &&
17287
- pointerDownEvent.target.closest?.(
17288
- `[${INSIDE_ATTRIBUTE}~="${CSS.escape(popupEl.id)}"]`,
17289
- )
17290
- ) {
17291
- return;
17292
- }
17293
- // Real DOM containment wins over the rectangle below — an element
17294
- // genuinely inside the popup (`overflow: visible`, a negative margin, an
17295
- // absolutely-positioned child) can be painted outside its border box, and
17296
- // a press there is not outside just because its coordinates are. The
17297
- // popup element itself is excluded (contains() answers true for it): a
17298
- // press on a modal dialog's native backdrop reports the dialog as its
17299
- // target, there being no `::backdrop` node to be one, so treating that as
17300
- // contained would keep the rectangle from ever running.
17301
- if (
17302
- pointerDownEvent.target !== popupEl &&
17303
- popupEl.contains(pointerDownEvent.target)
17304
- ) {
17305
- return;
17306
- }
17307
- const rect = popupEl.getBoundingClientRect();
17308
- const isOutside =
17309
- pointerDownEvent.clientX < rect.left ||
17310
- pointerDownEvent.clientX > rect.right ||
17311
- pointerDownEvent.clientY < rect.top ||
17312
- pointerDownEvent.clientY > rect.bottom;
17313
- if (!isOutside) {
17314
- return;
17315
- }
17316
- openController.requestClose(pointerDownEvent, {
17317
- isCancel: pressOutside === "cancel",
17318
- });
17319
- };
17320
- document.addEventListener(pressEventType, onDocumentPointerDown, {
17321
- capture: true,
17322
- });
17323
- return () => {
17324
- document.removeEventListener(pressEventType, onDocumentPointerDown, {
17325
- capture: true,
17326
- });
17327
- };
17328
- };
17329
-
17330
16851
  /**
17331
16852
  * Typography reset shared by the surfaces a control opens: callout, popover,
17332
16853
  * dialog.
@@ -17449,7 +16970,7 @@ installImportMetaCssBuild(import.meta);
17449
16970
  * (see calloutTemplate in callout.js).
17450
16971
  */
17451
16972
 
17452
- const css$15 = /* css */`.navi_callout_status_icon {
16973
+ const css$16 = /* css */`.navi_callout_status_icon {
17453
16974
  --x-callout-status-icon-color: var(--navi-callout-neutral-color);
17454
16975
  box-sizing: border-box;
17455
16976
  aspect-ratio: 1;
@@ -17520,7 +17041,7 @@ const CalloutStatusIcon = ({
17520
17041
  status = "info",
17521
17042
  shape = "square"
17522
17043
  }) => {
17523
- import.meta.css = [css$15, "@jsenv/navi/src/control/rules/callout/callout_status_icon.jsx"];
17044
+ import.meta.css = [css$16, "@jsenv/navi/src/control/rules/callout/callout_status_icon.jsx"];
17524
17045
  return jsx("span", {
17525
17046
  className: "navi_callout_status_icon",
17526
17047
  "data-status": status === "none" ? undefined : status,
@@ -17561,11 +17082,13 @@ const SPENT_PRESS_EVENT_TYPES = [
17561
17082
  "contextmenu",
17562
17083
  ];
17563
17084
  // Where a press lands on something drawn over the page rather than on the page
17564
- // itself: a popup of navi's (either layer) or a native top-layer element.
17085
+ // itself: a popup of navi's (either layer) or a native top-layer element. An
17086
+ // idle PopupGroup is a closed <dialog> that is part of the page (see
17087
+ // popup_group.jsx).
17565
17088
  const LAYER_SELECTOR =
17566
- '[popover], dialog, [navi-control="popover"], [navi-control="dialog"]';
17089
+ '[popover], dialog:not([navi-popup-group]:not(:modal)), [navi-control="popover"], [navi-control="dialog"]';
17567
17090
 
17568
- const css$14 = /* css */ `
17091
+ const css$15 = /* css */ `
17569
17092
  /* jsenv-css-opaque: same as dialog.jsx, cascade order. */
17570
17093
  @layer navi {
17571
17094
  .navi_callout {
@@ -17838,6 +17361,10 @@ const css$14 = /* css */ `
17838
17361
  * If not provided or too big, callout will be centered in viewport. It says where the
17839
17362
  * callout is drawn, not what dismisses it: see `openingEvent` for what a container anchor
17840
17363
  * costs
17364
+ * @param {HTMLElement} [options.containerElement] - Where the callout element goes in the
17365
+ * DOM, when that is not where the anchor puts it (the anchor itself, or beside it). What
17366
+ * holds a callout decides what it inherits and whether it can be reached at all: outside
17367
+ * an engaged PopupGroup it is inert. The anchor still says where it points
17841
17368
  * @param {string} [options.status=""] - Callout status: "info" | "warning" | "error" | "success"
17842
17369
  * @param {string} [options.testId] - `data-testid` on the callout element. The callout is
17843
17370
  * drawn by navi, so nothing the caller renders can carry the name a test needs — same
@@ -17859,10 +17386,6 @@ const css$14 = /* css */ `
17859
17386
  * still scrolls, a press on a layer drawn over the page (a sheet the callout's content
17860
17387
  * opened) is that layer's, and a press that does not close the callout (an "error" one) is
17861
17388
  * not spent either
17862
- * @param {string} [options.group] - With `backdrop`: the name the callout's peers carry in
17863
- * `data-navi-popup-group` — the triggers of other popups, callouts or not. A press inside a
17864
- * peer still closes the callout but is not spent, so the peer it landed on opens its own
17865
- * popup in the same press — a menu bar, where pressing another menu switches to it
17866
17389
  * @param {Event} [options.openingEvent] - The gesture the callout belongs to. It names the
17867
17390
  * opener — `currentTarget` while the event is still dispatching, `target` once it is over,
17868
17391
  * so awaiting before opening changes nothing — and the opener is the one part of the anchor
@@ -17923,12 +17446,12 @@ const openCallout = (
17923
17446
  // "" - neutral information
17924
17447
  status = "",
17925
17448
  testId,
17449
+ containerElement,
17926
17450
  onClose,
17927
17451
  closeByPressOutside = status === "info",
17928
17452
  closeOnFocusLeave = closeByPressOutside,
17929
17453
  closeByScroll = false,
17930
17454
  backdrop = false,
17931
- group,
17932
17455
  closeButton = true,
17933
17456
  openingEvent,
17934
17457
  reopen = "toggle",
@@ -17938,7 +17461,7 @@ const openCallout = (
17938
17461
  debug = () => {},
17939
17462
  } = {},
17940
17463
  ) => {
17941
- import.meta.css = [css$14, "@jsenv/navi/src/control/rules/callout/callout.js"];
17464
+ import.meta.css = [css$15, "@jsenv/navi/src/control/rules/callout/callout.js"];
17942
17465
  if (debug === true) {
17943
17466
  debug = (e, ...args) => console.debug(`"${e.type}" -> `, ...args);
17944
17467
  }
@@ -18240,10 +17763,11 @@ const openCallout = (
18240
17763
  }
18241
17764
  }
18242
17765
  const calloutContainer = (() => {
18243
- if (!anchorElement || anchorElement === document.body) {
17766
+ const holder = containerElement || anchorElement;
17767
+ if (!holder || holder === document.body) {
18244
17768
  return document.body;
18245
17769
  }
18246
- const closestLabel = anchorElement.closest("label");
17770
+ const closestLabel = holder.closest("label");
18247
17771
  if (closestLabel) {
18248
17772
  // Putting callout inside label would be problematic:
18249
17773
  // - Click on callout would be catched by the browser and re-dispatched to the label
@@ -18255,10 +17779,10 @@ const openCallout = (
18255
17779
  return labelParent;
18256
17780
  }
18257
17781
  // Some elements (e.g. <input>) cannot have children
18258
- if (canContainCallout(anchorElement)) {
18259
- return anchorElement;
17782
+ if (canContainCallout(holder)) {
17783
+ return holder;
18260
17784
  }
18261
- return anchorElement.parentNode || document.body;
17785
+ return holder.parentNode || document.body;
18262
17786
  })();
18263
17787
 
18264
17788
  {
@@ -18305,8 +17829,7 @@ const openCallout = (
18305
17829
  // Whether an outside press on `target` is spent on closing the callout
18306
17830
  // (`backdrop`). What is not the page behind the callout keeps its press: a
18307
17831
  // layer the callout is not part of — its own page may well be a sheet, a
18308
- // sheet its content opened is in front of it — and a peer, which the press
18309
- // is meant to open.
17832
+ // sheet its content opened is in front of it.
18310
17833
  const spendsPressOn = (target) => {
18311
17834
  if (!backdrop || !pressOutsideCloses()) {
18312
17835
  return false;
@@ -18315,9 +17838,6 @@ const openCallout = (
18315
17838
  if (layer && !layer.contains(calloutElement)) {
18316
17839
  return false;
18317
17840
  }
18318
- if (findPopupPeer(target, group)) {
18319
- return false;
18320
- }
18321
17841
  return true;
18322
17842
  };
18323
17843
  const spend = (event) => {
@@ -19533,7 +19053,7 @@ const generateSvgWithoutArrow = (width, height) => {
19533
19053
  *
19534
19054
  * Usage:
19535
19055
  * const myToken = createOpenToken();
19536
- * calloutManager.addOpenToken(myToken, { message, status, testId, closeByScroll, backdrop, group, anchorElement, event, skipFocus, onClose });
19056
+ * calloutManager.addOpenToken(myToken, { message, status, testId, closeByScroll, backdrop, closeByPressOutside, containerElement, anchorElement, event, skipFocus, onClose });
19537
19057
  * calloutManager.removeOpenToken(myToken, event);
19538
19058
  * calloutManager.requestCloseCallout(event, debugReason); // force-close all
19539
19059
  * calloutManager.callout // current open callout or null
@@ -19589,10 +19109,15 @@ const createCalloutManager = (
19589
19109
  testId: tokenData.testId,
19590
19110
  icon: tokenData.icon,
19591
19111
  closeButton: tokenData.closeButton,
19592
- closeByPressOutside: tokenData.status !== "error",
19112
+ // Unless the token says otherwise: what a press outside a picker's
19113
+ // callout does inside a PopupGroup is the group's to decide. Focus
19114
+ // leaving stays the callout's own way out, whoever answers the press.
19115
+ closeByPressOutside:
19116
+ tokenData.closeByPressOutside ?? tokenData.status !== "error",
19117
+ closeOnFocusLeave: tokenData.status !== "error",
19118
+ containerElement: tokenData.containerElement,
19593
19119
  closeByScroll: tokenData.closeByScroll,
19594
19120
  backdrop: tokenData.backdrop,
19595
- group: tokenData.group,
19596
19121
  anchorElement,
19597
19122
  openingEvent: event,
19598
19123
  skipFocus: tokenData.skipFocus,
@@ -19722,7 +19247,8 @@ const createCalloutManager = (
19722
19247
  closeButton,
19723
19248
  closeByScroll,
19724
19249
  backdrop,
19725
- group,
19250
+ closeByPressOutside,
19251
+ containerElement,
19726
19252
  anchorElement,
19727
19253
  event,
19728
19254
  skipFocus,
@@ -19741,7 +19267,8 @@ const createCalloutManager = (
19741
19267
  closeButton,
19742
19268
  closeByScroll,
19743
19269
  backdrop,
19744
- group,
19270
+ closeByPressOutside,
19271
+ containerElement,
19745
19272
  skipFocus,
19746
19273
  onClose,
19747
19274
  // Resolved as the token is added, and kept with it: a token shown later
@@ -22061,7 +21588,10 @@ const readNumberFromDom = (element, attribute, defaultValue) => {
22061
21588
  * what a popup outside the top layer says for itself).
22062
21589
  */
22063
21590
 
22064
- const LAYER_OVER_SELECTOR = "[data-drag-ignore],[popover],dialog";
21591
+ // An idle PopupGroup is a closed <dialog> that is part of the page, not a layer
21592
+ // over it (see popup_group.jsx).
21593
+ const LAYER_OVER_SELECTOR =
21594
+ "[data-drag-ignore],[popover],dialog:not([navi-popup-group]:not(:modal))";
22065
21595
 
22066
21596
  /**
22067
21597
  * The nearest word wins: a layer INSIDE the element takes the press away from
@@ -22240,7 +21770,7 @@ import.meta.css = /* css */ [`@property --swipe-progress {
22240
21770
  user-select: none;
22241
21771
  }
22242
21772
 
22243
- :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"]) {
21773
+ :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"]) {
22244
21774
  user-select: text;
22245
21775
  }
22246
21776
 
@@ -33179,7 +32709,7 @@ const keepLeavingPages = () => {
33179
32709
  };
33180
32710
  };
33181
32711
  const LEAVING_ATTRIBUTE = "data-navi-route-leaving";
33182
- const css$13 = /* css */`[data-navi-route-leaving] {
32712
+ const css$14 = /* css */`[data-navi-route-leaving] {
33183
32713
  display: none !important;
33184
32714
  }
33185
32715
  `;
@@ -33451,7 +32981,7 @@ const useContentKeepingLeavingPages = (branch, readShownBranch, parentPage) => {
33451
32981
  if (leaving.length === 0) {
33452
32982
  return vnode ? [vnode] : null;
33453
32983
  }
33454
- import.meta.css = [css$13, "@jsenv/navi/src/nav/route.jsx"];
32984
+ import.meta.css = [css$14, "@jsenv/navi/src/nav/route.jsx"];
33455
32985
  const children = vnode ? [vnode] : [];
33456
32986
  for (const entry of leaving) {
33457
32987
  children.push(h("template", {
@@ -33946,20 +33476,21 @@ const TOP_LAYER_SELECTOR = ":modal, :popover-open";
33946
33476
  // stands over the pages in the top layer. A sticky row inside the pages needs
33947
33477
  // none of this — it lives in the area, so it is already part of the pages' own
33948
33478
  // picture, and so is a popup rendered with layer="local".
33949
- const FURNITURE_SELECTOR = `.navi_fixed_bar, .navi_popover:is(${TOP_LAYER_SELECTOR}), .navi_dialog:is(${TOP_LAYER_SELECTOR})`;
33479
+ 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})`;
33950
33480
  // Worn by the root for the length of a route transition (route_transition.jsx
33951
33481
  // owns it). Written out rather than imported: importing the module that owns
33952
33482
  // it back into this one would close a cycle.
33953
33483
  const TRANSITION_ATTRIBUTE$1 = "data-navi-route-transition";
33954
33484
  const NAME_PROPERTY$1 = "view-transition-name";
33955
33485
  const FURNITURE_NAME_PREFIX = "navi-transition-furniture-";
33956
- // The walls painted in the top layer: a modal dialog's, which is its
33957
- // ::backdrop, and a popover's, which is a real element shown as a popover
33486
+ // The walls painted in the top layer: a modal dialog's (a PopupGroup's too,
33487
+ // layout/popup_group.jsx), which is its ::backdrop, and a popover's, which is
33488
+ // a real element shown as a popover
33958
33489
  // beside it (layout/popover.jsx) — unnamed, so covered by the pictures for
33959
33490
  // the length of the movement all the same. A top-layer dialog without a wall
33960
33491
  // is shown as a popover and has nothing to stand in for.
33961
33492
  const TOP_LAYER_WALL_SELECTOR =
33962
- ".navi_dialog:modal, .navi_popover_backdrop:popover-open";
33493
+ ".navi_dialog:modal, .navi_popover_backdrop:popover-open, .navi_popup_group:modal";
33963
33494
  const WALL_ATTRIBUTE = "data-navi-transition-wall";
33964
33495
  // Which side of the movement a bar is on, worn by the bar and read by the
33965
33496
  // browser as the class of its group (the CSS below): "leaving" from the first
@@ -34021,7 +33552,7 @@ const TRANSITION_FURNITURE_CSS = /* css */ `.navi_fixed_bar[data-navi-transition
34021
33552
  }
34022
33553
 
34023
33554
  :root[data-navi-route-transition-target="area"] {
34024
- & .navi_dialog::backdrop, & .navi_popover_backdrop {
33555
+ & .navi_dialog::backdrop, & .navi_popup_group::backdrop, & .navi_popover_backdrop {
34025
33556
  backdrop-filter: none;
34026
33557
  background: none;
34027
33558
  }
@@ -35122,7 +34653,7 @@ const ROUTE_TRAVEL_ATTRIBUTE = "data-navi-route-travel";
35122
34653
  // the root pictures must NOT move (they carry the whole viewport, blank bands
35123
34654
  // included).
35124
34655
 
35125
- const css$12 = /* css */`@property --navi-route-transition-leave {
34656
+ const css$13 = /* css */`@property --navi-route-transition-leave {
35126
34657
  syntax: "*";
35127
34658
  inherits: false
35128
34659
  }
@@ -36704,7 +36235,7 @@ const describePage = ({
36704
36235
  // is the application's element as often as <RouteTransitionArea>, and is
36705
36236
  // only found as a movement starts.
36706
36237
  const installRouteTransitionCss = () => {
36707
- import.meta.css = [css$12, "@jsenv/navi/src/nav/route_transition.jsx"];
36238
+ import.meta.css = [css$13, "@jsenv/navi/src/nav/route_transition.jsx"];
36708
36239
  installTransitionWindowCss();
36709
36240
  installTransitionFurnitureCss();
36710
36241
  };
@@ -36741,7 +36272,7 @@ const DRAGGED_ATTRIBUTE = "data-navi-route-travel-dragged";
36741
36272
  const TURNED_ATTRIBUTE = "data-navi-route-travel-turned";
36742
36273
  // The name the box wears while it travels, and only then (see nameForTravel).
36743
36274
  const TRAVEL_NAME = "navi-route-travel";
36744
- const css$11 = /* css */`.navi_route_travel {
36275
+ const css$12 = /* css */`.navi_route_travel {
36745
36276
  position: relative;
36746
36277
 
36747
36278
  &[data-travel-by-drag="x"] {
@@ -37039,7 +36570,7 @@ const RouteTravel = ({
37039
36570
  children,
37040
36571
  ...rest
37041
36572
  }) => {
37042
- import.meta.css = [css$11, "@jsenv/navi/src/nav/route_travel.jsx"];
36573
+ import.meta.css = [css$12, "@jsenv/navi/src/nav/route_travel.jsx"];
37043
36574
  installTransitionWindowCss();
37044
36575
  const elementRef = useRef();
37045
36576
  const gestureRef = useRef(null);
@@ -40439,6 +39970,504 @@ const NAVI_COMMANDS = {
40439
39970
  "--navi-unselect": unselectCommand,
40440
39971
  };
40441
39972
 
39973
+ /**
39974
+ * Small, renderer-agnostic helpers shared by Popover and Dialog's own custom
39975
+ * (non-top-layer) renderers — operate on a plain DOM element, no knowledge
39976
+ * of which of the two owns it.
39977
+ */
39978
+
39979
+
39980
+ /**
39981
+ * Whether a visibleRectEffect delivery is one that can have taken height away
39982
+ * from a popup, and so pushed whatever holds focus out of sight:
39983
+ * - "resize": the window/visual viewport settled — which is also how the
39984
+ * on-screen keyboard arrives, overlay or not (window_size.js);
39985
+ * - ELEMENT_SIZE_CHANGE: the popup's own box measured different;
39986
+ * - "focusin": the focus-settled re-measure, for the room that changes with
39987
+ * nothing announcing it (subscribeFocusSettled in window_size.js).
39988
+ *
39989
+ * Everything else is a scroll of one kind or another, where nothing got
39990
+ * smaller and scrolling the focused element back would fight the very gesture
39991
+ * that fired it.
39992
+ */
39993
+ const mayHaveHiddenFocus = (event) => {
39994
+ const type = event?.type;
39995
+ return (
39996
+ type === "resize" || type === ELEMENT_SIZE_CHANGE || type === "focusin"
39997
+ );
39998
+ };
39999
+
40000
+ const scrollportHeightMap = new WeakMap();
40001
+ /**
40002
+ * Scrolls whatever holds focus inside `popupEl` back into view, if the popup
40003
+ * getting shorter has pushed it out.
40004
+ *
40005
+ * The case this exists for: a field low in the scrolling body of a popup that
40006
+ * also has a footer (box.jsx — with a body, the body is the only thing that
40007
+ * scrolls and the footer is a sibling sitting right under it). Focusing the
40008
+ * field makes the browser scroll it into view, which it does against the
40009
+ * popup's height AT THAT MOMENT; the on-screen keyboard then opens and takes
40010
+ * that height away. The body shrinks, its scrollTop does not move, so the
40011
+ * content slides down relative to the shorter scrollport and the field ends up
40012
+ * past its bottom edge — visually, swallowed by the footer. The browser does
40013
+ * not redo a scroll-into-view it already answered, so this does.
40014
+ *
40015
+ * Only what the shrink itself hid, though: with the keyboard up the user reads
40016
+ * the rest of the popup by scrolling the field away — to reach the submit under
40017
+ * it, typically — and the room keeps changing while they do (a keyboard settling
40018
+ * in two steps, a suggestion strip, a browser bar). Answering each of those by
40019
+ * scrolling the field back takes the popup away from wherever they had just
40020
+ * scrolled it, over and over: what they were reading cannot be reached at all
40021
+ * without blurring the field first, and the popup reads as unscrollable. So the
40022
+ * field is brought back only when it was in view before the room shrank, which
40023
+ * one remembered number answers: a resize moves neither scrollTop nor the
40024
+ * element's offset inside the scrolled content, so measuring against the height
40025
+ * the scrollport HAD is measuring the state before the change.
40026
+ *
40027
+ * Scoped to the field's own scroll container (never the page): a popup traps
40028
+ * scrolling precisely so the document underneath cannot move, and a plain
40029
+ * scrollIntoView walks past a container whose scrollbar isn't visible — see
40030
+ * scrollIntoViewScoped's own doc.
40031
+ *
40032
+ * "nearest": the smallest scroll that makes it visible, and none at all when it
40033
+ * already is. Where it lands is the container's own business — a navi scroller
40034
+ * keeps a band free at its edges so a field never comes back glued to one (see
40035
+ * scroll-padding in box.jsx).
40036
+ */
40037
+ const keepFocusedElementVisible = (popupEl) => {
40038
+ const { activeElement } = document;
40039
+ if (!activeElement || activeElement === popupEl) {
40040
+ return;
40041
+ }
40042
+ if (!popupEl.contains(activeElement)) {
40043
+ return;
40044
+ }
40045
+ const scrollContainer = getScrollContainer(activeElement);
40046
+ if (!scrollContainer || !popupEl.contains(scrollContainer)) {
40047
+ // What scrolls the field is outside the popup, which means the page: a
40048
+ // popup holds it still on purpose (trapScrollInside), so there is nothing
40049
+ // here to scroll back.
40050
+ return;
40051
+ }
40052
+ const scrollportHeight = scrollContainer.clientHeight;
40053
+ const scrollportHeightBefore = scrollportHeightMap.get(scrollContainer);
40054
+ scrollportHeightMap.set(scrollContainer, scrollportHeight);
40055
+ if (scrollportHeightBefore !== undefined) {
40056
+ if (scrollportHeight >= scrollportHeightBefore) {
40057
+ // Nothing was taken away, so nothing was hidden by this.
40058
+ return;
40059
+ }
40060
+ if (
40061
+ !isVisibleInScrollport(
40062
+ activeElement,
40063
+ scrollContainer,
40064
+ scrollportHeightBefore,
40065
+ )
40066
+ ) {
40067
+ return;
40068
+ }
40069
+ }
40070
+ scrollIntoViewScoped(activeElement, {
40071
+ container: scrollContainer,
40072
+ block: "nearest",
40073
+ });
40074
+ };
40075
+
40076
+ // Whether any part of `el` was showing in `container` back when its scrollport
40077
+ // was `scrollportHeight` tall. Both boxes are read now: the container may have
40078
+ // moved as well as shrunk (a centered dialog re-centers itself), and the
40079
+ // difference between the two tops is what that move leaves alone.
40080
+ const isVisibleInScrollport = (el, container, scrollportHeight) => {
40081
+ const elRect = el.getBoundingClientRect();
40082
+ const containerRect = container.getBoundingClientRect();
40083
+ const elTop = elRect.top - containerRect.top + container.scrollTop;
40084
+ const elBottom = elTop + elRect.height;
40085
+ const scrollportTop = container.scrollTop;
40086
+ const scrollportBottom = scrollportTop + scrollportHeight;
40087
+ return elBottom > scrollportTop && elTop < scrollportBottom;
40088
+ };
40089
+
40090
+ /**
40091
+ * Calls `onSettled` once `el`'s current CSS transition is over — via
40092
+ * `transitionend`, with a safety `setTimeout` fallback matching the longest
40093
+ * `transition-duration`, in case nothing actually transitions or an event is
40094
+ * missed.
40095
+ *
40096
+ * Returns a "cancel" function, so a caller whose instance has been superseded
40097
+ * (a fresh open/close about to set its own state) can keep this stale one from
40098
+ * firing later. Cancelling only stops `onSettled`: undoing whatever the caller
40099
+ * did up front is that fresh call's business, not this one's.
40100
+ */
40101
+ const whenTransitionSettles = (el, onSettled) => {
40102
+ let settled = false;
40103
+ const onTransitionEnd = (transitionEvent) => {
40104
+ if (transitionEvent.target === el) {
40105
+ finish();
40106
+ }
40107
+ };
40108
+ const stopWatching = () => {
40109
+ settled = true;
40110
+ el.removeEventListener("transitionend", onTransitionEnd);
40111
+ clearTimeout(safetyTimeoutId);
40112
+ };
40113
+ const finish = () => {
40114
+ if (settled) {
40115
+ return;
40116
+ }
40117
+ stopWatching();
40118
+ onSettled();
40119
+ };
40120
+ el.addEventListener("transitionend", onTransitionEnd);
40121
+ const durationsInSeconds = getComputedStyle(el)
40122
+ .transitionDuration.split(",")
40123
+ .map((value) => parseFloat(value) || 0);
40124
+ const longestDurationMs = Math.max(0, ...durationsInSeconds) * 1000;
40125
+ const safetyTimeoutId = setTimeout(finish, longestDurationMs + 50);
40126
+ return () => {
40127
+ if (settled) {
40128
+ return;
40129
+ }
40130
+ stopWatching();
40131
+ };
40132
+ };
40133
+
40134
+ /**
40135
+ * Drops the document's text selection when it lives inside `el`, leaving a
40136
+ * selection made elsewhere on the page alone.
40137
+ *
40138
+ * A popup being closed takes its content with it, and a selection is a claim
40139
+ * on content the user can still act on: kept, it would outlive the surface it
40140
+ * was made on — painted on the box for the length of its exit transition, with
40141
+ * the handles and the copy toolbar a phone draws from the live selection
40142
+ * hanging over something that is going away — and reappear with the box on
40143
+ * the next opening. Dropping the selection is the only way to remove that
40144
+ * chrome: a `user-select: none` on the closing box hides the highlight in some
40145
+ * browsers only, and the handles are the browser's own, drawn from the
40146
+ * selection object rather than from any style.
40147
+ */
40148
+ const clearTextSelectionInside = (el) => {
40149
+ const selection = window.getSelection();
40150
+ if (!selection || selection.rangeCount === 0) {
40151
+ return;
40152
+ }
40153
+ const range = selection.getRangeAt(0);
40154
+ if (!el.contains(range.commonAncestorContainer)) {
40155
+ return;
40156
+ }
40157
+ selection.removeAllRanges();
40158
+ };
40159
+
40160
+ /**
40161
+ * Disables pointer-events on `el` until its current CSS transition settles —
40162
+ * avoids the cursor changing/something becoming clickable while the popup is
40163
+ * still visually moving into or out of place.
40164
+ *
40165
+ * Returns whenTransitionSettles' own "cancel" function: it doesn't restore
40166
+ * pointer-events, since a fresh call for the next open/close is about to set
40167
+ * its own state.
40168
+ */
40169
+ const suppressPointerEventsDuringTransition = (el) => {
40170
+ el.style.pointerEvents = "none";
40171
+ return whenTransitionSettles(el, () => {
40172
+ el.style.pointerEvents = "";
40173
+ });
40174
+ };
40175
+
40176
+ /**
40177
+ * Hides the backdrop, deferring until the browser's matching "click" fires
40178
+ * when `closeEvent` was triggered by a mousedown (see popover.jsx's top
40179
+ * comment for why) — same capture-phase-on-document pattern as
40180
+ * armSuppressNextOpenRequest in open_controller.js, which a plain timeout
40181
+ * can't safely replace: mouseup (and the click that follows it) can land an
40182
+ * arbitrarily long time after mousedown (the user is still holding the
40183
+ * button down), so a short timeout can fire first and hide the backdrop
40184
+ * before its own click ever arrives. A capture-phase listener on document
40185
+ * fires for every click regardless of what any bubble-phase handler does
40186
+ * downstream, so no fallback timer is needed.
40187
+ *
40188
+ * `hide` is the caller's own way to actually hide the backdrop
40189
+ * (`hidePopover()` for a top-layer backdrop, a plain `style.display = "none"`
40190
+ * for a plain div) — this helper only owns the mousedown/click timing.
40191
+ *
40192
+ * Returns a disarm function (or undefined if hidden immediately), so a
40193
+ * fresh open can cancel a pending hide it's about to make redundant.
40194
+ */
40195
+ const armPointerDownOutsideClose = (closeEvent, hide) => {
40196
+ const mousedownEvent = findEvent(closeEvent, "mousedown");
40197
+ if (!mousedownEvent) {
40198
+ hide();
40199
+ return undefined;
40200
+ }
40201
+ const onClick = () => {
40202
+ document.removeEventListener("click", onClick, { capture: true });
40203
+ hide();
40204
+ };
40205
+ document.addEventListener("click", onClick, { capture: true });
40206
+ return () => {
40207
+ document.removeEventListener("click", onClick, { capture: true });
40208
+ };
40209
+ };
40210
+
40211
+ /**
40212
+ * Maps a positionArea y/x pair to a concrete `navi-animation` value (a
40213
+ * `prefix` plus a direction word), or `null` if both axes overlap the anchor
40214
+ * (no direction at all — that's `resolvedAnimationKind === "scaling"`
40215
+ * territory instead, see resolveAutoAnimationKind below).
40216
+ *
40217
+ * `prefix: "slide-from"` (used with no real anchor — Dialog always, Popover
40218
+ * when docked) keeps the word as the compass direction the popup comes
40219
+ * from: placed "top" (a point/corner), it slides in from the top.
40220
+ * `prefix: "expand"` (a real anchor, Popover-only) uses the motion/growth
40221
+ * direction instead, the opposite compass point: placed "top" of the
40222
+ * anchor, it moves/grows up, away from the anchor (which sits below it).
40223
+ *
40224
+ * "inset-*"/"center" contribute no direction on their axis either way.
40225
+ */
40226
+ const resolveDirectionValue = (y, x, { prefix }) => {
40227
+ const yWord =
40228
+ y === "top"
40229
+ ? prefix === "expand"
40230
+ ? "up"
40231
+ : "top"
40232
+ : y === "bottom"
40233
+ ? prefix === "expand"
40234
+ ? "down"
40235
+ : "bottom"
40236
+ : null;
40237
+ const xWord = x === "left" ? "left" : x === "right" ? "right" : null;
40238
+ if (!yWord && !xWord) {
40239
+ return null;
40240
+ }
40241
+ return yWord && xWord
40242
+ ? `${prefix}-${yWord}-${xWord}`
40243
+ : `${prefix}-${yWord || xWord}`;
40244
+ };
40245
+
40246
+ /**
40247
+ * Shared `animation="auto"`/`true` resolution: "scaling" reads best overall
40248
+ * — picked for any real anchor, or for a point/corner placed dead-center
40249
+ * (both positionArea axes overlapping — there's no sensible direction to
40250
+ * slide from in that case). "sliding" otherwise. `anchor` is `undefined`
40251
+ * for any no-anchor/docked case (Dialog always, Popover's own custom
40252
+ * renderer when there's no real anchor), so this collapses to "scaling"
40253
+ * there only for the dead-center case, "sliding" otherwise. The two
40254
+ * "overlapping" booleans below describe the *positionArea* itself (a bare
40255
+ * word vs. "inset-"/"center"), not anything about the anchor — they'd
40256
+ * mean exactly the same thing even with no anchor at all, since it's the
40257
+ * position strategy, not the anchor, that decides whether there's a
40258
+ * direction to slide from.
40259
+ */
40260
+ const resolveAutoAnimationKind = (anchor, parsedPositionArea) => {
40261
+ const yIsOverlapping =
40262
+ parsedPositionArea.y !== "top" && parsedPositionArea.y !== "bottom";
40263
+ const xIsOverlapping =
40264
+ parsedPositionArea.x !== "left" && parsedPositionArea.x !== "right";
40265
+ return anchor || (yIsOverlapping && xIsOverlapping) ? "scaling" : "sliding";
40266
+ };
40267
+
40268
+ /**
40269
+ * A popup reads "outside" from its own border box: what a press lands on
40270
+ * decides nothing on its own (a genuine backdrop press and a press on the
40271
+ * popup's padding both report the popup element as their target, there being
40272
+ * no real ::backdrop node to be one), so the rectangle is what tells them
40273
+ * apart. That holds as long as the box and what the popup paints are the same
40274
+ * thing — which stops being true for a popup with no surface of its own
40275
+ * (`backgroundColor="transparent"`, no shadow, no padding): what the eye reads
40276
+ * as backdrop is then inside the box, and a press there stays a press on the
40277
+ * popup.
40278
+ *
40279
+ * `data-navi-popup-outside` is how a caller says which of its own boxes are
40280
+ * not the surface. It is opt-in because navi cannot infer it — a background
40281
+ * can come from anywhere — while the caller who made the popup see-through
40282
+ * knows exactly which box is decoration and which is paper.
40283
+ *
40284
+ * The marker answers for the element it is on, never for its descendants: a
40285
+ * box painted inside a marked one is still surface, so a row can be marked and
40286
+ * its empty halves read as backdrop while presses on the controls it holds do
40287
+ * not dismiss anything. A descendant with `pointer-events: none` never becomes
40288
+ * a press target at all, so the marked box answers in its place — while the
40289
+ * same declaration on the marked box itself takes it out of hit-testing and
40290
+ * makes the marker unreachable (see warnAboutUnreachableOutsideRegions).
40291
+ */
40292
+ const OUTSIDE_REGION_ATTRIBUTE = "data-navi-popup-outside";
40293
+
40294
+ /**
40295
+ * `data-navi-popup-inside` is the other direction: a box of the PAGE the
40296
+ * caller says belongs to a popup, so a press on it is not outside that popup
40297
+ * even though it lies beyond the border box — a card whose press is what
40298
+ * fills the one panel of a board. Its value names the popup by `id`, the way
40299
+ * `commandfor` does (several ids, space separated), because the exemption is
40300
+ * one popup's: a menu open beside the board still closes on the same press.
40301
+ *
40302
+ * Unlike the outside marker it answers for its whole subtree: the outside
40303
+ * marker carves free space out of a surface whose controls stay surface, this
40304
+ * one adds a whole thing — the card, its count, its buttons — to the popup's
40305
+ * own ground, and nothing in it is a dismissal.
40306
+ */
40307
+ const INSIDE_ATTRIBUTE = "data-navi-popup-inside";
40308
+ // Whether `target` is in a box of the page declared as part of the popup
40309
+ // named `popupId`.
40310
+ const isDeclaredInsidePopup = (target, popupId) =>
40311
+ Boolean(popupId) &&
40312
+ Boolean(target?.closest?.(`[${INSIDE_ATTRIBUTE}~="${CSS.escape(popupId)}"]`));
40313
+
40314
+ /**
40315
+ * What a press landing on a region the caller declared as not-its-surface
40316
+ * does: exactly what the same press on the backdrop would do.
40317
+ *
40318
+ * Lives on the popup's own content element rather than in the backdrop's
40319
+ * handler, for both renderers: the backdrop is a sibling behind the popup
40320
+ * (never an ancestor), so a press inside the popup's box never reaches it.
40321
+ */
40322
+ const handlePressOnOutsideRegion = (
40323
+ mouseDownEvent,
40324
+ { popupEl, openController, pressOutside },
40325
+ ) => {
40326
+ if (mouseDownEvent.button !== 0) {
40327
+ return;
40328
+ }
40329
+ const { target } = mouseDownEvent;
40330
+ if (!target.hasAttribute(OUTSIDE_REGION_ATTRIBUTE)) {
40331
+ return;
40332
+ }
40333
+ if (openedDuringThisPress(openController)) {
40334
+ // The release of the press that opened it is not somebody dismissing it
40335
+ // (see openedDuringThisPress).
40336
+ return;
40337
+ }
40338
+ // A popup opens inside its opener's own subtree, so a press on a region
40339
+ // belonging to a popup nested in this one bubbles through here too — and it
40340
+ // is a press on what is in front, not on this popup's own decoration.
40341
+ if (
40342
+ target.closest(`[navi-control="dialog"], [navi-control="popover"]`) !==
40343
+ popupEl
40344
+ ) {
40345
+ return;
40346
+ }
40347
+ if (pressOutside === "capture") {
40348
+ mouseDownEvent.preventDefault();
40349
+ return;
40350
+ }
40351
+ if (pressOutside === "close" || pressOutside === "cancel") {
40352
+ openController.requestClose(mouseDownEvent, {
40353
+ isCancel: pressOutside === "cancel",
40354
+ });
40355
+ }
40356
+ };
40357
+
40358
+ /**
40359
+ * Hears an outside press from the document itself, for a popup with no
40360
+ * backdrop of its own to catch one: a modal `<dialog>` (whose native
40361
+ * `::backdrop` dispatches nothing a handler can be put on) and any popup the
40362
+ * caller asked to leave the page reachable (`backdrop={false}`).
40363
+ *
40364
+ * The press is read and never taken: no `preventDefault`, no
40365
+ * `stopPropagation`, and the listener is on `document` rather than on
40366
+ * anything covering the page — so what the press landed on hears it too, in
40367
+ * the same gesture, and the popup only takes note. That is the whole point of
40368
+ * a popup with no wall: a press outside is a press on the page, and the page
40369
+ * answers it. The one popup where that cannot hold is the modal dialog, where
40370
+ * the browser has already made everything behind inert before this runs.
40371
+ *
40372
+ * `pressEventType` says which event IS the press, and the two callers want
40373
+ * opposite ones:
40374
+ *
40375
+ * - `"pointerdown"` for a popup with no wall: a page with no wall over it is
40376
+ * a page whose own elements arbitrate their presses, and cancelling a
40377
+ * `pointerdown` — what a drag source and a control keeping the focus where
40378
+ * it is both do — suppresses every compatibility mouse event that would have
40379
+ * followed it. The press happens, reaches its target, and no `mousedown` is
40380
+ * ever dispatched for it. Reading the pointer event is reading the press
40381
+ * itself, whatever anything does with it afterwards, and it is the same
40382
+ * event the drag and surface detectors read.
40383
+ * - `"mousedown"` for the modal dialog, whose native `::backdrop` exists to
40384
+ * spend the press. The page's controls act on `mousedown`, and on a touch
40385
+ * screen that is not the same moment as `pointerdown`: the compatibility
40386
+ * mouse events are synthesized at `touchend` and hit-tested again then.
40387
+ * Closing on `pointerdown` takes the `::backdrop` away before that second
40388
+ * hit-test, so the `mousedown` lands on whatever the page holds under the
40389
+ * finger, and one tap both dismisses the dialog and presses the page. Read
40390
+ * on `mousedown`, the wall is still up when the press the page acts on is
40391
+ * aimed, and it catches it. Nothing behind a modal wall can cancel that
40392
+ * press, since nothing behind it hears the `pointerdown` at all.
40393
+ *
40394
+ * Capture phase, so a handler downstream that stops propagation cannot keep
40395
+ * the popup open either — the press is still outside whatever is made of it.
40396
+ *
40397
+ * Returns a cleanup that removes the listener.
40398
+ */
40399
+ const armOutsidePressClose = (
40400
+ popupEl,
40401
+ { openController, pressOutside, pressEventType = "pointerdown" },
40402
+ ) => {
40403
+ const onDocumentPointerDown = (pointerDownEvent) => {
40404
+ if (pointerDownEvent.button !== 0) {
40405
+ return;
40406
+ }
40407
+ if (openedDuringThisPress(openController)) {
40408
+ // The release of the press that opened it is not somebody dismissing it
40409
+ // (see openedDuringThisPress).
40410
+ return;
40411
+ }
40412
+ // The press landed inside another popup: that is a press on what is in
40413
+ // front, not outside. Asking the target where it lives rather than asking
40414
+ // this popup whether it was pushed — a popup in front does not have to be
40415
+ // one this popup knows about. In front means neither inside nor around: a
40416
+ // popup nested in this one falls to the containment check below as the
40417
+ // inside press it is, and one this popup opened INSIDE (a bubble rendered
40418
+ // in a sheet's content) is around it, so what that popup holds is outside
40419
+ // this one exactly like the rest of the page.
40420
+ const popupUnderPointer = pointerDownEvent.target.closest?.(
40421
+ `[navi-control="dialog"], [navi-control="popover"]`,
40422
+ );
40423
+ if (
40424
+ popupUnderPointer &&
40425
+ popupUnderPointer !== popupEl &&
40426
+ !popupEl.contains(popupUnderPointer) &&
40427
+ !popupUnderPointer.contains(popupEl)
40428
+ ) {
40429
+ return;
40430
+ }
40431
+ if (isDeclaredInsidePopup(pointerDownEvent.target, popupEl.id)) {
40432
+ return;
40433
+ }
40434
+ // Real DOM containment wins over the rectangle below — an element
40435
+ // genuinely inside the popup (`overflow: visible`, a negative margin, an
40436
+ // absolutely-positioned child) can be painted outside its border box, and
40437
+ // a press there is not outside just because its coordinates are. The
40438
+ // popup element itself is excluded (contains() answers true for it): a
40439
+ // press on a modal dialog's native backdrop reports the dialog as its
40440
+ // target, there being no `::backdrop` node to be one, so treating that as
40441
+ // contained would keep the rectangle from ever running.
40442
+ if (
40443
+ pointerDownEvent.target !== popupEl &&
40444
+ popupEl.contains(pointerDownEvent.target)
40445
+ ) {
40446
+ return;
40447
+ }
40448
+ const rect = popupEl.getBoundingClientRect();
40449
+ const isOutside =
40450
+ pointerDownEvent.clientX < rect.left ||
40451
+ pointerDownEvent.clientX > rect.right ||
40452
+ pointerDownEvent.clientY < rect.top ||
40453
+ pointerDownEvent.clientY > rect.bottom;
40454
+ if (!isOutside) {
40455
+ return;
40456
+ }
40457
+ openController.requestClose(pointerDownEvent, {
40458
+ isCancel: pressOutside === "cancel",
40459
+ });
40460
+ };
40461
+ document.addEventListener(pressEventType, onDocumentPointerDown, {
40462
+ capture: true,
40463
+ });
40464
+ return () => {
40465
+ document.removeEventListener(pressEventType, onDocumentPointerDown, {
40466
+ capture: true,
40467
+ });
40468
+ };
40469
+ };
40470
+
40442
40471
  /**
40443
40472
  * When a popup builds what it holds, and when it throws it away.
40444
40473
  *
@@ -49156,7 +49185,7 @@ const startNetworkMonitoring = () => {
49156
49185
  };
49157
49186
 
49158
49187
  installImportMetaCssBuild(import.meta);
49159
- const css$10 = /* css */`.navi_loading_indicator_fluid_container {
49188
+ const css$11 = /* css */`.navi_loading_indicator_fluid_container {
49160
49189
  border-radius: inherit;
49161
49190
  opacity: 1;
49162
49191
  width: 100%;
@@ -49187,7 +49216,7 @@ const LoadingIndicatorFluid = ({
49187
49216
  visuallyHidden,
49188
49217
  ...rest
49189
49218
  }) => {
49190
- import.meta.css = [css$10, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
49219
+ import.meta.css = [css$11, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
49191
49220
  const ref = useRef(null);
49192
49221
  // The container dimensions can be deduced from the ref itself as the indicator is absolute inset 0
49193
49222
  const [containerWidth, setContainerWidth] = useState(0);
@@ -49389,7 +49418,7 @@ const LoadingRectangleSvg = ({
49389
49418
  };
49390
49419
 
49391
49420
  installImportMetaCssBuild(import.meta);
49392
- const css$$ = /* css */`.navi_loading_outline_wrapper {
49421
+ const css$10 = /* css */`.navi_loading_outline_wrapper {
49393
49422
  top: max(var(--loading-outline-min-inset, -100vh),
49394
49423
  var(--loading-rectangle-top, 0px));
49395
49424
  right: max(var(--loading-outline-min-inset, -100vh),
@@ -49410,7 +49439,7 @@ const css$$ = /* css */`.navi_loading_outline_wrapper {
49410
49439
  }
49411
49440
  `;
49412
49441
  const LoadingOutline = props => {
49413
- import.meta.css = [css$$, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
49442
+ import.meta.css = [css$10, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
49414
49443
  if (props.containerRef) {
49415
49444
  const container = props.containerRef.current;
49416
49445
  if (!container) {
@@ -49612,7 +49641,7 @@ const flush = () => {
49612
49641
  };
49613
49642
 
49614
49643
  installImportMetaCssBuild(import.meta);
49615
- const css$_ = /* css */`.navi_text_anchor {
49644
+ const css$$ = /* css */`.navi_text_anchor {
49616
49645
  vertical-align: baseline;
49617
49646
  user-select: none;
49618
49647
  overflow: hidden;
@@ -49650,7 +49679,7 @@ const TextAnchor = ({
49650
49679
  textSize,
49651
49680
  lineLayout
49652
49681
  }) => {
49653
- import.meta.css = [css$_, "@jsenv/navi/src/text/text_anchor.jsx"];
49682
+ import.meta.css = [css$$, "@jsenv/navi/src/text/text_anchor.jsx"];
49654
49683
  const anchorRef = useRef();
49655
49684
  // The correction reads the layout, with the other reads of the commit (see
49656
49685
  // layout_batch.js) — an anchor per icon, each measured between two writes,
@@ -49901,7 +49930,7 @@ const selectByTextStrings = (element, range, startText, endText) => {
49901
49930
  };
49902
49931
 
49903
49932
  installImportMetaCssBuild(import.meta);
49904
- const css$Z = /* css */`@layer navi {
49933
+ const css$_ = /* css */`@layer navi {
49905
49934
  .navi_text {
49906
49935
  &[data-skeleton] {
49907
49936
  border-radius: .2em;
@@ -50493,7 +50522,7 @@ const TextShrinkWrap = props => {
50493
50522
  });
50494
50523
  };
50495
50524
  const TextUI = props => {
50496
- import.meta.css = [css$Z, "@jsenv/navi/src/text/text.jsx"];
50525
+ import.meta.css = [css$_, "@jsenv/navi/src/text/text.jsx"];
50497
50526
  let {
50498
50527
  ref,
50499
50528
  spacing,
@@ -50731,7 +50760,7 @@ const Icon = ({
50731
50760
  fillLine,
50732
50761
  ...props
50733
50762
  }) => {
50734
- import.meta.css = [css$Z, "@jsenv/navi/src/text/text.jsx"];
50763
+ import.meta.css = [css$_, "@jsenv/navi/src/text/text.jsx"];
50735
50764
  const innerChildren = href ? jsx("svg", {
50736
50765
  width: "100%",
50737
50766
  height: "100%",
@@ -51065,7 +51094,7 @@ const useDimColorWhen = (elementRef, shouldDim) => {
51065
51094
  };
51066
51095
 
51067
51096
  installImportMetaCssBuild(import.meta);
51068
- const css$Y = /* css */`@layer navi {
51097
+ const css$Z = /* css */`@layer navi {
51069
51098
  .navi_link {
51070
51099
  --link-border-radius: unset;
51071
51100
  --link-outline-color: var(--navi-focus-outline-color);
@@ -51539,7 +51568,7 @@ const LinkPseudoElements = ["::-navi-loader", "::-navi-indicator"];
51539
51568
  * @param {boolean} [props.readOnly]
51540
51569
  */
51541
51570
  const Link = props => {
51542
- import.meta.css = [css$Y, "@jsenv/navi/src/nav/link/link.jsx"];
51571
+ import.meta.css = [css$Z, "@jsenv/navi/src/nav/link/link.jsx"];
51543
51572
  if (props.route) {
51544
51573
  return jsx(LinkWithRoute, {
51545
51574
  ...props
@@ -52099,7 +52128,7 @@ const rememberProgressAnimation = (navElement, travel) => {
52099
52128
  travel.progressAnimation = animation;
52100
52129
  travel.progressFrom = animation ? parseFloat(animation.effect.getKeyframes()[0]["--slide-travel-progress"]) || 0 : 0;
52101
52130
  };
52102
- const css$X = /* css */`@layer navi {
52131
+ const css$Y = /* css */`@layer navi {
52103
52132
  .navi_nav {
52104
52133
  --nav-border: none;
52105
52134
  --nav-padding: 0px;
@@ -52368,7 +52397,7 @@ const Nav = ({
52368
52397
  slideContainer,
52369
52398
  ...props
52370
52399
  }) => {
52371
- import.meta.css = [css$X, "@jsenv/navi/src/nav/link/nav.jsx"];
52400
+ import.meta.css = [css$Y, "@jsenv/navi/src/nav/link/nav.jsx"];
52372
52401
  const defaultRef = useRef();
52373
52402
  props.ref = props.ref || defaultRef;
52374
52403
  const navRef = props.ref;
@@ -53244,7 +53273,7 @@ const buildBinderCenterlinePathBelow = ({
53244
53273
  };
53245
53274
 
53246
53275
  installImportMetaCssBuild(import.meta);
53247
- const css$W = /* css */`@layer navi {
53276
+ const css$X = /* css */`@layer navi {
53248
53277
  .navi_binder {
53249
53278
  --binder-border-width: var(--navi-control-border-width);
53250
53279
  --binder-border-radius: var(--navi-control-border-radius);
@@ -53531,7 +53560,7 @@ const Binder = ({
53531
53560
  pagePadding,
53532
53561
  ...props
53533
53562
  }) => {
53534
- import.meta.css = [css$W, "@jsenv/navi/src/nav/binder/binder.jsx"];
53563
+ import.meta.css = [css$X, "@jsenv/navi/src/nav/binder/binder.jsx"];
53535
53564
  const items = toChildArray(children).map((child, index) => {
53536
53565
  const {
53537
53566
  value: itemValue,
@@ -53981,7 +54010,7 @@ const writeSpace = (area) => {
53981
54010
  };
53982
54011
 
53983
54012
  installImportMetaCssBuild(import.meta);
53984
- const css$V = /* css */`@layer navi {
54013
+ const css$W = /* css */`@layer navi {
53985
54014
  :root {
53986
54015
  --navi-fixed-bar-width: 56px;
53987
54016
  --navi-fixed-bar-height: 56px;
@@ -54104,7 +54133,7 @@ const FixedBar = ({
54104
54133
  border = true,
54105
54134
  ...props
54106
54135
  }) => {
54107
- import.meta.css = [css$V, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
54136
+ import.meta.css = [css$W, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
54108
54137
  const defaultRef = useRef();
54109
54138
  props.ref = props.ref || defaultRef;
54110
54139
  // Said with the width the border rule reads rather than with an attribute of
@@ -54198,7 +54227,7 @@ const FixedBar = ({
54198
54227
  // Subpixel layout rounds rectangles up on boxes that fit exactly.
54199
54228
  const OVERFLOW_TOLERANCE = 1;
54200
54229
 
54201
- const css$U = /* css */ `
54230
+ const css$V = /* css */ `
54202
54231
  [data-navi-overflow-x] {
54203
54232
  outline: 2px dashed #e74c3c;
54204
54233
  outline-offset: -2px;
@@ -54222,7 +54251,7 @@ const detectHorizontalOverflow = ({
54222
54251
  let styleEl = null;
54223
54252
  if (highlight) {
54224
54253
  styleEl = document.createElement("style");
54225
- styleEl.textContent = css$U;
54254
+ styleEl.textContent = css$V;
54226
54255
  document.head.appendChild(styleEl);
54227
54256
  }
54228
54257
 
@@ -54312,7 +54341,11 @@ const findOverflows = (root) => {
54312
54341
  if (style.display === "none" || style.position === "fixed") {
54313
54342
  continue;
54314
54343
  }
54315
- if (el.hasAttribute("popover") || el.tagName === "DIALOG") {
54344
+ if (
54345
+ el.hasAttribute("popover") ||
54346
+ // An idle PopupGroup is a box of the page (see popup_group.jsx).
54347
+ (el.tagName === "DIALOG" && !el.hasAttribute("navi-popup-group"))
54348
+ ) {
54316
54349
  continue;
54317
54350
  }
54318
54351
  const rect = el.getBoundingClientRect();
@@ -54378,7 +54411,7 @@ const useFocusGroup = (
54378
54411
 
54379
54412
  installImportMetaCssBuild(import.meta);
54380
54413
  const rightArrowPath = "M680-480L360-160l-80-80 240-240-240-240 80-80 320 320z";
54381
- const css$T = /* css */`.navi_summary_marker {
54414
+ const css$U = /* css */`.navi_summary_marker {
54382
54415
  flex-shrink: 0;
54383
54416
  width: 1em;
54384
54417
  height: 1em;
@@ -54470,7 +54503,7 @@ const SummaryMarker = ({
54470
54503
  loading,
54471
54504
  openDirection = "down"
54472
54505
  }) => {
54473
- import.meta.css = [css$T, "@jsenv/navi/src/control/details/summary_marker.jsx"];
54506
+ import.meta.css = [css$U, "@jsenv/navi/src/control/details/summary_marker.jsx"];
54474
54507
  const showLoading = useDebounceTrue(loading, 300);
54475
54508
  return jsx("span", {
54476
54509
  className: "navi_summary_marker",
@@ -54516,7 +54549,7 @@ const SummaryMarker = ({
54516
54549
  };
54517
54550
 
54518
54551
  installImportMetaCssBuild(import.meta);
54519
- const css$S = /* css */`.navi_details {
54552
+ const css$T = /* css */`.navi_details {
54520
54553
  z-index: 1;
54521
54554
  flex-direction: column;
54522
54555
  flex-shrink: 0;
@@ -54561,7 +54594,7 @@ const Details = props => {
54561
54594
  return details;
54562
54595
  };
54563
54596
  const DetailsField = props => {
54564
- import.meta.css = [css$S, "@jsenv/navi/src/control/details/details.jsx"];
54597
+ import.meta.css = [css$T, "@jsenv/navi/src/control/details/details.jsx"];
54565
54598
  const {
54566
54599
  ref,
54567
54600
  persists,
@@ -54727,7 +54760,7 @@ const DetailsFieldContent = ({
54727
54760
  };
54728
54761
 
54729
54762
  installImportMetaCssBuild(import.meta);
54730
- const css$R = /* css */`.navi_expandable {
54763
+ const css$S = /* css */`.navi_expandable {
54731
54764
  flex-direction: column;
54732
54765
  flex-shrink: 0;
54733
54766
  display: flex;
@@ -54971,7 +55004,7 @@ const useExpandableContext = partName => {
54971
55004
  * around them (docs/control_object.md, "A control that is not there").
54972
55005
  */
54973
55006
  const Expandable = props => {
54974
- import.meta.css = [css$R, "@jsenv/navi/src/control/expandable/expandable.jsx"];
55007
+ import.meta.css = [css$S, "@jsenv/navi/src/control/expandable/expandable.jsx"];
54975
55008
  /* The open props are read from `props` by the open controller below; they
54976
55009
  are named here only to keep them out of `rest`, and so out of the DOM. */
54977
55010
  /* eslint-disable no-unused-vars */
@@ -55952,7 +55985,7 @@ const useAccentColorAttributes = (
55952
55985
  };
55953
55986
 
55954
55987
  installImportMetaCssBuild(import.meta);
55955
- const css$Q = /* css */`@layer navi {
55988
+ const css$R = /* css */`@layer navi {
55956
55989
  .navi_button {
55957
55990
  --button-border-radius: var(--navi-control-border-radius);
55958
55991
  --button-border-width: var(--navi-control-border-width);
@@ -56256,7 +56289,7 @@ a.navi_button {
56256
56289
  }
56257
56290
  `;
56258
56291
  const ButtonUI = props => {
56259
- import.meta.css = [css$Q, "@jsenv/navi/src/control/input/button_ui.jsx"];
56292
+ import.meta.css = [css$R, "@jsenv/navi/src/control/input/button_ui.jsx"];
56260
56293
  const {
56261
56294
  ref,
56262
56295
  // href/link
@@ -56471,7 +56504,7 @@ const ButtonShadow = () => {
56471
56504
  markAsOutsideTextFlow(ButtonShadow);
56472
56505
 
56473
56506
  installImportMetaCssBuild(import.meta);
56474
- const css$P = /* css */`.navi_button_confirm {
56507
+ const css$Q = /* css */`.navi_button_confirm {
56475
56508
  align-items: center;
56476
56509
  gap: var(--navi-s);
56477
56510
  flex-wrap: wrap;
@@ -56499,7 +56532,7 @@ const ButtonConfirm = ({
56499
56532
  cancelTestId,
56500
56533
  ...props
56501
56534
  }) => {
56502
- import.meta.css = [css$P, "@jsenv/navi/src/control/input/button_confirm.jsx"];
56535
+ import.meta.css = [css$Q, "@jsenv/navi/src/control/input/button_confirm.jsx"];
56503
56536
  const [asking, setAsking] = useState(false);
56504
56537
  const askingRef = useRef(null);
56505
56538
  const cancelRef = useRef(null);
@@ -56960,7 +56993,7 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
56960
56993
  const Button = /*#__PURE__*/createComponentResolver([ButtonFirstResolver, ButtonRouteResolver, ButtonCommandPropResolver, ButtonConfirmResolver, ButtonHrefResolver, ButtonUI]);
56961
56994
 
56962
56995
  installImportMetaCssBuild(import.meta);
56963
- const css$O = /* css */`.navi_control_swap {
56996
+ const css$P = /* css */`.navi_control_swap {
56964
56997
  font-size: var(--navi-control-font-size);
56965
56998
  --x-control-swap-size: var(--navi-control-swap-size, calc(var(--navi-control-line-height) + 2 * var(--navi-s) + 2 *
56966
56999
  var(--navi-control-border-width)));
@@ -57065,7 +57098,7 @@ const css$O = /* css */`.navi_control_swap {
57065
57098
  * is the space between the boxes.
57066
57099
  */
57067
57100
  const ControlSwap = props => {
57068
- import.meta.css = [css$O, "@jsenv/navi/src/control/control_swap/control_swap.jsx"];
57101
+ import.meta.css = [css$P, "@jsenv/navi/src/control/control_swap/control_swap.jsx"];
57069
57102
  const {
57070
57103
  value,
57071
57104
  defaultValue,
@@ -57441,7 +57474,7 @@ const ControlGroup = props => {
57441
57474
  const CONTROL_GROUP_PSEUDO_CLASSES = [":hover", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
57442
57475
 
57443
57476
  installImportMetaCssBuild(import.meta);
57444
- const css$N = /* css */`@layer navi {
57477
+ const css$O = /* css */`@layer navi {
57445
57478
  .navi_checkbox {
57446
57479
  --switch-margin: 0;
57447
57480
  --switch-padding: 2px;
@@ -57498,7 +57531,7 @@ const css$N = /* css */`@layer navi {
57498
57531
  }
57499
57532
  `;
57500
57533
  const SwitchUI = () => {
57501
- import.meta.css = [css$N, "@jsenv/navi/src/control/input/switch_ui.jsx"];
57534
+ import.meta.css = [css$O, "@jsenv/navi/src/control/input/switch_ui.jsx"];
57502
57535
  return jsx(Box, {
57503
57536
  className: "navi_switch",
57504
57537
  as: "svg",
@@ -57541,7 +57574,7 @@ const useCheckableProps = (props, options) => {
57541
57574
  };
57542
57575
 
57543
57576
  installImportMetaCssBuild(import.meta);
57544
- const css$M = /* css */`@layer navi {
57577
+ const css$N = /* css */`@layer navi {
57545
57578
  .navi_checkbox {
57546
57579
  --border-radius: var(--navi-checkbox-border-radius);
57547
57580
  --border-width: var(--navi-control-border-width);
@@ -57814,7 +57847,7 @@ const InputCheckboxHeadless = props => {
57814
57847
  });
57815
57848
  };
57816
57849
  const InputCheckboxFieldInterface = props => {
57817
- import.meta.css = [css$M, "@jsenv/navi/src/control/input/input_checkbox.jsx"];
57850
+ import.meta.css = [css$N, "@jsenv/navi/src/control/input/input_checkbox.jsx"];
57818
57851
  const [checkboxRootProps, checkboxHostProps] = useCheckableProps(props);
57819
57852
  const {
57820
57853
  icon,
@@ -57936,7 +57969,7 @@ const CheckboxPseudoClasses = [":hover", ":active", ":focus", ":focus-visible",
57936
57969
  const CheckboxPseudoElements = ["::-navi-loader", "::-navi-checkmark"];
57937
57970
 
57938
57971
  installImportMetaCssBuild(import.meta);
57939
- const css$L = /* css */`@layer navi {
57972
+ const css$M = /* css */`@layer navi {
57940
57973
  .navi_label {
57941
57974
  --label-required-indicator-color: var(--navi-color-danger, #b42318);
57942
57975
 
@@ -58014,7 +58047,7 @@ const css$L = /* css */`@layer navi {
58014
58047
  * </Field>
58015
58048
  */
58016
58049
  const Field = props => {
58017
- import.meta.css = [css$L, "@jsenv/navi/src/control/field.jsx"];
58050
+ import.meta.css = [css$M, "@jsenv/navi/src/control/field.jsx"];
58018
58051
  const refDefault = useRef();
58019
58052
  props.ref = props.ref || refDefault;
58020
58053
  const {
@@ -58049,7 +58082,7 @@ const FieldCSSVars = {
58049
58082
  spacingWithControl: ["--spacing-with-control", "padding"]
58050
58083
  };
58051
58084
  const FieldAsContainer = props => {
58052
- import.meta.css = [css$L, "@jsenv/navi/src/control/field.jsx"];
58085
+ import.meta.css = [css$M, "@jsenv/navi/src/control/field.jsx"];
58053
58086
  const {
58054
58087
  children
58055
58088
  } = props;
@@ -58080,7 +58113,7 @@ const FieldAsContainer = props => {
58080
58113
  };
58081
58114
  const FIELD_PSEUDO_CLASSES = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
58082
58115
  const Label = props => {
58083
- import.meta.css = [css$L, "@jsenv/navi/src/control/field.jsx"];
58116
+ import.meta.css = [css$M, "@jsenv/navi/src/control/field.jsx"];
58084
58117
  const {
58085
58118
  children,
58086
58119
  // Marks the label when its control is required. Takes what to show, or
@@ -58248,7 +58281,7 @@ const InputSlot = ({
58248
58281
  };
58249
58282
 
58250
58283
  installImportMetaCssBuild(import.meta);
58251
- const css$K = /* css */`@layer navi {
58284
+ const css$L = /* css */`@layer navi {
58252
58285
  .navi_radio {
58253
58286
  --margin: 3px 3px 3px 5px;
58254
58287
  --outline-offset: 1px;
@@ -58554,7 +58587,7 @@ const InputRadioHeadless = props => {
58554
58587
  };
58555
58588
  const VARIANT_SET = new Set(["icon", "button", "radio"]);
58556
58589
  const InputRadioFieldInterface = props => {
58557
- import.meta.css = [css$K, "@jsenv/navi/src/control/input/input_radio.jsx"];
58590
+ import.meta.css = [css$L, "@jsenv/navi/src/control/input/input_radio.jsx"];
58558
58591
  const [radioRootProps, radioHostProps] = useCheckableProps(props);
58559
58592
  const {
58560
58593
  icon,
@@ -58700,7 +58733,7 @@ const RadioPseudoClasses = [":hover", ":active", ":focus", ":focus-visible", ":r
58700
58733
  const RadioPseudoElements = ["::-navi-loader", "::-navi-radiomark"];
58701
58734
 
58702
58735
  installImportMetaCssBuild(import.meta);
58703
- const css$J = /* css */`@layer navi {
58736
+ const css$K = /* css */`@layer navi {
58704
58737
  .navi_input_range {
58705
58738
  --border-radius: 6px;
58706
58739
  --border-width: var(--navi-control-border-width);
@@ -58939,7 +58972,7 @@ const InputRange = props => {
58939
58972
  });
58940
58973
  };
58941
58974
  const InputRangeFieldInterface = props => {
58942
- import.meta.css = [css$J, "@jsenv/navi/src/control/input/input_range.jsx"];
58975
+ import.meta.css = [css$K, "@jsenv/navi/src/control/input/input_range.jsx"];
58943
58976
  const {
58944
58977
  ref
58945
58978
  } = props;
@@ -60448,7 +60481,7 @@ Input.UI = {
60448
60481
  };
60449
60482
 
60450
60483
  installImportMetaCssBuild(import.meta);
60451
- const css$I = /* css */`.navi_textarea {
60484
+ const css$J = /* css */`.navi_textarea {
60452
60485
  & textarea {
60453
60486
  min-height: calc(var(--textarea-min-rows, 1.5) * 1lh);
60454
60487
  max-height: calc(var(--textarea-max-rows, 99999) * 1lh);
@@ -60535,7 +60568,7 @@ const Textarea = ({
60535
60568
  ...props
60536
60569
  }) => {
60537
60570
  installInputCss();
60538
- import.meta.css = [css$I, "@jsenv/navi/src/control/input/textarea.jsx"];
60571
+ import.meta.css = [css$J, "@jsenv/navi/src/control/input/textarea.jsx"];
60539
60572
  const defaultRef = useRef(null);
60540
60573
  props.ref = props.ref || defaultRef;
60541
60574
  usePlaceholderHeight(props.ref, props.placeholder);
@@ -60617,7 +60650,7 @@ const TextareaCharCount = ({
60617
60650
  maxLength,
60618
60651
  ...rest
60619
60652
  }) => {
60620
- import.meta.css = [css$I, "@jsenv/navi/src/control/input/textarea.jsx"];
60653
+ import.meta.css = [css$J, "@jsenv/navi/src/control/input/textarea.jsx"];
60621
60654
  const resolvedValue = signal ? signal.value : value;
60622
60655
  const length = typeof resolvedValue === "string" ? resolvedValue.length : 0;
60623
60656
  return jsx(Box, {
@@ -60727,7 +60760,7 @@ const RealTextarea = ({
60727
60760
  };
60728
60761
 
60729
60762
  installImportMetaCssBuild(import.meta);
60730
- const css$H = /* css */`.navi_editable_wrapper {
60763
+ const css$I = /* css */`.navi_editable_wrapper {
60731
60764
  --inset-top: 0px;
60732
60765
  --inset-right: 0px;
60733
60766
  --inset-bottom: 0px;
@@ -60897,7 +60930,7 @@ const useEditionController = () => {
60897
60930
  * Every other unknown prop goes to the field itself, styling props included.
60898
60931
  */
60899
60932
  const Editable = props => {
60900
- import.meta.css = [css$H, "@jsenv/navi/src/control/edition/editable.jsx"];
60933
+ import.meta.css = [css$I, "@jsenv/navi/src/control/edition/editable.jsx"];
60901
60934
  let {
60902
60935
  children,
60903
60936
  action,
@@ -61508,7 +61541,7 @@ const useRequestSubmitThroughGates = ref => {
61508
61541
  // };
61509
61542
 
61510
61543
  installImportMetaCssBuild(import.meta);
61511
- const css$G = /* css */`.navi_group {
61544
+ const css$H = /* css */`.navi_group {
61512
61545
  --group-border-width: var(--navi-control-border-width);
61513
61546
 
61514
61547
  & > :not([navi-out-of-flow]) {
@@ -61569,7 +61602,7 @@ const Group = ({
61569
61602
  vertical = row,
61570
61603
  ...props
61571
61604
  }) => {
61572
- import.meta.css = [css$G, "@jsenv/navi/src/control/group.jsx"];
61605
+ import.meta.css = [css$H, "@jsenv/navi/src/control/group.jsx"];
61573
61606
  return jsx(Box, {
61574
61607
  baseClassName: "navi_group",
61575
61608
  "data-vertical": vertical ? "" : undefined
@@ -61701,7 +61734,7 @@ const unfreezeSize = (el) => {
61701
61734
  };
61702
61735
 
61703
61736
  installImportMetaCssBuild(import.meta);
61704
- const css$F = /* css */`@property --slide-travel-progress {
61737
+ const css$G = /* css */`@property --slide-travel-progress {
61705
61738
  syntax: "<number>";
61706
61739
  inherits: true;
61707
61740
  initial-value: 0;
@@ -62188,7 +62221,7 @@ const SlideContainer = ({
62188
62221
  children,
62189
62222
  ...rest
62190
62223
  }) => {
62191
- import.meta.css = [css$F, "@jsenv/navi/src/layout/slide_container.jsx"];
62224
+ import.meta.css = [css$G, "@jsenv/navi/src/layout/slide_container.jsx"];
62192
62225
  const debugFocus = useDebugFocus();
62193
62226
  const trackRef = useRef();
62194
62227
  // The box itself: it is what takes the keyboard when what is on screen holds
@@ -65539,6 +65572,1269 @@ const renderSafe = (value) => {
65539
65572
  return String(value);
65540
65573
  };
65541
65574
 
65575
+ installImportMetaCssBuild(import.meta);
65576
+ const PopupGroupContext = createContext(null);
65577
+
65578
+ /**
65579
+ * What a press outside a popup of the group does, read against a wall that is
65580
+ * always up: "ignore" closes, as "close" does. For a press the group does not
65581
+ * see itself, on a region the popup declared outside
65582
+ * (handlePressOnOutsideRegion).
65583
+ */
65584
+ const readPressOutsideInPopupGroup = pressOutside => pressOutside === "ignore" ? "close" : pressOutside;
65585
+
65586
+ // The groups shown modal right now, for the popup around one of them to close
65587
+ // it first (see closePopupGroupsInside).
65588
+ const engagedGroupSet = new Set();
65589
+
65590
+ /**
65591
+ * Takes the groups engaged inside `popupElement` down, with what they have
65592
+ * open, before that popup gives the focus back: while a group is the topmost
65593
+ * modal everything else is inert, the element the focus goes back to
65594
+ * included. Called by Dialog and Popover as they close.
65595
+ */
65596
+ const closePopupGroupsInside = (popupElement, closeEvent) => {
65597
+ for (const group of engagedGroupSet) {
65598
+ if (popupElement.contains(group.getElement())) {
65599
+ group.closeEverything(closeEvent);
65600
+ }
65601
+ }
65602
+ };
65603
+ const css$F = /* css */`@property --x-popup-group-top {
65604
+ syntax: "*";
65605
+ inherits: false
65606
+ }
65607
+
65608
+ @property --x-popup-group-left {
65609
+ syntax: "*";
65610
+ inherits: false
65611
+ }
65612
+
65613
+ @property --x-popup-group-width {
65614
+ syntax: "*";
65615
+ inherits: false
65616
+ }
65617
+
65618
+ @property --x-popup-group-height {
65619
+ syntax: "*";
65620
+ inherits: false
65621
+ }
65622
+
65623
+ @property --x-popup-group-padding {
65624
+ syntax: "*";
65625
+ inherits: false
65626
+ }
65627
+
65628
+ @property --x-popup-group-clip {
65629
+ syntax: "*";
65630
+ inherits: false
65631
+ }
65632
+
65633
+ @layer navi {
65634
+ .navi_popup_group {
65635
+ --backdrop-background: var(--navi-backdrop-close-background);
65636
+ --backdrop-filter: var(--navi-backdrop-close-backdrop-filter);
65637
+ width: auto;
65638
+ max-width: none;
65639
+ height: auto;
65640
+ max-height: none;
65641
+ color: inherit;
65642
+ background: none;
65643
+ border: none;
65644
+ margin: 0;
65645
+ padding: 0;
65646
+ display: flow-root;
65647
+ position: static;
65648
+ inset: auto;
65649
+ overflow: visible;
65650
+ }
65651
+ }
65652
+
65653
+ .navi_popup_group:modal {
65654
+ inset: auto !important;
65655
+ top: var(--x-popup-group-top) !important;
65656
+ left: var(--x-popup-group-left) !important;
65657
+ box-sizing: border-box !important;
65658
+ width: var(--x-popup-group-width) !important;
65659
+ min-width: 0 !important;
65660
+ max-width: none !important;
65661
+ height: var(--x-popup-group-height) !important;
65662
+ min-height: 0 !important;
65663
+ max-height: none !important;
65664
+ padding: var(--x-popup-group-padding) !important;
65665
+ clip-path: var(--x-popup-group-clip, none) !important;
65666
+ margin: 0 !important;
65667
+ position: fixed !important;
65668
+ }
65669
+
65670
+ .navi_popup_group[data-backdrop-variant="lift"] {
65671
+ --backdrop-background: var(--navi-backdrop-lift-background);
65672
+ --backdrop-filter: var(--navi-backdrop-lift-backdrop-filter);
65673
+ }
65674
+
65675
+ .navi_popup_group[data-backdrop-variant="discrete"] {
65676
+ --backdrop-background: var(--navi-backdrop-discrete-background);
65677
+ --backdrop-filter: var(--navi-backdrop-discrete-backdrop-filter);
65678
+ }
65679
+
65680
+ .navi_popup_group[data-backdrop-variant="invisible"] {
65681
+ --backdrop-background: transparent;
65682
+ --backdrop-filter: none;
65683
+ }
65684
+
65685
+ .navi_popup_group::backdrop {
65686
+ background: var(--backdrop-background);
65687
+ opacity: 1;
65688
+ backdrop-filter: var(--backdrop-filter);
65689
+ transition: opacity var(--popup-animation-duration, .18s) ease;
65690
+
65691
+ @starting-style {
65692
+ opacity: 0;
65693
+ }
65694
+ }
65695
+
65696
+ .navi_popup_group[navi-transitions-off]::backdrop {
65697
+ transition: none;
65698
+ }
65699
+
65700
+ :is(:root[data-navi-route-transition] .navi_popup_group:modal, :root[data-navi-route-travel] .navi_popup_group:modal) {
65701
+ view-transition-class: navi_popup;
65702
+ }
65703
+
65704
+ @supports (view-transition-name: match-element) {
65705
+ :root[data-navi-route-transition]:not([data-navi-route-transition-target="area"]) .navi_popup_group:modal {
65706
+ view-transition-name: match-element;
65707
+ }
65708
+
65709
+ :root[data-navi-route-travel] .navi_popup_group:modal {
65710
+ view-transition-name: match-element;
65711
+ }
65712
+ }
65713
+ `;
65714
+
65715
+ /**
65716
+ * The box around popups that hand over to each other: score cells, a menu bar,
65717
+ * a toolbar. Being inside it is what makes a popup and a button peers: no name
65718
+ * to give.
65719
+ *
65720
+ * While one of its popups is open, the group is shown modal where it stands:
65721
+ * what it holds stays lit and live, the rest of the screen is behind its wall,
65722
+ * and a press on another of its popups' triggers closes the open one and opens
65723
+ * that one in the same press. Its popups draw no wall of their own (their
65724
+ * backdrop props are not read), and a Dialog in it is never modal. The page
65725
+ * behind stays still while one of its Popovers or Dialogs is open; a callout
65726
+ * of it lets the page scroll, as a callout does anywhere.
65727
+ *
65728
+ * Its element is a `<dialog>`, closed at rest and laid out like any box: the
65729
+ * app styles it as it would a `<div>`. While engaged, an invisible placeholder
65730
+ * holds its place in the page, as the element before it, marked
65731
+ * `navi-out-of-flow` like everything navi adds to a tree. See
65732
+ * docs/popup_backdrop.md.
65733
+ *
65734
+ * @type {import("ignore:preact").FunctionComponent<{
65735
+ * backdropVariant?: "auto" | "lift" | "discrete" | "invisible",
65736
+ * backdropColor?: string,
65737
+ * backdropFilter?: string,
65738
+ * themeColor?: string | false,
65739
+ * role?: string,
65740
+ * children?: import("ignore:preact").ComponentChildren,
65741
+ * [key: string]: any,
65742
+ * }>}
65743
+ * @param {"auto"|"lift"|"discrete"|"invisible"} [props.backdropVariant="auto"] -
65744
+ * How visible the wall is: `"auto"` is the wash a popup closing on an
65745
+ * outside press paints, `"lift"` the opaque blurred wall, `"discrete"` a
65746
+ * barely-there dim, `"invisible"` a wall that is not seen and still takes the
65747
+ * press.
65748
+ * @param {string} [props.backdropColor] - The wash itself, over the tokens.
65749
+ * @param {string} [props.backdropFilter] - What that wash does to what is
65750
+ * behind it.
65751
+ * @param {string|false} [props.themeColor] - The browser's colour while the
65752
+ * wall is up (see docs/popup_backdrop.md): worked out from the wash by
65753
+ * default.
65754
+ * @param {string} [props.role="group"] - A box of the page, not a dialog: the
65755
+ * element is a `<dialog>` for its modality alone.
65756
+ */
65757
+ const PopupGroup = props => {
65758
+ import.meta.css = [css$F, "@jsenv/navi/src/layout/popup_group.jsx"];
65759
+ const {
65760
+ backdropVariant = "auto",
65761
+ backdropColor,
65762
+ backdropFilter,
65763
+ themeColor,
65764
+ role = "group",
65765
+ children,
65766
+ ...rest
65767
+ } = props;
65768
+ const group = useMemo(createPopupGroup, []);
65769
+ group.themeColor = themeColor;
65770
+ // Unmounted or parked while engaged: a modal left open with its tree gone
65771
+ // keeps the whole document inert.
65772
+ useLayoutEffect(() => {
65773
+ return () => {
65774
+ group.disengage({
65775
+ leavingTree: true
65776
+ });
65777
+ };
65778
+ }, [group]);
65779
+ return jsx(Box, {
65780
+ as: "dialog",
65781
+ ...rest,
65782
+ // A callback ignoring null: a Suspense park nulls an object ref and
65783
+ // gives it back only when its identity changes.
65784
+ ref: group.captureElement,
65785
+ baseClassName: "navi_popup_group",
65786
+ "navi-popup-group": "",
65787
+ role: role,
65788
+ "data-backdrop-variant": backdropVariant === "auto" ? undefined : backdropVariant,
65789
+ backdropColor: backdropColor,
65790
+ backdropFilter: backdropFilter,
65791
+ styleCSSVars: POPUP_GROUP_STYLE_CSS_VARS,
65792
+ children: jsx(PopupGroupContext.Provider, {
65793
+ value: group,
65794
+ children: children
65795
+ })
65796
+ });
65797
+ };
65798
+ const POPUP_GROUP_STYLE_CSS_VARS = {
65799
+ backdropColor: "--backdrop-background",
65800
+ backdropFilter: "--backdrop-filter"
65801
+ };
65802
+
65803
+ // How long after the release a press that closed a popup may still bring the
65804
+ // click that ends it: the compatibility events of a tap come after touchend,
65805
+ // in a task of their own.
65806
+ const END_OF_PRESS_SAFETY_MS = 1000;
65807
+ const createPopupGroup = () => {
65808
+ let groupEl = null;
65809
+ // Every popup rendered in the group, open or not: "is one of them open" is
65810
+ // their own decision, read before any of them shows (a lifting dialog shows a
65811
+ // frame after it decided).
65812
+ const memberControllerSet = new Set();
65813
+ // The popups of the group that are open, as their openEffect described
65814
+ // them, the last one to open last. One at a time, except when the open one
65815
+ // refuses to close as another opens (a value it cannot note): it is kept
65816
+ // here, so the group can still close it.
65817
+ const openMemberMap = new Map();
65818
+ let engagement = null;
65819
+ const noMemberOpen = () => {
65820
+ for (const openController of memberControllerSet) {
65821
+ if (openController.opened) {
65822
+ return false;
65823
+ }
65824
+ }
65825
+ return true;
65826
+ };
65827
+ const getOpenMembers = () => {
65828
+ const openMembers = [];
65829
+ for (const [openController, member] of openMemberMap) {
65830
+ if (openController.opened) {
65831
+ openMembers.push(member);
65832
+ } else {
65833
+ openMemberMap.delete(openController);
65834
+ }
65835
+ }
65836
+ return openMembers;
65837
+ };
65838
+ // The page behind is held still unless what is open is a callout, which
65839
+ // lets it scroll as it does anywhere (its closeByScroll answers that
65840
+ // scroll). Kept as it is while nothing is open: the wall stays up until the
65841
+ // press that closed the last popup ends.
65842
+ const followScrollLock = () => {
65843
+ const openMembers = getOpenMembers();
65844
+ if (openMembers.length === 0) {
65845
+ return;
65846
+ }
65847
+ engagement.followScrollLock(openMembers.some(openMember => !openMember.pageScrolls));
65848
+ };
65849
+ const group = {
65850
+ themeColor: undefined,
65851
+ captureElement: element => {
65852
+ if (element) {
65853
+ groupEl = element;
65854
+ }
65855
+ },
65856
+ getElement: () => groupEl,
65857
+ register: openController => {
65858
+ memberControllerSet.add(openController);
65859
+ return () => {
65860
+ memberControllerSet.delete(openController);
65861
+ };
65862
+ },
65863
+ /**
65864
+ * Called by a popup of the group as it opens, before it shows itself.
65865
+ * `member`: { openController, popupElement, pressOutside, event,
65866
+ * pageScrolls, popupId }. `popupElement` is read at each press, so a
65867
+ * member whose element is drawn after it engaged (a callout) passes a
65868
+ * getter. `pageScrolls`: the page behind may scroll while it is open.
65869
+ * `popupId`: the id a data-navi-popup-inside names it by, when it is not
65870
+ * the popup element's own.
65871
+ */
65872
+ engage: member => {
65873
+ // One popup of the group at a time. A press on a peer has closed the
65874
+ // open one already (see armPressArbiter): this one opened from the
65875
+ // keyboard or from code.
65876
+ for (const openMember of getOpenMembers()) {
65877
+ if (openMember.openController === member.openController) {
65878
+ continue;
65879
+ }
65880
+ if (openMember.openController.requestClose(member.event)) {
65881
+ openMemberMap.delete(openMember.openController);
65882
+ }
65883
+ }
65884
+ openMemberMap.delete(member.openController);
65885
+ openMemberMap.set(member.openController, member);
65886
+ if (engagement) {
65887
+ engagement.cancelPendingCheck();
65888
+ } else {
65889
+ if (!groupEl || !groupEl.isConnected) {
65890
+ return;
65891
+ }
65892
+ engagement = engageGroup(groupEl, group, {
65893
+ silent: Boolean(member.event?.detail?.silent)
65894
+ });
65895
+ engagedGroupSet.add(group);
65896
+ }
65897
+ followScrollLock();
65898
+ },
65899
+ /**
65900
+ * Called by a popup of the group as it closes. Whether the group goes back
65901
+ * into the page is decided once nothing of the group is open — at the end
65902
+ * of the press that closed it when a press did, since that same press may
65903
+ * be opening a peer.
65904
+ */
65905
+ release: (openController, closeEvent) => {
65906
+ openMemberMap.delete(openController);
65907
+ if (!engagement) {
65908
+ return;
65909
+ }
65910
+ followScrollLock();
65911
+ if (findEvent(closeEvent, ["pointerdown", "mousedown", "pointerup", "mouseup", "click"])) {
65912
+ if (findEvent(closeEvent, "click") || !engagement.pressInFlight) {
65913
+ // Closed from inside the click, or after the press ended (a lifting
65914
+ // Dialog closes in a view transition's update callback, a frame
65915
+ // later): what is left of the press is over once this task is.
65916
+ engagement.checkAfterTask();
65917
+ return;
65918
+ }
65919
+ engagement.pressClosed = true;
65920
+ return;
65921
+ }
65922
+ // Not a press (Escape, a command, code): a peer opening in the same
65923
+ // stack, from the keyboard, is the only thing to wait for.
65924
+ engagement.checkAfterMicrotask();
65925
+ },
65926
+ getOpenMember: () => {
65927
+ const openMembers = getOpenMembers();
65928
+ return openMembers.length ? openMembers[openMembers.length - 1] : null;
65929
+ },
65930
+ disengageIfNothingOpen: () => {
65931
+ if (engagement && noMemberOpen()) {
65932
+ group.disengage();
65933
+ }
65934
+ },
65935
+ // Nothing of the group may stay open over a page that is live again.
65936
+ closeEverything: closeEvent => {
65937
+ for (const openMember of getOpenMembers()) {
65938
+ openMember.openController.close(closeEvent);
65939
+ }
65940
+ openMemberMap.clear();
65941
+ group.disengage();
65942
+ },
65943
+ disengage: ({
65944
+ leavingTree = false
65945
+ } = {}) => {
65946
+ if (!engagement) {
65947
+ return;
65948
+ }
65949
+ const leavingEngagement = engagement;
65950
+ engagement = null;
65951
+ engagedGroupSet.delete(group);
65952
+ leavingEngagement.cleanup({
65953
+ leavingTree
65954
+ });
65955
+ }
65956
+ };
65957
+ return group;
65958
+ };
65959
+ const engageGroup = (groupEl, group, {
65960
+ silent
65961
+ }) => {
65962
+ // Everything read before anything is written.
65963
+ const rect = groupEl.getBoundingClientRect();
65964
+ const groupStyle = getComputedStyle(groupEl);
65965
+ // Copied out: the style is live, and shown modal the group has its margins
65966
+ // and its bounds forced.
65967
+ const {
65968
+ marginLeft,
65969
+ marginRight,
65970
+ minWidth,
65971
+ maxWidth
65972
+ } = groupStyle;
65973
+ const ancestorClip = readAncestorClip(groupEl, groupStyle);
65974
+ const baselineDescent = readBaselineDescent(groupEl, groupStyle, rect);
65975
+ const themeColor = readPopupThemeColor(groupEl, group.themeColor);
65976
+ const focusedBefore = document.activeElement;
65977
+ const focusWasInGroup = Boolean(focusedBefore) && focusedBefore !== groupEl && groupEl.contains(focusedBefore);
65978
+ const ringBefore = Boolean(focusedBefore?.matches?.(":focus-visible"));
65979
+ const {
65980
+ scrollTop,
65981
+ scrollLeft
65982
+ } = groupEl;
65983
+ // A gesture on a box fixed in the top layer goes to the viewport: only a
65984
+ // place that scrolls with the viewport moves under it.
65985
+ const placeScroller = getScrollContainer(groupEl);
65986
+ const placeScrollsWithViewport = (placeScroller === document.scrollingElement ||
65987
+ // Reported as a scroll container on an app setting `body {
65988
+ // overflow-x: hidden }` (its computed overflow-y turns auto), it never
65989
+ // scrolls itself: the viewport does.
65990
+ placeScroller === document.body && bodyOverflowIsTheViewports()) && !findSelfOrAncestorFixedPosition(groupEl);
65991
+ const placeholder = createPlaceholder(groupStyle, rect, baselineDescent);
65992
+ groupEl.before(placeholder);
65993
+ groupEl.style.setProperty("--x-popup-group-padding", `${groupStyle.paddingTop} ${groupStyle.paddingRight} ${groupStyle.paddingBottom} ${groupStyle.paddingLeft}`);
65994
+ const visibleBox = writePlacement(groupEl, rect, ancestorClip);
65995
+ if (silent) {
65996
+ groupEl.setAttribute("navi-transitions-off", "");
65997
+ }
65998
+ // showModal() runs the dialog focusing steps: it focuses the first element
65999
+ // marked autofocus, or else the first focusable the group holds — and
66000
+ // scrolls the group's own scroller to it. Pointed at what already has the
66001
+ // focus, or at the group itself, with any other mark set aside for the
66002
+ // call, so nothing moves. A native autofocus is otherwise never used (see
66003
+ // the focus skill): this one exists for the length of the call, to steer
66004
+ // the browser's own pick, not to transfer anything.
66005
+ const autofocusTarget = focusWasInGroup ? focusedBefore : groupEl;
66006
+ const autofocusWasSet = autofocusTarget.hasAttribute("autofocus");
66007
+ const autofocusSetAside = focusWasInGroup ? [...groupEl.querySelectorAll("[autofocus]")].filter(element => element !== autofocusTarget) : [];
66008
+ for (const element of autofocusSetAside) {
66009
+ element.removeAttribute("autofocus");
66010
+ }
66011
+ if (!autofocusWasSet) {
66012
+ autofocusTarget.setAttribute("autofocus", "");
66013
+ }
66014
+ try {
66015
+ groupEl.showModal();
66016
+ } catch (error) {
66017
+ placeholder.remove();
66018
+ clearPlacement(groupEl);
66019
+ groupEl.removeAttribute("navi-transitions-off");
66020
+ throw error;
66021
+ } finally {
66022
+ if (!autofocusWasSet) {
66023
+ autofocusTarget.removeAttribute("autofocus");
66024
+ }
66025
+ for (const element of autofocusSetAside) {
66026
+ element.setAttribute("autofocus", "");
66027
+ }
66028
+ }
66029
+ // Measured once the group has left the flow, where a parent sized by its
66030
+ // content no longer counts it. Only a box given its width by what holds it
66031
+ // goes on following that width (a rotation while engaged); one sized by its
66032
+ // content, bounded by a min or max width the placeholder does not carry, or
66033
+ // placed by margins read here as px (auto ones, percentages), is held at
66034
+ // the width it has.
66035
+ const placeholderWidth = placeholder.getBoundingClientRect().width;
66036
+ if (parseFloat(marginLeft) !== 0 || parseFloat(marginRight) !== 0 || maxWidth !== "none" || minWidth !== "auto" && parseFloat(minWidth) !== 0 || placeholderWidth < rect.width - 0.5 || placeholderWidth > rect.width + 0.5) {
66037
+ placeholder.style.width = `${rect.width}px`;
66038
+ }
66039
+ // Where the engine focused something else anyway.
66040
+ if (groupEl.scrollTop !== scrollTop) {
66041
+ groupEl.scrollTop = scrollTop;
66042
+ }
66043
+ if (groupEl.scrollLeft !== scrollLeft) {
66044
+ groupEl.scrollLeft = scrollLeft;
66045
+ }
66046
+ if (focusWasInGroup && document.activeElement !== focusedBefore) {
66047
+ moveFocusTo(focusedBefore, {
66048
+ focusVisible: ringBefore
66049
+ });
66050
+ }
66051
+ // What showModal() left focused when the focus came from outside: that is
66052
+ // the browser's pick, not a restore of navi's, at the close (see cleanup).
66053
+ const focusLeftByShowModal = focusWasInGroup ? null : document.activeElement;
66054
+ if (silent) {
66055
+ // The wall's starting style is resolved with no transition to play from
66056
+ // it; giving the transition back afterwards changes no value, so nothing
66057
+ // fades.
66058
+ groupEl.getBoundingClientRect();
66059
+ groupEl.removeAttribute("navi-transitions-off");
66060
+ }
66061
+ // Top-layer order is the order things were shown in: whatever was already
66062
+ // open inside the group (a callout on a peer) is now under it, and goes back
66063
+ // on top.
66064
+ for (const topLayerEl of groupEl.querySelectorAll(":popover-open")) {
66065
+ topLayerEl.hidePopover();
66066
+ topLayerEl.showPopover();
66067
+ }
66068
+ const [cleanups, addCleanup] = createCleanupList();
66069
+ const engagement = {
66070
+ // From a press's pointerdown to its click (or its pointercancel).
66071
+ pressInFlight: false,
66072
+ // A popup closed during the press in flight: its end decides.
66073
+ pressClosed: false,
66074
+ visibleBox,
66075
+ cleanup: ({
66076
+ leavingTree
66077
+ }) => {
66078
+ for (const cleanup of cleanups) {
66079
+ cleanup();
66080
+ }
66081
+ clearPlacement(groupEl);
66082
+ placeholder.remove();
66083
+ if (leavingTree) {
66084
+ // Closed once the commit is over, as a Dialog leaving the tree is (see
66085
+ // its close cleanup): closing now would hand the focus to what held it
66086
+ // at showModal() time, often leaving in this same commit. A park
66087
+ // takes the group out of the top layer and leaves its `open`
66088
+ // attribute, which the reveal would show.
66089
+ queueMicrotask(() => {
66090
+ if (groupEl.open) {
66091
+ groupEl.close();
66092
+ }
66093
+ });
66094
+ return;
66095
+ }
66096
+ // close() hands the focus back to whatever held it at showModal() time
66097
+ // (focusedBefore), undoing where a popup of the group, or the user, put
66098
+ // it since. Unless no popup moved the focus since showModal() did, that
66099
+ // element is made no focus target for the call, so the focus stays where
66100
+ // it is: inert — or, when the focus is inside it (the sheet around the
66101
+ // popover around the group, focused by a press on its blank), without
66102
+ // its tabindex, inert taking the focus with it. A <dialog> stays
66103
+ // focusable without one: around a Dialog sheet, close() focuses it and
66104
+ // the moveFocusTo below brings the focus back.
66105
+ const focusedBeforeClose = document.activeElement;
66106
+ const ringBeforeClose = Boolean(focusedBeforeClose?.matches?.(":focus-visible"));
66107
+ const focusMovedSinceShowModal = focusedBeforeClose && focusedBeforeClose !== document.body && focusedBeforeClose !== groupEl && focusedBeforeClose !== focusLeftByShowModal && focusedBeforeClose.isConnected;
66108
+ const keepsFocusWhereItIs = focusMovedSinceShowModal && focusedBefore && focusedBefore !== focusedBeforeClose && focusedBefore !== document.body && focusedBefore.isConnected;
66109
+ const holdsFocusInside = keepsFocusWhereItIs && focusedBefore.contains(focusedBeforeClose);
66110
+ const madeInert = keepsFocusWhereItIs && !holdsFocusInside && !focusedBefore.inert;
66111
+ const tabindexSetAside = holdsFocusInside && focusedBefore.tagName !== "DIALOG" ? focusedBefore.getAttribute("tabindex") : null;
66112
+ if (madeInert) {
66113
+ focusedBefore.inert = true;
66114
+ }
66115
+ if (tabindexSetAside !== null) {
66116
+ focusedBefore.removeAttribute("tabindex");
66117
+ }
66118
+ try {
66119
+ if (groupEl.open) {
66120
+ groupEl.close();
66121
+ }
66122
+ } finally {
66123
+ if (madeInert) {
66124
+ focusedBefore.inert = false;
66125
+ }
66126
+ if (tabindexSetAside !== null) {
66127
+ focusedBefore.setAttribute("tabindex", tabindexSetAside);
66128
+ }
66129
+ }
66130
+ // Where the engine moved it anyway (an element focusable without a
66131
+ // tabindex).
66132
+ if (focusMovedSinceShowModal && document.activeElement !== focusedBeforeClose) {
66133
+ moveFocusTo(focusedBeforeClose, {
66134
+ focusVisible: ringBeforeClose
66135
+ });
66136
+ }
66137
+ }
66138
+ };
66139
+ let pendingCheck = null;
66140
+ engagement.cancelPendingCheck = () => {
66141
+ if (pendingCheck) {
66142
+ pendingCheck.cancel();
66143
+ pendingCheck = null;
66144
+ }
66145
+ };
66146
+ engagement.checkAfterTask = (delay = 0) => {
66147
+ engagement.cancelPendingCheck();
66148
+ const timeout = setTimeout(() => {
66149
+ pendingCheck = null;
66150
+ group.disengageIfNothingOpen();
66151
+ }, delay);
66152
+ pendingCheck = {
66153
+ cancel: () => {
66154
+ clearTimeout(timeout);
66155
+ }
66156
+ };
66157
+ };
66158
+ engagement.checkAfterMicrotask = () => {
66159
+ engagement.cancelPendingCheck();
66160
+ let cancelled = false;
66161
+ queueMicrotask(() => {
66162
+ if (cancelled) {
66163
+ return;
66164
+ }
66165
+ pendingCheck = null;
66166
+ group.disengageIfNothingOpen();
66167
+ });
66168
+ pendingCheck = {
66169
+ cancel: () => {
66170
+ cancelled = true;
66171
+ }
66172
+ };
66173
+ };
66174
+ addCleanup(engagement.cancelPendingCheck);
66175
+ addCleanup(armPressArbiter(groupEl, group, engagement));
66176
+ addCleanup(followPlaceholder(groupEl, placeholder, engagement));
66177
+ // A wheel or a finger on the wall would scroll the page behind, and with it
66178
+ // the place the group is pinned to. On the wall means outside what is shown
66179
+ // of the group, which a clip can make less than its box. Let go for a
66180
+ // callout only where the group's place scrolls with the viewport: in a
66181
+ // sheet, a list scrolling on its own, a fixed bar, the gesture would scroll
66182
+ // the document behind and never the box the group stands in.
66183
+ let releaseScrollLock = null;
66184
+ const letGoOfScroll = () => {
66185
+ if (releaseScrollLock) {
66186
+ releaseScrollLock();
66187
+ releaseScrollLock = null;
66188
+ }
66189
+ };
66190
+ engagement.followScrollLock = openMemberHoldsPage => {
66191
+ if (!openMemberHoldsPage && placeScrollsWithViewport) {
66192
+ letGoOfScroll();
66193
+ return;
66194
+ }
66195
+ if (!releaseScrollLock) {
66196
+ releaseScrollLock = trapScrollInside(groupEl, {
66197
+ backdrop: true,
66198
+ getElementBox: () => engagement.visibleBox,
66199
+ // The group is no scroll container: shown fixed in the top layer, a
66200
+ // gesture over what it holds, or a scroll key pressed in it, would go
66201
+ // straight on to the page.
66202
+ containInside: true
66203
+ });
66204
+ }
66205
+ };
66206
+ addCleanup(letGoOfScroll);
66207
+ if (themeColor !== "none") {
66208
+ addCleanup(pushPopupThemeColor({
66209
+ themeColor,
66210
+ backdropColor: readBackdropColor(groupEl)
66211
+ }));
66212
+ }
66213
+
66214
+ // Escape and the back gesture reach the topmost modal, which is the group:
66215
+ // they are the open popup's to answer, as they would be with focus in it.
66216
+ // Escape is answered at its keydown, before it becomes the browser's close
66217
+ // request: a cancel can be prevented only while the page has a user
66218
+ // activation to spend, and Escape gives none — at the second one the
66219
+ // browser closes the group itself, whatever the open popup said. A keydown
66220
+ // prevented is no close request at all. Left to what the key was pressed in
66221
+ // when that uses it (a search field clearing itself), as the open popup's
66222
+ // own shortcut leaves it.
66223
+ const onKeyDown = keydownEvent => {
66224
+ if (keydownEvent.key !== "Escape" || keydownEvent.defaultPrevented) {
66225
+ // Answered already: by the open popup's own shortcut, when the focus is
66226
+ // in it.
66227
+ return;
66228
+ }
66229
+ if (keydownEvent.target !== groupEl) {
66230
+ const defaultAction = getKeyboardEventDefaultAction(keydownEvent);
66231
+ if (defaultAction && defaultAction !== "dismiss") {
66232
+ return;
66233
+ }
66234
+ }
66235
+ const openMember = group.getOpenMember();
66236
+ if (!openMember) {
66237
+ return;
66238
+ }
66239
+ // requestClose prevents the keydown, whether the popup closes or refuses.
66240
+ openMember.openController.requestClose(keydownEvent, {
66241
+ isCancel: true
66242
+ });
66243
+ };
66244
+ groupEl.addEventListener("keydown", onKeyDown);
66245
+ addCleanup(() => {
66246
+ groupEl.removeEventListener("keydown", onKeyDown);
66247
+ });
66248
+ // What still reaches the group as a close request: the back gesture, an
66249
+ // Escape with nothing of the group open.
66250
+ const onCancel = cancelEvent => {
66251
+ if (cancelEvent.target !== cancelEvent.currentTarget) {
66252
+ // A file input inside the group cancelling its own picker.
66253
+ return;
66254
+ }
66255
+ cancelEvent.preventDefault();
66256
+ const openMember = group.getOpenMember();
66257
+ if (openMember) {
66258
+ openMember.openController.requestClose(cancelEvent, {
66259
+ isCancel: true
66260
+ });
66261
+ return;
66262
+ }
66263
+ group.disengageIfNothingOpen();
66264
+ };
66265
+ groupEl.addEventListener("cancel", onCancel);
66266
+ addCleanup(() => {
66267
+ groupEl.removeEventListener("cancel", onCancel);
66268
+ });
66269
+ // The browser may close the group itself: a close request with no user
66270
+ // activation left to spend (a second back gesture) ignores preventDefault.
66271
+ const onNativeClose = closeEvent => {
66272
+ if (groupEl.open) {
66273
+ // Queued by an earlier engagement's close(): the group has been shown
66274
+ // modal again since, and nothing closed it.
66275
+ return;
66276
+ }
66277
+ group.closeEverything(closeEvent);
66278
+ };
66279
+ groupEl.addEventListener("close", onNativeClose);
66280
+ addCleanup(() => {
66281
+ groupEl.removeEventListener("close", onNativeClose);
66282
+ });
66283
+ // What holds the group closing (the sheet it is in) does not close the
66284
+ // group: a modal left open in a closed dialog keeps the whole document inert
66285
+ // with nothing on screen to dismiss. A navi popup closes the groups inside it
66286
+ // itself, before it gives the focus back (closePopupGroupsInside); this is
66287
+ // for any other.
66288
+ const openableAncestor = closestOpenableAncestor(groupEl);
66289
+ if (openableAncestor) {
66290
+ addCleanup(observeAncestorOpenState(openableAncestor, ({
66291
+ isOpen
66292
+ }) => {
66293
+ if (isOpen) {
66294
+ return;
66295
+ }
66296
+ group.closeEverything(new CustomEvent("popup_group_ancestor_close", {
66297
+ detail: {}
66298
+ }));
66299
+ }));
66300
+ }
66301
+ return engagement;
66302
+ };
66303
+
66304
+ /**
66305
+ * Hears every press while the group is engaged, for the popup of the group
66306
+ * that is open. A press outside that popup closes it, per its pressOutside,
66307
+ * and goes on to what it landed on: another popup's trigger opens it, a button
66308
+ * does its thing. A press on the wall — the ::backdrop reports the group as its
66309
+ * target, outside its box — closes and reaches nothing else, everything behind
66310
+ * being inert.
66311
+ *
66312
+ * Decided on the press's mousedown, the event the page acts on: on a touch
66313
+ * screen it is synthesized at touchend and hit-tested again, with the group
66314
+ * still up, so a finger that scrolled the group (pointercancel, no mousedown)
66315
+ * closes nothing. A pointerdown cancelled downstream (a drag source) brings no
66316
+ * mousedown: its click decides instead. A popup that captures the outside is
66317
+ * answered at the pointerdown itself: nothing of that press may reach a peer.
66318
+ */
66319
+ const armPressArbiter = (groupEl, group, engagement) => {
66320
+ let press = null;
66321
+ const isOnWall = event => {
66322
+ if (event.target !== groupEl) {
66323
+ return false;
66324
+ }
66325
+ const {
66326
+ left,
66327
+ top,
66328
+ right,
66329
+ bottom
66330
+ } = engagement.visibleBox;
66331
+ return event.clientX < left || event.clientX > right || event.clientY < top || event.clientY > bottom;
66332
+ };
66333
+ const spend = event => {
66334
+ event.preventDefault();
66335
+ event.stopImmediatePropagation();
66336
+ };
66337
+ // The open popup this press is outside of, or null.
66338
+ const findMemberPressedOutside = event => {
66339
+ const openMember = group.getOpenMember();
66340
+ if (!openMember) {
66341
+ return null;
66342
+ }
66343
+ if (openedDuringThisPress(openMember.openController)) {
66344
+ // The release of the press that opened it.
66345
+ return null;
66346
+ }
66347
+ const {
66348
+ target
66349
+ } = event;
66350
+ const {
66351
+ popupElement
66352
+ } = openMember;
66353
+ if (popupElement.contains(target)) {
66354
+ return null;
66355
+ }
66356
+ if (isDeclaredInsidePopup(target, openMember.popupId || popupElement.id)) {
66357
+ return null;
66358
+ }
66359
+ // In front of the open popup: something open inside the group that is
66360
+ // neither that popup nor around it (a callout on a peer).
66361
+ const layerUnderPointer = target.closest?.(':popover-open, dialog[open]:not([navi-popup-group]), [navi-control="popover"][aria-expanded="true"]');
66362
+ if (layerUnderPointer && !layerUnderPointer.contains(popupElement) && !popupElement.contains(layerUnderPointer)) {
66363
+ return null;
66364
+ }
66365
+ return openMember;
66366
+ };
66367
+ const readPressOutside = openMember => readPressOutsideInPopupGroup(openMember.pressOutside);
66368
+ const arbitrate = event => {
66369
+ const openMember = findMemberPressedOutside(event);
66370
+ if (!openMember) {
66371
+ return;
66372
+ }
66373
+ const pressOutside = readPressOutside(openMember);
66374
+ if (pressOutside === "capture") {
66375
+ spend(event);
66376
+ press.spendClick = true;
66377
+ return;
66378
+ }
66379
+ // A press on a peer goes on to it, so the close is told the pointerdown:
66380
+ // told the mousedown, the focus restore cancels that mousedown, and the
66381
+ // peer's own reaction to it with it (a picker opening at mousedown).
66382
+ let closeEvent;
66383
+ if (isOnWall(event)) {
66384
+ closeEvent = event;
66385
+ } else if (event.type === "click" && press.pointerdown) {
66386
+ // Decided at the click (the pointerdown was cancelled): the close is
66387
+ // told both, so what holds a reopen back for this press knows the press
66388
+ // ends with this very click.
66389
+ closeEvent = new CustomEvent("popup_group_press_outside", {
66390
+ detail: {
66391
+ event,
66392
+ eventChain: [press.pointerdown, event]
66393
+ }
66394
+ });
66395
+ } else {
66396
+ closeEvent = press.pointerdown || event;
66397
+ }
66398
+ const closed = openMember.openController.requestClose(closeEvent, {
66399
+ isCancel: pressOutside === "cancel"
66400
+ });
66401
+ if (!closed) {
66402
+ // The popup refuses to be left (a value it cannot note): the press must
66403
+ // not go on to open a peer next to it.
66404
+ spend(event);
66405
+ press.spendClick = true;
66406
+ }
66407
+ };
66408
+ const onPointerDown = pointerDownEvent => {
66409
+ if (engagement.pressClosed) {
66410
+ // The press that closed the last popup ended without a click (a gesture
66411
+ // swallowed it): this one is a new press.
66412
+ engagement.pressClosed = false;
66413
+ group.disengageIfNothingOpen();
66414
+ if (!groupEl.open) {
66415
+ return;
66416
+ }
66417
+ }
66418
+ press = {
66419
+ pointerdown: pointerDownEvent,
66420
+ mousedown: null,
66421
+ spendClick: false
66422
+ };
66423
+ if (pointerDownEvent.button !== 0) {
66424
+ return;
66425
+ }
66426
+ engagement.pressInFlight = true;
66427
+ const openMember = findMemberPressedOutside(pointerDownEvent);
66428
+ if (openMember && readPressOutside(openMember) === "capture") {
66429
+ // Cancelling the pointerdown also cancels the mousedown it would bring.
66430
+ spend(pointerDownEvent);
66431
+ press.spendClick = true;
66432
+ }
66433
+ };
66434
+ const onMouseDown = mouseDownEvent => {
66435
+ if (mouseDownEvent.button !== 0) {
66436
+ return;
66437
+ }
66438
+ if (!press) {
66439
+ press = {
66440
+ pointerdown: null,
66441
+ mousedown: null,
66442
+ spendClick: false
66443
+ };
66444
+ }
66445
+ engagement.pressInFlight = true;
66446
+ press.mousedown = mouseDownEvent;
66447
+ if (press.spendClick) {
66448
+ spend(mouseDownEvent);
66449
+ return;
66450
+ }
66451
+ arbitrate(mouseDownEvent);
66452
+ };
66453
+ const onClick = clickEvent => {
66454
+ if (!isPressDrivenClick(clickEvent)) {
66455
+ // A keyboard activation or a click() from code: not the click of the
66456
+ // press in flight, which the next pointerdown replaces anyway.
66457
+ return;
66458
+ }
66459
+ if (press?.spendClick) {
66460
+ spend(clickEvent);
66461
+ } else if (press && !press.mousedown) {
66462
+ arbitrate(clickEvent);
66463
+ }
66464
+ press = null;
66465
+ engagement.pressInFlight = false;
66466
+ if (engagement.pressClosed) {
66467
+ engagement.pressClosed = false;
66468
+ // After the click's dispatch: a peer it opens counts.
66469
+ engagement.checkAfterTask();
66470
+ }
66471
+ };
66472
+ const onPointerUp = () => {
66473
+ if (engagement.pressClosed) {
66474
+ engagement.checkAfterTask(END_OF_PRESS_SAFETY_MS);
66475
+ }
66476
+ };
66477
+ const onPointerCancel = () => {
66478
+ press = null;
66479
+ engagement.pressInFlight = false;
66480
+ if (engagement.pressClosed) {
66481
+ engagement.pressClosed = false;
66482
+ engagement.checkAfterTask();
66483
+ }
66484
+ };
66485
+ const listeners = [["pointerdown", onPointerDown], ["mousedown", onMouseDown], ["click", onClick], ["pointerup", onPointerUp], ["pointercancel", onPointerCancel]];
66486
+ for (const [type, listener] of listeners) {
66487
+ document.addEventListener(type, listener, {
66488
+ capture: true
66489
+ });
66490
+ }
66491
+ return () => {
66492
+ for (const [type, listener] of listeners) {
66493
+ document.removeEventListener(type, listener, {
66494
+ capture: true
66495
+ });
66496
+ }
66497
+ };
66498
+ };
66499
+
66500
+ // The group follows the place it holds in the page: a scroll of what holds it,
66501
+ // the viewport resizing (a keyboard, a rotation), the page reflowing around
66502
+ // it, a popup it is in being placed again. Its own scroll moves nothing.
66503
+ const followPlaceholder = (groupEl, placeholder, engagement) => {
66504
+ const [cleanups, addCleanup] = createCleanupList();
66505
+ // An ancestor's movement may end after this engagement did, and the group a
66506
+ // later one engaged is the same element.
66507
+ let disposed = false;
66508
+ addCleanup(() => {
66509
+ disposed = true;
66510
+ });
66511
+ const update = () => {
66512
+ if (disposed) {
66513
+ return;
66514
+ }
66515
+ const rect = placeholder.getBoundingClientRect();
66516
+ const ancestorClip = readAncestorClip(placeholder, getComputedStyle(placeholder));
66517
+ engagement.visibleBox = writePlacement(groupEl, rect, ancestorClip);
66518
+ };
66519
+ const onScroll = scrollEvent => {
66520
+ if (scrollEvent.target === groupEl || groupEl.contains(scrollEvent.target)) {
66521
+ return;
66522
+ }
66523
+ update();
66524
+ };
66525
+ document.addEventListener("scroll", onScroll, {
66526
+ capture: true,
66527
+ passive: true
66528
+ });
66529
+ window.addEventListener("resize", update);
66530
+ const {
66531
+ visualViewport
66532
+ } = window;
66533
+ visualViewport?.addEventListener("resize", update);
66534
+ addCleanup(() => {
66535
+ document.removeEventListener("scroll", onScroll, {
66536
+ capture: true
66537
+ });
66538
+ window.removeEventListener("resize", update);
66539
+ visualViewport?.removeEventListener("resize", update);
66540
+ });
66541
+ const resizeObserver = new ResizeObserver(update);
66542
+ if (placeholder.parentElement) {
66543
+ resizeObserver.observe(placeholder.parentElement);
66544
+ }
66545
+ addCleanup(() => {
66546
+ resizeObserver.disconnect();
66547
+ });
66548
+ // A popup the group is in moves by a translate, animated, and says so only
66549
+ // to itself (applyNewPosition): followed frame by frame while it moves, as
66550
+ // visibleRectEffect follows it for what is anchored inside it.
66551
+ let ancestor = closestOpenableAncestor(groupEl);
66552
+ while (ancestor) {
66553
+ const openableAncestor = ancestor;
66554
+ let positionTransitionFrame = null;
66555
+ const onPositionChange = () => {
66556
+ update();
66557
+ };
66558
+ const onPositionTransition = positionTransitionEvent => {
66559
+ cancelAnimationFrame(positionTransitionFrame);
66560
+ const loop = () => {
66561
+ update();
66562
+ positionTransitionFrame = requestAnimationFrame(loop);
66563
+ };
66564
+ loop();
66565
+ positionTransitionEvent.detail.onEnd(() => {
66566
+ cancelAnimationFrame(positionTransitionFrame);
66567
+ positionTransitionFrame = null;
66568
+ update();
66569
+ });
66570
+ };
66571
+ openableAncestor.addEventListener("navi_position_change", onPositionChange);
66572
+ openableAncestor.addEventListener("navi_position_transition", onPositionTransition);
66573
+ addCleanup(() => {
66574
+ cancelAnimationFrame(positionTransitionFrame);
66575
+ openableAncestor.removeEventListener("navi_position_change", onPositionChange);
66576
+ openableAncestor.removeEventListener("navi_position_transition", onPositionTransition);
66577
+ });
66578
+ ancestor = closestOpenableAncestor(openableAncestor);
66579
+ }
66580
+ return () => {
66581
+ for (const cleanup of cleanups) {
66582
+ cleanup();
66583
+ }
66584
+ };
66585
+ };
66586
+
66587
+ // What stands in the page for the group while it is in the top layer: the same
66588
+ // outer box at the height it had, invisible, counted by nothing that skips
66589
+ // what navi puts in a tree (css_architecture.md, "Counting children").
66590
+ const createPlaceholder = (groupStyle, rect, baselineDescent) => {
66591
+ const placeholder = document.createElement("div");
66592
+ placeholder.setAttribute("navi-popup-group-placeholder", "");
66593
+ placeholder.setAttribute("navi-out-of-flow", "");
66594
+ placeholder.setAttribute("aria-hidden", "true");
66595
+ const {
66596
+ style
66597
+ } = placeholder;
66598
+ if (groupStyle.display.startsWith("inline")) {
66599
+ style.display = "inline-block";
66600
+ } else if (establishesFormattingContext(groupStyle)) {
66601
+ // Such a box keeps clear of a float beside it, where a plain block slides
66602
+ // under it.
66603
+ style.display = "flow-root";
66604
+ } else {
66605
+ style.display = "block";
66606
+ }
66607
+ style.visibility = "hidden";
66608
+ style.pointerEvents = "none";
66609
+ style.boxSizing = "border-box";
66610
+ style.height = `${rect.height}px`;
66611
+ style.flex = "none";
66612
+ for (const property of PLACEHOLDER_COPIED_PROPERTIES) {
66613
+ style[property] = groupStyle[property];
66614
+ }
66615
+ if (baselineDescent !== null) {
66616
+ // An empty inline box sits on its bottom edge; the group sat on its own
66617
+ // baseline, this far above its bottom.
66618
+ style.verticalAlign = `${-baselineDescent}px`;
66619
+ }
66620
+ return placeholder;
66621
+ };
66622
+ const establishesFormattingContext = style => {
66623
+ const {
66624
+ display,
66625
+ overflowX,
66626
+ overflowY,
66627
+ contain,
66628
+ containerType,
66629
+ contentVisibility,
66630
+ columnCount,
66631
+ columnWidth
66632
+ } = style;
66633
+ if (display === "flow-root" || display === "flex" || display === "grid" || display === "table" || display.includes("flow-root")) {
66634
+ return true;
66635
+ }
66636
+ if (overflowX !== "visible" && overflowX !== "clip" || overflowY !== "visible" && overflowY !== "clip") {
66637
+ return true;
66638
+ }
66639
+ // Containment the computed `contain` does not show: a size container, a box
66640
+ // whose content-visibility skips its content.
66641
+ if (/\b(?:size|inline-size)\b/.test(containerType) || contentVisibility === "auto" || contentVisibility === "hidden") {
66642
+ return true;
66643
+ }
66644
+ // A multi-column box.
66645
+ if (columnCount !== "auto" || columnWidth !== "auto") {
66646
+ return true;
66647
+ }
66648
+ return /\b(?:layout|paint|strict|content)\b/.test(contain);
66649
+ };
66650
+ const PLACEHOLDER_COPIED_PROPERTIES = ["position", "top", "right", "bottom", "left", "marginTop", "marginRight", "marginBottom", "marginLeft", "alignSelf", "justifySelf", "order", "gridRowStart", "gridRowEnd", "gridColumnStart", "gridColumnEnd", "float", "verticalAlign"];
66651
+
66652
+ // How far below the line's baseline an inline-level group on that baseline
66653
+ // reaches: what the placeholder, which has no line of its own, has to sink by.
66654
+ const readBaselineDescent = (groupEl, groupStyle, rect) => {
66655
+ if (!groupStyle.display.startsWith("inline") || groupStyle.verticalAlign !== "baseline") {
66656
+ return null;
66657
+ }
66658
+ const marginTop = parseFloat(groupStyle.marginTop) || 0;
66659
+ const marginBottom = parseFloat(groupStyle.marginBottom) || 0;
66660
+ const readBaselineY = insert => {
66661
+ const probe = document.createElement("span");
66662
+ probe.style.cssText = "display:inline-block;width:0;height:0;vertical-align:baseline";
66663
+ insert(probe);
66664
+ const baselineY = probe.getBoundingClientRect().bottom;
66665
+ probe.remove();
66666
+ return baselineY;
66667
+ };
66668
+ // On the group's own line, between its top and its bottom margin edge: a
66669
+ // probe that takes no room can stay at the end of the line before it.
66670
+ const isOnGroupLine = y => y >= rect.top - marginTop - 0.5 && y <= rect.bottom + marginBottom + 0.5;
66671
+ let baselineY = readBaselineY(probe => groupEl.before(probe));
66672
+ if (!isOnGroupLine(baselineY)) {
66673
+ baselineY = readBaselineY(probe => groupEl.after(probe));
66674
+ if (!isOnGroupLine(baselineY)) {
66675
+ return null;
66676
+ }
66677
+ }
66678
+ // Down to the margin edge: that is what an empty inline box sits on.
66679
+ return rect.bottom + marginBottom - baselineY;
66680
+ };
66681
+
66682
+ // Writes where the group stands and how its ancestors clip it; returns what of
66683
+ // it is on screen.
66684
+ const writePlacement = (groupEl, rect, ancestorClip) => {
66685
+ const {
66686
+ style
66687
+ } = groupEl;
66688
+ style.setProperty("--x-popup-group-top", `${rect.top}px`);
66689
+ style.setProperty("--x-popup-group-left", `${rect.left}px`);
66690
+ style.setProperty("--x-popup-group-width", `${rect.width}px`);
66691
+ style.setProperty("--x-popup-group-height", `${rect.height}px`);
66692
+ if (ancestorClip) {
66693
+ // From the group's border box, reaching past it on a side nothing clips:
66694
+ // what overflows the group was painted up to its ancestors' edge.
66695
+ const toInset = distance => distance === -Infinity ? "-100vmax" : `${distance}px`;
66696
+ 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)})`);
66697
+ } else {
66698
+ style.removeProperty("--x-popup-group-clip");
66699
+ }
66700
+ return intersectBox(rect, ancestorClip);
66701
+ };
66702
+ const clearPlacement = groupEl => {
66703
+ const {
66704
+ style
66705
+ } = groupEl;
66706
+ style.removeProperty("--x-popup-group-top");
66707
+ style.removeProperty("--x-popup-group-left");
66708
+ style.removeProperty("--x-popup-group-width");
66709
+ style.removeProperty("--x-popup-group-height");
66710
+ style.removeProperty("--x-popup-group-padding");
66711
+ style.removeProperty("--x-popup-group-clip");
66712
+ };
66713
+
66714
+ // The clip the ancestors of `element` put on it, up to the first box nothing
66715
+ // around clips (a top-layer or fixed one), -Infinity/Infinity on a side
66716
+ // nothing clips — or null when nothing clips it at all.
66717
+ const readAncestorClip = (element, elementStyle) => {
66718
+ if (elementStyle.position === "fixed") {
66719
+ return null;
66720
+ }
66721
+ // Read on body, an overflow that is the viewport's clips nothing.
66722
+ const bodyClipsNothing = bodyOverflowIsTheViewports();
66723
+ let left = -Infinity;
66724
+ let top = -Infinity;
66725
+ let right = Infinity;
66726
+ let bottom = Infinity;
66727
+ let clipped = false;
66728
+ // An absolutely placed box escapes the clip of every ancestor between it
66729
+ // and its containing block: those are skipped.
66730
+ let skipUntilContainingBlock = elementStyle.position === "absolute";
66731
+ let ancestor = element.parentElement;
66732
+ while (ancestor && ancestor !== document.documentElement) {
66733
+ const ancestorStyle = getComputedStyle(ancestor);
66734
+ if (skipUntilContainingBlock && !isContainingBlockForAbsolute(ancestorStyle)) {
66735
+ ancestor = ancestor.parentElement;
66736
+ continue;
66737
+ }
66738
+ skipUntilContainingBlock = false;
66739
+ const {
66740
+ overflowX,
66741
+ overflowY,
66742
+ position
66743
+ } = ancestorStyle;
66744
+ const overflowGoesToViewport = ancestor === document.body && bodyClipsNothing;
66745
+ const clipsX = !overflowGoesToViewport && overflowX !== "visible";
66746
+ const clipsY = !overflowGoesToViewport && overflowY !== "visible";
66747
+ if (clipsX || clipsY) {
66748
+ clipped = true;
66749
+ const ancestorRect = ancestor.getBoundingClientRect();
66750
+ const innerLeft = ancestorRect.left + ancestor.clientLeft;
66751
+ const innerTop = ancestorRect.top + ancestor.clientTop;
66752
+ if (clipsX) {
66753
+ const innerRight = innerLeft + ancestor.clientWidth;
66754
+ if (innerLeft > left) {
66755
+ left = innerLeft;
66756
+ }
66757
+ if (innerRight < right) {
66758
+ right = innerRight;
66759
+ }
66760
+ }
66761
+ if (clipsY) {
66762
+ const innerBottom = innerTop + ancestor.clientHeight;
66763
+ if (innerTop > top) {
66764
+ top = innerTop;
66765
+ }
66766
+ if (innerBottom < bottom) {
66767
+ bottom = innerBottom;
66768
+ }
66769
+ }
66770
+ }
66771
+ if (position === "fixed" || ancestor.matches(":popover-open, dialog:modal")) {
66772
+ break;
66773
+ }
66774
+ if (position === "absolute") {
66775
+ skipUntilContainingBlock = true;
66776
+ }
66777
+ ancestor = ancestor.parentElement;
66778
+ }
66779
+ return clipped ? {
66780
+ left,
66781
+ top,
66782
+ right,
66783
+ bottom
66784
+ } : null;
66785
+ };
66786
+ // The overflow of <body> is the viewport's when the root leaves its own
66787
+ // visible: body itself then neither scrolls nor clips.
66788
+ const bodyOverflowIsTheViewports = () => {
66789
+ const rootStyle = getComputedStyle(document.documentElement);
66790
+ return rootStyle.overflowX === "visible" && rootStyle.overflowY === "visible";
66791
+ };
66792
+ const isContainingBlockForAbsolute = style => style.position !== "static" || style.transform !== "none" || style.translate !== "none" || style.rotate !== "none" || style.scale !== "none" || style.perspective !== "none" || style.filter !== "none" ||
66793
+ // Prefixed alone before Safari 18.
66794
+ (style.backdropFilter ?? style.webkitBackdropFilter ?? "none") !== "none" || /\b(?:layout|paint|strict|content)\b/.test(style.contain) ||
66795
+ // Layout containment the computed `contain` does not show.
66796
+ 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);
66797
+ const intersectBox = (rect, clip) => {
66798
+ let {
66799
+ left,
66800
+ top,
66801
+ right,
66802
+ bottom
66803
+ } = rect;
66804
+ if (clip) {
66805
+ if (clip.left > left) {
66806
+ left = clip.left;
66807
+ }
66808
+ if (clip.top > top) {
66809
+ top = clip.top;
66810
+ }
66811
+ if (clip.right < right) {
66812
+ right = clip.right;
66813
+ }
66814
+ if (clip.bottom < bottom) {
66815
+ bottom = clip.bottom;
66816
+ }
66817
+ }
66818
+ if (right < left) {
66819
+ right = left;
66820
+ }
66821
+ if (bottom < top) {
66822
+ bottom = top;
66823
+ }
66824
+ return {
66825
+ left,
66826
+ top,
66827
+ right,
66828
+ bottom
66829
+ };
66830
+ };
66831
+ const createCleanupList = () => {
66832
+ const cleanups = [];
66833
+ return [cleanups, cleanup => {
66834
+ cleanups.push(cleanup);
66835
+ }];
66836
+ };
66837
+
65542
66838
  /**
65543
66839
  * Where the "popover or dialog?" answer lives, for both the components that
65544
66840
  * decide it and the content that renders inside one.
@@ -67289,7 +68585,8 @@ const css$E = /* css */`
67289
68585
  * wall is shown through the Popover API rather than `showModal()`, which is
67290
68586
  * what a sheet docked to the screen's bottom edge over a still-readable map
67291
68587
  * needs. What it gives up is what only a modal gets natively: the hardware
67292
- * back button no longer dismisses it. See docs/popup_backdrop.md.
68588
+ * back button no longer dismisses it. Not read inside a `<PopupGroup>`,
68589
+ * whose wall it is. See docs/popup_backdrop.md.
67293
68590
  * @param {"close"|"cancel"|"capture"|"ignore"} [props.pressOutside="close"]
67294
68591
  * - `"close"` closes the dialog on an outside press, `"cancel"` closes it
67295
68592
  * as a cancel (what it holds reverts, as on Escape). `"capture"`/`"ignore"`
@@ -67846,7 +69143,7 @@ const useDialogProps = props => {
67846
69143
  // before how it is painted (backdropVariant below). It also picks the show
67847
69144
  // call for layer="top": a wall is what showModal() is for, and without one
67848
69145
  // the dialog goes to the same top layer through the Popover API instead.
67849
- backdrop = true,
69146
+ backdrop: backdropProp = true,
67850
69147
  // How loudly the backdrop says it is there — independent of what it
67851
69148
  // *does* (that's pressOutside above). "lift" is the
67852
69149
  // opaque wall at one end, "invisible" a wall that is not seen at the
@@ -67914,9 +69211,20 @@ const useDialogProps = props => {
67914
69211
  // Where it is placed, and whether there is a wall — the two questions that
67915
69212
  // pick the show call (see this file's top comment).
67916
69213
  const isTopLayer = layer === "top";
69214
+ // Inside a <PopupGroup> the wall is the group's (see popup_group.jsx): the
69215
+ // dialog takes the wall-less path, never showModal() — a modal dialog makes
69216
+ // everything outside it inert, the group's other popups included.
69217
+ const popupGroup = useContext(PopupGroupContext);
69218
+ useLayoutEffect(() => {
69219
+ if (!popupGroup) {
69220
+ return undefined;
69221
+ }
69222
+ return popupGroup.register(openController);
69223
+ }, [popupGroup, openController]);
69224
+ const backdrop = backdropProp && !popupGroup;
67917
69225
  const isModal = isTopLayer && backdrop;
67918
69226
  const isTopLayerPopover = isTopLayer && !backdrop;
67919
- if (!backdrop && pressOutside === "capture") {
69227
+ if (!popupGroup && !backdrop && pressOutside === "capture") {
67920
69228
  console.warn(`Dialog: pressOutside="capture" needs a backdrop. Absorbing a press is what a wall does, and backdrop={false} takes it away.`);
67921
69229
  }
67922
69230
  const ref = props.ref;
@@ -68153,6 +69461,14 @@ const useDialogProps = props => {
68153
69461
  if (!dialogEl) {
68154
69462
  return undefined;
68155
69463
  }
69464
+ // First, so the group already stands where it stands in the top layer
69465
+ // when this dialog shows itself above it.
69466
+ popupGroup?.engage({
69467
+ openController,
69468
+ popupElement: dialogEl,
69469
+ pressOutside,
69470
+ event: e
69471
+ });
68156
69472
 
68157
69473
  // Set by useOpenControllerByProps for the very first open triggered by
68158
69474
  // `open`/`defaultOpen` already being truthy at mount, and for an open a
@@ -68243,7 +69559,8 @@ const useDialogProps = props => {
68243
69559
  // A modal dialog always has its own ::backdrop; a local one has the
68244
69560
  // backdrop element when it renders one.
68245
69561
  addCleanup(trapScrollInside(dialogEl, {
68246
- backdrop: isModal || backdropEl
69562
+ // A group's ::backdrop covers the page while this dialog is open.
69563
+ backdrop: popupGroup ? true : isModal || backdropEl
68247
69564
  }));
68248
69565
  } else if (!isTopLayer) {
68249
69566
  // A local dialog is confined to its positioned ancestor, and so is its
@@ -68474,7 +69791,9 @@ const useDialogProps = props => {
68474
69791
  // file's top comment), and a local one the caller asked to leave the page
68475
69792
  // reachable. Active for the dialog's entire open lifetime, not just
68476
69793
  // mid-transition.
68477
- if ((isModal || !backdrop) && (pressOutside === "close" || pressOutside === "cancel")) {
69794
+ if ((isModal || !backdrop) &&
69795
+ // The group's wall is up, and the group hears the press itself.
69796
+ !popupGroup && (pressOutside === "close" || pressOutside === "cancel")) {
68478
69797
  addCleanup(armOutsidePressClose(dialogEl, {
68479
69798
  openController,
68480
69799
  pressOutside,
@@ -68513,6 +69832,11 @@ const useDialogProps = props => {
68513
69832
  dialogEl.close();
68514
69833
  }
68515
69834
  };
69835
+ if (!findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
69836
+ // Before its own focus restore: a group engaged inside it is the
69837
+ // topmost modal, and keeps everything else inert until it goes.
69838
+ closePopupGroupsInside(dialogEl, closeEvent);
69839
+ }
68516
69840
  if (findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
68517
69841
  // Leaving the tree: hidden natively once the commit is over. Its
68518
69842
  // removal takes it out of the top layer and hands the focus to
@@ -68542,9 +69866,11 @@ const useDialogProps = props => {
68542
69866
  if (backdropEl) {
68543
69867
  backdropEl.setAttribute("aria-expanded", "false");
68544
69868
  disarmBackdropHideRef.current = armPointerDownOutsideClose(closeEvent, () => {
69869
+ disarmBackdropHideRef.current = null;
68545
69870
  backdropEl.setAttribute("navi-hidden", "");
68546
69871
  });
68547
69872
  }
69873
+ popupGroup?.release(openController, closeEvent);
68548
69874
  restoreFocus(closeEvent);
68549
69875
  cleanup();
68550
69876
  if (silent) {
@@ -68605,8 +69931,9 @@ const useDialogProps = props => {
68605
69931
  // there's no gap for the browser to ever paint this plain-div backdrop
68606
69932
  // visible before anything has actually opened it — see popover.jsx's
68607
69933
  // own identical prop for the full reasoning, and this file's own CSS
68608
- // for the rule it drives.
68609
- "navi-hidden": openController.openedInDom ? undefined : "",
69934
+ // for the rule it drives. Left up while the press that closed the dialog
69935
+ // has its click still to come, as popover.jsx's own wall is.
69936
+ "navi-hidden": openController.openedInDom || disarmBackdropHideRef.current ? undefined : "",
68610
69937
  "styleCSSVars": DIALOG_STYLE_CSS_VARS,
68611
69938
  "animationDuration": rest.animationDuration,
68612
69939
  "data-press-outside": pressOutside,
@@ -68733,7 +70060,8 @@ const useDialogProps = props => {
68733
70060
  handlePressOnOutsideRegion(e, {
68734
70061
  popupEl: ref.current,
68735
70062
  openController,
68736
- pressOutside
70063
+ // In a group, the press the group's wall would get.
70064
+ pressOutside: popupGroup ? readPressOutsideInPopupGroup(pressOutside) : pressOutside
68737
70065
  });
68738
70066
  },
68739
70067
  "onKeyDown": e => {
@@ -68761,7 +70089,12 @@ const useDialogProps = props => {
68761
70089
  // prevented it already (see requestClose).
68762
70090
  e.preventDefault();
68763
70091
  },
68764
- children
70092
+ // A popup opened from inside this one is in front of it, not a peer of
70093
+ // it: the group stops here.
70094
+ "children": popupGroup ? jsx(PopupGroupContext.Provider, {
70095
+ value: null,
70096
+ children: children
70097
+ }) : children
68765
70098
  });
68766
70099
 
68767
70100
  // Outside-click handling for the wall we render ourselves. A modal's is a
@@ -69255,13 +70588,6 @@ const css$D = /* css */`
69255
70588
  * is a `Popover`-level answer only for `"close"`/`"cancel"`; `"capture"` has
69256
70589
  * nothing left to absorb without a wall, and `"ignore"` renders no wall anyway.
69257
70590
  * See docs/popup_backdrop.md.
69258
- * @param {string} [props.group] - The popover's peers: the triggers carrying
69259
- * `data-navi-popup-group` with this name. A press on one, while the popover
69260
- * is open, closes it and opens the peer's popup in that same press — a
69261
- * menu bar, where pressing another menu switches to it. The wall still
69262
- * takes the press (the page behind stays out of reach, hover included) and
69263
- * hands its click to the peer it covered. Without a wall there is nothing
69264
- * to ask: the press reaches the peer on its own.
69265
70591
  * @param {"close"|"cancel"|"capture"|"ignore"} [props.pressOutside="ignore"]
69266
70592
  * - `"ignore"` (default): no backdrop at all, outside presses pass straight
69267
70593
  * through. `"close"` closes the popover on an outside press, `"cancel"`
@@ -69649,7 +70975,6 @@ const usePopoverProps = props => {
69649
70975
  // Read at the opening, with the backdrop it would otherwise be worked out
69650
70976
  // from (see openEffect).
69651
70977
  themeColor,
69652
- group,
69653
70978
  scrollCapture,
69654
70979
  focusCapture,
69655
70980
  // "auto" (default) → the popover follows its content. "frozen" → measured
@@ -69711,8 +71036,18 @@ const usePopoverProps = props => {
69711
71036
  // animation={true} or "auto" always resolves to "sliding" or "scaling"
69712
71037
  // (see resolveAutoAnimationKind).
69713
71038
  const isAutoAnimation = animation === true || animation === "auto";
69714
- const hasBackdrop = pressOutside !== "ignore" && backdrop;
69715
- if (!backdrop && pressOutside === "capture") {
71039
+
71040
+ // Inside a <PopupGroup> the wall is the group's (see popup_group.jsx): this
71041
+ // popover draws none, and the group decides what a press outside does.
71042
+ const popupGroup = useContext(PopupGroupContext);
71043
+ useLayoutEffect(() => {
71044
+ if (!popupGroup) {
71045
+ return undefined;
71046
+ }
71047
+ return popupGroup.register(openController);
71048
+ }, [popupGroup, openController]);
71049
+ const hasBackdrop = pressOutside !== "ignore" && backdrop && !popupGroup;
71050
+ if (!popupGroup && !backdrop && pressOutside === "capture") {
69716
71051
  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".`);
69717
71052
  }
69718
71053
  // positionPopover lives in openEffect's closure — created once, when the
@@ -69776,6 +71111,14 @@ const usePopoverProps = props => {
69776
71111
  if (!popoverEl) {
69777
71112
  return undefined;
69778
71113
  }
71114
+ // First, so the group already stands where it stands in the top layer
71115
+ // when this popover reads its anchor and shows itself above it.
71116
+ popupGroup?.engage({
71117
+ openController,
71118
+ popupElement: popoverEl,
71119
+ pressOutside,
71120
+ event: e
71121
+ });
69779
71122
 
69780
71123
  // Set by useOpenControllerByProps for the very first open triggered by
69781
71124
  // `open`/`defaultOpen` already being truthy at mount — there's nothing
@@ -70063,8 +71406,9 @@ const usePopoverProps = props => {
70063
71406
 
70064
71407
  // With no backdrop of its own to catch a press, the popover hears an
70065
71408
  // outside one from the document and takes nothing from it: it closes, and
70066
- // the press goes on to whatever it landed on (armOutsidePressClose).
70067
- if (!hasBackdrop && (pressOutside === "close" || pressOutside === "cancel")) {
71409
+ // the press goes on to whatever it landed on (armOutsidePressClose). Not
71410
+ // inside a group: the group's wall is up, and the group hears the press.
71411
+ if (!hasBackdrop && !popupGroup && (pressOutside === "close" || pressOutside === "cancel")) {
70068
71412
  addCleanup(armOutsidePressClose(popoverEl, {
70069
71413
  openController,
70070
71414
  pressOutside
@@ -70072,7 +71416,8 @@ const usePopoverProps = props => {
70072
71416
  }
70073
71417
  if (scrollCapture) {
70074
71418
  addCleanup(trapScrollInside(popoverEl, {
70075
- backdrop: backdropEl
71419
+ // A group's ::backdrop covers the page while this popover is open.
71420
+ backdrop: popupGroup ? true : backdropEl
70076
71421
  }));
70077
71422
  }
70078
71423
  if (focusCapture) {
@@ -70292,6 +71637,11 @@ const usePopoverProps = props => {
70292
71637
  // comment (openEffect above) for why hidePopover() alone isn't
70293
71638
  // reliably sufficient once a consumer's own CSS also sets display.
70294
71639
  popoverEl.setAttribute("navi-hidden", "");
71640
+ if (!findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
71641
+ // Before its own focus restore: a group engaged inside it is the
71642
+ // topmost modal, and keeps everything else inert until it goes.
71643
+ closePopupGroupsInside(popoverEl, closeEvent);
71644
+ }
70295
71645
  if (isTopLayer) {
70296
71646
  // Left to its removal when the popover leaves the tree: that hides it
70297
71647
  // and hands the focus to nobody, where hidePopover() hands the focus
@@ -70318,23 +71668,17 @@ const usePopoverProps = props => {
70318
71668
  }
70319
71669
  if (backdropEl) {
70320
71670
  backdropEl.setAttribute("aria-expanded", "false");
70321
- disarmBackdropHideRef.current = armPointerDownOutsideClose(closeEvent, clickEvent => {
71671
+ disarmBackdropHideRef.current = armPointerDownOutsideClose(closeEvent, () => {
71672
+ disarmBackdropHideRef.current = null;
70322
71673
  // Set regardless of isTopLayer — see openEffect's own identical
70323
71674
  // comment for why hidePopover() alone isn't reliably sufficient.
70324
71675
  backdropEl.setAttribute("navi-hidden", "");
70325
71676
  if (isTopLayer) {
70326
71677
  backdropEl.hidePopover();
70327
71678
  }
70328
- if (group && clickEvent) {
70329
- handWallClickToPeer(clickEvent, {
70330
- wallEl: backdropEl,
70331
- popupEl: popoverEl,
70332
- anchorEl: anchorElement,
70333
- group
70334
- });
70335
- }
70336
71679
  });
70337
71680
  }
71681
+ popupGroup?.release(openController, closeEvent);
70338
71682
  restoreFocus?.(closeEvent);
70339
71683
 
70340
71684
  // pickPositionRelativeTo (visible_rect.js) writes data-position-y/x-
@@ -70413,8 +71757,11 @@ const usePopoverProps = props => {
70413
71757
  // Read fresh on every render (not frozen at mount), so it stays
70414
71758
  // correct even across a re-render that happens to occur while open —
70415
71759
  // see contentProps' own identical prop just below for the full
70416
- // reasoning (kept once, not repeated here).
70417
- "navi-hidden": openController.openedInDom ? undefined : "",
71760
+ // reasoning (kept once, not repeated here). The wall outlives its popup
71761
+ // by the press that closed it (armPointerDownOutsideClose): a render
71762
+ // before that press's click must leave it up — a popup's owner commonly
71763
+ // renders on its close, in a microtask the release comes after.
71764
+ "navi-hidden": openController.openedInDom || disarmBackdropHideRef.current ? undefined : "",
70418
71765
  "styleCSSVars": POPUP_STYLE_CSS_VARS,
70419
71766
  "animationDuration": rest.animationDuration,
70420
71767
  "data-press-outside": pressOutside,
@@ -70512,14 +71859,20 @@ const usePopoverProps = props => {
70512
71859
  handlePressOnOutsideRegion(e, {
70513
71860
  popupEl: ref.current,
70514
71861
  openController,
70515
- pressOutside
71862
+ // In a group, the press the group's wall would get.
71863
+ pressOutside: popupGroup ? readPressOutsideInPopupGroup(pressOutside) : pressOutside
70516
71864
  });
70517
71865
  },
70518
71866
  "onKeyDown": e => {
70519
71867
  onKeyDown?.(e);
70520
71868
  onKeyDownShortcuts(e);
70521
71869
  },
70522
- children
71870
+ // A popup opened from inside this one is in front of it, not a peer of
71871
+ // it: the group stops here.
71872
+ "children": popupGroup ? jsx(PopupGroupContext.Provider, {
71873
+ value: null,
71874
+ children: children
71875
+ }) : children
70523
71876
  // onnavi_request_open/onnavi_request_close/onnavi_open_press: for the
70524
71877
  // uncontrolled case,
70525
71878
  // already arrive here as plain props via ...rest (wired by
@@ -70769,10 +72122,6 @@ const Popup = props => {
70769
72122
  scrollCapture,
70770
72123
  positionAreaFixed,
70771
72124
  positionAreaWhenAnchorIsInvalid,
70772
- // A top-layer dialog's wall is its native ::backdrop, which goes with the
70773
- // dialog on the press that closes it: no click is left on it to hand to a
70774
- // peer (see handWallClickToPeer).
70775
- group,
70776
72125
  // Dialog-only, destructured out for the same reason: forwarded below only
70777
72126
  // in the dialog branch, so they never reach the popover element.
70778
72127
  dockedOnSmallTouchScreen,
@@ -70822,7 +72171,6 @@ const Popup = props => {
70822
72171
  positionAreaWhenAnchorIsInvalid: positionAreaWhenAnchorIsInvalid,
70823
72172
  scrollCapture: scrollCapture === "popover" || scrollCapture,
70824
72173
  positionAreaFixed: positionAreaFixed,
70825
- group: group,
70826
72174
  className: withPropsClassName("navi_popup", className),
70827
72175
  expand: expand,
70828
72176
  expandX: expandX,
@@ -71734,7 +73082,10 @@ const PickerCustom = props => {
71734
73082
  }
71735
73083
  return renderResolver(PickerContentInsidePopup, {
71736
73084
  ...pickerProps,
71737
- mode
73085
+ mode,
73086
+ // What a callout's own Escape means (see PickerCalloutPopup): the bubble
73087
+ // answers the key itself when the focus is in it.
73088
+ escapeEffect
71738
73089
  });
71739
73090
  };
71740
73091
 
@@ -71849,18 +73200,14 @@ const PickerContentInsidePopup = props => {
71849
73200
  calloutIcon,
71850
73201
  calloutCloseButton,
71851
73202
  calloutCloseByScroll,
71852
- popupGroup,
73203
+ escapeEffect,
71853
73204
  ...rest
71854
73205
  } = props;
71855
73206
  const isPopover = mode === "popover";
71856
73207
  const isCallout = mode === "callout";
71857
73208
  return jsx(Next, {
71858
73209
  "aria-haspopup": isPopover ? "listbox" : "dialog",
71859
- "navi-popover-mode": isPopover ? popoverMode : undefined
71860
- // On the trigger, whatever the mode: a peer's popup reads it off the
71861
- // press that lands here (see `group` in callout.js).
71862
- ,
71863
- "data-navi-popup-group": popupGroup,
73210
+ "navi-popover-mode": isPopover ? popoverMode : undefined,
71864
73211
  ...rest,
71865
73212
  // On popupProps already (see the picker's popup assembly); they mean
71866
73213
  // nothing to the picker element.
@@ -71901,7 +73248,8 @@ const PickerContentInsidePopup = props => {
71901
73248
  // nothing, so the page looks as pressable as it did before.
71902
73249
  ,
71903
73250
  backdrop: backdrop,
71904
- group: popupGroup,
73251
+ pressOutside: pressOutside,
73252
+ escapeEffect: escapeEffect,
71905
73253
  children: jsx(PopupModeContext.Provider, {
71906
73254
  value: mode,
71907
73255
  children: children
@@ -71938,7 +73286,6 @@ const PickerContentInsidePopup = props => {
71938
73286
  backdropVariant: backdropVariant,
71939
73287
  backdropColor: backdropColor,
71940
73288
  backdropFilter: backdropFilter,
71941
- group: popupGroup,
71942
73289
  focusCapture: isPopover ? focusCapture : undefined,
71943
73290
  expand: isPopover ? undefined : dialogExpand,
71944
73291
  expandX: isPopover ? undefined : dialogExpandX,
@@ -71999,7 +73346,8 @@ const PickerCalloutPopup = ({
71999
73346
  closeButton,
72000
73347
  closeByScroll,
72001
73348
  backdrop,
72002
- group,
73349
+ pressOutside,
73350
+ escapeEffect,
72003
73351
  onnavi_request_open,
72004
73352
  onnavi_request_close,
72005
73353
  onnavi_request_confirm,
@@ -72016,6 +73364,16 @@ const PickerCalloutPopup = ({
72016
73364
  anchor
72017
73365
  });
72018
73366
  const children = contentMounted ? childrenProp : null;
73367
+ // Inside a <PopupGroup> the wall is the group's (see popup_group.jsx): it
73368
+ // decides what a press outside the callout does, and the callout spends and
73369
+ // closes on none of its own.
73370
+ const popupGroup = useContext(PopupGroupContext);
73371
+ useLayoutEffect(() => {
73372
+ if (!popupGroup) {
73373
+ return undefined;
73374
+ }
73375
+ return popupGroup.register(openController);
73376
+ }, [popupGroup, openController]);
72019
73377
  // Reassigned on every render, like Popover's own, so it closes over the
72020
73378
  // latest props.
72021
73379
  openController.getElement = () => pickerRef.current;
@@ -72030,6 +73388,23 @@ const PickerCalloutPopup = ({
72030
73388
  // picker's own input — which is where the data-callout-* attributes a
72031
73389
  // caller puts on the picker land, and where the callout reads them.
72032
73390
  const anchorElement = anchor === pickerRef ? undefined : anchor && "current" in anchor ? anchor.current : anchor;
73391
+ // First, so the group already stands where it stands in the top layer
73392
+ // when the callout reads its anchor and shows itself above it.
73393
+ popupGroup?.engage({
73394
+ openController,
73395
+ // Drawn by the manager below, once the group stands.
73396
+ get popupElement() {
73397
+ return calloutManager.callout.element;
73398
+ },
73399
+ // What a data-navi-popup-inside names this popup by (aria-controls
73400
+ // names it too): the dock, not the callout drawn for it.
73401
+ popupId: id,
73402
+ pressOutside,
73403
+ event: openEvent,
73404
+ // A callout never holds the page still: a scroll is its own
73405
+ // closeByScroll's to answer.
73406
+ pageScrolls: true
73407
+ });
72033
73408
  calloutManager.addOpenToken(PICKER_CALLOUT_CONTENT_TOKEN, {
72034
73409
  message: host,
72035
73410
  // The popup a `popupTestId` names is this callout: it is the surface the
@@ -72041,8 +73416,11 @@ const PickerCalloutPopup = ({
72041
73416
  icon,
72042
73417
  closeButton,
72043
73418
  closeByScroll,
72044
- backdrop,
72045
- group,
73419
+ backdrop: popupGroup ? false : backdrop,
73420
+ closeByPressOutside: popupGroup ? false : undefined,
73421
+ // Inside the group whatever it points at: what is not a descendant of
73422
+ // an engaged group is inert, a bubble drawn there included.
73423
+ containerElement: popupGroup ? pickerEl : undefined,
72046
73424
  anchorElement,
72047
73425
  // The request, chained to the press that made it: the callout reads the
72048
73426
  // mousedown off it to wait for the release before listening for a click
@@ -72051,10 +73429,15 @@ const PickerCalloutPopup = ({
72051
73429
  // Not skipped: the callout moves the focus into the picker when it is
72052
73430
  // elsewhere, which is what lets Escape find the callout right away.
72053
73431
  skipFocus: false,
73432
+ // An Escape the callout answered itself (the focus in the bubble) means
73433
+ // what the picker's own Escape means.
72054
73434
  onClose: ({
72055
- event
73435
+ event,
73436
+ reason
72056
73437
  }) => {
72057
- openController.close(event);
73438
+ openController.close(event, {
73439
+ isCancel: reason === "escape_key" && escapeEffect === "cancel"
73440
+ });
72058
73441
  }
72059
73442
  });
72060
73443
  // A --navi-confirm said inside the callout is aimed at the callout (its
@@ -72072,9 +73455,15 @@ const PickerCalloutPopup = ({
72072
73455
  dock.setAttribute("aria-expanded", "true");
72073
73456
  return closeEvent => {
72074
73457
  calloutElement.removeEventListener("navi_request_confirm", forwardConfirm);
73458
+ if (!findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
73459
+ // Before the content goes back to the dock: a group engaged in there
73460
+ // would leave the top layer with it, its arbiter still listening.
73461
+ closePopupGroupsInside(calloutElement, closeEvent);
73462
+ }
72075
73463
  calloutManager.removeOpenToken(PICKER_CALLOUT_CONTENT_TOKEN, closeEvent);
72076
73464
  dock.appendChild(host);
72077
73465
  dock.setAttribute("aria-expanded", "false");
73466
+ popupGroup?.release(openController, closeEvent);
72078
73467
  };
72079
73468
  };
72080
73469
  return (
@@ -72102,7 +73491,10 @@ const PickerCalloutPopup = ({
72102
73491
  children: jsx("div", {
72103
73492
  ref: hostRef,
72104
73493
  "data-picker-content": "",
72105
- children: children
73494
+ children: popupGroup ? jsx(PopupGroupContext.Provider, {
73495
+ value: null,
73496
+ children: children
73497
+ }) : children
72106
73498
  })
72107
73499
  })
72108
73500
  );
@@ -73626,7 +75018,11 @@ const css$y = /* css */`@layer navi {
73626
75018
  }
73627
75019
  }
73628
75020
 
73629
- & dialog, & [popover] {
75021
+ & [popover] {
75022
+ pointer-events: auto;
75023
+ }
75024
+
75025
+ & dialog:not([navi-popup-group]:not(:modal)) {
73630
75026
  pointer-events: auto;
73631
75027
  }
73632
75028
  }
@@ -74424,7 +75820,7 @@ const css$x = /* css */`@property --list-group-label-height {
74424
75820
  background: var(--list-background-color);
74425
75821
  }
74426
75822
 
74427
- :where([popover], dialog) > .navi_list_container, .navi_list_container[popover] {
75823
+ :where([popover], dialog:not([navi-popup-group])) > .navi_list_container, .navi_list_container[popover] {
74428
75824
  --list-border-width-default: 0px;
74429
75825
  }
74430
75826
 
@@ -82835,6 +84231,24 @@ const PickerButton = props => {
82835
84231
  clearConfirm,
82836
84232
  requestValue
82837
84233
  };
84234
+ // `--navi-select`/`--navi-unselect` about one entry of the list the picker
84235
+ // holds — a chip on the façade, a suggestion beside the field. Answered here
84236
+ // rather than by the control drawing that list in the popup, even though rows
84237
+ // are what such a control owns: a picker given its own value builds its popup
84238
+ // only on first open (see popup_content_mount.js), so before that there is no
84239
+ // such control at all — and building the whole popup to have someone to talk
84240
+ // to, for a cross, is the wrong price. The picker holds the value in the first
84241
+ // place and hands it down whenever the popup is built.
84242
+ //
84243
+ // Heard on both elements, like every request a control answers: a chip inside
84244
+ // the picker aims at its root (the parent control), one standing elsewhere
84245
+ // names it with `commandFor`, and the picker's id is on its input.
84246
+ const onRequestSelect = e => {
84247
+ requestPickerListEntry(ref.current, inputRef.current, e, "select");
84248
+ };
84249
+ const onRequestUnselect = e => {
84250
+ requestPickerListEntry(ref.current, inputRef.current, e, "unselect");
84251
+ };
82838
84252
  return (
82839
84253
  /* Read-only crosses into everything the picker is made of: what it really
82840
84254
  holds is drawn by controls of their own — in the popup, and on the façade
@@ -82895,23 +84309,9 @@ const PickerButton = props => {
82895
84309
  ,
82896
84310
  onnavi_request_open: inputProps.onnavi_request_open,
82897
84311
  onnavi_request_close: inputProps.onnavi_request_close,
82898
- onnavi_open_press: inputProps.onnavi_open_press
82899
- // `--navi-select`/`--navi-unselect` about one entry of the list the
82900
- // picker holds — a chip on the façade, a suggestion beside the field.
82901
- // Answered here rather than by the control drawing that list in the
82902
- // popup, even though rows are what such a control owns: a picker given
82903
- // its own value builds its popup only on first open (see
82904
- // popup_content_mount.js), so before that there is no such control at
82905
- // all — and building the whole popup to have someone to talk to, for a
82906
- // cross, is the wrong price. The picker holds the value in the first
82907
- // place and hands it down whenever the popup is built.
82908
- ,
82909
- onnavi_request_select: e => {
82910
- requestPickerListEntry(ref.current, inputRef.current, e, "select");
82911
- },
82912
- onnavi_request_unselect: e => {
82913
- requestPickerListEntry(ref.current, inputRef.current, e, "unselect");
82914
- },
84312
+ onnavi_open_press: inputProps.onnavi_open_press,
84313
+ onnavi_request_select: onRequestSelect,
84314
+ onnavi_request_unselect: onRequestUnselect,
82915
84315
  children: [jsx("span", {
82916
84316
  className: "navi_picker_box",
82917
84317
  style: hangingChevronSize === undefined ? undefined : {
@@ -82933,6 +84333,8 @@ const PickerButton = props => {
82933
84333
  children: undefined // we will render children into the div
82934
84334
  ,
82935
84335
  ui: ui,
84336
+ onnavi_request_select: onRequestSelect,
84337
+ onnavi_request_unselect: onRequestUnselect,
82936
84338
  onCopy: e => {
82937
84339
  const pickerEl = ref.current;
82938
84340
  if (isWithinPickerContent(e.target, pickerEl)) {
@@ -95819,8 +97221,9 @@ const css = /* css */`.navi_side_panel {
95819
97221
  * interaction only makes sense paired with not letting focus silently
95820
97222
  * leave the panel first. A box of the page whose press must not close the
95821
97223
  * panel (a card that fills it) names the panel:
95822
- * `data-navi-popup-inside={id}`, read when the panel has no wall
95823
- * (`backdrop={false}`) — see docs/popup_backdrop.md.
97224
+ * `data-navi-popup-inside={id}`, read when the panel has no wall of its own
97225
+ * (`backdrop={false}`, or inside a `<PopupGroup>`) — see
97226
+ * docs/popup_backdrop.md.
95824
97227
  * @param {boolean} [props.closeByDrag=true] - Pushing the panel back
95825
97228
  * towards the edge it is docked to closes it: the panel follows the
95826
97229
  * pointer and finishes leaving (or comes back to rest) when it is
@@ -96266,5 +97669,5 @@ const UserSvg = () => jsx("svg", {
96266
97669
  })
96267
97670
  });
96268
97671
 
96269
- 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 };
97672
+ 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 };
96270
97673
  //# sourceMappingURL=jsenv_navi.js.map