@jsenv/navi 0.29.416 → 0.29.417

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@
3
3
  * using @jsenv/navi as intended.
4
4
  */
5
5
  import { installImportMetaCssBuild, coarsePointerSignal, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
6
- import { createPubSub, elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, 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";
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";
7
7
  export { chainEvent, clickIsSuppressed, contrastColor, createDragGestureController, dragAfterIntent, findEvent, markDragSource, startDragTo } from "@jsenv/dom";
8
8
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
9
9
  import { options, isValidElement, createContext, render, h, toChildArray, createRef, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
@@ -15245,6 +15245,572 @@ const useOpenControllerByProps = (props, name) => {
15245
15245
  return openController;
15246
15246
  };
15247
15247
 
15248
+ /**
15249
+ * Small, renderer-agnostic helpers shared by Popover and Dialog's own custom
15250
+ * (non-top-layer) renderers — operate on a plain DOM element, no knowledge
15251
+ * of which of the two owns it.
15252
+ */
15253
+
15254
+
15255
+ /**
15256
+ * Whether a visibleRectEffect delivery is one that can have taken height away
15257
+ * from a popup, and so pushed whatever holds focus out of sight:
15258
+ * - "resize": the window/visual viewport settled — which is also how the
15259
+ * on-screen keyboard arrives, overlay or not (window_size.js);
15260
+ * - ELEMENT_SIZE_CHANGE: the popup's own box measured different;
15261
+ * - "focusin": the focus-settled re-measure, for the room that changes with
15262
+ * nothing announcing it (subscribeFocusSettled in window_size.js).
15263
+ *
15264
+ * Everything else is a scroll of one kind or another, where nothing got
15265
+ * smaller and scrolling the focused element back would fight the very gesture
15266
+ * that fired it.
15267
+ */
15268
+ const mayHaveHiddenFocus = (event) => {
15269
+ const type = event?.type;
15270
+ return (
15271
+ type === "resize" || type === ELEMENT_SIZE_CHANGE || type === "focusin"
15272
+ );
15273
+ };
15274
+
15275
+ const scrollportHeightMap = new WeakMap();
15276
+ /**
15277
+ * Scrolls whatever holds focus inside `popupEl` back into view, if the popup
15278
+ * getting shorter has pushed it out.
15279
+ *
15280
+ * The case this exists for: a field low in the scrolling body of a popup that
15281
+ * also has a footer (box.jsx — with a body, the body is the only thing that
15282
+ * scrolls and the footer is a sibling sitting right under it). Focusing the
15283
+ * field makes the browser scroll it into view, which it does against the
15284
+ * popup's height AT THAT MOMENT; the on-screen keyboard then opens and takes
15285
+ * that height away. The body shrinks, its scrollTop does not move, so the
15286
+ * content slides down relative to the shorter scrollport and the field ends up
15287
+ * past its bottom edge — visually, swallowed by the footer. The browser does
15288
+ * not redo a scroll-into-view it already answered, so this does.
15289
+ *
15290
+ * Only what the shrink itself hid, though: with the keyboard up the user reads
15291
+ * the rest of the popup by scrolling the field away — to reach the submit under
15292
+ * it, typically — and the room keeps changing while they do (a keyboard settling
15293
+ * in two steps, a suggestion strip, a browser bar). Answering each of those by
15294
+ * scrolling the field back takes the popup away from wherever they had just
15295
+ * scrolled it, over and over: what they were reading cannot be reached at all
15296
+ * without blurring the field first, and the popup reads as unscrollable. So the
15297
+ * field is brought back only when it was in view before the room shrank, which
15298
+ * one remembered number answers: a resize moves neither scrollTop nor the
15299
+ * element's offset inside the scrolled content, so measuring against the height
15300
+ * the scrollport HAD is measuring the state before the change.
15301
+ *
15302
+ * Scoped to the field's own scroll container (never the page): a popup traps
15303
+ * scrolling precisely so the document underneath cannot move, and a plain
15304
+ * scrollIntoView walks past a container whose scrollbar isn't visible — see
15305
+ * scrollIntoViewScoped's own doc.
15306
+ *
15307
+ * "nearest": the smallest scroll that makes it visible, and none at all when it
15308
+ * already is. Where it lands is the container's own business — a navi scroller
15309
+ * keeps a band free at its edges so a field never comes back glued to one (see
15310
+ * scroll-padding in box.jsx).
15311
+ */
15312
+ const keepFocusedElementVisible = (popupEl) => {
15313
+ const { activeElement } = document;
15314
+ if (!activeElement || activeElement === popupEl) {
15315
+ return;
15316
+ }
15317
+ if (!popupEl.contains(activeElement)) {
15318
+ return;
15319
+ }
15320
+ const scrollContainer = getScrollContainer(activeElement);
15321
+ if (!scrollContainer || !popupEl.contains(scrollContainer)) {
15322
+ // What scrolls the field is outside the popup, which means the page: a
15323
+ // popup holds it still on purpose (trapScrollInside), so there is nothing
15324
+ // here to scroll back.
15325
+ return;
15326
+ }
15327
+ const scrollportHeight = scrollContainer.clientHeight;
15328
+ const scrollportHeightBefore = scrollportHeightMap.get(scrollContainer);
15329
+ scrollportHeightMap.set(scrollContainer, scrollportHeight);
15330
+ if (scrollportHeightBefore !== undefined) {
15331
+ if (scrollportHeight >= scrollportHeightBefore) {
15332
+ // Nothing was taken away, so nothing was hidden by this.
15333
+ return;
15334
+ }
15335
+ if (
15336
+ !isVisibleInScrollport(
15337
+ activeElement,
15338
+ scrollContainer,
15339
+ scrollportHeightBefore,
15340
+ )
15341
+ ) {
15342
+ return;
15343
+ }
15344
+ }
15345
+ scrollIntoViewScoped(activeElement, {
15346
+ container: scrollContainer,
15347
+ block: "nearest",
15348
+ });
15349
+ };
15350
+
15351
+ // Whether any part of `el` was showing in `container` back when its scrollport
15352
+ // was `scrollportHeight` tall. Both boxes are read now: the container may have
15353
+ // moved as well as shrunk (a centered dialog re-centers itself), and the
15354
+ // difference between the two tops is what that move leaves alone.
15355
+ const isVisibleInScrollport = (el, container, scrollportHeight) => {
15356
+ const elRect = el.getBoundingClientRect();
15357
+ const containerRect = container.getBoundingClientRect();
15358
+ const elTop = elRect.top - containerRect.top + container.scrollTop;
15359
+ const elBottom = elTop + elRect.height;
15360
+ const scrollportTop = container.scrollTop;
15361
+ const scrollportBottom = scrollportTop + scrollportHeight;
15362
+ return elBottom > scrollportTop && elTop < scrollportBottom;
15363
+ };
15364
+
15365
+ /**
15366
+ * Calls `onSettled` once `el`'s current CSS transition is over — via
15367
+ * `transitionend`, with a safety `setTimeout` fallback matching the longest
15368
+ * `transition-duration`, in case nothing actually transitions or an event is
15369
+ * missed.
15370
+ *
15371
+ * Returns a "cancel" function, so a caller whose instance has been superseded
15372
+ * (a fresh open/close about to set its own state) can keep this stale one from
15373
+ * firing later. Cancelling only stops `onSettled`: undoing whatever the caller
15374
+ * did up front is that fresh call's business, not this one's.
15375
+ */
15376
+ const whenTransitionSettles = (el, onSettled) => {
15377
+ let settled = false;
15378
+ const onTransitionEnd = (transitionEvent) => {
15379
+ if (transitionEvent.target === el) {
15380
+ finish();
15381
+ }
15382
+ };
15383
+ const stopWatching = () => {
15384
+ settled = true;
15385
+ el.removeEventListener("transitionend", onTransitionEnd);
15386
+ clearTimeout(safetyTimeoutId);
15387
+ };
15388
+ const finish = () => {
15389
+ if (settled) {
15390
+ return;
15391
+ }
15392
+ stopWatching();
15393
+ onSettled();
15394
+ };
15395
+ el.addEventListener("transitionend", onTransitionEnd);
15396
+ const durationsInSeconds = getComputedStyle(el)
15397
+ .transitionDuration.split(",")
15398
+ .map((value) => parseFloat(value) || 0);
15399
+ const longestDurationMs = Math.max(0, ...durationsInSeconds) * 1000;
15400
+ const safetyTimeoutId = setTimeout(finish, longestDurationMs + 50);
15401
+ return () => {
15402
+ if (settled) {
15403
+ return;
15404
+ }
15405
+ stopWatching();
15406
+ };
15407
+ };
15408
+
15409
+ /**
15410
+ * Drops the document's text selection when it lives inside `el`, leaving a
15411
+ * selection made elsewhere on the page alone.
15412
+ *
15413
+ * A popup being closed takes its content with it, and a selection is a claim
15414
+ * on content the user can still act on: kept, it would outlive the surface it
15415
+ * was made on — painted on the box for the length of its exit transition, with
15416
+ * the handles and the copy toolbar a phone draws from the live selection
15417
+ * hanging over something that is going away — and reappear with the box on
15418
+ * the next opening. Dropping the selection is the only way to remove that
15419
+ * chrome: a `user-select: none` on the closing box hides the highlight in some
15420
+ * browsers only, and the handles are the browser's own, drawn from the
15421
+ * selection object rather than from any style.
15422
+ */
15423
+ const clearTextSelectionInside = (el) => {
15424
+ const selection = window.getSelection();
15425
+ if (!selection || selection.rangeCount === 0) {
15426
+ return;
15427
+ }
15428
+ const range = selection.getRangeAt(0);
15429
+ if (!el.contains(range.commonAncestorContainer)) {
15430
+ return;
15431
+ }
15432
+ selection.removeAllRanges();
15433
+ };
15434
+
15435
+ /**
15436
+ * Disables pointer-events on `el` until its current CSS transition settles —
15437
+ * avoids the cursor changing/something becoming clickable while the popup is
15438
+ * still visually moving into or out of place.
15439
+ *
15440
+ * Returns whenTransitionSettles' own "cancel" function: it doesn't restore
15441
+ * pointer-events, since a fresh call for the next open/close is about to set
15442
+ * its own state.
15443
+ */
15444
+ const suppressPointerEventsDuringTransition = (el) => {
15445
+ el.style.pointerEvents = "none";
15446
+ return whenTransitionSettles(el, () => {
15447
+ el.style.pointerEvents = "";
15448
+ });
15449
+ };
15450
+
15451
+ /**
15452
+ * Hides the backdrop, deferring until the browser's matching "click" fires
15453
+ * when `closeEvent` was triggered by a mousedown (see popover.jsx's top
15454
+ * comment for why) — same capture-phase-on-document pattern as
15455
+ * armSuppressNextOpenRequest in open_controller.js, which a plain timeout
15456
+ * can't safely replace: mouseup (and the click that follows it) can land an
15457
+ * arbitrarily long time after mousedown (the user is still holding the
15458
+ * button down), so a short timeout can fire first and hide the backdrop
15459
+ * before its own click ever arrives. A capture-phase listener on document
15460
+ * fires for every click regardless of what any bubble-phase handler does
15461
+ * downstream, so no fallback timer is needed.
15462
+ *
15463
+ * `hide` is the caller's own way to actually hide the backdrop
15464
+ * (`hidePopover()` for a top-layer backdrop, a plain `style.display = "none"`
15465
+ * for a plain div) — this helper only owns the mousedown/click timing.
15466
+ *
15467
+ * Returns a disarm function (or undefined if hidden immediately), so a
15468
+ * fresh open can cancel a pending hide it's about to make redundant.
15469
+ */
15470
+ const armPointerDownOutsideClose = (closeEvent, hide) => {
15471
+ const mousedownEvent = findEvent(closeEvent, "mousedown");
15472
+ if (!mousedownEvent) {
15473
+ hide();
15474
+ return undefined;
15475
+ }
15476
+ // `hide` is handed the click, for a wall with peers to re-aim it (see
15477
+ // handWallClickToPeer).
15478
+ const onClick = (clickEvent) => {
15479
+ document.removeEventListener("click", onClick, { capture: true });
15480
+ hide(clickEvent);
15481
+ };
15482
+ document.addEventListener("click", onClick, { capture: true });
15483
+ return () => {
15484
+ document.removeEventListener("click", onClick, { capture: true });
15485
+ };
15486
+ };
15487
+
15488
+ /**
15489
+ * Maps a positionArea y/x pair to a concrete `navi-animation` value (a
15490
+ * `prefix` plus a direction word), or `null` if both axes overlap the anchor
15491
+ * (no direction at all — that's `resolvedAnimationKind === "scaling"`
15492
+ * territory instead, see resolveAutoAnimationKind below).
15493
+ *
15494
+ * `prefix: "slide-from"` (used with no real anchor — Dialog always, Popover
15495
+ * when docked) keeps the word as the compass direction the popup comes
15496
+ * from: placed "top" (a point/corner), it slides in from the top.
15497
+ * `prefix: "expand"` (a real anchor, Popover-only) uses the motion/growth
15498
+ * direction instead, the opposite compass point: placed "top" of the
15499
+ * anchor, it moves/grows up, away from the anchor (which sits below it).
15500
+ *
15501
+ * "inset-*"/"center" contribute no direction on their axis either way.
15502
+ */
15503
+ const resolveDirectionValue = (y, x, { prefix }) => {
15504
+ const yWord =
15505
+ y === "top"
15506
+ ? prefix === "expand"
15507
+ ? "up"
15508
+ : "top"
15509
+ : y === "bottom"
15510
+ ? prefix === "expand"
15511
+ ? "down"
15512
+ : "bottom"
15513
+ : null;
15514
+ const xWord = x === "left" ? "left" : x === "right" ? "right" : null;
15515
+ if (!yWord && !xWord) {
15516
+ return null;
15517
+ }
15518
+ return yWord && xWord
15519
+ ? `${prefix}-${yWord}-${xWord}`
15520
+ : `${prefix}-${yWord || xWord}`;
15521
+ };
15522
+
15523
+ /**
15524
+ * Shared `animation="auto"`/`true` resolution: "scaling" reads best overall
15525
+ * — picked for any real anchor, or for a point/corner placed dead-center
15526
+ * (both positionArea axes overlapping — there's no sensible direction to
15527
+ * slide from in that case). "sliding" otherwise. `anchor` is `undefined`
15528
+ * for any no-anchor/docked case (Dialog always, Popover's own custom
15529
+ * renderer when there's no real anchor), so this collapses to "scaling"
15530
+ * there only for the dead-center case, "sliding" otherwise. The two
15531
+ * "overlapping" booleans below describe the *positionArea* itself (a bare
15532
+ * word vs. "inset-"/"center"), not anything about the anchor — they'd
15533
+ * mean exactly the same thing even with no anchor at all, since it's the
15534
+ * position strategy, not the anchor, that decides whether there's a
15535
+ * direction to slide from.
15536
+ */
15537
+ const resolveAutoAnimationKind = (anchor, parsedPositionArea) => {
15538
+ const yIsOverlapping =
15539
+ parsedPositionArea.y !== "top" && parsedPositionArea.y !== "bottom";
15540
+ const xIsOverlapping =
15541
+ parsedPositionArea.x !== "left" && parsedPositionArea.x !== "right";
15542
+ return anchor || (yIsOverlapping && xIsOverlapping) ? "scaling" : "sliding";
15543
+ };
15544
+
15545
+ /**
15546
+ * A popup reads "outside" from its own border box: what a press lands on
15547
+ * decides nothing on its own (a genuine backdrop press and a press on the
15548
+ * popup's padding both report the popup element as their target, there being
15549
+ * no real ::backdrop node to be one), so the rectangle is what tells them
15550
+ * apart. That holds as long as the box and what the popup paints are the same
15551
+ * thing — which stops being true for a popup with no surface of its own
15552
+ * (`backgroundColor="transparent"`, no shadow, no padding): what the eye reads
15553
+ * as backdrop is then inside the box, and a press there stays a press on the
15554
+ * popup.
15555
+ *
15556
+ * `data-navi-popup-outside` is how a caller says which of its own boxes are
15557
+ * not the surface. It is opt-in because navi cannot infer it — a background
15558
+ * can come from anywhere — while the caller who made the popup see-through
15559
+ * knows exactly which box is decoration and which is paper.
15560
+ *
15561
+ * The marker answers for the element it is on, never for its descendants: a
15562
+ * box painted inside a marked one is still surface, so a row can be marked and
15563
+ * its empty halves read as backdrop while presses on the controls it holds do
15564
+ * not dismiss anything. A descendant with `pointer-events: none` never becomes
15565
+ * a press target at all, so the marked box answers in its place — while the
15566
+ * same declaration on the marked box itself takes it out of hit-testing and
15567
+ * makes the marker unreachable (see warnAboutUnreachableOutsideRegions).
15568
+ */
15569
+ const OUTSIDE_REGION_ATTRIBUTE = "data-navi-popup-outside";
15570
+
15571
+ /**
15572
+ * `data-navi-popup-inside` is the other direction: a box of the PAGE the
15573
+ * caller says belongs to a popup, so a press on it is not outside that popup
15574
+ * even though it lies beyond the border box — a card whose press is what
15575
+ * fills the one panel of a board. Its value names the popup by `id`, the way
15576
+ * `commandfor` does (several ids, space separated), because the exemption is
15577
+ * one popup's: a menu open beside the board still closes on the same press.
15578
+ *
15579
+ * Unlike the outside marker it answers for its whole subtree: the outside
15580
+ * marker carves free space out of a surface whose controls stay surface, this
15581
+ * one adds a whole thing — the card, its count, its buttons — to the popup's
15582
+ * own ground, and nothing in it is a dismissal.
15583
+ */
15584
+ const INSIDE_ATTRIBUTE = "data-navi-popup-inside";
15585
+
15586
+ /**
15587
+ * `data-navi-popup-group` names the peers of a popup: the triggers of the other
15588
+ * popups a press goes to from this one — a menu bar, where pressing another
15589
+ * menu while one is open switches to it, in that one press. A trigger joins by
15590
+ * carrying the name (a Picker's `popupGroup` writes it on its own), and a popup
15591
+ * reads it off what the press landed on.
15592
+ *
15593
+ * Returns the peer `target` is in, or null.
15594
+ */
15595
+ const findPopupPeer = (target, group) => {
15596
+ if (!group || !target || typeof target.closest !== "function") {
15597
+ return null;
15598
+ }
15599
+ return target.closest(`[data-navi-popup-group="${CSS.escape(group)}"]`);
15600
+ };
15601
+
15602
+ /**
15603
+ * The click a wall took, handed to the peer (see findPopupPeer) it covered.
15604
+ *
15605
+ * A wall cannot let one press through and not another: it is what the pointer
15606
+ * hits, and that is the whole of how it spends a press. So it goes on taking
15607
+ * every one — the page behind stays as unhoverable and unpressable as it looks
15608
+ * — and once the press is over, its click is re-aimed at what the wall stood
15609
+ * over at that point, when that is a peer. The click alone, for the reason
15610
+ * transition_press.js gives: a pointer stream made up with no pointer behind it
15611
+ * starts gestures (a hold, a drag) that nothing ever ends, and a click is what
15612
+ * opens a popup's trigger.
15613
+ *
15614
+ * Called with the click that takes the wall down after a press closed the
15615
+ * popup (see armPointerDownOutsideClose), so the peer opens as the popup it
15616
+ * replaces goes.
15617
+ */
15618
+ const handWallClickToPeer = (
15619
+ clickEvent,
15620
+ { wallEl, popupEl, anchorEl, group },
15621
+ ) => {
15622
+ if (!clickEvent.isTrusted || clickEvent.target !== wallEl) {
15623
+ // A click dispatched by hand is already aimed, and one that did not land
15624
+ // on the wall (a press on the popup's own outside region) was no wall's.
15625
+ return;
15626
+ }
15627
+ // The topmost element under the wall at that point: what the press would
15628
+ // have landed on with no wall. Asked past the wall rather than after hiding
15629
+ // it, which a fading wall may not be yet.
15630
+ const covered = document
15631
+ .elementsFromPoint(clickEvent.clientX, clickEvent.clientY)
15632
+ .find((el) => el !== wallEl && !popupEl.contains(el));
15633
+ const peer = findPopupPeer(covered, group);
15634
+ if (!peer) {
15635
+ return;
15636
+ }
15637
+ if (peer.contains(popupEl) || (anchorEl && peer.contains(anchorEl))) {
15638
+ // The popup's own trigger — the one holding it (a picker renders its
15639
+ // popup inside itself) or the one it opened against: the press through
15640
+ // the wall closed the popup, and handing the trigger its click would open
15641
+ // it again.
15642
+ return;
15643
+ }
15644
+ // The page sees one click, on the peer, rather than two.
15645
+ clickEvent.stopPropagation();
15646
+ // A PointerEvent where the browser makes clicks of them, a MouseEvent
15647
+ // elsewhere: the same kind, copied field by field.
15648
+ const ClickEvent = clickEvent.constructor;
15649
+ covered.dispatchEvent(new ClickEvent(clickEvent.type, clickEvent));
15650
+ };
15651
+
15652
+ /**
15653
+ * What a press landing on a region the caller declared as not-its-surface
15654
+ * does: exactly what the same press on the backdrop would do.
15655
+ *
15656
+ * Lives on the popup's own content element rather than in the backdrop's
15657
+ * handler, for both renderers: the backdrop is a sibling behind the popup
15658
+ * (never an ancestor), so a press inside the popup's box never reaches it.
15659
+ */
15660
+ const handlePressOnOutsideRegion = (
15661
+ mouseDownEvent,
15662
+ { popupEl, openController, pressOutside },
15663
+ ) => {
15664
+ if (mouseDownEvent.button !== 0) {
15665
+ return;
15666
+ }
15667
+ const { target } = mouseDownEvent;
15668
+ if (!target.hasAttribute(OUTSIDE_REGION_ATTRIBUTE)) {
15669
+ return;
15670
+ }
15671
+ if (openedDuringThisPress(openController)) {
15672
+ // The release of the press that opened it is not somebody dismissing it
15673
+ // (see openedDuringThisPress).
15674
+ return;
15675
+ }
15676
+ // A popup opens inside its opener's own subtree, so a press on a region
15677
+ // belonging to a popup nested in this one bubbles through here too — and it
15678
+ // is a press on what is in front, not on this popup's own decoration.
15679
+ if (
15680
+ target.closest(`[navi-control="dialog"], [navi-control="popover"]`) !==
15681
+ popupEl
15682
+ ) {
15683
+ return;
15684
+ }
15685
+ if (pressOutside === "capture") {
15686
+ mouseDownEvent.preventDefault();
15687
+ return;
15688
+ }
15689
+ if (pressOutside === "close" || pressOutside === "cancel") {
15690
+ openController.requestClose(mouseDownEvent, {
15691
+ isCancel: pressOutside === "cancel",
15692
+ });
15693
+ }
15694
+ };
15695
+
15696
+ /**
15697
+ * Hears an outside press from the document itself, for a popup with no
15698
+ * backdrop of its own to catch one: a modal `<dialog>` (whose native
15699
+ * `::backdrop` dispatches nothing a handler can be put on) and any popup the
15700
+ * caller asked to leave the page reachable (`backdrop={false}`).
15701
+ *
15702
+ * The press is read and never taken: no `preventDefault`, no
15703
+ * `stopPropagation`, and the listener is on `document` rather than on
15704
+ * anything covering the page — so what the press landed on hears it too, in
15705
+ * the same gesture, and the popup only takes note. That is the whole point of
15706
+ * a popup with no wall: a press outside is a press on the page, and the page
15707
+ * answers it. The one popup where that cannot hold is the modal dialog, where
15708
+ * the browser has already made everything behind inert before this runs.
15709
+ *
15710
+ * `pressEventType` says which event IS the press, and the two callers want
15711
+ * opposite ones:
15712
+ *
15713
+ * - `"pointerdown"` for a popup with no wall: a page with no wall over it is
15714
+ * a page whose own elements arbitrate their presses, and cancelling a
15715
+ * `pointerdown` — what a drag source and a control keeping the focus where
15716
+ * it is both do — suppresses every compatibility mouse event that would have
15717
+ * followed it. The press happens, reaches its target, and no `mousedown` is
15718
+ * ever dispatched for it. Reading the pointer event is reading the press
15719
+ * itself, whatever anything does with it afterwards, and it is the same
15720
+ * event the drag and surface detectors read.
15721
+ * - `"mousedown"` for the modal dialog, whose native `::backdrop` exists to
15722
+ * spend the press. The page's controls act on `mousedown`, and on a touch
15723
+ * screen that is not the same moment as `pointerdown`: the compatibility
15724
+ * mouse events are synthesized at `touchend` and hit-tested again then.
15725
+ * Closing on `pointerdown` takes the `::backdrop` away before that second
15726
+ * hit-test, so the `mousedown` lands on whatever the page holds under the
15727
+ * finger, and one tap both dismisses the dialog and presses the page. Read
15728
+ * on `mousedown`, the wall is still up when the press the page acts on is
15729
+ * aimed, and it catches it. Nothing behind a modal wall can cancel that
15730
+ * press, since nothing behind it hears the `pointerdown` at all.
15731
+ *
15732
+ * Capture phase, so a handler downstream that stops propagation cannot keep
15733
+ * the popup open either — the press is still outside whatever is made of it.
15734
+ *
15735
+ * Returns a cleanup that removes the listener.
15736
+ */
15737
+ const armOutsidePressClose = (
15738
+ popupEl,
15739
+ { openController, pressOutside, pressEventType = "pointerdown" },
15740
+ ) => {
15741
+ const onDocumentPointerDown = (pointerDownEvent) => {
15742
+ if (pointerDownEvent.button !== 0) {
15743
+ return;
15744
+ }
15745
+ if (openedDuringThisPress(openController)) {
15746
+ // The release of the press that opened it is not somebody dismissing it
15747
+ // (see openedDuringThisPress).
15748
+ return;
15749
+ }
15750
+ // The press landed inside another popup: that is a press on what is in
15751
+ // front, not outside. Asking the target where it lives rather than asking
15752
+ // this popup whether it was pushed — a popup in front does not have to be
15753
+ // one this popup knows about. In front means neither inside nor around: a
15754
+ // popup nested in this one falls to the containment check below as the
15755
+ // inside press it is, and one this popup opened INSIDE (a bubble rendered
15756
+ // in a sheet's content) is around it, so what that popup holds is outside
15757
+ // this one exactly like the rest of the page.
15758
+ const popupUnderPointer = pointerDownEvent.target.closest?.(
15759
+ `[navi-control="dialog"], [navi-control="popover"]`,
15760
+ );
15761
+ if (
15762
+ popupUnderPointer &&
15763
+ popupUnderPointer !== popupEl &&
15764
+ !popupEl.contains(popupUnderPointer) &&
15765
+ !popupUnderPointer.contains(popupEl)
15766
+ ) {
15767
+ return;
15768
+ }
15769
+ if (
15770
+ popupEl.id &&
15771
+ pointerDownEvent.target.closest?.(
15772
+ `[${INSIDE_ATTRIBUTE}~="${CSS.escape(popupEl.id)}"]`,
15773
+ )
15774
+ ) {
15775
+ return;
15776
+ }
15777
+ // Real DOM containment wins over the rectangle below — an element
15778
+ // genuinely inside the popup (`overflow: visible`, a negative margin, an
15779
+ // absolutely-positioned child) can be painted outside its border box, and
15780
+ // a press there is not outside just because its coordinates are. The
15781
+ // popup element itself is excluded (contains() answers true for it): a
15782
+ // press on a modal dialog's native backdrop reports the dialog as its
15783
+ // target, there being no `::backdrop` node to be one, so treating that as
15784
+ // contained would keep the rectangle from ever running.
15785
+ if (
15786
+ pointerDownEvent.target !== popupEl &&
15787
+ popupEl.contains(pointerDownEvent.target)
15788
+ ) {
15789
+ return;
15790
+ }
15791
+ const rect = popupEl.getBoundingClientRect();
15792
+ const isOutside =
15793
+ pointerDownEvent.clientX < rect.left ||
15794
+ pointerDownEvent.clientX > rect.right ||
15795
+ pointerDownEvent.clientY < rect.top ||
15796
+ pointerDownEvent.clientY > rect.bottom;
15797
+ if (!isOutside) {
15798
+ return;
15799
+ }
15800
+ openController.requestClose(pointerDownEvent, {
15801
+ isCancel: pressOutside === "cancel",
15802
+ });
15803
+ };
15804
+ document.addEventListener(pressEventType, onDocumentPointerDown, {
15805
+ capture: true,
15806
+ });
15807
+ return () => {
15808
+ document.removeEventListener(pressEventType, onDocumentPointerDown, {
15809
+ capture: true,
15810
+ });
15811
+ };
15812
+ };
15813
+
15248
15814
  /**
15249
15815
  * Typography reset shared by the surfaces a control opens: callout, popover,
15250
15816
  * dialog.
@@ -15465,6 +16031,24 @@ let calloutCount = 0;
15465
16031
  // large enough to survive the pixel or two a trackpad rests on.
15466
16032
  const CLOSE_BY_SCROLL_DISTANCE = 10;
15467
16033
 
16034
+ // The events of a press a `backdrop` callout spends, up to its click (which
16035
+ // closes the callout and is spent there), and `pointercancel`, which says the
16036
+ // browser took the press. No touch events: they are the scroll's, and what
16037
+ // listens to them — a list learning the user took its scroll over — is
16038
+ // watching the finger, not answering the press.
16039
+ const SPENT_PRESS_EVENT_TYPES = [
16040
+ "pointerdown",
16041
+ "pointerup",
16042
+ "pointercancel",
16043
+ "mousedown",
16044
+ "mouseup",
16045
+ "contextmenu",
16046
+ ];
16047
+ // Where a press lands on something drawn over the page rather than on the page
16048
+ // itself: a popup of navi's (either layer) or a native top-layer element.
16049
+ const LAYER_SELECTOR =
16050
+ '[popover], dialog, [navi-control="popover"], [navi-control="dialog"]';
16051
+
15468
16052
  const css$14 = /* css */ `
15469
16053
  /* jsenv-css-opaque: same as dialog.jsx, cascade order. */
15470
16054
  @layer navi {
@@ -15753,6 +16337,17 @@ const css$14 = /* css */ `
15753
16337
  * the callout opened, so scrolling back cancels nothing but a round trip shorter than the
15754
16338
  * distance leaves it open. Unlike an outside click it also dismisses an "error" callout: the
15755
16339
  * caller asked for it explicitly.
16340
+ * @param {boolean} [options.backdrop=false] - Spend an outside press on closing the callout,
16341
+ * the way a popup's wall spends it: no link followed, no control pressed, no focus moved, no
16342
+ * hold or drag started under it. Without it that press closes the callout and is answered by
16343
+ * what it landed on too. Not a wall, though: the scroll a finger starts beside the callout
16344
+ * still scrolls, a press on a layer drawn over the page (a sheet the callout's content
16345
+ * opened) is that layer's, and a press that does not close the callout (an "error" one) is
16346
+ * not spent either
16347
+ * @param {string} [options.group] - With `backdrop`: the name the callout's peers carry in
16348
+ * `data-navi-popup-group` — the triggers of other popups, callouts or not. A press inside a
16349
+ * peer still closes the callout but is not spent, so the peer it landed on opens its own
16350
+ * popup in the same press — a menu bar, where pressing another menu switches to it
15756
16351
  * @param {Event} [options.openingEvent] - The gesture the callout belongs to. It names the
15757
16352
  * opener — `currentTarget` while the event is still dispatching, `target` once it is over,
15758
16353
  * so awaiting before opening changes nothing — and the opener is the one part of the anchor
@@ -15817,6 +16412,8 @@ const openCallout = (
15817
16412
  closeByPressOutside = status === "info",
15818
16413
  closeOnFocusLeave = closeByPressOutside,
15819
16414
  closeByScroll = false,
16415
+ backdrop = false,
16416
+ group,
15820
16417
  closeButton = true,
15821
16418
  openingEvent,
15822
16419
  reopen = "toggle",
@@ -15901,6 +16498,9 @@ const openCallout = (
15901
16498
  reason,
15902
16499
  });
15903
16500
  };
16501
+ // Asked again at each press rather than once: `update` changes both.
16502
+ const pressOutsideCloses = () =>
16503
+ closeByPressOutside && callout.status !== "error";
15904
16504
  const onRequestClose = (event) => {
15905
16505
  if (!callout.opened) {
15906
16506
  return;
@@ -15909,10 +16509,7 @@ const openCallout = (
15909
16509
  const clickOrSpaceOutside =
15910
16510
  reason === "click_outside" || reason === "space_outside";
15911
16511
  if (clickOrSpaceOutside) {
15912
- if (!closeByPressOutside) {
15913
- return;
15914
- }
15915
- if (callout.status === "error") {
16512
+ if (!pressOutsideCloses()) {
15916
16513
  return;
15917
16514
  }
15918
16515
  } else if (reason === "focus_leave") {
@@ -16190,16 +16787,36 @@ const openCallout = (
16190
16787
  }
16191
16788
  return openerElement === target || openerElement.contains(target);
16192
16789
  };
16790
+ // Whether an outside press on `target` is spent on closing the callout
16791
+ // (`backdrop`). What is not the page behind the callout keeps its press: a
16792
+ // layer the callout is not part of — its own page may well be a sheet, a
16793
+ // sheet its content opened is in front of it — and a peer, which the press
16794
+ // is meant to open.
16795
+ const spendsPressOn = (target) => {
16796
+ if (!backdrop || !pressOutsideCloses()) {
16797
+ return false;
16798
+ }
16799
+ const layer = target.closest?.(LAYER_SELECTOR);
16800
+ if (layer && !layer.contains(calloutElement)) {
16801
+ return false;
16802
+ }
16803
+ if (findPopupPeer(target, group)) {
16804
+ return false;
16805
+ }
16806
+ return true;
16807
+ };
16808
+ const spend = (event) => {
16809
+ debug(event, `outside press spent on closing the callout (backdrop)`);
16810
+ event.preventDefault();
16811
+ event.stopPropagation();
16812
+ };
16193
16813
  const handleClickOutside = (event) => {
16194
16814
  if (event.button !== 0) {
16195
16815
  // right click
16196
16816
  return;
16197
16817
  }
16198
16818
  const clickTarget = event.target;
16199
- if (
16200
- clickTarget === calloutElement ||
16201
- calloutElement.contains(clickTarget)
16202
- ) {
16819
+ if (calloutElement.contains(clickTarget)) {
16203
16820
  return;
16204
16821
  }
16205
16822
  if (isInsideOpener(clickTarget)) {
@@ -16212,14 +16829,20 @@ const openCallout = (
16212
16829
  debug(event, `click on opener, let its handler decide`);
16213
16830
  return;
16214
16831
  }
16832
+ // Read before the close, which takes the callout out of the document —
16833
+ // and out of the layer it stands in, for spendsPressOn to find.
16834
+ const spent = spendsPressOn(clickTarget);
16215
16835
  requestClose(event, "click_outside");
16836
+ if (spent) {
16837
+ spend(event);
16838
+ }
16216
16839
  };
16217
16840
  const handleSpaceOutside = (event) => {
16218
16841
  if (event.key !== " ") {
16219
16842
  return;
16220
16843
  }
16221
16844
  const keyTarget = event.target;
16222
- if (keyTarget === calloutElement || calloutElement.contains(keyTarget)) {
16845
+ if (calloutElement.contains(keyTarget)) {
16223
16846
  return;
16224
16847
  }
16225
16848
  if (isInsideOpener(keyTarget)) {
@@ -16228,7 +16851,41 @@ const openCallout = (
16228
16851
  debug(event, `space on opener, let its handler decide`);
16229
16852
  return;
16230
16853
  }
16854
+ const spent = spendsPressOn(keyTarget);
16231
16855
  requestClose(event, "space_outside");
16856
+ if (spent) {
16857
+ spend(event);
16858
+ }
16859
+ };
16860
+ // The press before its click: a click ends a gesture whose pointerdown
16861
+ // already reached the page, and what acts on the press itself — a hold, a
16862
+ // drag, the focus moving — would otherwise happen under a callout that only
16863
+ // closes on the click. Decided at the pointerdown and kept for the whole
16864
+ // press, wherever its later events land: a release beside the callout ends
16865
+ // a drag started inside it, and must reach it.
16866
+ let pressSpent = false;
16867
+ const handlePressOutside = (event) => {
16868
+ if (event.type === "pointerdown") {
16869
+ const pressTarget = event.target;
16870
+ pressSpent =
16871
+ event.button === 0 &&
16872
+ !calloutElement.contains(pressTarget) &&
16873
+ !isInsideOpener(pressTarget) &&
16874
+ spendsPressOn(pressTarget);
16875
+ } else if (event.type === "pointercancel") {
16876
+ // The browser took the press over — a scroll — and nothing follows it.
16877
+ pressSpent = false;
16878
+ return;
16879
+ }
16880
+ if (!pressSpent) {
16881
+ return;
16882
+ }
16883
+ if (event.type === "mousedown" || event.type === "contextmenu") {
16884
+ // What the press does on its own: the focus moving, a selection
16885
+ // starting, the menu of a held finger.
16886
+ event.preventDefault();
16887
+ }
16888
+ event.stopPropagation();
16232
16889
  };
16233
16890
 
16234
16891
  const registerClickOutsideListener = () => {
@@ -16238,6 +16895,16 @@ const openCallout = (
16238
16895
  document.removeEventListener("click", handleClickOutside, true);
16239
16896
  document.removeEventListener("keydown", handleSpaceOutside, true);
16240
16897
  });
16898
+ if (backdrop) {
16899
+ for (const type of SPENT_PRESS_EVENT_TYPES) {
16900
+ document.addEventListener(type, handlePressOutside, true);
16901
+ }
16902
+ addTeardown(() => {
16903
+ for (const type of SPENT_PRESS_EVENT_TYPES) {
16904
+ document.removeEventListener(type, handlePressOutside, true);
16905
+ }
16906
+ });
16907
+ }
16241
16908
  };
16242
16909
  // A callout opened during a press (mousedown or pointerdown) must wait for the
16243
16910
  // matching release before listening for click-outside, otherwise the same
@@ -17351,7 +18018,7 @@ const generateSvgWithoutArrow = (width, height) => {
17351
18018
  *
17352
18019
  * Usage:
17353
18020
  * const myToken = createOpenToken();
17354
- * calloutManager.addOpenToken(myToken, { message, status, testId, closeByScroll, anchorElement, event, skipFocus, onClose });
18021
+ * calloutManager.addOpenToken(myToken, { message, status, testId, closeByScroll, backdrop, group, anchorElement, event, skipFocus, onClose });
17355
18022
  * calloutManager.removeOpenToken(myToken, event);
17356
18023
  * calloutManager.requestCloseCallout(event, debugReason); // force-close all
17357
18024
  * calloutManager.callout // current open callout or null
@@ -17409,6 +18076,8 @@ const createCalloutManager = (
17409
18076
  closeButton: tokenData.closeButton,
17410
18077
  closeByPressOutside: tokenData.status !== "error",
17411
18078
  closeByScroll: tokenData.closeByScroll,
18079
+ backdrop: tokenData.backdrop,
18080
+ group: tokenData.group,
17412
18081
  anchorElement,
17413
18082
  openingEvent: event,
17414
18083
  skipFocus: tokenData.skipFocus,
@@ -17537,6 +18206,8 @@ const createCalloutManager = (
17537
18206
  icon,
17538
18207
  closeButton,
17539
18208
  closeByScroll,
18209
+ backdrop,
18210
+ group,
17540
18211
  anchorElement,
17541
18212
  event,
17542
18213
  skipFocus,
@@ -17554,6 +18225,8 @@ const createCalloutManager = (
17554
18225
  icon,
17555
18226
  closeButton,
17556
18227
  closeByScroll,
18228
+ backdrop,
18229
+ group,
17557
18230
  skipFocus,
17558
18231
  onClose,
17559
18232
  // Resolved as the token is added, and kept with it: a token shown later
@@ -23644,7 +24317,16 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
23644
24317
  }
23645
24318
  return itemSignal;
23646
24319
  };
23647
- const upsert = (...args) => {
24320
+ // A value changes a row when it is another object — the app writing a new
24321
+ // one says something changed, and that is taken as said. An answer says it
24322
+ // with every object nested in its rows: JSON.parse makes them all new, the
24323
+ // ones that did not move included, so a revalidation returning the same
24324
+ // rows would replace every one of them for every reader. What an answer
24325
+ // sends again equal in depth is the value already held (see upsertAnswer).
24326
+ const valueChangedByWrite = (newValue, oldValue) => newValue !== oldValue;
24327
+ const valueChangedByAnswer = (newValue, oldValue) =>
24328
+ newValue !== oldValue && !compareTwoJsValues(newValue, oldValue);
24329
+ const upsertWith = (args, valueChanged) => {
23648
24330
  const mutationsMap = new Map(); // Map<itemId, propertyMutations>
23649
24331
  const triggerPropertyMutations = () => {
23650
24332
  for (const itemPropertiesObserver of itemPropertiesObserverSet) {
@@ -23708,7 +24390,27 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
23708
24390
  const newValue = props[key];
23709
24391
  if (itemOwnKeys.includes(key)) {
23710
24392
  const oldValue = item[key];
23711
- if (newValue !== oldValue) {
24393
+ const { set } = itemOwnPropertyDescriptors[key];
24394
+ if (set) {
24395
+ // A relation (.one(), .many() and their scoped forms in
24396
+ // resource_graph.js): the row holds which children it points to,
24397
+ // the children live in their own store, and whoever reads them
24398
+ // follows them there. Its setter writes them and says whether the
24399
+ // row points elsewhere now. Compared as a value instead, the
24400
+ // children received are never the object the row hands out, and
24401
+ // every write naming the relation would replace the row.
24402
+ if (set.call(itemWithProps, newValue)) {
24403
+ hasChanges = true;
24404
+ propertyMutations[key] = {
24405
+ oldValue,
24406
+ newValue,
24407
+ target: item,
24408
+ newTarget: itemWithProps,
24409
+ };
24410
+ }
24411
+ continue;
24412
+ }
24413
+ if (valueChanged(newValue, oldValue)) {
23712
24414
  hasChanges = true;
23713
24415
  itemWithProps[key] = newValue;
23714
24416
  propertyMutations[key] = {
@@ -23860,6 +24562,9 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
23860
24562
  triggerPropertyMutations();
23861
24563
  return item;
23862
24564
  };
24565
+ const upsert = (...args) => upsertWith(args, valueChangedByWrite);
24566
+ // The same, for the result of a callback — what the server answered.
24567
+ const upsertAnswer = (...args) => upsertWith(args, valueChangedByAnswer);
23863
24568
  const drop = (...args) => {
23864
24569
  const removedItemArray = [];
23865
24570
  const triggerRemovedMutations = () => {
@@ -24036,6 +24741,7 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
24036
24741
  selectAll,
24037
24742
  itemSignalForId,
24038
24743
  upsert,
24744
+ upsertAnswer,
24039
24745
  drop,
24040
24746
 
24041
24747
  observeItemProperties,
@@ -24793,7 +25499,7 @@ const createRangeReader = (
24793
25499
  `${actionName} must return { items, start, count }, received ${describeResult(result)}.`,
24794
25500
  );
24795
25501
  }
24796
- const items = store.upsert(result.items);
25502
+ const items = store.upsertAnswer(result.items);
24797
25503
  let { start, count } = result;
24798
25504
  if (start === undefined) {
24799
25505
  const startAsked = rangeParams.start;
@@ -25454,7 +26160,7 @@ const createResource = (
25454
26160
  : // GET/PUT contract (see .one() JSDoc): the parent object with the
25455
26161
  // relationship nested inside, or null for no relationship.
25456
26162
  (result) => {
25457
- const item = store.upsert(result);
26163
+ const item = store.upsertAnswer(result);
25458
26164
  const childItem = item[propertyName];
25459
26165
  return childItem ? childItem[childIdKey] : undefined;
25460
26166
  };
@@ -25635,8 +26341,8 @@ const createResource = (
25635
26341
  : (childData) => {
25636
26342
  // an array is [property, value, props], used to rename the child id
25637
26343
  const childItem = Array.isArray(childData)
25638
- ? childStore.upsert(...childData)
25639
- : childStore.upsert(childData);
26344
+ ? childStore.upsertAnswer(...childData)
26345
+ : childStore.upsertAnswer(childData);
25640
26346
  return childItem[childIdKey];
25641
26347
  };
25642
26348
  const throwInvalidResult = createInvalidResultThrower(
@@ -25678,7 +26384,7 @@ const createResource = (
25678
26384
  ? (result) => {
25679
26385
  // GET_MANY contract (see .many() JSDoc): the parent object with
25680
26386
  // the child array nested inside; the array replaces the relationship.
25681
- const item = store.upsert(result);
26387
+ const item = store.upsertAnswer(result);
25682
26388
  const childItemArray = item[propertyName];
25683
26389
  return childItemArray.map((childItem) => childItem[childIdKey]);
25684
26390
  }
@@ -25709,7 +26415,7 @@ const createResource = (
25709
26415
  return deletedChildItemIdArray;
25710
26416
  }
25711
26417
  : (childDataArray) => {
25712
- const childItemArray = childStore.upsert(childDataArray);
26418
+ const childItemArray = childStore.upsertAnswer(childDataArray);
25713
26419
  return childItemArray.map((childItem) => childItem[childIdKey]);
25714
26420
  };
25715
26421
  const throwInvalidResult = createInvalidResultThrower(
@@ -25848,18 +26554,37 @@ const createResource = (
25848
26554
  childSetup(childItem);
25849
26555
  }
25850
26556
  const childSignal = signal(null);
26557
+ // Returns whether the child changed — what the store asks a relation
26558
+ // (see assign in array_signal_store.js). Assigned in place, the child
26559
+ // tells nobody itself: a prop it receives changed is a change of the
26560
+ // owner's row, which is how its readers see it.
25851
26561
  const applyProps = (props) => {
25852
26562
  if (!props) {
26563
+ if (childSignal.peek() === null) {
26564
+ return false;
26565
+ }
25853
26566
  childSignal.value = null;
25854
- return;
26567
+ return true;
25855
26568
  }
25856
- // Assign each prop in place. Reactive setters (from chained .one() etc.) will fire.
26569
+ let changed = false;
26570
+ // Assign each prop in place. Reactive setters (from chained .one()
26571
+ // etc.) fire, and say whether their relation changed.
25857
26572
  for (const [key, value] of Object.entries(props)) {
25858
- childItem[key] = value;
26573
+ const descriptor = Object.getOwnPropertyDescriptor(childItem, key);
26574
+ if (descriptor && descriptor.set) {
26575
+ if (descriptor.set.call(childItem, value)) {
26576
+ changed = true;
26577
+ }
26578
+ } else if (childItem[key] !== value) {
26579
+ childItem[key] = value;
26580
+ changed = true;
26581
+ }
25859
26582
  }
25860
26583
  if (childSignal.peek() !== childItem) {
25861
26584
  childSignal.value = childItem; // first activation: null → childItem
26585
+ changed = true;
25862
26586
  }
26587
+ return changed;
25863
26588
  };
25864
26589
  if (!ownerName) {
25865
26590
  // Declared on a scoped child, an owner id is only unique inside its
@@ -26147,7 +26872,7 @@ const createResource = (
26147
26872
  if (isMany) {
26148
26873
  // GET_MANY, POST_MANY, PUT_MANY etc: rest[0] is the array of items,
26149
26874
  // and it replaces the whole collection.
26150
- const itemArray = childStore.upsert(rest[0]);
26875
+ const itemArray = childStore.upsertAnswer(rest[0]);
26151
26876
  const idArray = itemArray.map((childItem) => childItem[childIdKey]);
26152
26877
  idArraySignal.value = idArray;
26153
26878
  return [ownerId, idArray];
@@ -26156,8 +26881,8 @@ const createResource = (
26156
26881
  // GET, POST, PUT, PATCH: rest may be [props] or [oldId, props] for renames
26157
26882
  const childItem =
26158
26883
  rest.length > 1
26159
- ? childStore.upsert(...rest)
26160
- : childStore.upsert(rest[0]);
26884
+ ? childStore.upsertAnswer(...rest)
26885
+ : childStore.upsertAnswer(rest[0]);
26161
26886
  return [ownerId, childItem[childIdKey]];
26162
26887
  },
26163
26888
  valueToData: (value) => {
@@ -26293,8 +27018,8 @@ const createRestActionFactoryForRoot = (
26293
27018
  // An array result is [property, value, props] — used to rename the
26294
27019
  // idKey of an item: store.upsert("name", "currentName", { name: "newName" })
26295
27020
  const item = Array.isArray(result)
26296
- ? store.upsert(...result)
26297
- : store.upsert(result);
27021
+ ? store.upsertAnswer(...result)
27022
+ : store.upsertAnswer(result);
26298
27023
  return item[idKey];
26299
27024
  };
26300
27025
  const throwInvalidResult = createInvalidResultThrower(
@@ -26358,7 +27083,7 @@ const createRestActionFactoryForRoot = (
26358
27083
  verb === "DELETE"
26359
27084
  ? (idOrMutableIdArray) => store.drop(idOrMutableIdArray)
26360
27085
  : (dataArray) => {
26361
- const itemArray = store.upsert(dataArray);
27086
+ const itemArray = store.upsertAnswer(dataArray);
26362
27087
  return itemArray.map((item) => item[idKey]);
26363
27088
  };
26364
27089
 
@@ -26487,14 +27212,17 @@ ${relationLabel} source location: ${declarationSite}`,
26487
27212
  // array of child props/ids, or anything else meaning "empty") into an array of
26488
27213
  // child ids, upserting each entry into the child store. The id array signal is
26489
27214
  // only touched when the resulting ids actually differ.
27215
+ // Returns whether the children the row points to changed — what the store asks
27216
+ // a relation (see assign in array_signal_store.js).
26490
27217
  const createChildIdArrayUpdater = (childStore, childIdKey, idArraySignal) => {
26491
27218
  return (valueArray) => {
26492
27219
  const currentIdArray = idArraySignal.peek();
26493
27220
  if (!Array.isArray(valueArray)) {
26494
27221
  if (currentIdArray.length > 0) {
26495
27222
  idArraySignal.value = [];
27223
+ return true;
26496
27224
  }
26497
- return;
27225
+ return false;
26498
27226
  }
26499
27227
  const idArray = [];
26500
27228
  let modified = false;
@@ -26516,7 +27244,9 @@ const createChildIdArrayUpdater = (childStore, childIdKey, idArraySignal) => {
26516
27244
  }
26517
27245
  if (modified || currentIdArray.length !== idArray.length) {
26518
27246
  idArraySignal.value = idArray;
27247
+ return true;
26519
27248
  }
27249
+ return false;
26520
27250
  };
26521
27251
  };
26522
27252
 
@@ -26934,6 +27664,24 @@ const TYPE_CONVERTERS = {
26934
27664
  },
26935
27665
  };
26936
27666
 
27667
+ /**
27668
+ * What a component can learn about the page it is rendered in, from the route
27669
+ * container rendering that page (see useContentKeepingLeavingPages in
27670
+ * route.jsx). `null` outside of any route container.
27671
+ *
27672
+ * - `isShown()` — whether the container would still show the page. False as
27673
+ * soon as the address leads elsewhere: the page is about to be taken down, or
27674
+ * hidden until the route transition leaving it is over, and nothing it reads
27675
+ * is worth a render any more. False for good once it is kept while leaving: a
27676
+ * return to its route mounts the page anew. Asked outside any render — by a
27677
+ * subscription deciding whether to re-render — and it reads the routes
27678
+ * untracked, so asking subscribes nobody to them.
27679
+ * - `leavingSignal` — true once the page is hidden and kept while its movement
27680
+ * plays: still mounted, so what it fills outside its own nodes (a slot in a
27681
+ * bar) steps out rather than showing with the page arriving.
27682
+ */
27683
+ const RoutePageContext = createContext(null);
27684
+
26937
27685
  const promiseStateWeakMap = new WeakMap();
26938
27686
  const usePromiseAsyncData = (
26939
27687
  promise,
@@ -27069,12 +27817,39 @@ const useActionAsyncData = (action, {
27069
27817
  // has already processed the settlement and the detached DOM is discarded.
27070
27818
  const runningState = action.runningStateSignal.peek();
27071
27819
  const [, setTick] = useState(0);
27820
+ const routePage = useContext(RoutePageContext);
27072
27821
  useEffect(() => {
27822
+ let stopOwingRender = null;
27823
+ const rerender = () => {
27824
+ // Not for a page the address has left: it is about to be taken down,
27825
+ // or hidden until the route transition leaving it is over, and its
27826
+ // route's action moving as that route stops matching would re-render
27827
+ // the whole page — inside the transition's update callback, where
27828
+ // every render delays the movement (see route_page_context.js).
27829
+ if (routePage && !routePage.isShown()) {
27830
+ // Owed rather than dropped: the address can come back before the
27831
+ // container renders (a redirect straight back), and the page then
27832
+ // stays on screen with what it read last.
27833
+ if (!stopOwingRender) {
27834
+ stopOwingRender = observeRouteRender(() => {
27835
+ if (routePage.isShown()) {
27836
+ rerender();
27837
+ }
27838
+ });
27839
+ }
27840
+ return;
27841
+ }
27842
+ if (stopOwingRender) {
27843
+ stopOwingRender();
27844
+ stopOwingRender = null;
27845
+ }
27846
+ setTick(n => n + 1);
27847
+ };
27073
27848
  const unsubscribeFromRunningState = action.runningStateSignal.subscribe(state => {
27074
27849
  if (state === RUNNING) {
27075
27850
  dismissedActionWeakSet.delete(action);
27076
27851
  }
27077
- setTick(n => n + 1);
27852
+ rerender();
27078
27853
  });
27079
27854
  // The data does not come from this action's runs alone: dataSignal is a
27080
27855
  // computed over the resource store, so an other action writing that store
@@ -27088,7 +27863,7 @@ const useActionAsyncData = (action, {
27088
27863
  dataNotificationIsInitial = false;
27089
27864
  return;
27090
27865
  }
27091
- setTick(n => n + 1);
27866
+ rerender();
27092
27867
  });
27093
27868
  // The params say WHICH question this is, and this hook reads them: to know
27094
27869
  // there is nothing to ask for, and to start the run it owns. A binding
@@ -27101,7 +27876,7 @@ const useActionAsyncData = (action, {
27101
27876
  paramsNotificationIsInitial = false;
27102
27877
  return;
27103
27878
  }
27104
- setTick(n => n + 1);
27879
+ rerender();
27105
27880
  });
27106
27881
  // A debounced binding waits before it retargets, so nothing above changes
27107
27882
  // while the delay runs — but what is on screen is already out of date.
@@ -27111,18 +27886,21 @@ const useActionAsyncData = (action, {
27111
27886
  settlingNotificationIsInitial = false;
27112
27887
  return;
27113
27888
  }
27114
- setTick(n => n + 1);
27889
+ rerender();
27115
27890
  });
27116
27891
  return () => {
27117
27892
  unsubscribeFromRunningState();
27118
27893
  unsubscribeFromData();
27119
27894
  unsubscribeFromParams();
27120
27895
  unsubscribeFromParamsSettling();
27896
+ if (stopOwingRender) {
27897
+ stopOwingRender();
27898
+ }
27121
27899
  };
27122
27900
  // Bound to the action, not to the mount: params given as a plain object
27123
27901
  // make another action instance, and the component would otherwise stay
27124
27902
  // subscribed to the state of the one it no longer reads.
27125
- }, [action]);
27903
+ }, [action, routePage]);
27126
27904
 
27127
27905
  // The params moved and the run has not started: under a debounce the action
27128
27906
  // is still the previous one, COMPLETED with the previous answer. There is no
@@ -31223,13 +32001,6 @@ const keepLeavingPages = () => {
31223
32001
  hold.containers.clear();
31224
32002
  };
31225
32003
  };
31226
-
31227
- // What a page can learn about its place from the container rendering it:
31228
- // whether it is a page kept while leaving (see keepLeavingPages). Such a page
31229
- // is still mounted, and whatever it fills OUTSIDE its own nodes — a slot in a
31230
- // bar, say — would otherwise go on showing with the page arriving (see
31231
- // layout/slot.jsx).
31232
- const RoutePageContext = createContext(null);
31233
32004
  const LEAVING_ATTRIBUTE = "data-navi-route-leaving";
31234
32005
  const css$13 = /* css */`[data-navi-route-leaving] {
31235
32006
  display: none !important;
@@ -31354,7 +32125,21 @@ const RouteContainer = ({
31354
32125
  shownBranchRef.current = activeBranch;
31355
32126
  }
31356
32127
  const branch = shownBranchRef.current || activeBranch;
31357
- const content = useContentKeepingLeavingPages(branch);
32128
+ // What this container would show if it rendered now, by the same rule as
32129
+ // the render above — asked from outside any render (see isShown below).
32130
+ const childrenRef = useRef(null);
32131
+ childrenRef.current = children;
32132
+ const readShownBranchRef = useRef(null);
32133
+ if (!readShownBranchRef.current) {
32134
+ readShownBranchRef.current = () => {
32135
+ if (routeRenderFrozen) {
32136
+ return shownBranchRef.current;
32137
+ }
32138
+ return collectBranches(childrenRef.current).activeBranch;
32139
+ };
32140
+ }
32141
+ const parentPage = useContext(RoutePageContext);
32142
+ const content = useContentKeepingLeavingPages(branch, readShownBranchRef.current, parentPage);
31358
32143
 
31359
32144
  // The two things this component knows that nobody outside can find out: the
31360
32145
  // pages a named fallback is the absence of — the children it was written
@@ -31391,7 +32176,7 @@ const RouteContainer = ({
31391
32176
  // page being left must stay the child it was when a page is added beside it,
31392
32177
  // or Preact takes it down and builds it again.
31393
32178
  let leavingKeyCount = 0;
31394
- const useContentKeepingLeavingPages = branch => {
32179
+ const useContentKeepingLeavingPages = (branch, readShownBranch, parentPage) => {
31395
32180
  const [, rerender] = useReducer(countRenders, 0);
31396
32181
  const shownRef = useRef(null);
31397
32182
  const leavingRef = useRef([]);
@@ -31409,6 +32194,7 @@ const useContentKeepingLeavingPages = branch => {
31409
32194
  // of reach — the page arriving could not even take the focus — for as long
31410
32195
  // as it is open, and one in a page kept while leaving stays open for the
31411
32196
  // whole movement. The page is then taken down at once, as without one.
32197
+ shown.page.left = true;
31412
32198
  leavingRef.current.push({
31413
32199
  vnode: shown.vnode,
31414
32200
  page: shown.page,
@@ -31425,10 +32211,11 @@ const useContentKeepingLeavingPages = branch => {
31425
32211
  let vnode = null;
31426
32212
  if (branch) {
31427
32213
  if (!page) {
31428
- page = {
31429
- leavingSignal: signal(false)
31430
- };
32214
+ page = createRoutePage(readShownBranch, parentPage);
31431
32215
  }
32216
+ // A page carried over follows the branch it is now the page of.
32217
+ page.branchId = branch.id;
32218
+ page.element = element;
31432
32219
  vnode = h(RoutePageContext.Provider, {
31433
32220
  key,
31434
32221
  value: page
@@ -31495,6 +32282,33 @@ const useContentKeepingLeavingPages = branch => {
31495
32282
  return children;
31496
32283
  };
31497
32284
  const countRenders = count => count + 1;
32285
+ // The page a container renders, as RoutePageContext hands it over (see
32286
+ // route_page_context.js). Shown while the container would pick its branch, or
32287
+ // a branch rendering the same element (it is then carried over), and while the
32288
+ // page it sits in is shown itself. Never again once kept while leaving: coming
32289
+ // back to its route mounts the page anew.
32290
+ const createRoutePage = (readShownBranch, parentPage) => {
32291
+ const page = {
32292
+ branchId: null,
32293
+ element: undefined,
32294
+ left: false,
32295
+ leavingSignal: signal(false),
32296
+ isShown: () => {
32297
+ if (page.left) {
32298
+ return false;
32299
+ }
32300
+ if (parentPage && !parentPage.isShown()) {
32301
+ return false;
32302
+ }
32303
+ const shownBranch = untracked(readShownBranch);
32304
+ if (!shownBranch) {
32305
+ return false;
32306
+ }
32307
+ return shownBranch.id === page.branchId || branchElement(shownBranch) === page.element;
32308
+ }
32309
+ };
32310
+ return page;
32311
+ };
31498
32312
  // What Preact compares when a container changes page, below the <Route> it
31499
32313
  // renders either way: the element the page is rendered with — a layout, for a
31500
32314
  // container branch.
@@ -38090,504 +38904,6 @@ const flushSyncRendering = (fn) => {
38090
38904
  }
38091
38905
  };
38092
38906
 
38093
- /**
38094
- * Small, renderer-agnostic helpers shared by Popover and Dialog's own custom
38095
- * (non-top-layer) renderers — operate on a plain DOM element, no knowledge
38096
- * of which of the two owns it.
38097
- */
38098
-
38099
-
38100
- /**
38101
- * Whether a visibleRectEffect delivery is one that can have taken height away
38102
- * from a popup, and so pushed whatever holds focus out of sight:
38103
- * - "resize": the window/visual viewport settled — which is also how the
38104
- * on-screen keyboard arrives, overlay or not (window_size.js);
38105
- * - ELEMENT_SIZE_CHANGE: the popup's own box measured different;
38106
- * - "focusin": the focus-settled re-measure, for the room that changes with
38107
- * nothing announcing it (subscribeFocusSettled in window_size.js).
38108
- *
38109
- * Everything else is a scroll of one kind or another, where nothing got
38110
- * smaller and scrolling the focused element back would fight the very gesture
38111
- * that fired it.
38112
- */
38113
- const mayHaveHiddenFocus = (event) => {
38114
- const type = event?.type;
38115
- return (
38116
- type === "resize" || type === ELEMENT_SIZE_CHANGE || type === "focusin"
38117
- );
38118
- };
38119
-
38120
- const scrollportHeightMap = new WeakMap();
38121
- /**
38122
- * Scrolls whatever holds focus inside `popupEl` back into view, if the popup
38123
- * getting shorter has pushed it out.
38124
- *
38125
- * The case this exists for: a field low in the scrolling body of a popup that
38126
- * also has a footer (box.jsx — with a body, the body is the only thing that
38127
- * scrolls and the footer is a sibling sitting right under it). Focusing the
38128
- * field makes the browser scroll it into view, which it does against the
38129
- * popup's height AT THAT MOMENT; the on-screen keyboard then opens and takes
38130
- * that height away. The body shrinks, its scrollTop does not move, so the
38131
- * content slides down relative to the shorter scrollport and the field ends up
38132
- * past its bottom edge — visually, swallowed by the footer. The browser does
38133
- * not redo a scroll-into-view it already answered, so this does.
38134
- *
38135
- * Only what the shrink itself hid, though: with the keyboard up the user reads
38136
- * the rest of the popup by scrolling the field away — to reach the submit under
38137
- * it, typically — and the room keeps changing while they do (a keyboard settling
38138
- * in two steps, a suggestion strip, a browser bar). Answering each of those by
38139
- * scrolling the field back takes the popup away from wherever they had just
38140
- * scrolled it, over and over: what they were reading cannot be reached at all
38141
- * without blurring the field first, and the popup reads as unscrollable. So the
38142
- * field is brought back only when it was in view before the room shrank, which
38143
- * one remembered number answers: a resize moves neither scrollTop nor the
38144
- * element's offset inside the scrolled content, so measuring against the height
38145
- * the scrollport HAD is measuring the state before the change.
38146
- *
38147
- * Scoped to the field's own scroll container (never the page): a popup traps
38148
- * scrolling precisely so the document underneath cannot move, and a plain
38149
- * scrollIntoView walks past a container whose scrollbar isn't visible — see
38150
- * scrollIntoViewScoped's own doc.
38151
- *
38152
- * "nearest": the smallest scroll that makes it visible, and none at all when it
38153
- * already is. Where it lands is the container's own business — a navi scroller
38154
- * keeps a band free at its edges so a field never comes back glued to one (see
38155
- * scroll-padding in box.jsx).
38156
- */
38157
- const keepFocusedElementVisible = (popupEl) => {
38158
- const { activeElement } = document;
38159
- if (!activeElement || activeElement === popupEl) {
38160
- return;
38161
- }
38162
- if (!popupEl.contains(activeElement)) {
38163
- return;
38164
- }
38165
- const scrollContainer = getScrollContainer(activeElement);
38166
- if (!scrollContainer || !popupEl.contains(scrollContainer)) {
38167
- // What scrolls the field is outside the popup, which means the page: a
38168
- // popup holds it still on purpose (trapScrollInside), so there is nothing
38169
- // here to scroll back.
38170
- return;
38171
- }
38172
- const scrollportHeight = scrollContainer.clientHeight;
38173
- const scrollportHeightBefore = scrollportHeightMap.get(scrollContainer);
38174
- scrollportHeightMap.set(scrollContainer, scrollportHeight);
38175
- if (scrollportHeightBefore !== undefined) {
38176
- if (scrollportHeight >= scrollportHeightBefore) {
38177
- // Nothing was taken away, so nothing was hidden by this.
38178
- return;
38179
- }
38180
- if (
38181
- !isVisibleInScrollport(
38182
- activeElement,
38183
- scrollContainer,
38184
- scrollportHeightBefore,
38185
- )
38186
- ) {
38187
- return;
38188
- }
38189
- }
38190
- scrollIntoViewScoped(activeElement, {
38191
- container: scrollContainer,
38192
- block: "nearest",
38193
- });
38194
- };
38195
-
38196
- // Whether any part of `el` was showing in `container` back when its scrollport
38197
- // was `scrollportHeight` tall. Both boxes are read now: the container may have
38198
- // moved as well as shrunk (a centered dialog re-centers itself), and the
38199
- // difference between the two tops is what that move leaves alone.
38200
- const isVisibleInScrollport = (el, container, scrollportHeight) => {
38201
- const elRect = el.getBoundingClientRect();
38202
- const containerRect = container.getBoundingClientRect();
38203
- const elTop = elRect.top - containerRect.top + container.scrollTop;
38204
- const elBottom = elTop + elRect.height;
38205
- const scrollportTop = container.scrollTop;
38206
- const scrollportBottom = scrollportTop + scrollportHeight;
38207
- return elBottom > scrollportTop && elTop < scrollportBottom;
38208
- };
38209
-
38210
- /**
38211
- * Calls `onSettled` once `el`'s current CSS transition is over — via
38212
- * `transitionend`, with a safety `setTimeout` fallback matching the longest
38213
- * `transition-duration`, in case nothing actually transitions or an event is
38214
- * missed.
38215
- *
38216
- * Returns a "cancel" function, so a caller whose instance has been superseded
38217
- * (a fresh open/close about to set its own state) can keep this stale one from
38218
- * firing later. Cancelling only stops `onSettled`: undoing whatever the caller
38219
- * did up front is that fresh call's business, not this one's.
38220
- */
38221
- const whenTransitionSettles = (el, onSettled) => {
38222
- let settled = false;
38223
- const onTransitionEnd = (transitionEvent) => {
38224
- if (transitionEvent.target === el) {
38225
- finish();
38226
- }
38227
- };
38228
- const stopWatching = () => {
38229
- settled = true;
38230
- el.removeEventListener("transitionend", onTransitionEnd);
38231
- clearTimeout(safetyTimeoutId);
38232
- };
38233
- const finish = () => {
38234
- if (settled) {
38235
- return;
38236
- }
38237
- stopWatching();
38238
- onSettled();
38239
- };
38240
- el.addEventListener("transitionend", onTransitionEnd);
38241
- const durationsInSeconds = getComputedStyle(el)
38242
- .transitionDuration.split(",")
38243
- .map((value) => parseFloat(value) || 0);
38244
- const longestDurationMs = Math.max(0, ...durationsInSeconds) * 1000;
38245
- const safetyTimeoutId = setTimeout(finish, longestDurationMs + 50);
38246
- return () => {
38247
- if (settled) {
38248
- return;
38249
- }
38250
- stopWatching();
38251
- };
38252
- };
38253
-
38254
- /**
38255
- * Drops the document's text selection when it lives inside `el`, leaving a
38256
- * selection made elsewhere on the page alone.
38257
- *
38258
- * A popup being closed takes its content with it, and a selection is a claim
38259
- * on content the user can still act on: kept, it would outlive the surface it
38260
- * was made on — painted on the box for the length of its exit transition, with
38261
- * the handles and the copy toolbar a phone draws from the live selection
38262
- * hanging over something that is going away — and reappear with the box on
38263
- * the next opening. Dropping the selection is the only way to remove that
38264
- * chrome: a `user-select: none` on the closing box hides the highlight in some
38265
- * browsers only, and the handles are the browser's own, drawn from the
38266
- * selection object rather than from any style.
38267
- */
38268
- const clearTextSelectionInside = (el) => {
38269
- const selection = window.getSelection();
38270
- if (!selection || selection.rangeCount === 0) {
38271
- return;
38272
- }
38273
- const range = selection.getRangeAt(0);
38274
- if (!el.contains(range.commonAncestorContainer)) {
38275
- return;
38276
- }
38277
- selection.removeAllRanges();
38278
- };
38279
-
38280
- /**
38281
- * Disables pointer-events on `el` until its current CSS transition settles —
38282
- * avoids the cursor changing/something becoming clickable while the popup is
38283
- * still visually moving into or out of place.
38284
- *
38285
- * Returns whenTransitionSettles' own "cancel" function: it doesn't restore
38286
- * pointer-events, since a fresh call for the next open/close is about to set
38287
- * its own state.
38288
- */
38289
- const suppressPointerEventsDuringTransition = (el) => {
38290
- el.style.pointerEvents = "none";
38291
- return whenTransitionSettles(el, () => {
38292
- el.style.pointerEvents = "";
38293
- });
38294
- };
38295
-
38296
- /**
38297
- * Hides the backdrop, deferring until the browser's matching "click" fires
38298
- * when `closeEvent` was triggered by a mousedown (see popover.jsx's top
38299
- * comment for why) — same capture-phase-on-document pattern as
38300
- * armSuppressNextOpenRequest in open_controller.js, which a plain timeout
38301
- * can't safely replace: mouseup (and the click that follows it) can land an
38302
- * arbitrarily long time after mousedown (the user is still holding the
38303
- * button down), so a short timeout can fire first and hide the backdrop
38304
- * before its own click ever arrives. A capture-phase listener on document
38305
- * fires for every click regardless of what any bubble-phase handler does
38306
- * downstream, so no fallback timer is needed.
38307
- *
38308
- * `hide` is the caller's own way to actually hide the backdrop
38309
- * (`hidePopover()` for a top-layer backdrop, a plain `style.display = "none"`
38310
- * for a plain div) — this helper only owns the mousedown/click timing.
38311
- *
38312
- * Returns a disarm function (or undefined if hidden immediately), so a
38313
- * fresh open can cancel a pending hide it's about to make redundant.
38314
- */
38315
- const armPointerDownOutsideClose = (closeEvent, hide) => {
38316
- const mousedownEvent = findEvent(closeEvent, "mousedown");
38317
- if (!mousedownEvent) {
38318
- hide();
38319
- return undefined;
38320
- }
38321
- const onClick = () => {
38322
- document.removeEventListener("click", onClick, { capture: true });
38323
- hide();
38324
- };
38325
- document.addEventListener("click", onClick, { capture: true });
38326
- return () => {
38327
- document.removeEventListener("click", onClick, { capture: true });
38328
- };
38329
- };
38330
-
38331
- /**
38332
- * Maps a positionArea y/x pair to a concrete `navi-animation` value (a
38333
- * `prefix` plus a direction word), or `null` if both axes overlap the anchor
38334
- * (no direction at all — that's `resolvedAnimationKind === "scaling"`
38335
- * territory instead, see resolveAutoAnimationKind below).
38336
- *
38337
- * `prefix: "slide-from"` (used with no real anchor — Dialog always, Popover
38338
- * when docked) keeps the word as the compass direction the popup comes
38339
- * from: placed "top" (a point/corner), it slides in from the top.
38340
- * `prefix: "expand"` (a real anchor, Popover-only) uses the motion/growth
38341
- * direction instead, the opposite compass point: placed "top" of the
38342
- * anchor, it moves/grows up, away from the anchor (which sits below it).
38343
- *
38344
- * "inset-*"/"center" contribute no direction on their axis either way.
38345
- */
38346
- const resolveDirectionValue = (y, x, { prefix }) => {
38347
- const yWord =
38348
- y === "top"
38349
- ? prefix === "expand"
38350
- ? "up"
38351
- : "top"
38352
- : y === "bottom"
38353
- ? prefix === "expand"
38354
- ? "down"
38355
- : "bottom"
38356
- : null;
38357
- const xWord = x === "left" ? "left" : x === "right" ? "right" : null;
38358
- if (!yWord && !xWord) {
38359
- return null;
38360
- }
38361
- return yWord && xWord
38362
- ? `${prefix}-${yWord}-${xWord}`
38363
- : `${prefix}-${yWord || xWord}`;
38364
- };
38365
-
38366
- /**
38367
- * Shared `animation="auto"`/`true` resolution: "scaling" reads best overall
38368
- * — picked for any real anchor, or for a point/corner placed dead-center
38369
- * (both positionArea axes overlapping — there's no sensible direction to
38370
- * slide from in that case). "sliding" otherwise. `anchor` is `undefined`
38371
- * for any no-anchor/docked case (Dialog always, Popover's own custom
38372
- * renderer when there's no real anchor), so this collapses to "scaling"
38373
- * there only for the dead-center case, "sliding" otherwise. The two
38374
- * "overlapping" booleans below describe the *positionArea* itself (a bare
38375
- * word vs. "inset-"/"center"), not anything about the anchor — they'd
38376
- * mean exactly the same thing even with no anchor at all, since it's the
38377
- * position strategy, not the anchor, that decides whether there's a
38378
- * direction to slide from.
38379
- */
38380
- const resolveAutoAnimationKind = (anchor, parsedPositionArea) => {
38381
- const yIsOverlapping =
38382
- parsedPositionArea.y !== "top" && parsedPositionArea.y !== "bottom";
38383
- const xIsOverlapping =
38384
- parsedPositionArea.x !== "left" && parsedPositionArea.x !== "right";
38385
- return anchor || (yIsOverlapping && xIsOverlapping) ? "scaling" : "sliding";
38386
- };
38387
-
38388
- /**
38389
- * A popup reads "outside" from its own border box: what a press lands on
38390
- * decides nothing on its own (a genuine backdrop press and a press on the
38391
- * popup's padding both report the popup element as their target, there being
38392
- * no real ::backdrop node to be one), so the rectangle is what tells them
38393
- * apart. That holds as long as the box and what the popup paints are the same
38394
- * thing — which stops being true for a popup with no surface of its own
38395
- * (`backgroundColor="transparent"`, no shadow, no padding): what the eye reads
38396
- * as backdrop is then inside the box, and a press there stays a press on the
38397
- * popup.
38398
- *
38399
- * `data-navi-popup-outside` is how a caller says which of its own boxes are
38400
- * not the surface. It is opt-in because navi cannot infer it — a background
38401
- * can come from anywhere — while the caller who made the popup see-through
38402
- * knows exactly which box is decoration and which is paper.
38403
- *
38404
- * The marker answers for the element it is on, never for its descendants: a
38405
- * box painted inside a marked one is still surface, so a row can be marked and
38406
- * its empty halves read as backdrop while presses on the controls it holds do
38407
- * not dismiss anything. A descendant with `pointer-events: none` never becomes
38408
- * a press target at all, so the marked box answers in its place — while the
38409
- * same declaration on the marked box itself takes it out of hit-testing and
38410
- * makes the marker unreachable (see warnAboutUnreachableOutsideRegions).
38411
- */
38412
- const OUTSIDE_REGION_ATTRIBUTE = "data-navi-popup-outside";
38413
-
38414
- /**
38415
- * `data-navi-popup-inside` is the other direction: a box of the PAGE the
38416
- * caller says belongs to a popup, so a press on it is not outside that popup
38417
- * even though it lies beyond the border box — a card whose press is what
38418
- * fills the one panel of a board. Its value names the popup by `id`, the way
38419
- * `commandfor` does (several ids, space separated), because the exemption is
38420
- * one popup's: a menu open beside the board still closes on the same press.
38421
- *
38422
- * Unlike the outside marker it answers for its whole subtree: the outside
38423
- * marker carves free space out of a surface whose controls stay surface, this
38424
- * one adds a whole thing — the card, its count, its buttons — to the popup's
38425
- * own ground, and nothing in it is a dismissal.
38426
- */
38427
- const INSIDE_ATTRIBUTE = "data-navi-popup-inside";
38428
-
38429
- /**
38430
- * What a press landing on a region the caller declared as not-its-surface
38431
- * does: exactly what the same press on the backdrop would do.
38432
- *
38433
- * Lives on the popup's own content element rather than in the backdrop's
38434
- * handler, for both renderers: the backdrop is a sibling behind the popup
38435
- * (never an ancestor), so a press inside the popup's box never reaches it.
38436
- */
38437
- const handlePressOnOutsideRegion = (
38438
- mouseDownEvent,
38439
- { popupEl, openController, pressOutside },
38440
- ) => {
38441
- if (mouseDownEvent.button !== 0) {
38442
- return;
38443
- }
38444
- const { target } = mouseDownEvent;
38445
- if (!target.hasAttribute(OUTSIDE_REGION_ATTRIBUTE)) {
38446
- return;
38447
- }
38448
- if (openedDuringThisPress(openController)) {
38449
- // The release of the press that opened it is not somebody dismissing it
38450
- // (see openedDuringThisPress).
38451
- return;
38452
- }
38453
- // A popup opens inside its opener's own subtree, so a press on a region
38454
- // belonging to a popup nested in this one bubbles through here too — and it
38455
- // is a press on what is in front, not on this popup's own decoration.
38456
- if (
38457
- target.closest(`[navi-control="dialog"], [navi-control="popover"]`) !==
38458
- popupEl
38459
- ) {
38460
- return;
38461
- }
38462
- if (pressOutside === "capture") {
38463
- mouseDownEvent.preventDefault();
38464
- return;
38465
- }
38466
- if (pressOutside === "close" || pressOutside === "cancel") {
38467
- openController.requestClose(mouseDownEvent, {
38468
- isCancel: pressOutside === "cancel",
38469
- });
38470
- }
38471
- };
38472
-
38473
- /**
38474
- * Hears an outside press from the document itself, for a popup with no
38475
- * backdrop of its own to catch one: a modal `<dialog>` (whose native
38476
- * `::backdrop` dispatches nothing a handler can be put on) and any popup the
38477
- * caller asked to leave the page reachable (`backdrop={false}`).
38478
- *
38479
- * The press is read and never taken: no `preventDefault`, no
38480
- * `stopPropagation`, and the listener is on `document` rather than on
38481
- * anything covering the page — so what the press landed on hears it too, in
38482
- * the same gesture, and the popup only takes note. That is the whole point of
38483
- * a popup with no wall: a press outside is a press on the page, and the page
38484
- * answers it. The one popup where that cannot hold is the modal dialog, where
38485
- * the browser has already made everything behind inert before this runs.
38486
- *
38487
- * `pressEventType` says which event IS the press, and the two callers want
38488
- * opposite ones:
38489
- *
38490
- * - `"pointerdown"` for a popup with no wall: a page with no wall over it is
38491
- * a page whose own elements arbitrate their presses, and cancelling a
38492
- * `pointerdown` — what a drag source and a control keeping the focus where
38493
- * it is both do — suppresses every compatibility mouse event that would have
38494
- * followed it. The press happens, reaches its target, and no `mousedown` is
38495
- * ever dispatched for it. Reading the pointer event is reading the press
38496
- * itself, whatever anything does with it afterwards, and it is the same
38497
- * event the drag and surface detectors read.
38498
- * - `"mousedown"` for the modal dialog, whose native `::backdrop` exists to
38499
- * spend the press. The page's controls act on `mousedown`, and on a touch
38500
- * screen that is not the same moment as `pointerdown`: the compatibility
38501
- * mouse events are synthesized at `touchend` and hit-tested again then.
38502
- * Closing on `pointerdown` takes the `::backdrop` away before that second
38503
- * hit-test, so the `mousedown` lands on whatever the page holds under the
38504
- * finger, and one tap both dismisses the dialog and presses the page. Read
38505
- * on `mousedown`, the wall is still up when the press the page acts on is
38506
- * aimed, and it catches it. Nothing behind a modal wall can cancel that
38507
- * press, since nothing behind it hears the `pointerdown` at all.
38508
- *
38509
- * Capture phase, so a handler downstream that stops propagation cannot keep
38510
- * the popup open either — the press is still outside whatever is made of it.
38511
- *
38512
- * Returns a cleanup that removes the listener.
38513
- */
38514
- const armOutsidePressClose = (
38515
- popupEl,
38516
- { openController, pressOutside, pressEventType = "pointerdown" },
38517
- ) => {
38518
- const onDocumentPointerDown = (pointerDownEvent) => {
38519
- if (pointerDownEvent.button !== 0) {
38520
- return;
38521
- }
38522
- if (openedDuringThisPress(openController)) {
38523
- // The release of the press that opened it is not somebody dismissing it
38524
- // (see openedDuringThisPress).
38525
- return;
38526
- }
38527
- // The press landed inside another popup: that is a press on what is in
38528
- // front, not outside. Asking the target where it lives rather than asking
38529
- // this popup whether it was pushed — a popup in front does not have to be
38530
- // one this popup knows about. In front means neither inside nor around: a
38531
- // popup nested in this one falls to the containment check below as the
38532
- // inside press it is, and one this popup opened INSIDE (a bubble rendered
38533
- // in a sheet's content) is around it, so what that popup holds is outside
38534
- // this one exactly like the rest of the page.
38535
- const popupUnderPointer = pointerDownEvent.target.closest?.(
38536
- `[navi-control="dialog"], [navi-control="popover"]`,
38537
- );
38538
- if (
38539
- popupUnderPointer &&
38540
- popupUnderPointer !== popupEl &&
38541
- !popupEl.contains(popupUnderPointer) &&
38542
- !popupUnderPointer.contains(popupEl)
38543
- ) {
38544
- return;
38545
- }
38546
- if (
38547
- popupEl.id &&
38548
- pointerDownEvent.target.closest?.(
38549
- `[${INSIDE_ATTRIBUTE}~="${CSS.escape(popupEl.id)}"]`,
38550
- )
38551
- ) {
38552
- return;
38553
- }
38554
- // Real DOM containment wins over the rectangle below — an element
38555
- // genuinely inside the popup (`overflow: visible`, a negative margin, an
38556
- // absolutely-positioned child) can be painted outside its border box, and
38557
- // a press there is not outside just because its coordinates are. The
38558
- // popup element itself is excluded (contains() answers true for it): a
38559
- // press on a modal dialog's native backdrop reports the dialog as its
38560
- // target, there being no `::backdrop` node to be one, so treating that as
38561
- // contained would keep the rectangle from ever running.
38562
- if (
38563
- pointerDownEvent.target !== popupEl &&
38564
- popupEl.contains(pointerDownEvent.target)
38565
- ) {
38566
- return;
38567
- }
38568
- const rect = popupEl.getBoundingClientRect();
38569
- const isOutside =
38570
- pointerDownEvent.clientX < rect.left ||
38571
- pointerDownEvent.clientX > rect.right ||
38572
- pointerDownEvent.clientY < rect.top ||
38573
- pointerDownEvent.clientY > rect.bottom;
38574
- if (!isOutside) {
38575
- return;
38576
- }
38577
- openController.requestClose(pointerDownEvent, {
38578
- isCancel: pressOutside === "cancel",
38579
- });
38580
- };
38581
- document.addEventListener(pressEventType, onDocumentPointerDown, {
38582
- capture: true,
38583
- });
38584
- return () => {
38585
- document.removeEventListener(pressEventType, onDocumentPointerDown, {
38586
- capture: true,
38587
- });
38588
- };
38589
- };
38590
-
38591
38907
  /**
38592
38908
  * When a popup builds what it holds, and when it throws it away.
38593
38909
  *
@@ -65577,6 +65893,13 @@ const css$D = /* css */`
65577
65893
  * is a `Popover`-level answer only for `"close"`/`"cancel"`; `"capture"` has
65578
65894
  * nothing left to absorb without a wall, and `"ignore"` renders no wall anyway.
65579
65895
  * See docs/popup_backdrop.md.
65896
+ * @param {string} [props.group] - The popover's peers: the triggers carrying
65897
+ * `data-navi-popup-group` with this name. A press on one, while the popover
65898
+ * is open, closes it and opens the peer's popup in that same press — a
65899
+ * menu bar, where pressing another menu switches to it. The wall still
65900
+ * takes the press (the page behind stays out of reach, hover included) and
65901
+ * hands its click to the peer it covered. Without a wall there is nothing
65902
+ * to ask: the press reaches the peer on its own.
65580
65903
  * @param {"close"|"cancel"|"capture"|"ignore"} [props.pressOutside="ignore"]
65581
65904
  * - `"ignore"` (default): no backdrop at all, outside presses pass straight
65582
65905
  * through. `"close"` closes the popover on an outside press, `"cancel"`
@@ -65951,6 +66274,7 @@ const usePopoverProps = props => {
65951
66274
  // which is a sibling element (see this file's top comment).
65952
66275
  backdropColor,
65953
66276
  backdropFilter,
66277
+ group,
65954
66278
  scrollCapture,
65955
66279
  focusCapture,
65956
66280
  // "auto" (default) → the popover follows its content. "frozen" → measured
@@ -66596,13 +66920,21 @@ const usePopoverProps = props => {
66596
66920
  }
66597
66921
  if (backdropEl) {
66598
66922
  backdropEl.setAttribute("aria-expanded", "false");
66599
- disarmBackdropHideRef.current = armPointerDownOutsideClose(closeEvent, () => {
66923
+ disarmBackdropHideRef.current = armPointerDownOutsideClose(closeEvent, clickEvent => {
66600
66924
  // Set regardless of isTopLayer — see openEffect's own identical
66601
66925
  // comment for why hidePopover() alone isn't reliably sufficient.
66602
66926
  backdropEl.setAttribute("navi-hidden", "");
66603
66927
  if (isTopLayer) {
66604
66928
  backdropEl.hidePopover();
66605
66929
  }
66930
+ if (group && clickEvent) {
66931
+ handWallClickToPeer(clickEvent, {
66932
+ wallEl: backdropEl,
66933
+ popupEl: popoverEl,
66934
+ anchorEl: anchorElement,
66935
+ group
66936
+ });
66937
+ }
66606
66938
  });
66607
66939
  }
66608
66940
  restoreFocus?.(closeEvent);
@@ -67024,6 +67356,10 @@ const Popup = props => {
67024
67356
  scrollCapture,
67025
67357
  positionAreaFixed,
67026
67358
  positionAreaWhenAnchorIsInvalid,
67359
+ // A top-layer dialog's wall is its native ::backdrop, which goes with the
67360
+ // dialog on the press that closes it: no click is left on it to hand to a
67361
+ // peer (see handWallClickToPeer).
67362
+ group,
67027
67363
  // Dialog-only, destructured out for the same reason: forwarded below only
67028
67364
  // in the dialog branch, so they never reach the popover element.
67029
67365
  dockedOnSmallTouchScreen,
@@ -67073,6 +67409,7 @@ const Popup = props => {
67073
67409
  positionAreaWhenAnchorIsInvalid: positionAreaWhenAnchorIsInvalid,
67074
67410
  scrollCapture: scrollCapture === "popover" || scrollCapture,
67075
67411
  positionAreaFixed: positionAreaFixed,
67412
+ group: group,
67076
67413
  className: withPropsClassName("navi_popup", className),
67077
67414
  expand: expand,
67078
67415
  expandX: expandX,
@@ -67976,13 +68313,18 @@ const PickerContentInsidePopup = props => {
67976
68313
  calloutIcon,
67977
68314
  calloutCloseButton,
67978
68315
  calloutCloseByScroll,
68316
+ popupGroup,
67979
68317
  ...rest
67980
68318
  } = props;
67981
68319
  const isPopover = mode === "popover";
67982
68320
  const isCallout = mode === "callout";
67983
68321
  return jsx(Next, {
67984
68322
  "aria-haspopup": isPopover ? "listbox" : "dialog",
67985
- "navi-popover-mode": isPopover ? popoverMode : undefined,
68323
+ "navi-popover-mode": isPopover ? popoverMode : undefined
68324
+ // On the trigger, whatever the mode: a peer's popup reads it off the
68325
+ // press that lands here (see `group` in callout.js).
68326
+ ,
68327
+ "data-navi-popup-group": popupGroup,
67986
68328
  ...rest,
67987
68329
  // On popupProps already (see the picker's popup assembly); they mean
67988
68330
  // nothing to the picker element.
@@ -68018,7 +68360,12 @@ const PickerContentInsidePopup = props => {
68018
68360
  status: calloutStatus,
68019
68361
  icon: calloutIcon,
68020
68362
  closeButton: calloutCloseButton,
68021
- closeByScroll: calloutCloseByScroll,
68363
+ closeByScroll: calloutCloseByScroll
68364
+ // Left unsaid, the callout's own default: no backdrop. It dims
68365
+ // nothing, so the page looks as pressable as it did before.
68366
+ ,
68367
+ backdrop: backdrop,
68368
+ group: popupGroup,
68022
68369
  children: jsx(PopupModeContext.Provider, {
68023
68370
  value: mode,
68024
68371
  children: children
@@ -68055,6 +68402,7 @@ const PickerContentInsidePopup = props => {
68055
68402
  backdropVariant: backdropVariant,
68056
68403
  backdropColor: backdropColor,
68057
68404
  backdropFilter: backdropFilter,
68405
+ group: popupGroup,
68058
68406
  focusCapture: isPopover ? focusCapture : undefined,
68059
68407
  expand: isPopover ? undefined : dialogExpand,
68060
68408
  expandX: isPopover ? undefined : dialogExpandX,
@@ -68114,6 +68462,8 @@ const PickerCalloutPopup = ({
68114
68462
  icon,
68115
68463
  closeButton,
68116
68464
  closeByScroll,
68465
+ backdrop,
68466
+ group,
68117
68467
  onnavi_request_open,
68118
68468
  onnavi_request_close,
68119
68469
  onnavi_request_confirm,
@@ -68155,6 +68505,8 @@ const PickerCalloutPopup = ({
68155
68505
  icon,
68156
68506
  closeButton,
68157
68507
  closeByScroll,
68508
+ backdrop,
68509
+ group,
68158
68510
  anchorElement,
68159
68511
  // The request, chained to the press that made it: the callout reads the
68160
68512
  // mousedown off it to wait for the release before listening for a click
@@ -70321,7 +70673,13 @@ const ListRowContext = createContext(null);
70321
70673
  // row takes its place in the collection by slot: the place is then the list's
70322
70674
  // to move, and the row's to follow — see list_rows.js.
70323
70675
  const ListSlotContext = createContext(null);
70324
- const css$x = /* css */`@layer navi {
70676
+ const css$x = /* css */`@property --list-group-label-height {
70677
+ syntax: "<length>";
70678
+ inherits: false;
70679
+ initial-value: 0;
70680
+ }
70681
+
70682
+ @layer navi {
70325
70683
  .navi_list_container {
70326
70684
  --list-outline-width: 1px;
70327
70685
  --list-border-radius: 4px;
@@ -70720,6 +71078,7 @@ const css$x = /* css */`@layer navi {
70720
71078
  }
70721
71079
 
70722
71080
  & .navi_list_item_group_list {
71081
+ --list-group-label-height: inherit;
70723
71082
  flex-direction: column;
70724
71083
  width: 100%;
70725
71084
  margin: 0;
@@ -70727,8 +71086,12 @@ const css$x = /* css */`@layer navi {
70727
71086
  list-style: none;
70728
71087
  display: flex;
70729
71088
 
71089
+ & > .navi_list_item {
71090
+ --list-group-label-height: inherit;
71091
+ }
71092
+
70730
71093
  & .navi_list_item {
70731
- scroll-margin-top: calc(var(--x-list-scroll-spacing-top) + var(--list-group-label-height, 0px));
71094
+ scroll-margin-top: calc(var(--x-list-scroll-spacing-top) + var(--list-group-label-height));
70732
71095
  }
70733
71096
  }
70734
71097