@jsenv/navi 0.30.0 → 0.30.3

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, createIterableWeakSet, elementIsFocusable, getScrollContainer, dispatchInternalCustomEvent, dispatchCustomEvent, createEventGroupLogger, findEvent, mergeOneStyle, findFocusable, getElementSignature, chainEvent, isTouchDrivenEvent, getKeyboardEventDefaultAction, isPressDrivenClick, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, createValueEffect, findFocusDelegateTarget, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, getScrollContainerSet, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, keepTouchRefusable, waitForTap, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, dragSourceThatStoodDown, markDragSource, refuseDragTo, startDragTo, installPanZoom, createInternalCustomEvent, activeElementSignal, normalizeStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, trapScrollInside, scrollIntoViewScoped, isPressDisputedByDrag, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, initFocusGroup, stringifyStyle as stringifyStyle$1, isScrollable, resolveOklchLightness, contrastColor, scrollRoomTowards, overflowGoesToViewport, getVirtualKeyboardOverlayHeight, subscribeWindowResizeSettled, subscribeVirtualKeyboardGeometryChange, parsePositionArea, snapToPixel, trapFocusInside, onAncestorReopen, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, setVirtualKeyboardOverlaysContent } from "@jsenv/dom";
6
+ import { createPubSub, createIterableWeakSet, elementIsFocusable, getScrollContainer, dispatchInternalCustomEvent, dispatchCustomEvent, createEventGroupLogger, findEvent, mergeOneStyle, findFocusable, getElementSignature, chainEvent, isTouchDrivenEvent, getKeyboardEventDefaultAction, isPressDrivenClick, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, createValueEffect, findFocusDelegateTarget, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, getScrollContainerSet, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, keepTouchRefusable, waitForTap, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, dragSourceThatStoodDown, markDragSource, refuseDragTo, startDragTo, installPanZoom, createInternalCustomEvent, activeElementSignal, normalizeStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, trapScrollInside, scrollIntoViewScoped, isPressDisputedByDrag, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, initFocusGroup, stringifyStyle as stringifyStyle$1, isScrollable, resolveOklchLightness, contrastColor, scrollRoomTowards, overflowGoesToViewport, getVirtualKeyboardOverlayHeight, subscribeVisualViewportResizeSettled, subscribeVirtualKeyboardGeometryChange, parsePositionArea, snapToPixel, trapFocusInside, onAncestorReopen, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, setVirtualKeyboardOverlaysContent } 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 { useState, useLayoutEffect, useErrorBoundary, useContext, useMemo, useRef, useEffect, useReducer, useCallback } from "preact/hooks";
@@ -41552,6 +41552,73 @@ const publishRouteRowMove = (move) => {
41552
41552
  }
41553
41553
  };
41554
41554
 
41555
+ /**
41556
+ * What a hand holding a row of routes leaves the address unable to say.
41557
+ *
41558
+ * A page not built yet builds by the address moving, so the address runs ahead of
41559
+ * the hand. Nothing is decided while the hand holds the row: whatever says where the
41560
+ * reader is answers from here until the hand lets go.
41561
+ *
41562
+ * Apart from route_travel.jsx so that every link can read it without bringing a row
41563
+ * along.
41564
+ */
41565
+
41566
+
41567
+ /**
41568
+ * The page the reader is still on while a hand holds the row and the address has run
41569
+ * ahead of it, `null` otherwise. A link to a page of the row answers from it by
41570
+ * itself; a counter, a title or a breadcrumb around the box reads it first.
41571
+ *
41572
+ * It goes back to `null` as soon as the hand has let go and the row moves. Nothing
41573
+ * says when the fingers leave a trackpad: there it lasts until the row has come to
41574
+ * rest. A travel no hand set off holds nothing: it goes where it goes.
41575
+ *
41576
+ * One for the document. `route` says which row, for a page holding several boxes.
41577
+ */
41578
+ const routeTravelSettledPageSignal = signal(null);
41579
+
41580
+ // Where the address ran ahead to. A link has to tell it from any other link that
41581
+ // matches the address: an entry of a bottom bar whose route is a section around the
41582
+ // row stays lit.
41583
+ const headingPageSignal = signal(null);
41584
+
41585
+ const holdPage = (page, headingPage) => {
41586
+ headingPageSignal.value = headingPage;
41587
+ if (routeTravelSettledPageSignal.peek() === null) {
41588
+ routeTravelSettledPageSignal.value = page;
41589
+ }
41590
+ };
41591
+
41592
+ const releaseHeldPage = () => {
41593
+ routeTravelSettledPageSignal.value = null;
41594
+ headingPageSignal.value = null;
41595
+ };
41596
+
41597
+ /**
41598
+ * What a link to `route` with `params` answers because a hand holds a row.
41599
+ *
41600
+ * @returns {boolean|undefined} `undefined` when the hold says nothing about this
41601
+ * link, and the address answers.
41602
+ */
41603
+ const readHeldLinkCurrent = (route, params) => {
41604
+ const settledPage = routeTravelSettledPageSignal.value;
41605
+ const headingPage = headingPageSignal.value;
41606
+ if (!settledPage) {
41607
+ return undefined;
41608
+ }
41609
+ // By address: a link may name fewer params than the page it leads to.
41610
+ const leadsTo = (page) =>
41611
+ page.route === route &&
41612
+ route.buildUrl(params) === route.buildUrl(page.params);
41613
+ if (leadsTo(settledPage)) {
41614
+ return true;
41615
+ }
41616
+ if (leadsTo(headingPage)) {
41617
+ return false;
41618
+ }
41619
+ return undefined;
41620
+ };
41621
+
41555
41622
  /**
41556
41623
  * A row of pages the browser scrolls itself, and what is left to read from it.
41557
41624
  *
@@ -41640,12 +41707,21 @@ const readRowStretch = (scroller, pages, page) => {
41640
41707
  * @param {(event: Event|null) => void} options.onLand - the row may have stopped.
41641
41708
  * Read `pageAtRest` to know whether it stopped on a page. Heard more than once
41642
41709
  * for one landing, so what it does must find nothing to do the second time.
41710
+ * @param {(forward: boolean) => void} [options.onLeftToSnap] - a finger has lifted
41711
+ * and the row moves: snapping plays out what the browser has decided and does not
41712
+ * turn round, so this is where it is going. Heard once per lift, after `onScroll`.
41713
+ * A trackpad has no lift, and never says this.
41643
41714
  * @returns {() => void} stop listening.
41644
41715
  */
41645
- const watchRowScroll = (scroller, { onScroll, onLand }) => {
41716
+ const watchRowScroll = (
41717
+ scroller,
41718
+ { onScroll, onLand, onLeftToSnap },
41719
+ ) => {
41646
41720
  let stoppedTimeout = null;
41647
41721
  let touching = false;
41722
+ let liftedAt = null;
41648
41723
  const landOnSilence = () => {
41724
+ liftedAt = null;
41649
41725
  // A finger holding the row still: nothing has stopped.
41650
41726
  if (!touching) {
41651
41727
  onLand(null);
@@ -41658,14 +41734,27 @@ const watchRowScroll = (scroller, { onScroll, onLand }) => {
41658
41734
  const onScrollEvent = () => {
41659
41735
  onScroll();
41660
41736
  armSilence();
41737
+ if (!liftedAt) {
41738
+ return;
41739
+ }
41740
+ // A map moves along one axis at a time, so the two add up to the one it moves on.
41741
+ const moved =
41742
+ scroller.scrollLeft - liftedAt.left + (scroller.scrollTop - liftedAt.top);
41743
+ if (Math.abs(moved) < AT_REST_DISTANCE) {
41744
+ return;
41745
+ }
41746
+ liftedAt = null;
41747
+ onLeftToSnap?.(moved > 0);
41661
41748
  };
41662
41749
  const onTouchStart = () => {
41663
41750
  touching = true;
41751
+ liftedAt = null;
41664
41752
  clearTimeout(stoppedTimeout);
41665
41753
  };
41666
41754
  const onTouchEnd = (touchEvent) => {
41667
41755
  touching = touchEvent.touches.length > 0;
41668
41756
  if (!touching) {
41757
+ liftedAt = { left: scroller.scrollLeft, top: scroller.scrollTop };
41669
41758
  armSilence();
41670
41759
  }
41671
41760
  };
@@ -41764,19 +41853,6 @@ const css$16 = /* css */`.navi_route_travel {
41764
41853
  // neither a navigation nor the requests of a page.
41765
41854
  const SET_OFF_PULL = 16;
41766
41855
 
41767
- /**
41768
- * What the URL says while a hand holds the row is where it is heading, for a page
41769
- * not built yet: it builds by the address moving. A counter, a title or a
41770
- * breadcrumb around the box must not move for a pull that is let go of: it reads
41771
- * this first. The page the reader is still on while that lasts, and `null` when
41772
- * the row has come to rest, which is when `scrollsnapchange` fires and a pager's
41773
- * `settledPage` changes. A travel no hand set off holds nothing: it goes where it
41774
- * goes.
41775
- *
41776
- * One for the document. `route` says which row, for a page holding several boxes.
41777
- */
41778
- const routeTravelSettledPageSignal = signal(null);
41779
-
41780
41856
  /**
41781
41857
  * @type {import("preact").FunctionComponent<{
41782
41858
  * routes?: Array<object|{route: object, params?: object}>,
@@ -41922,6 +41998,33 @@ const RouteTravel = ({
41922
41998
  cause: "drag"
41923
41999
  });
41924
42000
  };
42001
+
42002
+ // The page the row goes to, once nothing is left to the hand.
42003
+ const askForPageOfRow = (index, movement) => {
42004
+ const row = rowRef.current;
42005
+ const page = latestRef.current.pages[index];
42006
+ if (windowed && !movement.pictures.has(index)) {
42007
+ leavePictureOfLive(movement);
42008
+ }
42009
+ Promise.resolve(askFor(page)).then(() => {
42010
+ if (pageIsCurrent(page)) {
42011
+ return;
42012
+ }
42013
+ // Refused, or sent elsewhere: the row says where the address is.
42014
+ askedPageRef.current = null;
42015
+ const {
42016
+ currentIndex
42017
+ } = latestRef.current;
42018
+ const paneCurrent = panesOf(row)[currentIndex];
42019
+ if (paneCurrent) {
42020
+ movement.travelling = true;
42021
+ row.scrollTo({
42022
+ left: paneCurrent.offsetLeft,
42023
+ behavior: "smooth"
42024
+ });
42025
+ }
42026
+ });
42027
+ };
41925
42028
  const sayRowMoves = () => {
41926
42029
  const row = rowRef.current;
41927
42030
  const panes = panesOf(row);
@@ -41954,7 +42057,7 @@ const RouteTravel = ({
41954
42057
  movementRef.current = null;
41955
42058
  row.removeAttribute("data-moving");
41956
42059
  publishRouteRowMove(null);
41957
- routeTravelSettledPageSignal.value = null;
42060
+ releaseHeldPage();
41958
42061
  if (liveRef.current) {
41959
42062
  liveRef.current.style.top = "";
41960
42063
  }
@@ -42100,11 +42203,38 @@ const RouteTravel = ({
42100
42203
  } else if (paneTowards.hasAttribute("data-built")) {
42101
42204
  return;
42102
42205
  }
42103
- if (routeTravelSettledPageSignal.peek() === null) {
42104
- routeTravelSettledPageSignal.value = pages[currentIndex];
42105
- }
42206
+ holdPage(pages[currentIndex], page);
42106
42207
  askFor(page);
42107
42208
  };
42209
+ // The address, the tabs and what reads the held page all move here, the way they
42210
+ // do for a tab pressed. If the row ends up elsewhere after all, onLand corrects
42211
+ // the address when it rests.
42212
+ const onLeftToSnap = forward => {
42213
+ const movement = movementRef.current;
42214
+ const {
42215
+ pages,
42216
+ travelByDrag
42217
+ } = latestRef.current;
42218
+ if (!movement || movement.travelling || !travelByDrag) {
42219
+ return;
42220
+ }
42221
+ const panes = panesOf(row);
42222
+ const {
42223
+ base,
42224
+ toward
42225
+ } = readRowStretch(row, panes, panes[0]);
42226
+ const index = panes.indexOf(forward && toward ? toward : base);
42227
+ const page = pages[index];
42228
+ if (!page) {
42229
+ return;
42230
+ }
42231
+ if (!pageIsCurrent(page) && !samePage(askedPageRef.current, page)) {
42232
+ askForPageOfRow(index, movement);
42233
+ }
42234
+ // After the address: a pull coming back to its page must not light the tab the
42235
+ // address ran ahead to, even for a render.
42236
+ releaseHeldPage();
42237
+ };
42108
42238
  const onLand = () => {
42109
42239
  const movement = movementRef.current;
42110
42240
  if (!movement) {
@@ -42129,37 +42259,19 @@ const RouteTravel = ({
42129
42259
  if (samePage(askedPageRef.current, page)) {
42130
42260
  return;
42131
42261
  }
42132
- if (windowed && !movement.pictures.has(panes.indexOf(pane))) {
42133
- leavePictureOfLive(movement);
42134
- }
42135
- Promise.resolve(askFor(page)).then(() => {
42136
- if (pageIsCurrent(page)) {
42137
- return;
42138
- }
42139
- // Refused, or sent elsewhere: the row says where the address is.
42140
- askedPageRef.current = null;
42141
- const {
42142
- currentIndex
42143
- } = latestRef.current;
42144
- const paneCurrent = panesOf(row)[currentIndex];
42145
- if (paneCurrent) {
42146
- movement.travelling = true;
42147
- row.scrollTo({
42148
- left: paneCurrent.offsetLeft,
42149
- behavior: "smooth"
42150
- });
42151
- }
42152
- });
42262
+ askForPageOfRow(panes.indexOf(pane), movement);
42153
42263
  };
42154
42264
  const stopWatching = watchRowScroll(row, {
42155
42265
  onScroll,
42156
- onLand
42266
+ onLand,
42267
+ onLeftToSnap
42157
42268
  });
42158
42269
  return () => {
42159
42270
  stopWatching();
42160
42271
  movementRef.current?.resumeScrollRecording?.();
42161
42272
  movementRef.current = null;
42162
42273
  publishRouteRowMove(null);
42274
+ releaseHeldPage();
42163
42275
  };
42164
42276
  }, [windowed]);
42165
42277
 
@@ -56845,7 +56957,8 @@ const NavContext = createContext(null);
56845
56957
 
56846
56958
  /**
56847
56959
  * Whether the page one is on is `route` with `params` — what a link or a button
56848
- * pointing at a route shows as current.
56960
+ * pointing at a route shows as current. While a hand holds a row of routes, the
56961
+ * page one is on is the one held, wherever the address has run ahead to.
56849
56962
  *
56850
56963
  * Read through a computed of that one answer, so the component reading it
56851
56964
  * re-renders when the answer flips, and not whenever the route's status moves:
@@ -56897,6 +57010,10 @@ const readRouteCurrent = (route, params, exceptRoutes, alsoRoutes) => {
56897
57010
  someAlsoMatching = true;
56898
57011
  }
56899
57012
  }
57013
+ const heldAnswer = readHeldLinkCurrent(route, params);
57014
+ if (heldAnswer !== undefined) {
57015
+ return heldAnswer;
57016
+ }
56900
57017
  if (someExceptMatching) {
56901
57018
  return false;
56902
57019
  }
@@ -75094,72 +75211,73 @@ const popupCss = /* css */ `
75094
75211
  `;
75095
75212
 
75096
75213
  /**
75097
- * A modal dialog covered by another modal dialog is not in use, so it does not
75098
- * answer the keyboard: a sheet raising one leaves what is behind it where it
75099
- * is, under the keyboard like anything else behind that sheet.
75100
- *
75101
- * Two things make an open dialog follow the keyboard, and both are held here
75102
- * for as long as it is covered:
75103
- * - its ceiling (--dialog-maxmax-height), which CSS keeps tracking
75104
- * --navi-app-height. Written back resolved, inline. `freezeSize` cannot do it:
75105
- * a max-* wins over the width/height it writes;
75214
+ * A popup covered by a modal dialog is not in use, so it does not answer the
75215
+ * keyboard: a sheet raising one leaves what is behind it where it is, under the
75216
+ * keyboard like anything else behind that sheet.
75217
+ *
75218
+ * Two things make an open popup follow the keyboard, and both are held here for
75219
+ * as long as it is covered:
75220
+ * - its ceiling (--dialog-maxmax-height, --popover-maxmax-height), which CSS keeps
75221
+ * tracking --navi-app-height. Written back resolved, inline. `freezeSize` cannot
75222
+ * do it: a max-* wins over the width/height it writes;
75106
75223
  * - its placement, which reads the live viewport. Deferred, see
75107
75224
  * deferPlacementWhileCovered.
75108
75225
  *
75109
- * A dialog back on top answers the screen as it is, at once, with one exception:
75110
- * a keyboard on its way out. It is still counted for a while after the sheet
75111
- * closes (the browser takes its time, then --navi-vvh waits for the resize to
75112
- * settle, see responsive.js), and answering that makes the dialog jump up and
75226
+ * A popup uncovered answers the screen as it is, at once, with one
75227
+ * exception: a keyboard on its way out. It is still counted for a while after the
75228
+ * sheet closes (the browser takes its time, then --navi-vvh waits for the resize
75229
+ * to settle, see responsive.js), and answering that makes the popup jump up and
75113
75230
  * back down. A keyboard belongs to a focused field, so the focus says which case
75114
75231
  * this is: on a field the keyboard stays, anywhere else it is leaving and the
75115
- * dialog waits for the viewport to change. No timer: how long a keyboard takes
75116
- * to leave is unknown, and a duration would also delay a keyboard that stays.
75232
+ * popup waits for the viewport to change. No timer: how long a keyboard takes to
75233
+ * leave is unknown, and a duration would also delay a keyboard that stays.
75117
75234
  *
75118
- * Only modal dialogs stack here. What sits under a non-modal one is still
75119
- * usable, and keeps following the keyboard.
75235
+ * Modal dialogs hold what is below them. What sits under a non-modal one is
75236
+ * still usable, and keeps following the keyboard. A top-layer popover can be
75237
+ * held and holds nothing: it follows its anchor, and that anchor may be inside
75238
+ * the popup below, whose rising is what carries a field in the popover above the
75239
+ * keyboard.
75120
75240
  */
75121
75241
 
75122
75242
 
75123
- const CEILING = "--dialog-maxmax-height";
75124
-
75125
- const modalStack = [];
75243
+ const coverStack = [];
75126
75244
 
75127
75245
  /**
75128
- * @param {HTMLDialogElement} dialogEl - The modal dialog being shown.
75246
+ * @param {HTMLElement} popupEl - The popup being shown.
75129
75247
  * @param {object} params
75130
- * @param {() => void} params.place - Places the dialog against the viewport as
75131
- * it is. Called on a dialog that was held once it answers the viewport again.
75132
- * @returns {() => void} To call when the dialog closes.
75248
+ * @param {string} params.ceiling - The custom property capping its height.
75249
+ * @param {boolean} params.holdsBelow - Whether it covers what is below it.
75250
+ * @param {() => void} params.place - Places the popup against the viewport as
75251
+ * it is. Called on a popup that was held once it answers the viewport again.
75252
+ * @returns {() => void} To call when the popup closes.
75133
75253
  */
75134
- const enterModalStack = (dialogEl, { place }) => {
75135
- const entry = { dialogEl, place, hold: null };
75136
- const entryBelow = modalStack.at(-1);
75137
- if (entryBelow) {
75138
- holdEntry(entryBelow);
75254
+ const enterCoverStack = (popupEl, { ceiling, holdsBelow, place }) => {
75255
+ const entry = { popupEl, ceiling, holdsBelow, place, hold: null };
75256
+ if (holdsBelow) {
75257
+ // All of them: one under a popover was not held when that popover opened.
75258
+ for (const entryBelow of coverStack) {
75259
+ holdEntry(entryBelow);
75260
+ }
75139
75261
  }
75140
- modalStack.push(entry);
75262
+ coverStack.push(entry);
75141
75263
  return () => {
75142
- const index = modalStack.indexOf(entry);
75143
- const wasOnTop = index === modalStack.length - 1;
75144
- modalStack.splice(index, 1);
75264
+ coverStack.splice(coverStack.indexOf(entry), 1);
75145
75265
  dropHold(entry);
75146
- if (wasOnTop && modalStack.length > 0) {
75147
- releaseBackOnTop(modalStack.at(-1), { closedDialogEl: dialogEl });
75148
- }
75266
+ releaseUncovered({ closedPopupEl: popupEl });
75149
75267
  };
75150
75268
  };
75151
75269
 
75152
75270
  /**
75153
- * Answers true when the dialog must not be placed right now, and remembers to
75271
+ * Answers true when the popup must not be placed right now, and remembers to
75154
75272
  * place it on release.
75155
75273
  *
75156
75274
  * Only while the room differs from the one it was covered with: the placement
75157
75275
  * reads the live viewport whatever triggered it, so with the same room it lands
75158
- * where the dialog already is, and content growing under the sheet is answered
75276
+ * where the popup already is, and content growing under the sheet is answered
75159
75277
  * as usual.
75160
75278
  */
75161
- const deferPlacementWhileCovered = (dialogEl) => {
75162
- const entry = modalStack.find((candidate) => candidate.dialogEl === dialogEl);
75279
+ const deferPlacementWhileCovered = (popupEl) => {
75280
+ const entry = coverStack.find((candidate) => candidate.popupEl === popupEl);
75163
75281
  if (!entry || !entry.hold) {
75164
75282
  return false;
75165
75283
  }
@@ -75178,35 +75296,63 @@ const holdEntry = (entry) => {
75178
75296
  entry.hold.stopWaiting = null;
75179
75297
  return;
75180
75298
  }
75181
- const { dialogEl, place } = entry;
75182
- holdCeiling(dialogEl);
75299
+ const { place } = entry;
75300
+ holdCeiling(entry);
75183
75301
  entry.hold = {
75184
75302
  room: readRoom(),
75185
75303
  placementOwed: false,
75186
75304
  stopWaiting: null,
75187
- // The keyboard does not fire this one (see safe_area.md): this is the
75188
- // window itself changing, a rotation, which a covered dialog has to fit.
75189
- stopWatchingWindow: subscribeWindowResizeSettled(() => {
75190
- holdCeiling(dialogEl);
75191
- entry.hold.room = readRoom();
75192
- entry.hold.placementOwed = false;
75305
+ windowWidth: window.innerWidth,
75306
+ windowHeight: window.innerHeight,
75307
+ // A rotation, which a covered dialog has to fit, is told from a keyboard by
75308
+ // the window's size: the keyboard leaves it alone (see safe_area.md). Read
75309
+ // once the visual viewport settles, not on the window's own resize: that one
75310
+ // can fire first, while --navi-vvh still describes the screen being left.
75311
+ stopWatchingWindow: subscribeVisualViewportResizeSettled(() => {
75312
+ const { hold } = entry;
75313
+ if (
75314
+ window.innerWidth === hold.windowWidth &&
75315
+ window.innerHeight === hold.windowHeight
75316
+ ) {
75317
+ return;
75318
+ }
75319
+ hold.windowWidth = window.innerWidth;
75320
+ hold.windowHeight = window.innerHeight;
75321
+ holdCeiling(entry);
75322
+ hold.room = readRoom();
75323
+ hold.placementOwed = false;
75193
75324
  place();
75194
75325
  }),
75195
75326
  };
75196
75327
  };
75197
75328
 
75198
- const releaseBackOnTop = (entry, { closedDialogEl }) => {
75329
+ // From the top down to the first popup that holds what is below it: nothing
75330
+ // above covers any of these.
75331
+ const releaseUncovered = ({ closedPopupEl }) => {
75332
+ for (let i = coverStack.length - 1; i >= 0; i--) {
75333
+ const entry = coverStack[i];
75334
+ releaseBackOnTop(entry, { closedPopupEl });
75335
+ if (entry.holdsBelow) {
75336
+ return;
75337
+ }
75338
+ }
75339
+ };
75340
+
75341
+ const releaseBackOnTop = (entry, { closedPopupEl }) => {
75199
75342
  const { hold } = entry;
75200
75343
  if (!hold) {
75201
75344
  return;
75202
75345
  }
75346
+ // Already waiting when a popover above it closes in turn: this close asks again.
75347
+ hold.stopWaiting?.();
75348
+ hold.stopWaiting = null;
75203
75349
  // Nothing taken away, or a keyboard that stays: the room read here is the
75204
- // screen as it is. A field in the dialog that closed is one still leaving: a
75205
- // dialog leaving the tree only gives the focus back after the commit.
75350
+ // screen as it is. A field in the popup that closed is one still leaving: a
75351
+ // popup leaving the tree only gives the focus back after the commit.
75206
75352
  const { activeElement } = document;
75207
75353
  if (
75208
75354
  readRoom() >= hold.room ||
75209
- (isEditableTarget(activeElement) && !closedDialogEl.contains(activeElement))
75355
+ (isEditableTarget(activeElement) && !closedPopupEl.contains(activeElement))
75210
75356
  ) {
75211
75357
  release(entry);
75212
75358
  return;
@@ -75252,25 +75398,25 @@ const release = (entry) => {
75252
75398
  };
75253
75399
 
75254
75400
  const dropHold = (entry) => {
75255
- const { hold, dialogEl } = entry;
75401
+ const { hold, popupEl, ceiling } = entry;
75256
75402
  if (!hold) {
75257
75403
  return;
75258
75404
  }
75259
75405
  hold.stopWaiting?.();
75260
75406
  hold.stopWatchingWindow();
75261
- dialogEl.style.removeProperty(CEILING);
75407
+ popupEl.style.removeProperty(ceiling);
75262
75408
  entry.hold = null;
75263
75409
  };
75264
75410
 
75265
75411
  // The declared ceiling rather than the used max-height: it is the one cap that
75266
75412
  // follows the keyboard, and the other caps (maxHeight) keep their say. Its
75267
75413
  // computed value has every var() resolved, so it stops following once written
75268
- // back. Registered with inherits: false, it does not reach a dialog nested
75269
- // inside this one.
75270
- const holdCeiling = (dialogEl) => {
75271
- dialogEl.style.removeProperty(CEILING);
75272
- const ceiling = getComputedStyle(dialogEl).getPropertyValue(CEILING);
75273
- dialogEl.style.setProperty(CEILING, ceiling);
75414
+ // back. Registered with inherits: false, it does not reach a popup nested inside
75415
+ // this one.
75416
+ const holdCeiling = ({ popupEl, ceiling }) => {
75417
+ popupEl.style.removeProperty(ceiling);
75418
+ const value = getComputedStyle(popupEl).getPropertyValue(ceiling);
75419
+ popupEl.style.setProperty(ceiling, value);
75274
75420
  };
75275
75421
 
75276
75422
  // An overlaying keyboard leaves the viewport alone and arrives on its own.
@@ -77706,7 +77852,9 @@ const useDialogProps = props => {
77706
77852
  }
77707
77853
  rectEffect.observeSize(dialogEl);
77708
77854
  if (isModal) {
77709
- addCleanup(enterModalStack(dialogEl, {
77855
+ addCleanup(enterCoverStack(dialogEl, {
77856
+ ceiling: "--dialog-maxmax-height",
77857
+ holdsBelow: true,
77710
77858
  // "resize": what it was held from is a viewport change, and it answers
77711
77859
  // the way it answers one, animated. Called directly because the
77712
77860
  // effect's dedup already saw that viewport go by.
@@ -78284,6 +78432,10 @@ const css$C = /* css */`
78284
78432
  syntax: "*";
78285
78433
  inherits: false;
78286
78434
  }
78435
+ @property --popover-maxmax-height {
78436
+ syntax: "*";
78437
+ inherits: false;
78438
+ }
78287
78439
  @property --x-popover-max-width {
78288
78440
  syntax: "*";
78289
78441
  inherits: false;
@@ -79659,27 +79811,37 @@ const usePopoverProps = props => {
79659
79811
  popoverEl.showPopover();
79660
79812
  }));
79661
79813
  }
79662
- const rectEffect = visibleRectEffect(effectiveAnchor, ({
79663
- visibilityRatio
79664
- }, {
79665
- event
79666
- }) => {
79814
+ let anchorVisibilityRatio = 1;
79815
+ const placeUnlessAnchorOutOfView = placeEvent => {
79667
79816
  // Only a real anchor can meaningfully go "out of view" — gating on
79668
79817
  // document.documentElement's own visibilityRatio (used for
79669
79818
  // anchorless/docked popups) would wrongly skip positioning on a
79670
79819
  // tall page, since its ratio is often low even when nothing's
79671
79820
  // hidden. hasAnchorElement is already false for the custom
79672
79821
  // renderer, so this never triggers for it either.
79673
- if (hasAnchorElement && visibilityRatio <= 0.2) {
79822
+ if (hasAnchorElement && anchorVisibilityRatio <= 0.2) {
79674
79823
  popoverEl.setAttribute("data-anchor-out-of-view", "");
79675
- return;
79824
+ return false;
79676
79825
  }
79677
79826
  popoverEl.removeAttribute("data-anchor-out-of-view");
79678
- positionPopover(event);
79827
+ positionPopover(placeEvent);
79828
+ return true;
79829
+ };
79830
+ const rectEffect = visibleRectEffect(effectiveAnchor, ({
79831
+ visibilityRatio
79832
+ }, {
79833
+ event
79834
+ }) => {
79835
+ anchorVisibilityRatio = visibilityRatio;
79836
+ // Before the out-of-view rule: a keyboard going over the anchor, behind
79837
+ // the sheet that raised it, hides nothing.
79838
+ if (deferPlacementWhileCovered(popoverEl)) {
79839
+ return;
79840
+ }
79679
79841
  // Same as Dialog's own — a popup with a scrolling body and a footer
79680
79842
  // swallows the field it just got shorter around. See
79681
79843
  // keepFocusedElementVisible's own doc in popup_shared.js.
79682
- if (mayHaveHiddenFocus(event)) {
79844
+ if (placeUnlessAnchorOutOfView(event) && mayHaveHiddenFocus(event)) {
79683
79845
  keepFocusedElementVisible(popoverEl);
79684
79846
  }
79685
79847
  }, {
@@ -79692,6 +79854,21 @@ const usePopoverProps = props => {
79692
79854
  // while open (e.g. an expand/collapse toggle inside it) — not just when
79693
79855
  // the anchor itself moves/resizes/re-anchors.
79694
79856
  rectEffect.observeSize(popoverEl);
79857
+ // Whatever its own backdrop: a modal dialog above leaves all of the page
79858
+ // inert, a popover without a wall included. layer="local" is left out, its
79859
+ // ceiling answers to its container.
79860
+ if (isTopLayer) {
79861
+ addCleanup(enterCoverStack(popoverEl, {
79862
+ ceiling: "--popover-maxmax-height",
79863
+ holdsBelow: false,
79864
+ // "resize": see Dialog's own.
79865
+ place: () => {
79866
+ if (placeUnlessAnchorOutOfView(new CustomEvent("resize"))) {
79867
+ keepFocusedElementVisible(popoverEl);
79868
+ }
79869
+ }
79870
+ }));
79871
+ }
79695
79872
  // Content that sizes itself once on screen (a shrink-wrapped BadgeList)
79696
79873
  // says so, after the placement read it: placed again on the spot, where
79697
79874
  // observeSize answers a frame late. Stopped here, since a popup further up
@@ -107973,10 +108150,10 @@ const css = /* css */`.navi_side_panel {
107973
108150
  height: var(--navi-side-panel-height, auto);
107974
108151
 
107975
108152
  &[data-layer="top"] {
107976
- --popover-max-height: var(--navi-app-height);
107977
108153
  --popover-maxmax-height: var(--navi-app-height);
108154
+ --popover-max-height: var(--popover-maxmax-height);
107978
108155
  --popover-maxmax-width: var(--navi-app-width);
107979
- --dialog-maxmax-height: var(--navi-app-height);
108156
+ --dialog-maxmax-height: var(--popover-maxmax-height);
107980
108157
  --dialog-maxmax-width: var(--navi-app-width);
107981
108158
 
107982
108159
  &[navi-side="left"], &[navi-side="right"] {