@jsenv/dom 0.14.7 → 0.16.0

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.
Files changed (2) hide show
  1. package/dist/jsenv_dom.js +1476 -283
  2. package/package.json +2 -2
package/dist/jsenv_dom.js CHANGED
@@ -5514,6 +5514,16 @@ const performTabNavigation = (
5514
5514
  outsideOfElement = null,
5515
5515
  debug = () => {},
5516
5516
  excludeAriaHidden,
5517
+ // When reaching the edge of rootElement would normally wrap back
5518
+ // around inside it, escapeRoot changes that: Tab instead continues
5519
+ // past escapeRoot's *entire* subtree (not just rootElement's), landing
5520
+ // on the next/previous focusable element in the document beyond it.
5521
+ // Used by focus_trap.js when boundaryElement is a real container
5522
+ // (not document) — a trapped element nested inside a bigger container
5523
+ // (e.g. a local-layer Dialog) shouldn't just wrap on itself; Tab should
5524
+ // exit the whole container, skipping over any other focusable
5525
+ // siblings inside it (they're not part of what's actually trapped).
5526
+ escapeRoot = null,
5517
5527
  } = {},
5518
5528
  ) => {
5519
5529
  if (!isTabEvent$1(event)) {
@@ -5626,6 +5636,18 @@ const performTabNavigation = (
5626
5636
  if (nextFocusableElement) {
5627
5637
  return onTargetToFocus(nextFocusableElement);
5628
5638
  }
5639
+ if (escapeRoot) {
5640
+ // Skip escapeRoot's own children entirely — anything else still
5641
+ // inside it (a sibling of rootElement) isn't part of what's
5642
+ // trapped, so it must never become the next Tab stop either.
5643
+ const nextOutsideEscapeRoot = findAfter(escapeRoot, predicate, {
5644
+ skipChildren: true,
5645
+ });
5646
+ if (nextOutsideEscapeRoot) {
5647
+ return onTargetToFocus(nextOutsideEscapeRoot);
5648
+ }
5649
+ return false;
5650
+ }
5629
5651
  // Wrap around: go back to the first focusable element in root.
5630
5652
  const firstFocusableElement = findDescendant(rootElement, predicate, {
5631
5653
  skipRoot: outsideOfElement,
@@ -5658,6 +5680,16 @@ const performTabNavigation = (
5658
5680
  if (previousFocusableElement) {
5659
5681
  return onTargetToFocus(previousFocusableElement);
5660
5682
  }
5683
+ if (escapeRoot) {
5684
+ // findBefore already searches strictly *before* escapeRoot's own
5685
+ // position (previous sibling / ancestor's previous sibling), never
5686
+ // descending into its children — exactly "outside its subtree".
5687
+ const previousOutsideEscapeRoot = findBefore(escapeRoot, predicate);
5688
+ if (previousOutsideEscapeRoot) {
5689
+ return onTargetToFocus(previousOutsideEscapeRoot);
5690
+ }
5691
+ return false;
5692
+ }
5661
5693
  // Wrap around: go back to the last focusable element in root.
5662
5694
  const lastFocusableElement = findLastDescendant(rootElement, predicate, {
5663
5695
  skipRoot: outsideOfElement,
@@ -5855,12 +5887,19 @@ const preventFocusNavViaKeyboard = (keyboardEvent) => {
5855
5887
  *
5856
5888
  * Once active:
5857
5889
  * - **Tab / Shift+Tab** cycle through focusable descendants of `element`,
5858
- * wrapping from last → first and first → last. If no focusable element
5859
- * exists, the default browser Tab action is suppressed so focus cannot
5860
- * escape.
5890
+ * wrapping from last → first and first → last *unless* `boundaryElement`
5891
+ * is a real container (not `document`), in which case Tab escapes the
5892
+ * whole container instead of wrapping (see `boundaryElement`'s own doc).
5893
+ * If no focusable element exists, the default browser Tab action is
5894
+ * suppressed so focus cannot escape.
5861
5895
  * - **Mouse clicks** outside `element` are only blocked when `pointerTrap`
5862
5896
  * is `true`. Backdrop clicks (on `<dialog>` elements) still propagate even
5863
5897
  * then, so the dialog can close itself.
5898
+ * - **Focus entering `boundaryElement` from outside it** (e.g. a `focus()`
5899
+ * call, or Tab arriving from further out in the document) always lands on
5900
+ * `element`'s own first focusable descendant — never on some other
5901
+ * focusable sibling `boundaryElement` happens to also contain. Only
5902
+ * relevant when `boundaryElement` isn't `document` (see below).
5864
5903
  *
5865
5904
  * Multiple traps can be stacked. When a new trap is activated the previous
5866
5905
  * one is paused; when the new trap is released the previous one resumes.
@@ -5873,11 +5912,30 @@ const preventFocusNavViaKeyboard = (keyboardEvent) => {
5873
5912
  * Backdrop clicks (target is a `<dialog>` element) only receive `preventDefault`
5874
5913
  * and still propagate, allowing the dialog to react to them (e.g. close itself).
5875
5914
  * @param {Function} [options.debug] - Optional debug logger passed to tab navigation.
5915
+ * @param {Document|HTMLElement} [options.boundaryElement=document] - Where the
5916
+ * mousedown/keydown/focusin listeners are attached. Defaults to `document`
5917
+ * (a genuinely page-wide modal — the usual case, where none of the
5918
+ * container-specific behavior below applies). Pass a specific container
5919
+ * element instead for a trap that should only apply *within* that
5920
+ * container: a Tab press or click occurring entirely outside it never
5921
+ * reaches a listener attached there at all (events only bubble through
5922
+ * their own ancestor chain), so the rest of the page keeps its normal tab
5923
+ * order/interactions untouched. Inside the container, `element` behaves
5924
+ * as if it were the *only* focusable thing `boundaryElement` contains:
5925
+ * Tab reaching either edge of `element` skips over any other focusable
5926
+ * sibling sharing the container, exiting the container entirely (not
5927
+ * wrapping back into `element`), and focus arriving at some other
5928
+ * focusable sibling inside the container gets redirected into `element`'s
5929
+ * own first focusable descendant instead. Used by Dialog's own
5930
+ * `layer="local"` renderer, which is only meant to be modal within its
5931
+ * own positioned ancestor, not the whole document — a case where that
5932
+ * ancestor can genuinely contain other, unrelated focusable content
5933
+ * (e.g. a trigger button placed right next to it).
5876
5934
  * @returns {() => void} Cleanup function — call it to release the trap.
5877
5935
  */
5878
5936
  const trapFocusInside = (
5879
5937
  element,
5880
- { debug, pointerTrap = false } = {},
5938
+ { debug, pointerTrap = false, boundaryElement = document } = {},
5881
5939
  ) => {
5882
5940
  if (element.nodeType === 3) {
5883
5941
  console.warn("cannot trap focus inside a text node");
@@ -5902,6 +5960,10 @@ const trapFocusInside = (
5902
5960
  return true;
5903
5961
  };
5904
5962
 
5963
+ // A real container (not document) — element must behave as the only
5964
+ // focusable thing boundaryElement contains, see this file's own doc.
5965
+ const escapeRoot = boundaryElement === document ? null : boundaryElement;
5966
+
5905
5967
  const lock = () => {
5906
5968
  const onmousedown = pointerTrap
5907
5969
  ? (event) => {
@@ -5928,6 +5990,7 @@ const trapFocusInside = (
5928
5990
  const handled = performTabNavigation(event, {
5929
5991
  rootElement: element,
5930
5992
  debug,
5993
+ escapeRoot,
5931
5994
  });
5932
5995
  if (!handled) {
5933
5996
  // No focusable target found — prevent the browser from moving focus outside the trap.
@@ -5936,25 +5999,50 @@ const trapFocusInside = (
5936
5999
  }
5937
6000
  };
5938
6001
 
6002
+ // Focus landing on some other focusable sibling boundaryElement also
6003
+ // contains (not element itself) gets redirected into element's own
6004
+ // first focusable descendant — e.g. a direct .focus() call, or Tab
6005
+ // arriving from further out in the document. Click-driven focus theft
6006
+ // is already prevented above by onmousedown (when pointerTrap is on);
6007
+ // this covers the rest (keyboard-driven entry, programmatic focus()).
6008
+ const onfocusin = escapeRoot
6009
+ ? (event) => {
6010
+ const target = event.target;
6011
+ if (target === element || element.contains(target)) {
6012
+ return;
6013
+ }
6014
+ const firstFocusable = findDescendant(element, (node) =>
6015
+ elementIsFocusable(node),
6016
+ );
6017
+ firstFocusable?.focus();
6018
+ }
6019
+ : null;
6020
+
5939
6021
  if (onmousedown) {
5940
- document.addEventListener("mousedown", onmousedown, {
6022
+ boundaryElement.addEventListener("mousedown", onmousedown, {
5941
6023
  capture: true,
5942
6024
  passive: false,
5943
6025
  });
5944
6026
  }
5945
- document.addEventListener("keydown", onkeydown, {
6027
+ boundaryElement.addEventListener("keydown", onkeydown, {
5946
6028
  capture: true,
5947
6029
  passive: false,
5948
6030
  });
6031
+ if (onfocusin) {
6032
+ boundaryElement.addEventListener("focusin", onfocusin);
6033
+ }
5949
6034
 
5950
6035
  return () => {
5951
6036
  if (onmousedown) {
5952
- document.removeEventListener("mousedown", onmousedown, {
6037
+ boundaryElement.removeEventListener("mousedown", onmousedown, {
5953
6038
  capture: true,
5954
6039
  passive: false,
5955
6040
  });
5956
6041
  }
5957
- document.removeEventListener("keydown", onkeydown, {
6042
+ if (onfocusin) {
6043
+ boundaryElement.removeEventListener("focusin", onfocusin);
6044
+ }
6045
+ boundaryElement.removeEventListener("keydown", onkeydown, {
5958
6046
  capture: true,
5959
6047
  passive: false,
5960
6048
  });
@@ -6235,7 +6323,10 @@ const getScrollContainer = (arg, { includeHidden } = {}) => {
6235
6323
  }
6236
6324
  return null;
6237
6325
  }
6238
- if (element.hasAttribute("popover") && element.matches(":popover-open")) {
6326
+ if (element.hasAttribute("popover")) {
6327
+ return getScrollingElement(element.ownerDocument);
6328
+ }
6329
+ if (element.tagName === "DIALOG" && element.matches(":modal")) {
6239
6330
  return getScrollingElement(element.ownerDocument);
6240
6331
  }
6241
6332
  const position = getStyle(element, "position");
@@ -6640,6 +6731,24 @@ const viewportPosToScrollRelativePos = (
6640
6731
  ];
6641
6732
  };
6642
6733
 
6734
+ // position: fixed is already viewport-relative, so no scroll offset is
6735
+ // needed to place it correctly — adding one would double-count the scroll.
6736
+ // position: absolute (assumed relative to the initial containing block, the
6737
+ // common case for a document-relative absolutely positioned element) needs
6738
+ // the current scroll offset added to convert a viewport-relative coordinate
6739
+ // into one it can be set to directly. Read the element's own computed style
6740
+ // rather than assuming one or the other, since callers may use either.
6741
+ const getPositioningScrollOffset = (element) => {
6742
+ const isFixed = getComputedStyle(element).position === "fixed";
6743
+ if (isFixed) {
6744
+ return { scrollLeft: 0, scrollTop: 0 };
6745
+ }
6746
+ return {
6747
+ scrollLeft: documentElement$1.scrollLeft,
6748
+ scrollTop: documentElement$1.scrollTop,
6749
+ };
6750
+ };
6751
+
6643
6752
  const addScrollToRect = (scrollRelativeRect) => {
6644
6753
  const { left, top, width, height, scrollLeft, scrollTop } =
6645
6754
  scrollRelativeRect;
@@ -7257,6 +7366,74 @@ const applyWheelScrollThrough = (element, wheelEvent) => {
7257
7366
  });
7258
7367
  };
7259
7368
 
7369
+ /**
7370
+ * The element `element` is genuinely `position: absolute`/`fixed` relative
7371
+ * to: its own nearest positioned ancestor (walking up the DOM tree), or
7372
+ * `document.documentElement` (the viewport) if none is found.
7373
+ *
7374
+ * Also aware of `element` itself being promoted to the top layer: a
7375
+ * `<dialog>` actually shown modally (`showModal()`, matches `:modal` — a
7376
+ * `.show()`'d, non-modal dialog does NOT match and is positioned like any
7377
+ * other in-flow element instead, walked up normally below), or *any*
7378
+ * `[popover]` element, always uses the initial containing block (the
7379
+ * viewport) regardless of its own `position` or DOM ancestry — walking up
7380
+ * its own parent chain (what the rest of this function does) would give
7381
+ * the wrong answer for these two specifically, since their real DOM
7382
+ * position becomes irrelevant to their own containing block the moment
7383
+ * they're actually promoted. Checked via the `popover` attribute itself,
7384
+ * not the live `:popover-open` state — unlike `<dialog>`, a `[popover]`
7385
+ * element has no "local" mode: it's always top-layer-bound once shown,
7386
+ * regardless of whether it happens to be open right this moment, so the
7387
+ * static attribute alone is enough (and correct even when called just
7388
+ * before `showPopover()` actually runs, when `:popover-open` isn't true
7389
+ * yet).
7390
+ *
7391
+ * `document.documentElement` (not `document.body`, not `null`) is this
7392
+ * function's own "no real container — use the viewport" sentinel:
7393
+ * `documentElement` is the actual initial containing block, so the walk
7394
+ * below stops there without testing its own `position` (there's nothing
7395
+ * beyond it to fall back to anyway) — unlike the previous version of this
7396
+ * function, which stopped one level too early, at `document.body`, without
7397
+ * ever testing *its* `position` either (a `position: relative` body, for
7398
+ * instance, would have been silently skipped). Returning `documentElement`
7399
+ * instead of `null` also means no special-casing is needed by callers that
7400
+ * already compare a resolved container against `document.documentElement`
7401
+ * (see e.g. visible_rect.js's own `hasRealContainer` check).
7402
+ */
7403
+ const getPositionedParent = (element) => {
7404
+ const isPromotedToTopLayer =
7405
+ (element.tagName === "DIALOG" && element.matches(":modal")) ||
7406
+ element.hasAttribute("popover");
7407
+ if (isPromotedToTopLayer) {
7408
+ return document.documentElement;
7409
+ }
7410
+ let parent = element.parentElement;
7411
+ while (parent && parent !== document.documentElement) {
7412
+ const position = window.getComputedStyle(parent).position;
7413
+ if (
7414
+ position === "relative" ||
7415
+ position === "absolute" ||
7416
+ position === "fixed"
7417
+ ) {
7418
+ return parent;
7419
+ }
7420
+ parent = parent.parentElement;
7421
+ }
7422
+ return document.documentElement;
7423
+ };
7424
+
7425
+ /**
7426
+ * Walks `element` and its ancestors (stopping at, but not including,
7427
+ * `document.documentElement`) looking for the first one whose *computed*
7428
+ * `position` is `fixed` — i.e. pinned to the viewport, ignoring document
7429
+ * scroll, regardless of what `element` itself is positioned relative to.
7430
+ *
7431
+ * @param {Element} element
7432
+ * @returns {[left: number, top: number] | null} The fixed ancestor's own
7433
+ * viewport-relative `getBoundingClientRect()` origin, or `null` if neither
7434
+ * `element` nor any ancestor is fixed (i.e. `element` genuinely scrolls
7435
+ * with the document).
7436
+ */
7260
7437
  const findSelfOrAncestorFixedPosition = (element) => {
7261
7438
  let current = element;
7262
7439
  while (true) {
@@ -7354,14 +7531,18 @@ const createDragElementPositioner = (
7354
7531
  let scrollableTop;
7355
7532
  let convertScrollablePosition;
7356
7533
 
7357
- const positionedParent = elementToMove
7358
- ? elementToMove.offsetParent
7359
- : element.offsetParent;
7534
+ // getPositionedParent, not raw .offsetParent — offsetParent is null for a
7535
+ // position: fixed element, and also for one promoted to the top layer
7536
+ // (e.g. a <dialog>/[popover] being dragged by its own handle), which
7537
+ // crashes the fixed-position lookup below (findSelfOrAncestorFixedPosition
7538
+ // assumes a real starting element, not null). getPositionedParent never
7539
+ // returns null (document.documentElement instead — see its own doc).
7540
+ const positionedParent = getPositionedParent(elementToMove || element);
7360
7541
  const scrollContainer = getScrollContainer(element);
7361
7542
  const [getPositionOffsets, getScrollOffsets] = createGetOffsets({
7362
7543
  positionedParent,
7363
7544
  referencePositionedParent: referenceElement
7364
- ? referenceElement.offsetParent
7545
+ ? getPositionedParent(referenceElement)
7365
7546
  : positionedParent,
7366
7547
  scrollContainer,
7367
7548
  referenceScrollContainer: referenceElement
@@ -7615,7 +7796,7 @@ const isOverlayOf = (element, potentialTarget) => {
7615
7796
  if (overlayTarget === potentialTarget) {
7616
7797
  return true;
7617
7798
  }
7618
- const overlayTargetPositionedParent = overlayTarget.offsetParent;
7799
+ const overlayTargetPositionedParent = getPositionedParent(overlayTarget);
7619
7800
  if (overlayTargetPositionedParent === potentialTarget) {
7620
7801
  return true;
7621
7802
  }
@@ -10758,20 +10939,160 @@ const getResizeDirection = (element) => {
10758
10939
  return { x, y };
10759
10940
  };
10760
10941
 
10761
- const getPositionedParent = (element) => {
10762
- let parent = element.parentElement;
10763
- while (parent && parent !== document.body) {
10764
- const position = window.getComputedStyle(parent).position;
10765
- if (
10766
- position === "relative" ||
10767
- position === "absolute" ||
10768
- position === "fixed"
10769
- ) {
10770
- return parent;
10942
+ // Shared by navi's own use_displayed_layout_effect.js (rich "navi_displayed"
10943
+ // CustomEvent, open transitions only) and visible_rect.js (needs both
10944
+ // directions: hide when a container closes, recheck when it reopens) — the
10945
+ // selector/open-detection/timing primitives are identical for both, only
10946
+ // what each does with a transition differs.
10947
+ const ANCESTOR_OPEN_SELECTOR = "dialog, details, [popover], [aria-expanded]";
10948
+
10949
+ const closestOpenableAncestor = (element) => {
10950
+ const parentElement = element.parentElement;
10951
+ if (!parentElement) {
10952
+ return null;
10953
+ }
10954
+ if (!parentElement.closest) {
10955
+ return null;
10956
+ }
10957
+ return parentElement.closest(ANCESTOR_OPEN_SELECTOR);
10958
+ };
10959
+
10960
+ const isAncestorOpen = (ancestor) => {
10961
+ if (ancestor.tagName === "DIALOG" || ancestor.hasAttribute("popover")) {
10962
+ return ancestor.matches(":popover-open, [open]");
10963
+ }
10964
+ if (ancestor.tagName === "DETAILS") {
10965
+ return ancestor.open;
10966
+ }
10967
+ if (ancestor.hasAttribute("aria-expanded")) {
10968
+ return ancestor.getAttribute("aria-expanded") === "true";
10969
+ }
10970
+ return true;
10971
+ };
10972
+
10973
+ const getAncestorOpenType = (ancestor) => {
10974
+ if (ancestor === document) {
10975
+ return "document";
10976
+ }
10977
+ if (ancestor.tagName === "DIALOG") {
10978
+ return "dialog";
10979
+ }
10980
+ if (ancestor.hasAttribute("popover")) {
10981
+ return "popover";
10982
+ }
10983
+ if (ancestor.tagName === "DETAILS") {
10984
+ return "details";
10985
+ }
10986
+ if (ancestor.hasAttribute("aria-expanded")) {
10987
+ return `${ancestor.tagName}[aria-expanded]`;
10988
+ }
10989
+ return `${ancestor.tagName}`;
10990
+ };
10991
+
10992
+ /**
10993
+ * Notifies `callback({ isOpen, ancestor, ancestorType, toggleEvent })` the
10994
+ * moment `ancestor`'s open state changes, in either direction — timed to
10995
+ * land strictly before the browser's next paint, so a caller reacting to it
10996
+ * (measurement, visibility tracking, layout) never flashes the stale state
10997
+ * first. Plain object, not a CustomEvent — there's no real DOM event behind
10998
+ * most of these transitions (see `toggleEvent` below), so wrapping the info
10999
+ * in one would mostly be manufacturing a fake event for no benefit.
11000
+ *
11001
+ * We deliberately do NOT use the native `toggle` event as the primary
11002
+ * signal, even though every <dialog>/<details>/[popover] fires one: per the
11003
+ * WHATWG spec it's dispatched via a *queued task* ("queue a popover toggle
11004
+ * event task"), not synchronously and not as a microtask. The element's
11005
+ * shown state itself (showPopover()/showModal()) still flips synchronously,
11006
+ * so the browser can — and does — paint it in its default, uncorrected
11007
+ * state before that queued task ever runs. Relying on `toggle` alone means
11008
+ * a reaction to it always arrives one paint late.
11009
+ *
11010
+ * Instead we watch `open`/`aria-expanded` via MutationObserver:
11011
+ * - <dialog>/<details> reflect `open` themselves, natively, synchronously.
11012
+ * - navi's own Popover.jsx sets `aria-expanded` synchronously in the same
11013
+ * call stack as showPopover() (see popover.jsx's own aria-expanded
11014
+ * comments) — not part of any web standard, just that library's own
11015
+ * convention, but reliable for anything built through it.
11016
+ * MutationObserver callbacks run as a microtask, strictly before paint —
11017
+ * exactly the timing needed, no ambiguity. `toggleEvent` is `undefined` on
11018
+ * this path (there's no native event to report — a mutation record isn't
11019
+ * one).
11020
+ *
11021
+ * The `toggle` listener is kept as a fallback, attached ONLY where the
11022
+ * MutationObserver above has no chance of ever firing: a bare [popover]
11023
+ * element with no `aria-expanded` of its own — i.e. one not built through
11024
+ * navi's own Popover.jsx (the only thing that reliably sets it). That's the
11025
+ * one case with no other synchronously-observable signal at all. It still
11026
+ * arrives a paint late, but a late correction beats none. `toggleEvent` is
11027
+ * the real `toggle` event on this path.
11028
+ *
11029
+ * @param {Element} ancestor
11030
+ * @param {(info: { isOpen: boolean, ancestor: Element, ancestorType: string, toggleEvent: Event | undefined }) => void} callback
11031
+ * @returns {() => void} cleanup — removes the observer/listener
11032
+ */
11033
+ const observeAncestorOpenState = (ancestor, callback) => {
11034
+ const ancestorType = getAncestorOpenType(ancestor);
11035
+ const needsToggleFallback =
11036
+ ancestor.hasAttribute("popover") && !ancestor.hasAttribute("aria-expanded");
11037
+ if (needsToggleFallback) {
11038
+ const onToggle = (toggleEvent) => {
11039
+ callback({
11040
+ isOpen: isAncestorOpen(ancestor),
11041
+ ancestor,
11042
+ ancestorType,
11043
+ toggleEvent,
11044
+ });
11045
+ };
11046
+ ancestor.addEventListener("toggle", onToggle);
11047
+ return () => {
11048
+ ancestor.removeEventListener("toggle", onToggle);
11049
+ };
11050
+ }
11051
+
11052
+ // Edge-triggered on purpose: some consumers (e.g. Popover.jsx) set
11053
+ // aria-expanded both imperatively (in their own openEffect, for precise
11054
+ // ordering relative to forced reflows/transitions) AND declaratively via a
11055
+ // JSX prop derived from the same open state — the latter is a deliberate
11056
+ // "always reflect current truth" prop, but Preact diffs against its own
11057
+ // previous *rendered* value, not the live DOM, so any later re-render that
11058
+ // happens to occur while already open re-applies the same "true" value as
11059
+ // a genuinely new attribute mutation. Tracking wasOpen here collapses that
11060
+ // redundant open→open (or close→close) mutation instead of notifying
11061
+ // callback a second time for the same state.
11062
+ let wasOpen = isAncestorOpen(ancestor);
11063
+ const observer = new MutationObserver(() => {
11064
+ const isOpen = isAncestorOpen(ancestor);
11065
+ if (isOpen === wasOpen) {
11066
+ return;
10771
11067
  }
10772
- parent = parent.parentElement;
11068
+ wasOpen = isOpen;
11069
+ callback({
11070
+ isOpen,
11071
+ ancestor,
11072
+ ancestorType,
11073
+ toggleEvent: undefined,
11074
+ });
11075
+ });
11076
+ observer.observe(ancestor, {
11077
+ attributes: true,
11078
+ attributeFilter: ["open", "aria-expanded"],
11079
+ });
11080
+ return () => {
11081
+ observer.disconnect();
11082
+ };
11083
+ };
11084
+
11085
+ const onAncestorReopen = (el, callback) => {
11086
+ const nearestOpenableAncestor = closestOpenableAncestor(el);
11087
+ if (!nearestOpenableAncestor) {
11088
+ return () => {};
10773
11089
  }
10774
- return document.body;
11090
+ return observeAncestorOpenState(nearestOpenableAncestor, ({ isOpen }) => {
11091
+ if (!isOpen) {
11092
+ return;
11093
+ }
11094
+ callback();
11095
+ });
10775
11096
  };
10776
11097
 
10777
11098
  const getHeight = (element) => {
@@ -11100,6 +11421,49 @@ const stickyAsRelativeCoords = (
11100
11421
  return [leftPosition, topPosition];
11101
11422
  };
11102
11423
 
11424
+ // Both "resize" sources fire transiently on mobile (keyboard/UI chrome
11425
+ // briefly shifting when focus moves between inputs) — debounced so
11426
+ // consumers skip that in-between state. One shared timer per source (not
11427
+ // one per subscriber) so everything settles on the same tick.
11428
+ const RESIZE_SETTLE_MS = 100;
11429
+
11430
+ // Set while a visualViewport resize is debouncing, cleared once it settles —
11431
+ // read by the window resize listener below.
11432
+ let visualViewportResizePending = false;
11433
+
11434
+ const [publishVisualViewportResize, subscribeVisualViewportResizeSettled] =
11435
+ createPubSub();
11436
+ const [publishWindowResize, subscribeWindowResizeSettled] = createPubSub();
11437
+
11438
+ if (window.visualViewport) {
11439
+ let timeoutId;
11440
+ window.visualViewport.addEventListener("resize", (event) => {
11441
+ visualViewportResizePending = true;
11442
+ clearTimeout(timeoutId);
11443
+ timeoutId = setTimeout(() => {
11444
+ visualViewportResizePending = false;
11445
+ publishVisualViewportResize(event);
11446
+ }, RESIZE_SETTLE_MS);
11447
+ });
11448
+ }
11449
+
11450
+ let windowResizeTimeoutId;
11451
+ window.addEventListener("resize", (event) => {
11452
+ clearTimeout(windowResizeTimeoutId);
11453
+ // Mobile browsers appear to dispatch visualViewport resize, then window
11454
+ // resize, then visualViewport resize again for the same keyboard/UI-chrome
11455
+ // shift — debounce the same way only when it looks like part of that
11456
+ // sequence (a visualViewport resize is already pending); otherwise react
11457
+ // immediately, so a genuine window resize isn't delayed for nothing.
11458
+ if (!visualViewportResizePending) {
11459
+ publishWindowResize(event);
11460
+ return;
11461
+ }
11462
+ windowResizeTimeoutId = setTimeout(() => {
11463
+ publishWindowResize(event);
11464
+ }, RESIZE_SETTLE_MS);
11465
+ });
11466
+
11103
11467
  // Minimum fraction of element width/height that must be visible on the preferred side
11104
11468
  // before flipping to the opposite side. Prevents flickering near the flip threshold.
11105
11469
  const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
@@ -11125,10 +11489,10 @@ const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
11125
11489
  * Is 0 when ancestorClosed is true.
11126
11490
  *
11127
11491
  * @typedef {Object} VisibleRectInfo
11128
- * @property {Event} event - The DOM event (or CustomEvent) that triggered the check.
11129
- * @property {number} width - Raw getBoundingClientRect() width of the element.
11130
- * @property {number} height - Raw getBoundingClientRect() height of the element.
11131
- * @property {boolean} ancestorClosed - True when a popover, dialog, or details ancestor is
11492
+ * @property {Event} event - The DOM event (or CustomEvent) that triggered the check.
11493
+ * @property {number} width - Raw getBoundingClientRect() width of the element.
11494
+ * @property {number} height - Raw getBoundingClientRect() height of the element.
11495
+ * @property {boolean} ancestorClosed - True when a popover, dialog, or details ancestor is
11132
11496
  * currently closed so the element is not rendered. All visibleRect values are 0 in that case.
11133
11497
  * update() is called immediately on ancestor close and again (with false) on reopen.
11134
11498
  *
@@ -11136,6 +11500,7 @@ const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
11136
11500
  * - Once synchronously on initialization (event.type = "initialization")
11137
11501
  * - On document/container scroll, window resize, element resize, intersection changes, touch move
11138
11502
  * - Immediately when an ancestor popover/dialog/details opens or closes
11503
+ * - Immediately when an ancestor popover/dialog starts or stops repositioning itself
11139
11504
  *
11140
11505
  * A bit like https://tetherjs.dev/ but different
11141
11506
  */
@@ -11148,14 +11513,85 @@ const visibleRectEffect = (
11148
11513
  } = {},
11149
11514
  ) => {
11150
11515
  const [teardown, addTeardown] = createPubSub();
11151
- const scrollContainer = getScrollContainer(element);
11516
+ // getScrollContainer(document.documentElement) returns null specifically
11517
+ // when the document itself has no overflow to scroll (e.g. a small
11518
+ // dialog/popover on an otherwise short page) — document.documentElement
11519
+ // is still a perfectly valid fallback in that case (scrollLeft/scrollTop
11520
+ // are just 0), so this never needs to crash the way a bare
11521
+ // `getScrollContainer(element)` result would below.
11522
+ const scrollContainer =
11523
+ getScrollContainer(element) ?? document.documentElement;
11152
11524
  const scrollContainerIsDocument =
11153
11525
  scrollContainer === document.documentElement;
11154
11526
  let lastMeasuredWidth;
11155
11527
  let lastMeasuredHeight;
11156
11528
  let ancestorClosedCount = 0;
11529
+ // Every ResizeObserver this effect owns (its own element-resize watcher
11530
+ // below, plus one per observeSize() call) unobserves itself the moment an
11531
+ // ancestor closes, reobserving once it reopens (see on_ancestor_events) —
11532
+ // closing a dialog/popover containing several watched elements can make
11533
+ // them all collapse to zero size in the same reflow, which is what trips
11534
+ // the browser's "ResizeObserver loop completed with undelivered
11535
+ // notifications" warning. Proactively unobserving avoids generating those
11536
+ // notifications instead of just reacting differently to them.
11537
+ // Set while an ancestor is itself mid-repositioning — on_ancestor_events'
11538
+ // own onNaviPositionTransition already drives this element's position
11539
+ // every frame for that duration, more accurately than the direct
11540
+ // window/visualViewport resize reaction below (on_resize) could. Gates
11541
+ // that reaction so it doesn't also fire mid-transition and animate its
11542
+ // own competing move toward a target computed from the anchor's still
11543
+ // mid-flight rect, racing the frame-by-frame follow loop.
11544
+ let ancestorRepositioningCount = 0;
11545
+ // check() runs on every scroll/resize/frame of an ancestor's own
11546
+ // transition, but plenty of those land on an identical result — skip
11547
+ // calling update() again when neither snapshot changed. Two snapshots,
11548
+ // not one, because pickPositionRelativeTo depends on both separately:
11549
+ // - lastVisibleRect: left/top/width/height, plus visibilityRatio (which
11550
+ // can change on its own — see its own ratio formula further down —
11551
+ // without any of the other four moving).
11552
+ // - lastViewportRect: not part of visibleRect at all, but an on-screen
11553
+ // keyboard opening/closing can shrink the viewport without moving
11554
+ // this element's own visibleRect by a single pixel, and
11555
+ // pickPositionRelativeTo's available space depends on it too.
11556
+ let lastVisibleRect = null;
11557
+ let lastViewportRect = null;
11558
+ let resizeWatchingPaused = false;
11559
+ const [publishResizeWatchingPausedChange, onResizeWatchingPausedChange] =
11560
+ createPubSub();
11561
+ const pauseResizeWatching = () => {
11562
+ if (resizeWatchingPaused) {
11563
+ return;
11564
+ }
11565
+ resizeWatchingPaused = true;
11566
+ publishResizeWatchingPausedChange(true);
11567
+ };
11568
+ const resumeResizeWatching = () => {
11569
+ if (!resizeWatchingPaused) {
11570
+ return;
11571
+ }
11572
+ resizeWatchingPaused = false;
11573
+ publishResizeWatchingPausedChange(false);
11574
+ };
11157
11575
  const check = (event) => {
11158
11576
 
11577
+ // visualViewport, not window.innerWidth/Height: the layout viewport
11578
+ // doesn't shrink when the on-screen keyboard opens (same reasoning as
11579
+ // pickPositionRelativeTo's own identical choice). offsetLeft/Top matter
11580
+ // too, for pinch-zoom/pan. Computed here regardless of scroll container
11581
+ // (not just where the non-document branch below needs it) because a
11582
+ // keyboard opening can change pickPositionRelativeTo's available space
11583
+ // without moving this element's own visibleRect at all — see
11584
+ // viewportRectChanged further down.
11585
+ const visualViewport = window.visualViewport;
11586
+ const viewportWidth = visualViewport
11587
+ ? visualViewport.width
11588
+ : window.innerWidth;
11589
+ const viewportHeight = visualViewport
11590
+ ? visualViewport.height
11591
+ : window.innerHeight;
11592
+ const viewportOffsetLeft = visualViewport ? visualViewport.offsetLeft : 0;
11593
+ const viewportOffsetTop = visualViewport ? visualViewport.offsetTop : 0;
11594
+
11159
11595
  // 1. Calculate element position relative to scrollable parent
11160
11596
  const { scrollLeft, scrollTop } = scrollContainer;
11161
11597
  const visibleAreaLeft = scrollLeft;
@@ -11256,22 +11692,26 @@ const visibleRectEffect = (
11256
11692
  if (scrollContainerIsDocument) {
11257
11693
  visibilityRatio = (widthVisible * heightVisible) / (width * height);
11258
11694
  } else {
11259
- // widthVisible/heightVisible are already clipped to the scroll container.
11260
- // Now clip their viewport-relative counterparts against the viewport.
11261
- const viewportWidth = window.innerWidth;
11262
- const viewportHeight = window.innerHeight;
11695
+ // widthVisible/heightVisible are already clipped to the scroll
11696
+ // container. Now clip their viewport-relative counterparts against
11697
+ // the viewport (viewportWidth/Height/OffsetLeft/OffsetTop computed
11698
+ // once, at the top of check() — see their own comment there).
11263
11699
  // Container-clipped visible rect in viewport coordinates
11264
11700
  const visibleLeft = overlayLeft;
11265
11701
  const visibleTop = overlayTop;
11266
11702
  const visibleRight = overlayLeft + widthVisible;
11267
11703
  const visibleBottom = overlayTop + heightVisible;
11268
11704
  // Intersect with viewport
11269
- const clippedLeft = visibleLeft < 0 ? 0 : visibleLeft;
11270
- const clippedTop = visibleTop < 0 ? 0 : visibleTop;
11705
+ const clippedLeft =
11706
+ visibleLeft < viewportOffsetLeft ? viewportOffsetLeft : visibleLeft;
11707
+ const clippedTop =
11708
+ visibleTop < viewportOffsetTop ? viewportOffsetTop : visibleTop;
11709
+ const viewportRight = viewportOffsetLeft + viewportWidth;
11710
+ const viewportBottom = viewportOffsetTop + viewportHeight;
11271
11711
  const clippedRight =
11272
- visibleRight > viewportWidth ? viewportWidth : visibleRight;
11712
+ visibleRight > viewportRight ? viewportRight : visibleRight;
11273
11713
  const clippedBottom =
11274
- visibleBottom > viewportHeight ? viewportHeight : visibleBottom;
11714
+ visibleBottom > viewportBottom ? viewportBottom : visibleBottom;
11275
11715
  const clippedWidth =
11276
11716
  clippedRight > clippedLeft ? clippedRight - clippedLeft : 0;
11277
11717
  const clippedHeight =
@@ -11288,27 +11728,64 @@ const visibleRectEffect = (
11288
11728
  height: heightVisible,
11289
11729
  visibilityRatio,
11290
11730
  };
11291
- update(visibleRect, {
11292
- event,
11293
- width,
11294
- height,
11295
- ancestorClosed: ancestorClosedCount > 0,
11296
- });
11731
+ // Not part of visibleRect itself, tracked only so viewportRectChanged
11732
+ // below can catch a keyboard opening/closing even when it doesn't move
11733
+ // this element's own visibleRect.
11734
+ const viewportRect = {
11735
+ viewportWidth,
11736
+ viewportHeight,
11737
+ viewportOffsetLeft,
11738
+ viewportOffsetTop,
11739
+ };
11740
+ const notify = (reason) => {
11741
+ update(visibleRect, {
11742
+ event,
11743
+ width,
11744
+ height,
11745
+ ancestorClosed: ancestorClosedCount > 0,
11746
+ });
11747
+ };
11748
+
11749
+ const visibleRectChanged =
11750
+ !lastVisibleRect ||
11751
+ lastVisibleRect.left !== visibleRect.left ||
11752
+ lastVisibleRect.top !== visibleRect.top ||
11753
+ lastVisibleRect.width !== visibleRect.width ||
11754
+ lastVisibleRect.height !== visibleRect.height ||
11755
+ lastVisibleRect.visibilityRatio !== visibleRect.visibilityRatio;
11756
+ if (visibleRectChanged) {
11757
+ lastVisibleRect = visibleRect;
11758
+ lastViewportRect = viewportRect;
11759
+ notify();
11760
+ return;
11761
+ }
11762
+ const viewportRectChanged =
11763
+ !lastViewportRect ||
11764
+ lastViewportRect.viewportWidth !== viewportRect.viewportWidth ||
11765
+ lastViewportRect.viewportHeight !== viewportRect.viewportHeight ||
11766
+ lastViewportRect.viewportOffsetLeft !== viewportRect.viewportOffsetLeft ||
11767
+ lastViewportRect.viewportOffsetTop !== viewportRect.viewportOffsetTop;
11768
+ if (viewportRectChanged) {
11769
+ lastVisibleRect = visibleRect;
11770
+ lastViewportRect = viewportRect;
11771
+ notify();
11772
+ return;
11773
+ }
11297
11774
  };
11298
11775
 
11299
11776
  check(initialEvent);
11300
11777
 
11301
11778
  const [publishBeforeAutoCheck, onBeforeAutoCheck] = createPubSub();
11302
- {
11303
- const autoCheck = (event) => {
11304
- const beforeCheckResults = publishBeforeAutoCheck(event);
11305
- check(event);
11306
- for (const beforeCheckResult of beforeCheckResults) {
11307
- if (typeof beforeCheckResult === "function") {
11308
- beforeCheckResult();
11309
- }
11779
+ const autoCheck = (event) => {
11780
+ const beforeCheckResults = publishBeforeAutoCheck(event);
11781
+ check(event);
11782
+ for (const beforeCheckResult of beforeCheckResults) {
11783
+ if (typeof beforeCheckResult === "function") {
11784
+ beforeCheckResult();
11310
11785
  }
11311
- };
11786
+ }
11787
+ };
11788
+ {
11312
11789
  // let rafId = null;
11313
11790
  // const scheduleCheck = (reason) => {
11314
11791
  // cancelAnimationFrame(rafId);
@@ -11369,40 +11846,26 @@ const visibleRectEffect = (
11369
11846
  }
11370
11847
  }
11371
11848
  {
11372
- if (window.visualViewport) {
11373
- // visualViewport resize fires when the virtual keyboard opens/closes on mobile.
11374
- // On mobile, tapping from one input to another triggers a resize because
11375
- // the virtual keyboard briefly starts to close before the new input receives
11376
- // focus and the keyboard reopens. Debouncing prevents repositioning the
11377
- // during that transient state, which would cause a visible flicker.
11378
- let resizeTimeout;
11379
- const cancelDelayedAutoCheck = () => {
11380
- clearTimeout(resizeTimeout);
11381
- };
11382
- const onVisualViewportResize = (e) => {
11383
- cancelDelayedAutoCheck();
11384
- resizeTimeout = setTimeout(() => {
11385
- autoCheck(e);
11386
- }, 100);
11387
- };
11388
- window.visualViewport.addEventListener(
11389
- "resize",
11390
- onVisualViewportResize,
11391
- );
11392
- addTeardown(() => {
11393
- window.visualViewport.removeEventListener(
11394
- "resize",
11395
- onVisualViewportResize,
11396
- );
11397
- });
11398
- }
11399
- const onWindowResize = (e) => {
11400
- autoCheck(e);
11849
+ // See window_size.js's own module comment for why both of these go
11850
+ // through their shared debounce instead of each keeping its own timer.
11851
+ const onWindowOrViewportResize = (event) => {
11852
+ // An ancestor's own navi_position_transition follow loop (see
11853
+ // on_ancestor_events below) is already re-checking this element's
11854
+ // position every frame, tracking the ancestor's live in-flight
11855
+ // position — more accurately than this debounced, ~100ms-after-the-
11856
+ // fact check could. Reacting here too would race it: a real
11857
+ // "resize" event makes shouldTransition true, so this would animate
11858
+ // its own competing move toward a target computed from the
11859
+ // anchor's current (still mid-flight) rect.
11860
+ if (ancestorRepositioningCount > 0) {
11861
+ return;
11862
+ }
11863
+ autoCheck(event);
11401
11864
  };
11402
- window.addEventListener("resize", onWindowResize);
11403
- addTeardown(() => {
11404
- window.removeEventListener("resize", onWindowResize);
11405
- });
11865
+ addTeardown(
11866
+ subscribeVisualViewportResizeSettled(onWindowOrViewportResize),
11867
+ );
11868
+ addTeardown(subscribeWindowResizeSettled(onWindowOrViewportResize));
11406
11869
  }
11407
11870
  on_element_resize: {
11408
11871
  if (skipElementResize) {
@@ -11437,16 +11900,30 @@ const visibleRectEffect = (
11437
11900
  handlingResize = false;
11438
11901
  });
11439
11902
  resizeObserver.observe(element);
11903
+ const unsubscribeResizeWatchingPausedChange =
11904
+ onResizeWatchingPausedChange((paused) => {
11905
+ if (paused) {
11906
+ resizeObserver.unobserve(element);
11907
+ } else {
11908
+ resizeObserver.observe(element);
11909
+ }
11910
+ });
11440
11911
  // Temporarily disconnect ResizeObserver to prevent feedback loops eventually caused by update function
11441
11912
  onBeforeAutoCheck(() => {
11442
11913
  resizeObserver.unobserve(element);
11443
11914
  return () => {
11444
- // This triggers a new call to the resive observer that will be ignored thanks to
11445
- // the widthDiff/heightDiff early return
11446
- resizeObserver.observe(element);
11915
+ // Not reobserved at all while an ancestor is closed (see
11916
+ // pauseResizeWatching/resumeResizeWatching above) — resumeResizeWatching's
11917
+ // own publish is what reobserves once it reopens instead.
11918
+ if (!resizeWatchingPaused) {
11919
+ // This triggers a new call to the resive observer that will be ignored thanks to
11920
+ // the widthDiff/heightDiff early return
11921
+ resizeObserver.observe(element);
11922
+ }
11447
11923
  };
11448
11924
  });
11449
11925
  addTeardown(() => {
11926
+ unsubscribeResizeWatchingPausedChange();
11450
11927
  resizeObserver.disconnect();
11451
11928
  });
11452
11929
  }
@@ -11500,29 +11977,27 @@ const visibleRectEffect = (
11500
11977
  });
11501
11978
  }
11502
11979
  {
11503
- let current = element.parentElement;
11504
- while (current) {
11505
- if (
11506
- current.hasAttribute("popover") ||
11507
- current.tagName === "DIALOG" ||
11508
- current.tagName === "DETAILS"
11509
- ) {
11510
- const ancestor = current;
11511
- const isInitiallyClosed =
11512
- ancestor.tagName === "DIALOG" || ancestor.tagName === "DETAILS"
11513
- ? !ancestor.open
11514
- : !ancestor.matches(":popover-open");
11515
- if (isInitiallyClosed) {
11516
- ancestorClosedCount++;
11517
- }
11980
+ let currentOpenableAncestor = closestOpenableAncestor(element);
11981
+ while (currentOpenableAncestor) {
11982
+ const openableAncestor = currentOpenableAncestor;
11983
+ if (!isAncestorOpen(openableAncestor)) {
11984
+ ancestorClosedCount++;
11985
+ pauseResizeWatching();
11986
+ }
11987
+ const removeOpenStateObserver = observeAncestorOpenState(
11988
+ openableAncestor,
11518
11989
  // eslint-disable-next-line no-loop-func
11519
- const onToggle = (e) => {
11520
- const isClosed =
11521
- ancestor.tagName === "DETAILS"
11522
- ? !ancestor.open
11523
- : e.newState === "closed";
11524
- if (isClosed) {
11990
+ ({ isOpen, toggleEvent }) => {
11991
+ if (!isOpen) {
11525
11992
  ancestorClosedCount++;
11993
+ pauseResizeWatching();
11994
+ // Invalidates check()'s own "did anything actually change"
11995
+ // caches — without this, reopening onto the exact same
11996
+ // geometry/viewport as before closing would look unchanged to
11997
+ // check() and it would skip calling update() again, leaving a
11998
+ // consumer stuck showing this closed/zeroed state.
11999
+ lastVisibleRect = null;
12000
+ lastViewportRect = null;
11526
12001
  update(
11527
12002
  {
11528
12003
  left: 0,
@@ -11533,130 +12008,567 @@ const visibleRectEffect = (
11533
12008
  height: 0,
11534
12009
  visibilityRatio: 0,
11535
12010
  },
11536
- { event: e, width: 0, height: 0, ancestorClosed: true },
12011
+ {
12012
+ event: toggleEvent ?? new CustomEvent("ancestor_close"),
12013
+ width: 0,
12014
+ height: 0,
12015
+ ancestorClosed: true,
12016
+ },
11537
12017
  );
11538
- } else {
11539
- if (ancestorClosedCount > 0) {
11540
- ancestorClosedCount--;
11541
- }
11542
- if (ancestorClosedCount === 0) {
11543
- check(e);
11544
- }
12018
+ return;
11545
12019
  }
11546
- };
11547
- ancestor.addEventListener("toggle", onToggle);
12020
+ if (ancestorClosedCount > 0) {
12021
+ ancestorClosedCount--;
12022
+ }
12023
+ if (ancestorClosedCount === 0) {
12024
+ resumeResizeWatching();
12025
+ check(toggleEvent ?? new CustomEvent("ancestor_open"));
12026
+ }
12027
+ },
12028
+ );
11548
12029
 
11549
- const onNaviPositionUpdate = (e) => {
12030
+ const onNaviPositionChange = (e) => {
12031
+ autoCheck(e);
12032
+ };
12033
+ openableAncestor.addEventListener(
12034
+ "navi_position_change",
12035
+ onNaviPositionChange,
12036
+ );
12037
+ // Dispatched by applyNewPosition's own notifyPositionTransition
12038
+ // around this ancestor's own left/top animation (distinct from
12039
+ // navi_position_change, fired once with the final target, not per
12040
+ // frame). The anchor this element is positioned against may live
12041
+ // inside that ancestor and be moving right now — rather than hiding
12042
+ // for the duration (an opacity flicker once it settles reads worse
12043
+ // than a slightly-behind position), autoCheck() every frame for as
12044
+ // long as the animation runs, so this element stays in lockstep.
12045
+ // autoCheck, not check directly, so this element's own
12046
+ // ResizeObserver(s) stay unobserved for the loop's duration too —
12047
+ // repositioning every frame can itself cause reflows. e.detail.onEnd
12048
+ // stops the loop and settles on one final check once it ends.
12049
+ let positionTransitionRafId = null;
12050
+ let isTrackingPositionTransition = false;
12051
+ // ancestorRepositioningCount is intentionally shared across every
12052
+ // ancestor level (declared once, outside this loop) — it's a
12053
+ // single "is this element's position currently being driven by
12054
+ // some ancestor's transition" flag for the element itself, not
12055
+ // per-ancestor state.
12056
+ // eslint-disable-next-line no-loop-func
12057
+ const onNaviPositionTransition = (e) => {
12058
+ cancelAnimationFrame(positionTransitionRafId);
12059
+ if (!isTrackingPositionTransition) {
12060
+ isTrackingPositionTransition = true;
12061
+ ancestorRepositioningCount++;
12062
+ }
12063
+ const loop = () => {
11550
12064
  autoCheck(e);
12065
+ positionTransitionRafId = requestAnimationFrame(loop);
11551
12066
  };
11552
- ancestor.addEventListener(
11553
- "navi_position_update",
11554
- onNaviPositionUpdate,
11555
- );
11556
- addTeardown(() => {
11557
- ancestor.removeEventListener("toggle", onToggle);
11558
- ancestor.removeEventListener(
11559
- "navi_position_update",
11560
- onNaviPositionUpdate,
11561
- );
12067
+ loop();
12068
+ e.detail.onEnd(() => {
12069
+ cancelAnimationFrame(positionTransitionRafId);
12070
+ if (isTrackingPositionTransition) {
12071
+ isTrackingPositionTransition = false;
12072
+ ancestorRepositioningCount--;
12073
+ }
12074
+ autoCheck(e);
11562
12075
  });
11563
- }
11564
- current = current.parentElement;
12076
+ };
12077
+ openableAncestor.addEventListener(
12078
+ "navi_position_transition",
12079
+ onNaviPositionTransition,
12080
+ );
12081
+ addTeardown(() => {
12082
+ removeOpenStateObserver();
12083
+ cancelAnimationFrame(positionTransitionRafId);
12084
+ openableAncestor.removeEventListener(
12085
+ "navi_position_change",
12086
+ onNaviPositionChange,
12087
+ );
12088
+ openableAncestor.removeEventListener(
12089
+ "navi_position_transition",
12090
+ onNaviPositionTransition,
12091
+ );
12092
+ });
12093
+ currentOpenableAncestor = closestOpenableAncestor(
12094
+ currentOpenableAncestor,
12095
+ );
11565
12096
  }
11566
12097
  }
11567
12098
  }
11568
12099
 
12100
+ // Re-checks whenever `elementToObserve` (some other element than the one
12101
+ // this effect tracks — e.g. a popover/callout's own content) changes size,
12102
+ // not just when `element` itself is scrolled/resized/re-anchored. Useful
12103
+ // when the tracked element's *position* depends on a size that lives
12104
+ // elsewhere (a callout re-measuring itself against its message body, a
12105
+ // popover reconsidering "top" vs "bottom" once its own content grows).
12106
+ // Can be called more than once, once per element worth watching.
12107
+ const observeSize = (elementToObserve) => {
12108
+ let lastWidth;
12109
+ let lastHeight;
12110
+ // Set right before a deferred check() runs, read right after — see
12111
+ // below for why a pending frame needs to be cancelable.
12112
+ let pendingFrame = null;
12113
+ const resizeObserver = new ResizeObserver((entries) => {
12114
+ const [entry] = entries;
12115
+ const { width, height } = entry.contentRect;
12116
+ // Debounce tiny changes that are likely sub-pixel rounding.
12117
+ if (lastWidth !== undefined) {
12118
+ const widthDiff = Math.abs(width - lastWidth);
12119
+ const heightDiff = Math.abs(height - lastHeight);
12120
+ const threshold = 1;
12121
+ if (widthDiff < threshold && heightDiff < threshold) {
12122
+ return;
12123
+ }
12124
+ }
12125
+ lastWidth = width;
12126
+ lastHeight = height;
12127
+ // Deferred to the next frame rather than calling check() here
12128
+ // directly: check() (via update()) commonly mutates
12129
+ // elementToObserve's own size again as a side effect of repositioning
12130
+ // it (e.g. a popover clearing then re-setting its own max-height
12131
+ // while reconsidering "top" vs "bottom" once it no longer fits where
12132
+ // it was) — when elementToObserve is the very element this observer
12133
+ // watches (a popover watching its own content, not some other
12134
+ // element), doing that synchronously from inside this callback is a
12135
+ // same-frame observer-triggers-itself loop, which the browser detects
12136
+ // and reports as "ResizeObserver loop completed with undelivered
12137
+ // notifications." The debounce above only guards against oscillation
12138
+ // across separate ResizeObserver deliveries — it does nothing for
12139
+ // this single legitimate resize-causes-a-reposition-causes-another-
12140
+ // resize step, since each individual size change here is real, not
12141
+ // sub-pixel noise. Deferring one frame breaks the synchronous chain:
12142
+ // by the time the reposition runs, this callback has already
12143
+ // returned, so any size change it causes is observed as a fresh,
12144
+ // later delivery instead of a nested one. Cancels/replaces any
12145
+ // still-pending frame from an earlier, superseded delivery, so only
12146
+ // the latest size ever actually gets checked.
12147
+ if (pendingFrame !== null) {
12148
+ cancelAnimationFrame(pendingFrame);
12149
+ }
12150
+ pendingFrame = requestAnimationFrame(() => {
12151
+ pendingFrame = null;
12152
+ check(
12153
+ new CustomEvent("observed_element_size_change", {
12154
+ detail: { width, height },
12155
+ }),
12156
+ );
12157
+ });
12158
+ });
12159
+ resizeObserver.observe(elementToObserve);
12160
+ // An ancestor may already be closed by the time a consumer calls
12161
+ // observeSize (e.g. Callout's own observeSize(calloutMessageElement)
12162
+ // call happens after visibleRectEffect itself returns) — keep this new
12163
+ // observer consistent with that already-paused state instead of
12164
+ // observing it only to immediately generate a closed-container
12165
+ // notification.
12166
+ if (resizeWatchingPaused) {
12167
+ resizeObserver.unobserve(elementToObserve);
12168
+ }
12169
+ const unsubscribeResizeWatchingPausedChange = onResizeWatchingPausedChange(
12170
+ (paused) => {
12171
+ if (paused) {
12172
+ resizeObserver.unobserve(elementToObserve);
12173
+ } else {
12174
+ resizeObserver.observe(elementToObserve);
12175
+ }
12176
+ },
12177
+ );
12178
+ const cleanupAutoCheck = onBeforeAutoCheck(() => {
12179
+ resizeObserver.unobserve(elementToObserve);
12180
+ return () => {
12181
+ // Not reobserved at all while an ancestor is closed (see
12182
+ // pauseResizeWatching/resumeResizeWatching) — resumeResizeWatching's
12183
+ // own publish is what reobserves once it reopens instead.
12184
+ if (!resizeWatchingPaused) {
12185
+ resizeObserver.observe(elementToObserve);
12186
+ }
12187
+ };
12188
+ });
12189
+ addTeardown(() => {
12190
+ if (pendingFrame !== null) {
12191
+ cancelAnimationFrame(pendingFrame);
12192
+ }
12193
+ unsubscribeResizeWatchingPausedChange();
12194
+ resizeObserver.disconnect();
12195
+ });
12196
+ return () => {
12197
+ cleanupAutoCheck();
12198
+ unsubscribeResizeWatchingPausedChange();
12199
+ if (pendingFrame !== null) {
12200
+ cancelAnimationFrame(pendingFrame);
12201
+ }
12202
+ resizeObserver.disconnect();
12203
+ };
12204
+ };
12205
+
11569
12206
  return {
11570
12207
  check,
11571
12208
  onBeforeAutoCheck,
12209
+ observeSize,
11572
12210
  disconnect: () => {
11573
12211
  teardown();
11574
12212
  },
11575
12213
  };
11576
12214
  };
11577
12215
 
12216
+ /**
12217
+ * The `positionArea` grammar `pickPositionRelativeTo` accepts (also reused
12218
+ * as-is by `@jsenv/navi`'s Popover/Dialog/Callout): a single compass token
12219
+ * (loosely inspired by CSS `position-area`'s own naming), optionally wrapped
12220
+ * in `inset(...)` when the element should overlap the anchor instead of
12221
+ * sitting fully to one side of it. Resolves internally to a { y, x } pair —
12222
+ * y: top/inset-top/center/inset-bottom/bottom, x: left/inset-left/center/
12223
+ * inset-right/right — the same vocabulary the rest of this file's
12224
+ * positioning math (spaceFor, oppositeX/Y, etc.) actually operates on: a
12225
+ * bare `top`/`bottom`/`left`/`right` means outside the anchor (no overlap on
12226
+ * that axis), `inset-*` means flush against/overlapping it.
12227
+ *
12228
+ * Outside the anchor (bare token — element placed fully to one side, no
12229
+ * overlap on that side's axis):
12230
+ *
12231
+ * top-left top-start top top-end top-right
12232
+ * right-start right right-end
12233
+ * bottom-right bottom-end bottom bottom-start bottom-left
12234
+ * left-end left left-start
12235
+ *
12236
+ * A corner token fixes one axis outside (top/bottom/left/right) and the
12237
+ * other the same way (a true corner, no cross-axis overlap at all).
12238
+ * "-start"/"-end" keep one axis outside but align the cross axis flush with
12239
+ * the anchor's near/far edge instead (`top-start` is above the anchor,
12240
+ * left-edges flush). The bare direction word centers the cross axis on the
12241
+ * anchor.
12242
+ *
12243
+ * Overlapping the anchor (wrapped in `inset(...)`, the classic 3×3 grid):
12244
+ *
12245
+ * inset(top-left) inset(top) inset(top-right)
12246
+ * inset(left) center inset(right)
12247
+ * inset(bottom-left) inset(bottom) inset(bottom-right)
12248
+ *
12249
+ * `center` and `inset(center)` are equivalent aliases for dead-center.
12250
+ */
12251
+ const OUTSIDE_POSITION_AREA_TOKENS = {
12252
+ "top-left": { y: "top", x: "left" },
12253
+ "top-start": { y: "top", x: "inset-left" },
12254
+ "top": { y: "top", x: "center" },
12255
+ "top-end": { y: "top", x: "inset-right" },
12256
+ "top-right": { y: "top", x: "right" },
12257
+
12258
+ "right-start": { y: "inset-top", x: "right" },
12259
+ "right": { y: "center", x: "right" },
12260
+ "right-end": { y: "inset-bottom", x: "right" },
12261
+
12262
+ "bottom-right": { y: "bottom", x: "right" },
12263
+ "bottom-end": { y: "bottom", x: "inset-right" },
12264
+ "bottom": { y: "bottom", x: "center" },
12265
+ "bottom-start": { y: "bottom", x: "inset-left" },
12266
+ "bottom-left": { y: "bottom", x: "left" },
12267
+
12268
+ "left-end": { y: "inset-bottom", x: "left" },
12269
+ "left": { y: "center", x: "left" },
12270
+ "left-start": { y: "inset-top", x: "left" },
12271
+
12272
+ "center": { y: "center", x: "center" },
12273
+ };
12274
+ const INSET_POSITION_AREA_TOKENS = {
12275
+ "top-left": { y: "inset-top", x: "inset-left" },
12276
+ "top": { y: "inset-top", x: "center" },
12277
+ "top-right": { y: "inset-top", x: "inset-right" },
12278
+
12279
+ "right": { y: "center", x: "inset-right" },
12280
+
12281
+ "bottom-right": { y: "inset-bottom", x: "inset-right" },
12282
+ "bottom": { y: "inset-bottom", x: "center" },
12283
+ "bottom-left": { y: "inset-bottom", x: "inset-left" },
12284
+
12285
+ "left": { y: "center", x: "inset-left" },
12286
+
12287
+ "center": { y: "center", x: "center" },
12288
+ };
12289
+ const INSET_TOKEN_RE = /^inset\(\s*([a-z-]+)\s*\)$/;
12290
+
12291
+ /**
12292
+ * Parses a positionArea string into a { y, x } pair, or null if it's not a
12293
+ * recognized token.
12294
+ */
12295
+ const parsePositionArea = (value) => {
12296
+ const insetMatch = INSET_TOKEN_RE.exec(value);
12297
+ if (insetMatch) {
12298
+ const parsed = INSET_POSITION_AREA_TOKENS[insetMatch[1]];
12299
+ return parsed ? { ...parsed } : null;
12300
+ }
12301
+ const parsed = OUTSIDE_POSITION_AREA_TOKENS[value];
12302
+ return parsed ? { ...parsed } : null;
12303
+ };
12304
+
12305
+ /**
12306
+ * Collapses a bare position value ("top"/"bottom"/"left"/"right") to its
12307
+ * "inset-*" equivalent — "inset-*"/"center" values pass through unchanged.
12308
+ * Only used by pickPositionRelativeTo's own no-anchor (container-docked)
12309
+ * mode — see its own doc for why.
12310
+ */
12311
+ const toContainerAlignedPosition = (value) => {
12312
+ if (value === "top") {
12313
+ return "inset-top";
12314
+ }
12315
+ if (value === "bottom") {
12316
+ return "inset-bottom";
12317
+ }
12318
+ if (value === "left") {
12319
+ return "inset-left";
12320
+ }
12321
+ if (value === "right") {
12322
+ return "inset-right";
12323
+ }
12324
+ return value;
12325
+ };
12326
+
11578
12327
  /**
11579
12328
  * Places element relative to anchor with independent control of horizontal and vertical axes.
11580
12329
  *
11581
- * Horizontal axis positionX / positionXFixed (left right):
11582
- * "to-the-left" element.right = anchor.left (sits entirely to the left of anchor)
11583
- * "left-aligned" element.left = anchor.left (left edges aligned)
11584
- * "center" element centered horizontally over anchor (default)
11585
- * "right-aligned" element.right = anchor.right (right edges aligned)
11586
- * "to-the-right" element.left = anchor.right (sits entirely to the right of anchor)
11587
- *
11588
- * Vertical axis positionY / positionYFixed (top → bottom):
11589
- * "above" element.bottom = anchor.top (sits above, no overlap)
11590
- * "above-overlap" element.bottom = anchor.bottom (sits above, overlapping anchor)
11591
- * "center" element centered vertically over anchor
11592
- * "below-overlap" element.top = anchor.top (sits below, overlapping anchor)
11593
- * "below" element.top = anchor.bottom (sits below, no overlap) (default)
11594
- *
11595
- * positionX / positionY attempt the requested placement and automatically flip to the
12330
+ * `positionArea` (see its own doc above `parsePositionArea`) is a single
12331
+ * compass token that resolves to a { y, x } pair internally:
12332
+ *
12333
+ * Horizontal (x) axis:
12334
+ * "left" element.right = anchor.left (sits entirely to the left of anchor)
12335
+ * "inset-left" element.left = anchor.left (left edges aligned, overlapping)
12336
+ * "center" element centered horizontally over anchor
12337
+ * "inset-right" element.right = anchor.right (right edges aligned, overlapping)
12338
+ * "right" element.left = anchor.right (sits entirely to the right of anchor)
12339
+ *
12340
+ * Vertical (y) axis:
12341
+ * "top" element.bottom = anchor.top (sits above, no overlap)
12342
+ * "inset-top" element.top = anchor.top (top edges aligned, overlapping)
12343
+ * "center" element centered vertically over anchor
12344
+ * "inset-bottom" element.bottom = anchor.bottom (bottom edges aligned, overlapping)
12345
+ * "bottom" element.top = anchor.bottom (sits below, no overlap)
12346
+ *
12347
+ * The resolved x/y attempt the requested placement and automatically flip to the
11596
12348
  * logical opposite when the element does not fit in the viewport:
11597
- * abovebelow, above-overlapbelow-overlap
12349
+ * topbottom, inset-topinset-bottom, left ↔ right, inset-left ↔ inset-right
11598
12350
  *
11599
- * positionXFixed / positionYFixed skip the fit check entirely.
12351
+ * `positionAreaFixed` skips the fit check entirely on both axes.
11600
12352
  *
11601
12353
  * The resolved X and Y are persisted as data-position-x-current / data-position-y-current
11602
12354
  * on the element so subsequent calls start from the last resolved position (avoids
11603
- * flickering when the element is near the flip threshold). Fixed axes are not persisted.
12355
+ * flickering when the element is near the flip threshold) and so other CSS/JS can read
12356
+ * "which side is this on right now" — including for a fixed axis, even though a fixed
12357
+ * axis never reads the attribute back itself (`positionAreaFixed` always wins).
11604
12358
  *
11605
- * @param {HTMLElement} element - The element to position (must be document-relative)
11606
- * @param {HTMLElement} anchor - The anchor element to position against
12359
+ * @param {HTMLElement} element - The element to position (position: absolute or
12360
+ * fixed detected from its own computed style, see the scroll offset comment below)
12361
+ * @param {HTMLElement} [anchor] - The anchor element to position against. Omit (or pass
12362
+ * `null`/`undefined`) when there's no real anchor to dock `element` against a *container*
12363
+ * instead — see `container` below; in that mode, "top"/"bottom"/"left"/"right" are
12364
+ * collapsed to their "inset-*" equivalent internally (docking has no "float away with
12365
+ * a gap" concept the way a real anchor does) and x/y always behave as if
12366
+ * `positionAreaFixed` were set (a docked edge/corner never flips to the other side —
12367
+ * there's no "other side" of a container the way there is of a real anchor).
11607
12368
  * @param {object} [options]
11608
- * @param {string} [options.positionX="center"] - Preferred X placement, with viewport fallback.
11609
- * "to-the-left" element.right = anchor.left (sits entirely to the left of anchor)
11610
- * "left-aligned" — element.left = anchor.left (left edges aligned)
11611
- * "center" — element centered horizontally over anchor (default)
11612
- * "right-aligned" element.right = anchor.right (right edges aligned)
11613
- * "to-the-right" — element.left = anchor.right (sits entirely to the right of anchor)
11614
- * @param {string} [options.positionY="below"] - Preferred Y placement, with viewport fallback.
11615
- * "above" — element.bottom = anchor.top (sits above, no overlap)
11616
- * "above-overlap" element.bottom = anchor.bottom (sits above, overlapping anchor)
11617
- * "center" — element centered vertically over anchor
11618
- * "below-overlap" element.top = anchor.top (sits below, overlapping anchor)
11619
- * "below" — element.top = anchor.bottom (sits below, no overlap) (default)
11620
- * @param {string} [options.positionXFixed] - Force X placement, skipping the fit-check. Same values as positionX.
11621
- * @param {string} [options.positionYFixed] - Force Y placement, skipping the fit-check. Same values as positionY.
11622
- * @param {number} [options.alignToViewportEdgeWhenAnchorNearEdge=0] - Snap to viewport left
11623
- * edge when anchor is within this many px of the left edge and element is wider than anchor.
12369
+ * @param {string} [options.positionArea="bottom"] - Preferred placement, with viewport
12370
+ * fallback see `parsePositionArea`'s own doc for the full token grammar (a single
12371
+ * compass token, optionally `inset(...)`-wrapped).
12372
+ * @param {string} [options.positionAreaFixed] - Forces this placement, skipping the
12373
+ * fit-check on both axes. Same grammar as `positionArea`.
12374
+ * @param {string} [options.positionAreaWhenAnchorIsInvalid="center"] - `positionArea`
12375
+ * used instead, as a plain no-anchor dock, whenever the anchor is too big to leave
12376
+ * room on the axis `positionArea` places it outside of. `hasValidAnchor` in the return
12377
+ * value reports which way it went.
12378
+ * @param {Event|CustomEvent} [options.event] - The event that triggered this particular
12379
+ * reposition (a scroll/resize/etc. handler simply forwarding whatever it was itself
12380
+ * called with) purely informational, never changes the computed `left`/`top`
12381
+ * themselves, only `shouldTransition` in the return value (see `applyNewPosition`'s
12382
+ * own doc for how that's meant to be used).
12383
+ * @param {number} [options.alignToContainerEdgeWhenAnchorNearEdge=0] - When centering
12384
+ * (positionArea's x is "center") an element wider than its anchor, snap to the available area's own
12385
+ * left edge (the page viewport normally, or the container's edge — see `container` below —
12386
+ * whenever there's no real `anchor`) instead of centering, once the anchor is within this
12387
+ * many px of that same edge — avoids the (wider) element overflowing past it. 0 disables
12388
+ * the snap entirely.
11624
12389
  * @param {number} [options.minLeft=0] - Minimum left coordinate (document-relative).
11625
- * @returns {{ positionX, positionY, left, top, width, height, anchorLeft, anchorTop, anchorRight, anchorBottom, spaceLeft, spaceRight, spaceAbove, spaceBelow }}
12390
+ * @param {HTMLElement|null} [options.container] - The container `element` is genuinely
12391
+ * `position: absolute` relative to (its own containing block) — decoupled from whether
12392
+ * there's a real `anchor`, since `element` can be container-relative either way (e.g. the
12393
+ * custom renderer in popover.jsx, always relative to its own positioned ancestor whether
12394
+ * or not it also has a real anchor). Whenever not explicitly given, this is always
12395
+ * resolved automatically via `getPositionedParent(element)` instead — regardless of
12396
+ * `hasValidAnchor` — so a caller that never thinks about `container` at all still gets the
12397
+ * right behavior on its own: `document.documentElement` from `getPositionedParent` (an
12398
+ * `element` promoted to the top layer — a `[popover]` while shown, or a `<dialog>` while
12399
+ * actually modal — or one with no positioned ancestor at all, e.g. Callout's own element)
12400
+ * falls back to the traditional document-relative path below, exactly as if `container`
12401
+ * genuinely didn't apply; anything else `getPositionedParent` finds (a real positioned
12402
+ * ancestor) is used the same way an explicit `container` would be. A container that
12403
+ * resolves to `document.documentElement` (the viewport) produces identical output to the
12404
+ * plain document-relative path either way, since the document's own scroll and the
12405
+ * viewport's own origin already coincide with what this generically computes for any other
12406
+ * container element. When there's a real container (explicit or resolved) either way: the final
12407
+ * `left`/`top` (and the returned `anchorLeft/Top/Right/Bottom`) are expressed relative to
12408
+ * its own padding-box origin plus its own scroll, instead of the document's — `element`'s
12409
+ * own computed `position` is *not* consulted in that case, unlike the traditional path.
12410
+ * When `anchor` is also omitted (no real anchor at all), the container additionally
12411
+ * becomes what's positioned against, and the boundary clamp uses its own (padding-box)
12412
+ * edges instead of the page viewport's, on both axes (the Y axis otherwise has no such
12413
+ * clamp at all — see the clamp's own comment) — that part *is* gated on `hasValidAnchor`,
12414
+ * unlike the coordinate-space conversion itself.
12415
+ * @returns {{ hasValidAnchor, shouldTransition, positionX, positionY, left, top, width, height, anchorLeft, anchorTop, anchorRight, anchorBottom, spaceLeft, spaceRight, spaceAbove, spaceBelow }}
11626
12416
  */
11627
12417
  const pickPositionRelativeTo = (
11628
12418
  element,
11629
12419
  anchor,
11630
12420
  {
11631
- positionX = "center",
11632
- positionY = "below",
11633
- positionXFixed,
11634
- positionYFixed,
11635
- alignToViewportEdgeWhenAnchorNearEdge = 0,
12421
+ positionArea = "bottom",
12422
+ positionAreaFixed,
12423
+ positionAreaWhenAnchorIsInvalid = "center",
12424
+ event,
12425
+ alignToContainerEdgeWhenAnchorNearEdge = 0,
11636
12426
  minLeft = 0,
11637
- spacing = 0,
12427
+ marginWithAnchor = 0,
11638
12428
  alignToAnchorBox = "border-box",
11639
- viewportSpacing = 0,
12429
+ marginWithContainer = 0,
12430
+ container,
11640
12431
  } = {},
11641
12432
  ) => {
11642
-
11643
- const viewportWidth = document.documentElement.clientWidth;
11644
- const viewportHeight = document.documentElement.clientHeight;
12433
+ // Needed before hasValidAnchor below. visualViewport, not
12434
+ // document.documentElement.clientWidth/Height: the layout viewport
12435
+ // doesn't shrink when the on-screen keyboard opens, only the visual one
12436
+ // does.
12437
+ const visualViewport = window.visualViewport;
12438
+ const viewportWidth = visualViewport
12439
+ ? visualViewport.width
12440
+ : document.documentElement.clientWidth;
12441
+ const viewportHeight = visualViewport
12442
+ ? visualViewport.height
12443
+ : document.documentElement.clientHeight;
12444
+ const viewportLeft = visualViewport ? visualViewport.offsetLeft : 0;
12445
+ const viewportTop = visualViewport ? visualViewport.offsetTop : 0;
12446
+
12447
+ // Resolved early: everything below that would otherwise reach for
12448
+ // viewportLeft/Top/Width/Height instead uses these, so a "local" popover
12449
+ // never gets offered more room (anchor-too-big check, flip decisions,
12450
+ // clamp) than its own container — resolvedContainer's own padding-box
12451
+ // edges when there is one — actually has.
12452
+ // Always a real element now (never null/undefined) — getPositionedParent
12453
+ // itself never returns anything falsy, document.documentElement (the
12454
+ // viewport) included.
12455
+ const resolvedContainer = container ?? getPositionedParent(element);
12456
+ const hasRealContainer = resolvedContainer !== document.documentElement;
12457
+ const containerRect = hasRealContainer
12458
+ ? resolvedContainer.getBoundingClientRect()
12459
+ : null;
12460
+ const containerBorders = hasRealContainer
12461
+ ? getBorderSizes(resolvedContainer)
12462
+ : { left: 0, top: 0, right: 0, bottom: 0 };
12463
+ const availableLeft = hasRealContainer
12464
+ ? snapToPixel(containerRect.left) + containerBorders.left
12465
+ : viewportLeft;
12466
+ const availableTop = hasRealContainer
12467
+ ? snapToPixel(containerRect.top) + containerBorders.top
12468
+ : viewportTop;
12469
+ const availableRight = hasRealContainer
12470
+ ? snapToPixel(containerRect.right) - containerBorders.right
12471
+ : viewportLeft + viewportWidth;
12472
+ const availableBottom = hasRealContainer
12473
+ ? snapToPixel(containerRect.bottom) - containerBorders.bottom
12474
+ : viewportTop + viewportHeight;
12475
+ const availableWidth = availableRight - availableLeft;
12476
+ const availableHeight = availableBottom - availableTop;
12477
+
12478
+ // Rejected only on the axis positionArea actually places `element`
12479
+ // outside of ("left"/"right" or "top"/"bottom") — that's the only axis
12480
+ // where the anchor's own size eats into the room available. Docks via
12481
+ // positionAreaWhenAnchorIsInvalid instead of `positionArea` once rejected.
12482
+ const requestedPositionArea = parsePositionArea(positionArea);
12483
+ const anchorRejected =
12484
+ Boolean(anchor) &&
12485
+ (() => {
12486
+ const rect = anchor.getBoundingClientRect();
12487
+ const { x, y } = requestedPositionArea ?? {};
12488
+ if (
12489
+ (y === "top" || y === "bottom") &&
12490
+ rect.height > availableHeight - 50
12491
+ ) {
12492
+ return true;
12493
+ }
12494
+ if ((x === "left" || x === "right") && rect.width > availableWidth - 50) {
12495
+ return true;
12496
+ }
12497
+ return false;
12498
+ })();
12499
+ const hasValidAnchor = Boolean(anchor) && !anchorRejected;
12500
+ const effectivePositionArea = anchorRejected
12501
+ ? positionAreaWhenAnchorIsInvalid
12502
+ : positionArea;
12503
+
12504
+ const parsedPositionArea = parsePositionArea(effectivePositionArea);
12505
+ if (!parsedPositionArea) {
12506
+ console.warn(
12507
+ `pickPositionRelativeTo: invalid positionArea="${effectivePositionArea}"`,
12508
+ );
12509
+ }
12510
+ let positionX = parsedPositionArea ? parsedPositionArea.x : "center";
12511
+ let positionY = parsedPositionArea ? parsedPositionArea.y : "bottom";
12512
+ let positionXFixed;
12513
+ let positionYFixed;
12514
+ if (positionAreaFixed) {
12515
+ const parsedPositionAreaFixed = parsePositionArea(positionAreaFixed);
12516
+ if (!parsedPositionAreaFixed) {
12517
+ console.warn(
12518
+ `pickPositionRelativeTo: invalid positionAreaFixed="${positionAreaFixed}"`,
12519
+ );
12520
+ } else {
12521
+ positionXFixed = parsedPositionAreaFixed.x;
12522
+ positionYFixed = parsedPositionAreaFixed.y;
12523
+ }
12524
+ }
12525
+ // No real anchor (or a rejected one): dock against a container instead.
12526
+ if (!hasValidAnchor) {
12527
+ positionX = toContainerAlignedPosition(positionX);
12528
+ positionY = toContainerAlignedPosition(positionY);
12529
+ positionXFixed = positionX;
12530
+ positionYFixed = positionY;
12531
+ }
12532
+ // resolvedContainer was already resolved above. document.documentElement
12533
+ // from getPositionedParent (a popover/dialog element, e.g. Callout's own,
12534
+ // or one with no positioned ancestor at all) falls through to the
12535
+ // traditional document-relative path below all the same, so an existing
12536
+ // caller that never thinks about `container` at all keeps behaving
12537
+ // exactly as before.
12538
+ const effectiveAnchor = hasValidAnchor ? anchor : resolvedContainer;
12539
+ // document.documentElement is used as a sentinel "the viewport" value: an
12540
+ // anchorless popup should center/place itself against the visual
12541
+ // viewport, not against <html>'s own box — which, unlike the viewport,
12542
+ // grows with document content and can be far taller than what's on
12543
+ // screen (its top is also negative once the page is scrolled). Using the
12544
+ // viewport rect here fixes that; the scroll offset is still applied
12545
+ // below like any other case (see getPositioningScrollOffset).
12546
+ const anchorIsViewport = effectiveAnchor === document.documentElement;
11645
12547
  // Get viewport-relative positions
11646
- const elementRect = element.getBoundingClientRect();
11647
- const anchorRect = anchor.getBoundingClientRect();
11648
- const {
11649
- left: elementLeft,
11650
- right: elementRight,
11651
- top: elementTop,
11652
- bottom: elementBottom,
11653
- } = elementRect;
12548
+ const anchorRect = anchorIsViewport
12549
+ ? {
12550
+ left: viewportLeft,
12551
+ top: viewportTop,
12552
+ right: viewportLeft + viewportWidth,
12553
+ bottom: viewportTop + viewportHeight,
12554
+ }
12555
+ : effectiveAnchor.getBoundingClientRect();
11654
12556
  const anchorLeft = snapToPixel(anchorRect.left);
11655
12557
  const anchorTop = snapToPixel(anchorRect.top);
11656
12558
  const anchorRight = snapToPixel(anchorRect.right);
11657
12559
  const anchorBottom = snapToPixel(anchorRect.bottom);
11658
- const elementWidth = elementRight - elementLeft;
11659
- const elementHeight = elementBottom - elementTop;
12560
+ // Horizontal clamp bounds see availableLeft/availableRight above.
12561
+ const clampLeftBound = availableLeft;
12562
+ const clampRightBound = availableRight;
12563
+ // offsetWidth/offsetHeight (layout box), not getBoundingClientRect() (the
12564
+ // painted/transformed box): the element being positioned may have an
12565
+ // active CSS `scale`/`translate` transform mid-animation (e.g. a popover
12566
+ // using animation="scale"/"grow", still at its @starting-style value the
12567
+ // instant it's first shown) — getBoundingClientRect() would then report
12568
+ // its *shrunk* transformed size, throwing off any math that centers/fits
12569
+ // against the element's own dimensions.
12570
+ const elementWidth = element.offsetWidth;
12571
+ const elementHeight = element.offsetHeight;
11660
12572
  const anchorWidth = anchorRight - anchorLeft;
11661
12573
  const anchorHeight = anchorBottom - anchorTop;
11662
12574
 
@@ -11671,19 +12583,19 @@ const pickPositionRelativeTo = (
11671
12583
  let insetLeft = 0;
11672
12584
  let insetRight = 0;
11673
12585
  if (alignToAnchorBox === "content-box") {
11674
- const anchorBorderSizes = getBorderSizes(anchor);
11675
- const anchorPaddingSizes = getPaddingSizes(anchor);
12586
+ const anchorBorderSizes = getBorderSizes(effectiveAnchor);
12587
+ const anchorPaddingSizes = getPaddingSizes(effectiveAnchor);
11676
12588
  insetTop = anchorBorderSizes.top + anchorPaddingSizes.top;
11677
12589
  insetBottom = anchorBorderSizes.bottom + anchorPaddingSizes.bottom;
11678
12590
  insetLeft = anchorBorderSizes.left + anchorPaddingSizes.left;
11679
12591
  insetRight = anchorBorderSizes.right + anchorPaddingSizes.right;
11680
12592
  }
11681
- const spaceAbove = anchorTop + insetTop;
11682
- const spaceBelow = viewportHeight - anchorBottom + insetBottom;
12593
+ const spaceAbove = anchorTop + insetTop - availableTop;
12594
+ const spaceBelow = availableBottom - anchorBottom + insetBottom;
11683
12595
  const effectiveAnchorLeft = anchorLeft + insetLeft;
11684
12596
  const effectiveAnchorRight = anchorRight - insetRight;
11685
- const spaceLeft = anchorLeft + insetLeft;
11686
- const spaceRight = viewportWidth - anchorRight + insetRight;
12597
+ const spaceLeft = anchorLeft + insetLeft - availableLeft;
12598
+ const spaceRight = availableRight - anchorRight + insetRight;
11687
12599
 
11688
12600
  // Resolve active X and Y, and whether each is fixed (no flip fallback)
11689
12601
  let activeX;
@@ -11709,24 +12621,24 @@ const pickPositionRelativeTo = (
11709
12621
  let finalY;
11710
12622
  {
11711
12623
  const oppositeY = {
11712
- "above": "below",
11713
- "below": "above",
11714
- "above-overlap": "below-overlap",
11715
- "below-overlap": "above-overlap",
12624
+ "top": "bottom",
12625
+ "bottom": "top",
12626
+ "inset-top": "inset-bottom",
12627
+ "inset-bottom": "inset-top",
11716
12628
  };
11717
12629
  // Compute effective space for a given Y value
11718
12630
  const spaceFor = (y) => {
11719
- if (y === "above") {
11720
- return spaceAbove - spacing - viewportSpacing;
12631
+ if (y === "top") {
12632
+ return spaceAbove - marginWithAnchor - marginWithContainer;
11721
12633
  }
11722
- if (y === "above-overlap") {
11723
- return spaceAbove + anchorHeight - viewportSpacing;
12634
+ if (y === "inset-bottom") {
12635
+ return spaceAbove + anchorHeight - marginWithContainer;
11724
12636
  }
11725
- if (y === "below") {
11726
- return spaceBelow - spacing - viewportSpacing;
12637
+ if (y === "bottom") {
12638
+ return spaceBelow - marginWithAnchor - marginWithContainer;
11727
12639
  }
11728
- if (y === "below-overlap") {
11729
- return spaceBelow + anchorHeight - viewportSpacing;
12640
+ if (y === "inset-top") {
12641
+ return spaceBelow + anchorHeight - marginWithContainer;
11730
12642
  }
11731
12643
  return Infinity; // center
11732
12644
  };
@@ -11770,24 +12682,24 @@ const pickPositionRelativeTo = (
11770
12682
  let finalX;
11771
12683
  {
11772
12684
  const oppositeX = {
11773
- "to-the-left": "to-the-right",
11774
- "to-the-right": "to-the-left",
11775
- "left-aligned": "right-aligned",
11776
- "right-aligned": "left-aligned",
12685
+ "left": "right",
12686
+ "right": "left",
12687
+ "inset-left": "inset-right",
12688
+ "inset-right": "inset-left",
11777
12689
  };
11778
12690
  // Compute effective space for a given X value
11779
12691
  const spaceFor = (x) => {
11780
- if (x === "to-the-left") {
11781
- return spaceLeft - spacing - viewportSpacing;
12692
+ if (x === "left") {
12693
+ return spaceLeft - marginWithAnchor - marginWithContainer;
11782
12694
  }
11783
- if (x === "left-aligned") {
11784
- return viewportWidth - anchorLeft - viewportSpacing;
12695
+ if (x === "inset-left") {
12696
+ return availableRight - anchorLeft - marginWithContainer;
11785
12697
  }
11786
- if (x === "right-aligned") {
11787
- return anchorRight - viewportSpacing;
12698
+ if (x === "inset-right") {
12699
+ return anchorRight - availableLeft - marginWithContainer;
11788
12700
  }
11789
- if (x === "to-the-right") {
11790
- return spaceRight - spacing - viewportSpacing;
12701
+ if (x === "right") {
12702
+ return spaceRight - marginWithAnchor - marginWithContainer;
11791
12703
  }
11792
12704
  return Infinity; // center
11793
12705
  };
@@ -11815,7 +12727,14 @@ const pickPositionRelativeTo = (
11815
12727
  if (currentFitsEnough) {
11816
12728
  finalX = activeX;
11817
12729
  } else {
11818
- finalX = oppositeX[activeX];
12730
+ // Only flip if the opposite side has more space — avoids oscillation
12731
+ // when neither side has enough room (both fail the ratio). Mirrors
12732
+ // the Y-axis branch above; missing here was the actual cause of a
12733
+ // real left/right flicker on a narrow viewport (neither side ever
12734
+ // "fits enough", so this branch ran on every reposition).
12735
+ const opposite = oppositeX[activeX];
12736
+ const oppositeHasMoreSpace = spaceFor(opposite) > spaceFor(activeX);
12737
+ finalX = oppositeHasMoreSpace ? opposite : activeX;
11819
12738
  }
11820
12739
  }
11821
12740
  }
@@ -11823,101 +12742,178 @@ const pickPositionRelativeTo = (
11823
12742
  // Calculate horizontal position (viewport-relative)
11824
12743
  let elementPositionLeft;
11825
12744
  {
11826
- if (finalX === "to-the-left") {
11827
- elementPositionLeft = effectiveAnchorLeft - elementWidth - spacing;
11828
- } else if (finalX === "left-aligned") {
12745
+ if (finalX === "left") {
12746
+ elementPositionLeft =
12747
+ effectiveAnchorLeft - elementWidth - marginWithAnchor;
12748
+ } else if (finalX === "inset-left") {
11829
12749
  elementPositionLeft = effectiveAnchorLeft;
11830
12750
  } else if (finalX === "center") {
11831
- // Complex logic handles wide anchors and viewport-edge snapping
11832
- const anchorIsWiderThanViewport = anchorWidth > viewportWidth;
11833
- if (anchorIsWiderThanViewport) {
11834
- const anchorLeftIsVisible = effectiveAnchorLeft >= 0;
11835
- const anchorRightIsVisible = effectiveAnchorRight <= viewportWidth;
12751
+ // Complex logic handles wide anchors and container-edge snapping
12752
+ const anchorIsWiderThanAvailable = anchorWidth > availableWidth;
12753
+ if (anchorIsWiderThanAvailable) {
12754
+ const anchorLeftIsVisible = effectiveAnchorLeft >= availableLeft;
12755
+ const anchorRightIsVisible = effectiveAnchorRight <= availableRight;
11836
12756
  if (!anchorLeftIsVisible && anchorRightIsVisible) {
11837
- const viewportCenter = viewportWidth / 2;
11838
- const distanceFromRightEdge = viewportWidth - effectiveAnchorRight;
12757
+ const availableCenter = availableLeft + availableWidth / 2;
12758
+ const distanceFromRightEdge = availableRight - effectiveAnchorRight;
11839
12759
  elementPositionLeft =
11840
- viewportCenter - distanceFromRightEdge / 2 - elementWidth / 2;
12760
+ availableCenter - distanceFromRightEdge / 2 - elementWidth / 2;
11841
12761
  } else if (anchorLeftIsVisible && !anchorRightIsVisible) {
11842
- const viewportCenter = viewportWidth / 2;
11843
- const distanceFromLeftEdge = -effectiveAnchorLeft;
12762
+ const availableCenter = availableLeft + availableWidth / 2;
12763
+ const distanceFromLeftEdge = availableLeft - effectiveAnchorLeft;
11844
12764
  elementPositionLeft =
11845
- viewportCenter - distanceFromLeftEdge / 2 - elementWidth / 2;
12765
+ availableCenter - distanceFromLeftEdge / 2 - elementWidth / 2;
11846
12766
  } else {
11847
- elementPositionLeft = viewportWidth / 2 - elementWidth / 2;
12767
+ elementPositionLeft =
12768
+ availableLeft + availableWidth / 2 - elementWidth / 2;
11848
12769
  }
11849
12770
  } else {
11850
12771
  elementPositionLeft =
11851
12772
  effectiveAnchorLeft +
11852
12773
  (effectiveAnchorRight - effectiveAnchorLeft) / 2 -
11853
12774
  elementWidth / 2;
11854
- if (alignToViewportEdgeWhenAnchorNearEdge) {
12775
+ if (alignToContainerEdgeWhenAnchorNearEdge) {
11855
12776
  const effectiveAnchorWidth =
11856
12777
  effectiveAnchorRight - effectiveAnchorLeft;
11857
12778
  const elementIsWiderThanAnchor = elementWidth > effectiveAnchorWidth;
11858
- const anchorIsNearLeftEdge =
11859
- effectiveAnchorLeft < alignToViewportEdgeWhenAnchorNearEdge;
11860
- if (elementIsWiderThanAnchor && anchorIsNearLeftEdge) {
11861
- elementPositionLeft = minLeft;
12779
+ const anchorIsNearContainerEdge =
12780
+ effectiveAnchorLeft - clampLeftBound <
12781
+ alignToContainerEdgeWhenAnchorNearEdge;
12782
+ if (elementIsWiderThanAnchor && anchorIsNearContainerEdge) {
12783
+ elementPositionLeft = clampLeftBound + minLeft;
11862
12784
  }
11863
12785
  }
11864
12786
  }
11865
- } else if (finalX === "right-aligned") {
12787
+ } else if (finalX === "inset-right") {
11866
12788
  elementPositionLeft = effectiveAnchorRight - elementWidth;
11867
12789
  } else {
11868
- // "to-the-right"
11869
- elementPositionLeft = effectiveAnchorRight + spacing;
12790
+ // "right"
12791
+ elementPositionLeft = effectiveAnchorRight + marginWithAnchor;
11870
12792
  }
11871
- // Constrain horizontal position to viewport boundaries (with viewportSpacing margin)
11872
- if (elementPositionLeft < viewportSpacing) {
11873
- elementPositionLeft = viewportSpacing;
12793
+ // Constrain horizontal position to the available area's boundaries
12794
+ // (with marginWithContainer margin).
12795
+ if (elementPositionLeft < clampLeftBound + marginWithContainer) {
12796
+ elementPositionLeft = clampLeftBound + marginWithContainer;
11874
12797
  } else if (
11875
12798
  elementPositionLeft + elementWidth >
11876
- viewportWidth - viewportSpacing
12799
+ clampRightBound - marginWithContainer
11877
12800
  ) {
11878
- elementPositionLeft = viewportWidth - viewportSpacing - elementWidth;
12801
+ elementPositionLeft =
12802
+ clampRightBound - marginWithContainer - elementWidth;
11879
12803
  }
11880
12804
  }
11881
12805
 
11882
12806
  // Calculate vertical position (viewport-relative)
11883
12807
  let elementPositionTop;
11884
12808
  {
11885
- if (finalY === "above") {
11886
- // top is always anchorTop + insetTop - elementHeight - spacing — max-height truncates if needed.
11887
- const idealTop = anchorTop + insetTop - elementHeight - spacing;
12809
+ if (finalY === "top") {
12810
+ // top is always anchorTop + insetTop - elementHeight - marginWithAnchor — max-height truncates if needed.
12811
+ const idealTop = anchorTop + insetTop - elementHeight - marginWithAnchor;
11888
12812
  elementPositionTop =
11889
- idealTop < viewportSpacing ? viewportSpacing : idealTop;
11890
- } else if (finalY === "above-overlap") {
12813
+ idealTop < marginWithContainer ? marginWithContainer : idealTop;
12814
+ } else if (finalY === "inset-bottom") {
11891
12815
  const idealTop = anchorBottom - elementHeight;
11892
12816
  elementPositionTop =
11893
- idealTop < viewportSpacing ? viewportSpacing : idealTop;
12817
+ idealTop < marginWithContainer ? marginWithContainer : idealTop;
11894
12818
  } else if (finalY === "center") {
11895
12819
  elementPositionTop = anchorTop + anchorHeight / 2 - elementHeight / 2;
11896
- } else if (finalY === "below-overlap") {
12820
+ } else if (finalY === "inset-top") {
11897
12821
  const idealTop = anchorTop;
11898
12822
  elementPositionTop =
11899
12823
  idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
11900
12824
  } else {
11901
- // "below"
11902
- // top is always anchorBottom - insetBottom + spacing — max-height (via --space-available) truncates
12825
+ // "bottom"
12826
+ // top is always anchorBottom - insetBottom + marginWithAnchor — max-height (via --container-position-remaining-height) truncates
11903
12827
  // the element height so it doesn't overflow the viewport bottom.
11904
- const idealTop = anchorBottom - insetBottom + spacing;
12828
+ const idealTop = anchorBottom - insetBottom + marginWithAnchor;
11905
12829
  elementPositionTop =
11906
12830
  idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
11907
12831
  }
11908
- }
11909
-
11910
- // Persist resolved X/Y so subsequent calls start from here (avoids flickering).
11911
- // Fixed axes are not persisted.
11912
- if (!xIsFixed) {
11913
- element.setAttribute("data-position-x-current", finalX);
11914
- }
11915
- if (!yIsFixed) {
11916
- element.setAttribute("data-position-y-current", finalY);
11917
- }
11918
-
11919
- // Get document scroll for final coordinate conversion
11920
- const { scrollLeft, scrollTop } = document.documentElement;
12832
+ // Unlike the horizontal clamp above, there's normally no universal
12833
+ // vertical boundary clamp at all — "top"/"bottom" already clamp their
12834
+ // own idealTop inline, "inset-*"/"center" don't, and changing that
12835
+ // for every existing consumer (real-anchor "bottom" near the viewport
12836
+ // bottom relies on --container-position-remaining-height/max-height truncation instead of
12837
+ // repositioning) is out of scope here. Scoped strictly to the no-anchor
12838
+ // (container-docked) case, where it's new and safe: a container is
12839
+ // always meant to be respected on both axes.
12840
+ if (!hasValidAnchor) {
12841
+ if (elementPositionTop < availableTop + marginWithContainer) {
12842
+ elementPositionTop = availableTop + marginWithContainer;
12843
+ } else if (
12844
+ elementPositionTop + elementHeight >
12845
+ availableBottom - marginWithContainer
12846
+ ) {
12847
+ elementPositionTop =
12848
+ availableBottom - marginWithContainer - elementHeight;
12849
+ }
12850
+ }
12851
+ }
12852
+
12853
+ // Persist resolved X/Y so subsequent calls start from here (avoids
12854
+ // flickering) — and so CSS consumers (e.g. Popover's "clip" animation,
12855
+ // which reads data-position-y-current to pick which edge to reveal from)
12856
+ // can rely on it always reflecting the current side, fixed or not. A fixed
12857
+ // axis is never read back from this attribute (xIsFixed/yIsFixed always
12858
+ // wins over the stored value above), so persisting it here is purely for
12859
+ // those outside readers, not for this function's own flip logic.
12860
+ element.setAttribute("data-position-x-current", finalX);
12861
+ element.setAttribute("data-position-y-current", finalY);
12862
+
12863
+ // Convert the viewport-relative math above into whatever coordinate space
12864
+ // `element.style.top/left` actually needs. This is decided independently
12865
+ // of whether there's a real anchor: `element` might be `position:
12866
+ // absolute` relative to some container regardless (e.g. the custom
12867
+ // renderer in popover.jsx, which is always relative to its own
12868
+ // positioned ancestor whether or not it also has a real anchor) — that's
12869
+ // what `resolvedContainer` (explicit or auto-resolved above) communicates
12870
+ // even when `anchor` is also given. The container to convert into is
12871
+ // `resolvedContainer` when there's a real anchor, or (in the no-anchor
12872
+ // case) `effectiveAnchor` itself, since there the container *is* what's
12873
+ // being positioned against.
12874
+ const coordinateContainer = hasValidAnchor
12875
+ ? resolvedContainer
12876
+ : effectiveAnchor;
12877
+ let scrollLeft;
12878
+ let scrollTop;
12879
+ if (coordinateContainer && coordinateContainer !== document.documentElement) {
12880
+ // Reuse anchorRect/containerBorders when the coordinate container is
12881
+ // the same element already measured above (the no-anchor case);
12882
+ // otherwise (a real anchor positioned within a *different*, explicitly
12883
+ // given container) measure the container separately — the anchor's own
12884
+ // rect only matters for the positioning math above, not for this.
12885
+ const isSameAsEffectiveAnchor = coordinateContainer === effectiveAnchor;
12886
+ const coordinateRect = isSameAsEffectiveAnchor
12887
+ ? anchorRect
12888
+ : coordinateContainer.getBoundingClientRect();
12889
+ const coordinateBorders = isSameAsEffectiveAnchor
12890
+ ? containerBorders
12891
+ : getBorderSizes(coordinateContainer);
12892
+ scrollLeft =
12893
+ -coordinateRect.left -
12894
+ coordinateBorders.left +
12895
+ coordinateContainer.scrollLeft;
12896
+ scrollTop =
12897
+ -coordinateRect.top -
12898
+ coordinateBorders.top +
12899
+ coordinateContainer.scrollTop;
12900
+ } else {
12901
+ // No container to convert into (a plain real anchor, the common case
12902
+ // for Callout/Picker/Popover's own via-attribute renderer), or the
12903
+ // container is the viewport itself (Popover's via-attribute renderer
12904
+ // when docked, no real anchor) — either way, `element`'s own computed
12905
+ // `position` (fixed vs absolute, detected dynamically) decides whether
12906
+ // any scroll offset applies at all: none for position: fixed (already
12907
+ // viewport-relative — adding scroll would double-count it), the
12908
+ // document's own scroll for position: absolute (relative to the
12909
+ // initial containing block, i.e. document-relative) — including when
12910
+ // docked to the viewport, so the result lands at the visual center of
12911
+ // the viewport at its current scroll position.
12912
+ ({ scrollLeft, scrollTop } = getPositioningScrollOffset(element));
12913
+ }
12914
+ // visibleRectEffect recomputes this on every scroll tick, which is what
12915
+ // keeps it looking anchored as the page (or the container) scrolls
12916
+ // either way.
11921
12917
  const elementDocumentLeft = snapToPixel(elementPositionLeft + scrollLeft);
11922
12918
  const elementDocumentTop = snapToPixel(elementPositionTop + scrollTop);
11923
12919
  const anchorDocumentLeft = anchorLeft + scrollLeft;
@@ -11927,18 +12923,32 @@ const pickPositionRelativeTo = (
11927
12923
 
11928
12924
  // For overlap variants the element starts at the anchor edge (not past it),
11929
12925
  // so the usable space includes the anchor dimension.
11930
- // spacing (gap between anchor and element) and viewportSpacing are subtracted
12926
+ // marginWithAnchor (gap between anchor and element) and marginWithContainer are subtracted
11931
12927
  // so callers get the net usable space directly.
11932
12928
  const effectiveSpaceAbove =
11933
- (finalY === "above-overlap" ? spaceAbove + anchorHeight : spaceAbove) -
11934
- (finalY === "above" ? spacing : 0) -
11935
- viewportSpacing;
12929
+ (finalY === "inset-bottom" ? spaceAbove + anchorHeight : spaceAbove) -
12930
+ (finalY === "top" ? marginWithAnchor : 0) -
12931
+ marginWithContainer;
11936
12932
  const effectiveSpaceBelow =
11937
- (finalY === "below-overlap" ? spaceBelow + anchorHeight : spaceBelow) -
11938
- (finalY === "below" ? spacing : 0) -
11939
- viewportSpacing;
12933
+ (finalY === "inset-top" ? spaceBelow + anchorHeight : spaceBelow) -
12934
+ (finalY === "bottom" ? marginWithAnchor : 0) -
12935
+ marginWithContainer;
12936
+ const effectiveSpaceLeft =
12937
+ (finalX === "inset-right" ? spaceLeft + anchorWidth : spaceLeft) -
12938
+ (finalX === "left" ? marginWithAnchor : 0) -
12939
+ marginWithContainer;
12940
+ const effectiveSpaceRight =
12941
+ (finalX === "inset-left" ? spaceRight + anchorWidth : spaceRight) -
12942
+ (finalX === "right" ? marginWithAnchor : 0) -
12943
+ marginWithContainer;
11940
12944
 
11941
12945
  return {
12946
+ // Whether a real anchor actually ended up used — false when there's no
12947
+ // `anchor`, or it was rejected as too big.
12948
+ hasValidAnchor,
12949
+ // True only when `event` is a "resize" — see applyNewPosition's own
12950
+ // doc for why only resize-triggered repositions are meant to animate.
12951
+ shouldTransition: event?.type === "resize",
11942
12952
  positionX: finalX,
11943
12953
  positionY: finalY,
11944
12954
  left: elementDocumentLeft,
@@ -11949,13 +12959,196 @@ const pickPositionRelativeTo = (
11949
12959
  anchorTop: anchorDocumentTop,
11950
12960
  anchorRight: anchorDocumentRight,
11951
12961
  anchorBottom: anchorDocumentBottom,
11952
- spaceLeft: spaceLeft - viewportSpacing,
11953
- spaceRight: spaceRight - viewportSpacing,
12962
+ spaceLeft: effectiveSpaceLeft,
12963
+ spaceRight: effectiveSpaceRight,
11954
12964
  spaceAbove: effectiveSpaceAbove,
11955
12965
  spaceBelow: effectiveSpaceBelow,
11956
12966
  };
11957
12967
  };
11958
12968
 
12969
+ // Per-element bookkeeping for the currently in-flight, self-driven position
12970
+ // transition, if any — see notifyPositionTransition's own doc for why this
12971
+ // is animation-driven rather than listening for the browser's own
12972
+ // transitionrun/transitionend: element -> { animation, endCallbacks }.
12973
+ const pendingPositionTransitions = new WeakMap();
12974
+
12975
+ // Reads `cssVarName` off `element` (getComputedStyle, so it's whatever the
12976
+ // cascade resolves to — a consumer can set it inline, in its own CSS rule,
12977
+ // or not at all) and converts it to milliseconds: "0.25s" -> 250, "250ms" ->
12978
+ // 250. Falls back to `fallbackMs` when unset/empty/unparsable, so a caller
12979
+ // never has to declare the CSS var itself just to get a sane default
12980
+ // duration — it only needs to when it actually wants to override it.
12981
+ const parseTransitionDurationMs = (element, cssVarName, fallbackMs) => {
12982
+ const trimmed = getStyle(element, cssVarName).trim();
12983
+ if (!trimmed) {
12984
+ return fallbackMs;
12985
+ }
12986
+ if (trimmed.endsWith("ms")) {
12987
+ return parseFloat(trimmed);
12988
+ }
12989
+ if (trimmed.endsWith("s")) {
12990
+ return parseFloat(trimmed) * 1000;
12991
+ }
12992
+ const parsed = parseFloat(trimmed);
12993
+ return Number.isNaN(parsed) ? fallbackMs : parsed;
12994
+ };
12995
+
12996
+ /**
12997
+ * Dispatches a single "navi_position_transition" event on `element`,
12998
+ * self-driven rather than confirmed by the browser's own `transitionrun` —
12999
+ * `applyNewPosition` calls this exactly when it knows it just started a
13000
+ * left/top `animation`, so there's nothing to wait for. transitionrun was
13001
+ * tried first and dropped: it reacts to *any* transition sharing the
13002
+ * element (a scale/opacity entrance would wrongly hide a descendant too),
13003
+ * and filtering by `propertyName` is unreliable (observed firing for "top"
13004
+ * instead of "left" in practice, despite the transition-property order).
13005
+ * A dedicated `Animation` sidesteps both.
13006
+ *
13007
+ * A descendant anchored inside `element` (see on_ancestor_events)
13008
+ * re-checks its own position every frame for as long as this animation
13009
+ * runs, instead of showing a stale position. `event.detail.onEnd(callback)`
13010
+ * is how it learns when the animation actually ends.
13011
+ *
13012
+ * A second reposition landing mid-animation cancels the pending one and
13013
+ * flushes its own registered callbacks immediately (same spirit as a real
13014
+ * `transitioncancel`), so nothing is left waiting on a superseded `onEnd`.
13015
+ *
13016
+ * `commitStyles()` below isn't what makes the final position correct —
13017
+ * `applyNewPosition` already sets the specified `left`/`top` before this
13018
+ * animation starts, so it takes back over once the active duration elapses
13019
+ * regardless. It just makes that explicit instead of relying on `fill:
13020
+ * "none"` timing, and drops the finished Animation instead of leaving it.
13021
+ */
13022
+ const notifyPositionTransition = (element, animation) => {
13023
+ const pending = pendingPositionTransitions.get(element);
13024
+ if (pending) {
13025
+ pending.animation.cancel();
13026
+ for (const callback of pending.endCallbacks) {
13027
+ callback();
13028
+ }
13029
+ }
13030
+ const endCallbacks = [];
13031
+ dispatchCustomEvent(element, "navi_position_transition", {
13032
+ onEnd: (callback) => {
13033
+ endCallbacks.push(callback);
13034
+ },
13035
+ });
13036
+ const current = { animation, endCallbacks };
13037
+ pendingPositionTransitions.set(element, current);
13038
+ animation.finished
13039
+ .then(() => {
13040
+ if (pendingPositionTransitions.get(element) === current) {
13041
+ pendingPositionTransitions.delete(element);
13042
+ }
13043
+ try {
13044
+ animation.commitStyles();
13045
+ } catch {
13046
+ // Element no longer rendered (removed/hidden mid-animation) —
13047
+ // nothing to commit to, and left/top were already final anyway.
13048
+ }
13049
+ animation.cancel();
13050
+ for (const callback of endCallbacks) {
13051
+ callback();
13052
+ }
13053
+ })
13054
+ .catch(() => {
13055
+ // Cancelled by a subsequent reposition — already flushed above.
13056
+ });
13057
+ };
13058
+
13059
+ /**
13060
+ * Applies a `pickPositionRelativeTo` result to `element`. `left`/`top` are
13061
+ * set instantly (a scroll-triggered reposition should never lag its
13062
+ * target); when `shouldTransition` is set (a resize-triggered reposition),
13063
+ * the visual move is played out via `element.animate()` instead — kept
13064
+ * independent of Popover/Dialog/Callout's own opacity/scale/display CSS
13065
+ * transition on the same element, so neither can clobber the other (see
13066
+ * notifyPositionTransition's own doc for why a dedicated Animation over a
13067
+ * CSS one). Duration comes from `--popup-position-transition-duration`
13068
+ * (parseTransitionDurationMs), falling back to 180ms unset.
13069
+ * Dispatches navi_position_transition when it starts such an animation, and
13070
+ * navi_position_change unconditionally — every caller (Dialog, Popover,
13071
+ * Callout) wants both, so a descendant anchored inside `element` can always
13072
+ * recheck its own position whenever `element` moves.
13073
+ */
13074
+ const applyNewPosition = (
13075
+ element,
13076
+ {
13077
+ left,
13078
+ top,
13079
+ shouldTransition,
13080
+ positionX,
13081
+ positionY,
13082
+ spaceLeft,
13083
+ spaceRight,
13084
+ spaceAbove,
13085
+ spaceBelow,
13086
+ },
13087
+ ) => {
13088
+ if (positionY === "top" || positionY === "inset-bottom") {
13089
+ element.style.setProperty(
13090
+ "--container-position-remaining-height",
13091
+ `${spaceAbove}px`,
13092
+ );
13093
+ } else if (positionY === "bottom" || positionY === "inset-top") {
13094
+ element.style.setProperty(
13095
+ "--container-position-remaining-height",
13096
+ `${spaceBelow}px`,
13097
+ );
13098
+ } else {
13099
+ element.style.removeProperty("--container-position-remaining-height");
13100
+ }
13101
+ if (positionX === "left" || positionX === "inset-right") {
13102
+ element.style.setProperty(
13103
+ "--container-position-remaining-width",
13104
+ `${spaceLeft}px`,
13105
+ );
13106
+ } else if (positionX === "right" || positionX === "inset-left") {
13107
+ element.style.setProperty(
13108
+ "--container-position-remaining-width",
13109
+ `${spaceRight}px`,
13110
+ );
13111
+ } else {
13112
+ element.style.removeProperty("--container-position-remaining-width");
13113
+ }
13114
+
13115
+ // A single implicit keyframe turned out not to work here: the WAAPI
13116
+ // "neutral" start keyframe isn't frozen at `animate()` call time, it's
13117
+ // resolved from the underlying value when the animation is first
13118
+ // *sampled* (the next frame) — by then `element.style.left`/`top` below
13119
+ // has already been overwritten with the new target, so start === end and
13120
+ // nothing visibly moves (observed as the dialog just jumping). Reading
13121
+ // the previous value ourselves, before overwriting it, and passing both
13122
+ // keyframes explicitly sidesteps that entirely.
13123
+ const previousLeft = parseFloat(element.style.left) || left;
13124
+ const previousTop = parseFloat(element.style.top) || top;
13125
+ if (shouldTransition) {
13126
+ const animation = element.animate(
13127
+ [
13128
+ { left: `${previousLeft}px`, top: `${previousTop}px` },
13129
+ { left: `${left}px`, top: `${top}px` },
13130
+ ],
13131
+ {
13132
+ duration: parseTransitionDurationMs(
13133
+ element,
13134
+ "--popup-position-transition-duration",
13135
+ 250,
13136
+ ),
13137
+ easing: "ease",
13138
+ },
13139
+ );
13140
+ notifyPositionTransition(element, animation);
13141
+ }
13142
+ // The specified `left`/`top` are set to their final target right away,
13143
+ // regardless of `shouldTransition` — the animation above only plays the
13144
+ // visual move from the old position, it never becomes the actual
13145
+ // specified style (see notifyPositionTransition's own commitStyles for
13146
+ // why that matters once it ends).
13147
+ element.style.left = `${left}px`;
13148
+ element.style.top = `${top}px`;
13149
+ dispatchCustomEvent(element, "navi_position_change");
13150
+ };
13151
+
11959
13152
  const [publishDebugger, subscribeDebugger] = createPubSub();
11960
13153
 
11961
13154
  const notifyDebuggerStart = () => {
@@ -15206,4 +16399,4 @@ const useResizeStatus = (elementRef, { as = "number" } = {}) => {
15206
16399
  };
15207
16400
  };
15208
16401
 
15209
- export { EASING, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles, captureScrollState, chainEvent, contrastColor, createBackgroundColorTransition, createBackgroundTransition, createBorderRadiusTransition, createBorderTransition, createDragGestureController, createDragToMoveGestureController, createEventGroupLogger, createGroupTransitionController, createHeightTransition, createIterableWeakSet, createOpacityTransition, createPubSub, createStyleController, createTimelineTransition, createTransition, createTranslateXTransition, createValueEffect, createWidthTransition, cubicBezier, dispatchCustomEvent, dispatchInternalCustomEvent, dispatchPublicCustomEvent, dragAfterThreshold, elementIsFocusable, elementIsVisibleForFocus, elementIsVisuallyVisible, findAfter, findAncestor, findBefore, findDescendant, findEvent, findFocusDelegateTarget, findFocusable, formatEventSideEffect, getAvailableHeight, getAvailableWidth, getBackground, getBackgroundColor, getBorder, getBorderRadius, getBorderSizes, getContrastRatio, getDefaultStyles, getDragCoordinates, getDropTargetInfo, getElementSignature, getFirstVisuallyVisibleAncestor, getFocusVisibilityInfo, getHeight, getHeightWithoutTransition, getInnerHeight, getInnerWidth, getKeyboardEventDefaultAction, getLuminance, getMarginSizes, getMaxHeight, getMaxWidth, getMinHeight, getMinWidth, getOpacity, getOpacityWithoutTransition, getPaddingSizes, getPositionedParent, getPreferedColorScheme, getScrollBox, getScrollContainer, getScrollContainerSet, getScrollRelativeRect, getSelfAndAncestorScrolls, getStyle, getTranslateX, getTranslateXWithoutTransition, getTranslateY, getVisuallyVisibleInfo, getWidth, getWidthWithoutTransition, hasCSSSizeUnit, initFlexDetailsSet, initFocusGroup, initPositionSticky, isSameColor, isScrollable, measureLongestVisualLineWidth, measureScrollbar, measureWidestChildRow, mergeOneStyle, mergeTwoStyles, normalizeKeyboardKey, normalizeStyle, normalizeStyles, parseStyle, performTabNavigation, pickPositionRelativeTo, prefersDarkColors, prefersLightColors, preventFocusNav, preventFocusNavViaKeyboard, preventIntermediateScrollbar, resolveCSSColor, resolveCSSSize, resolveColorLuminance, resolveOklchLightness, scrollIntoViewScoped, scrollIntoViewWithStickyAwareness, setAttribute, setAttributes, setStyles, snapToPixel, startDragToReorder, startDragToResizeGesture, stickyAsRelativeCoords, stringifyStyle, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect };
16402
+ export { EASING, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles, applyNewPosition, captureScrollState, chainEvent, closestOpenableAncestor, contrastColor, createBackgroundColorTransition, createBackgroundTransition, createBorderRadiusTransition, createBorderTransition, createDragGestureController, createDragToMoveGestureController, createEventGroupLogger, createGroupTransitionController, createHeightTransition, createIterableWeakSet, createOpacityTransition, createPubSub, createStyleController, createTimelineTransition, createTransition, createTranslateXTransition, createValueEffect, createWidthTransition, cubicBezier, dispatchCustomEvent, dispatchInternalCustomEvent, dispatchPublicCustomEvent, dragAfterThreshold, elementIsFocusable, elementIsVisibleForFocus, elementIsVisuallyVisible, findAfter, findAncestor, findBefore, findDescendant, findEvent, findFocusDelegateTarget, findFocusable, findSelfOrAncestorFixedPosition, formatEventSideEffect, getAncestorOpenType, getAvailableHeight, getAvailableWidth, getBackground, getBackgroundColor, getBorder, getBorderRadius, getBorderSizes, getContrastRatio, getDefaultStyles, getDragCoordinates, getDropTargetInfo, getElementSignature, getFirstVisuallyVisibleAncestor, getFocusVisibilityInfo, getHeight, getHeightWithoutTransition, getInnerHeight, getInnerWidth, getKeyboardEventDefaultAction, getLuminance, getMarginSizes, getMaxHeight, getMaxWidth, getMinHeight, getMinWidth, getOpacity, getOpacityWithoutTransition, getPaddingSizes, getPositionedParent, getPositioningScrollOffset, getPreferedColorScheme, getScrollBox, getScrollContainer, getScrollContainerSet, getScrollRelativeRect, getSelfAndAncestorScrolls, getStyle, getTranslateX, getTranslateXWithoutTransition, getTranslateY, getVisuallyVisibleInfo, getWidth, getWidthWithoutTransition, hasCSSSizeUnit, initFlexDetailsSet, initFocusGroup, initPositionSticky, isAncestorOpen, isSameColor, isScrollable, measureLongestVisualLineWidth, measureScrollbar, measureWidestChildRow, mergeOneStyle, mergeTwoStyles, normalizeKeyboardKey, normalizeStyle, normalizeStyles, observeAncestorOpenState, onAncestorReopen, parsePositionArea, parseStyle, performTabNavigation, pickPositionRelativeTo, prefersDarkColors, prefersLightColors, preventFocusNav, preventFocusNavViaKeyboard, preventIntermediateScrollbar, resolveCSSColor, resolveCSSSize, resolveColorLuminance, resolveOklchLightness, scrollIntoViewScoped, scrollIntoViewWithStickyAwareness, setAttribute, setAttributes, setStyles, snapToPixel, startDragToReorder, startDragToResizeGesture, stickyAsRelativeCoords, stringifyStyle, subscribeVisualViewportResizeSettled, subscribeWindowResizeSettled, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect };