@jsenv/dom 0.15.0 → 0.17.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 +778 -173
  2. package/package.json +3 -3
package/dist/jsenv_dom.js CHANGED
@@ -5550,7 +5550,22 @@ const performTabNavigation = (
5550
5550
  if (hasNegativeTabIndex(element)) {
5551
5551
  return false;
5552
5552
  }
5553
- return elementIsFocusable(element, { excludeAriaHidden });
5553
+ if (!elementIsFocusable(element, { excludeAriaHidden })) {
5554
+ return false;
5555
+ }
5556
+ // Native radio-group semantics: within a named radio group only ONE radio
5557
+ // is a Tab stop — the checked one, or the first focusable one when none is
5558
+ // checked. The rest are reachable with arrow keys, not Tab. Without this,
5559
+ // tabbing into a group would land on its first radio instead of its checked
5560
+ // value (e.g. tabbing between two wheels of a WheelGroup).
5561
+ if (
5562
+ element.matches?.('input[type="radio"]') &&
5563
+ element.name &&
5564
+ !radioIsGroupTabStop(element)
5565
+ ) {
5566
+ return false;
5567
+ }
5568
+ return true;
5554
5569
  };
5555
5570
 
5556
5571
  // A focus group "owns" the activeElement when activeElement is inside it.
@@ -5701,6 +5716,43 @@ const performTabNavigation = (
5701
5716
  }
5702
5717
  };
5703
5718
 
5719
+ // Whether a radio is the single Tab stop of its native radio group (checked, or
5720
+ // the first enabled radio when none is checked). Mirrors how the browser puts
5721
+ // only one radio of a group in the Tab order.
5722
+ const radioIsGroupTabStop = (radio) => {
5723
+ const scope = radio.form || radio.getRootNode();
5724
+ if (!scope || !scope.querySelectorAll) {
5725
+ return true;
5726
+ }
5727
+ const sameName = scope.querySelectorAll(
5728
+ `input[type="radio"][name="${CSS.escape(radio.name)}"]`,
5729
+ );
5730
+ const radioForm = radio.form || null;
5731
+ let checked = null;
5732
+ let firstEnabled = null;
5733
+ let groupSize = 0;
5734
+ for (const candidate of sameName) {
5735
+ // Radios only form one group when they share the same form owner.
5736
+ if ((candidate.form || null) !== radioForm) {
5737
+ continue;
5738
+ }
5739
+ groupSize++;
5740
+ if (candidate.disabled) {
5741
+ continue;
5742
+ }
5743
+ if (!firstEnabled) {
5744
+ firstEnabled = candidate;
5745
+ }
5746
+ if (candidate.checked && !checked) {
5747
+ checked = candidate;
5748
+ }
5749
+ }
5750
+ if (groupSize <= 1) {
5751
+ return true;
5752
+ }
5753
+ return radio === (checked || firstEnabled);
5754
+ };
5755
+
5704
5756
  const isTabEvent$1 = (event) => event.key === "Tab" || event.keyCode === 9;
5705
5757
 
5706
5758
  const hasNegativeTabIndex = (element) => {
@@ -6323,7 +6375,10 @@ const getScrollContainer = (arg, { includeHidden } = {}) => {
6323
6375
  }
6324
6376
  return null;
6325
6377
  }
6326
- if (element.hasAttribute("popover") && element.matches(":popover-open")) {
6378
+ if (element.hasAttribute("popover")) {
6379
+ return getScrollingElement(element.ownerDocument);
6380
+ }
6381
+ if (element.tagName === "DIALOG" && element.matches(":modal")) {
6327
6382
  return getScrollingElement(element.ownerDocument);
6328
6383
  }
6329
6384
  const position = getStyle(element, "position");
@@ -7173,49 +7228,61 @@ const getPaddingSizes = (element) => {
7173
7228
  */
7174
7229
  const trapScrollInside = (element) => {
7175
7230
  const cleanupCallbackSet = new Set();
7176
- const lockScroll = (el) => {
7231
+
7232
+ // Collect every element to lock first (preceding scrollable siblings + all
7233
+ // ancestor scroll containers).
7234
+ const elementsToLock = [];
7235
+ let previous = element.previousSibling;
7236
+ while (previous) {
7237
+ if (previous.nodeType === 1 && isScrollable(previous)) {
7238
+ elementsToLock.push(previous);
7239
+ }
7240
+ previous = previous.previousSibling;
7241
+ }
7242
+ for (const selfOrAncestorScroll of getSelfAndAncestorScrolls(element)) {
7243
+ elementsToLock.push(selfOrAncestorScroll.scrollContainer);
7244
+ }
7245
+
7246
+ // Phase 1 — MEASURE. Batch every layout/style read (scrollTop, scrollbar
7247
+ // size, padding) before any style write, so the layout that showModal
7248
+ // invalidated is recomputed once rather than thrashing between each write and
7249
+ // the next read. (measureScrollbar still forces its own reflow per element via
7250
+ // its probe node — that one is inherent.)
7251
+ const plans = elementsToLock.map((el) => {
7177
7252
  const savedScrollTop = el.scrollTop;
7178
7253
  const savedScrollLeft = el.scrollLeft;
7179
7254
  const scrollbarGutter = getStyle(el, "scrollbar-gutter");
7180
- const hasScrollbarGutterStrategy =
7181
- scrollbarGutter && scrollbarGutter !== "auto";
7182
- if (hasScrollbarGutterStrategy) {
7183
- // The element manages its own gutter — just hide overflow, no padding needed.
7184
- const removeScrollLockStyles = setStyles(el, { overflow: "hidden" });
7185
- cleanupCallbackSet.add(() => {
7186
- removeScrollLockStyles();
7187
- el.scrollTop = savedScrollTop;
7188
- el.scrollLeft = savedScrollLeft;
7189
- });
7190
- return;
7255
+ if (scrollbarGutter && scrollbarGutter !== "auto") {
7256
+ // The element manages its own gutter — just hide overflow, no padding.
7257
+ return {
7258
+ el,
7259
+ savedScrollTop,
7260
+ savedScrollLeft,
7261
+ styles: { overflow: "hidden" },
7262
+ };
7191
7263
  }
7192
7264
  const [scrollbarWidth, scrollbarHeight] = measureScrollbar(el);
7193
7265
  const { right, bottom } = getPaddingSizes(el);
7194
- const removeScrollLockStyles = setStyles(el, {
7195
- "padding-right": `${right + scrollbarWidth}px`,
7196
- "padding-bottom": `${bottom + scrollbarHeight}px`,
7197
- "overflow": "hidden",
7198
- });
7266
+ return {
7267
+ el,
7268
+ savedScrollTop,
7269
+ savedScrollLeft,
7270
+ styles: {
7271
+ "padding-right": `${right + scrollbarWidth}px`,
7272
+ "padding-bottom": `${bottom + scrollbarHeight}px`,
7273
+ "overflow": "hidden",
7274
+ },
7275
+ };
7276
+ });
7277
+
7278
+ // Phase 2 — MUTATE. All style writes together.
7279
+ for (const { el, savedScrollTop, savedScrollLeft, styles } of plans) {
7280
+ const removeScrollLockStyles = setStyles(el, styles);
7199
7281
  cleanupCallbackSet.add(() => {
7200
7282
  removeScrollLockStyles();
7201
7283
  el.scrollTop = savedScrollTop;
7202
7284
  el.scrollLeft = savedScrollLeft;
7203
7285
  });
7204
- };
7205
- let previous = element.previousSibling;
7206
- while (previous) {
7207
- if (previous.nodeType === 1) {
7208
- if (isScrollable(previous)) {
7209
- lockScroll(previous);
7210
- }
7211
- }
7212
- previous = previous.previousSibling;
7213
- }
7214
-
7215
- const selfAndAncestorScrolls = getSelfAndAncestorScrolls(element);
7216
- for (const selfOrAncestorScroll of selfAndAncestorScrolls) {
7217
- const elementToScrollLock = selfOrAncestorScroll.scrollContainer;
7218
- lockScroll(elementToScrollLock);
7219
7286
  }
7220
7287
 
7221
7288
  return () => {
@@ -7363,6 +7430,74 @@ const applyWheelScrollThrough = (element, wheelEvent) => {
7363
7430
  });
7364
7431
  };
7365
7432
 
7433
+ /**
7434
+ * The element `element` is genuinely `position: absolute`/`fixed` relative
7435
+ * to: its own nearest positioned ancestor (walking up the DOM tree), or
7436
+ * `document.documentElement` (the viewport) if none is found.
7437
+ *
7438
+ * Also aware of `element` itself being promoted to the top layer: a
7439
+ * `<dialog>` actually shown modally (`showModal()`, matches `:modal` — a
7440
+ * `.show()`'d, non-modal dialog does NOT match and is positioned like any
7441
+ * other in-flow element instead, walked up normally below), or *any*
7442
+ * `[popover]` element, always uses the initial containing block (the
7443
+ * viewport) regardless of its own `position` or DOM ancestry — walking up
7444
+ * its own parent chain (what the rest of this function does) would give
7445
+ * the wrong answer for these two specifically, since their real DOM
7446
+ * position becomes irrelevant to their own containing block the moment
7447
+ * they're actually promoted. Checked via the `popover` attribute itself,
7448
+ * not the live `:popover-open` state — unlike `<dialog>`, a `[popover]`
7449
+ * element has no "local" mode: it's always top-layer-bound once shown,
7450
+ * regardless of whether it happens to be open right this moment, so the
7451
+ * static attribute alone is enough (and correct even when called just
7452
+ * before `showPopover()` actually runs, when `:popover-open` isn't true
7453
+ * yet).
7454
+ *
7455
+ * `document.documentElement` (not `document.body`, not `null`) is this
7456
+ * function's own "no real container — use the viewport" sentinel:
7457
+ * `documentElement` is the actual initial containing block, so the walk
7458
+ * below stops there without testing its own `position` (there's nothing
7459
+ * beyond it to fall back to anyway) — unlike the previous version of this
7460
+ * function, which stopped one level too early, at `document.body`, without
7461
+ * ever testing *its* `position` either (a `position: relative` body, for
7462
+ * instance, would have been silently skipped). Returning `documentElement`
7463
+ * instead of `null` also means no special-casing is needed by callers that
7464
+ * already compare a resolved container against `document.documentElement`
7465
+ * (see e.g. visible_rect.js's own `hasRealContainer` check).
7466
+ */
7467
+ const getPositionedParent = (element) => {
7468
+ const isPromotedToTopLayer =
7469
+ (element.tagName === "DIALOG" && element.matches(":modal")) ||
7470
+ element.hasAttribute("popover");
7471
+ if (isPromotedToTopLayer) {
7472
+ return document.documentElement;
7473
+ }
7474
+ let parent = element.parentElement;
7475
+ while (parent && parent !== document.documentElement) {
7476
+ const position = window.getComputedStyle(parent).position;
7477
+ if (
7478
+ position === "relative" ||
7479
+ position === "absolute" ||
7480
+ position === "fixed"
7481
+ ) {
7482
+ return parent;
7483
+ }
7484
+ parent = parent.parentElement;
7485
+ }
7486
+ return document.documentElement;
7487
+ };
7488
+
7489
+ /**
7490
+ * Walks `element` and its ancestors (stopping at, but not including,
7491
+ * `document.documentElement`) looking for the first one whose *computed*
7492
+ * `position` is `fixed` — i.e. pinned to the viewport, ignoring document
7493
+ * scroll, regardless of what `element` itself is positioned relative to.
7494
+ *
7495
+ * @param {Element} element
7496
+ * @returns {[left: number, top: number] | null} The fixed ancestor's own
7497
+ * viewport-relative `getBoundingClientRect()` origin, or `null` if neither
7498
+ * `element` nor any ancestor is fixed (i.e. `element` genuinely scrolls
7499
+ * with the document).
7500
+ */
7366
7501
  const findSelfOrAncestorFixedPosition = (element) => {
7367
7502
  let current = element;
7368
7503
  while (true) {
@@ -7460,14 +7595,18 @@ const createDragElementPositioner = (
7460
7595
  let scrollableTop;
7461
7596
  let convertScrollablePosition;
7462
7597
 
7463
- const positionedParent = elementToMove
7464
- ? elementToMove.offsetParent
7465
- : element.offsetParent;
7598
+ // getPositionedParent, not raw .offsetParent — offsetParent is null for a
7599
+ // position: fixed element, and also for one promoted to the top layer
7600
+ // (e.g. a <dialog>/[popover] being dragged by its own handle), which
7601
+ // crashes the fixed-position lookup below (findSelfOrAncestorFixedPosition
7602
+ // assumes a real starting element, not null). getPositionedParent never
7603
+ // returns null (document.documentElement instead — see its own doc).
7604
+ const positionedParent = getPositionedParent(elementToMove || element);
7466
7605
  const scrollContainer = getScrollContainer(element);
7467
7606
  const [getPositionOffsets, getScrollOffsets] = createGetOffsets({
7468
7607
  positionedParent,
7469
7608
  referencePositionedParent: referenceElement
7470
- ? referenceElement.offsetParent
7609
+ ? getPositionedParent(referenceElement)
7471
7610
  : positionedParent,
7472
7611
  scrollContainer,
7473
7612
  referenceScrollContainer: referenceElement
@@ -7721,7 +7860,7 @@ const isOverlayOf = (element, potentialTarget) => {
7721
7860
  if (overlayTarget === potentialTarget) {
7722
7861
  return true;
7723
7862
  }
7724
- const overlayTargetPositionedParent = overlayTarget.offsetParent;
7863
+ const overlayTargetPositionedParent = getPositionedParent(overlayTarget);
7725
7864
  if (overlayTargetPositionedParent === potentialTarget) {
7726
7865
  return true;
7727
7866
  }
@@ -10864,42 +11003,160 @@ const getResizeDirection = (element) => {
10864
11003
  return { x, y };
10865
11004
  };
10866
11005
 
10867
- const getPositionedParent = (element) => {
10868
- let parent = element.parentElement;
10869
- while (parent && parent !== document.body) {
10870
- const position = window.getComputedStyle(parent).position;
10871
- if (
10872
- position === "relative" ||
10873
- position === "absolute" ||
10874
- position === "fixed"
10875
- ) {
10876
- return parent;
10877
- }
10878
- parent = parent.parentElement;
11006
+ // Shared by navi's own use_displayed_layout_effect.js (rich "navi_displayed"
11007
+ // CustomEvent, open transitions only) and visible_rect.js (needs both
11008
+ // directions: hide when a container closes, recheck when it reopens) — the
11009
+ // selector/open-detection/timing primitives are identical for both, only
11010
+ // what each does with a transition differs.
11011
+ const ANCESTOR_OPEN_SELECTOR = "dialog, details, [popover], [aria-expanded]";
11012
+
11013
+ const closestOpenableAncestor = (element) => {
11014
+ const parentElement = element.parentElement;
11015
+ if (!parentElement) {
11016
+ return null;
10879
11017
  }
10880
- return document.body;
11018
+ if (!parentElement.closest) {
11019
+ return null;
11020
+ }
11021
+ return parentElement.closest(ANCESTOR_OPEN_SELECTOR);
11022
+ };
11023
+
11024
+ const isAncestorOpen = (ancestor) => {
11025
+ if (ancestor.tagName === "DIALOG" || ancestor.hasAttribute("popover")) {
11026
+ return ancestor.matches(":popover-open, [open]");
11027
+ }
11028
+ if (ancestor.tagName === "DETAILS") {
11029
+ return ancestor.open;
11030
+ }
11031
+ if (ancestor.hasAttribute("aria-expanded")) {
11032
+ return ancestor.getAttribute("aria-expanded") === "true";
11033
+ }
11034
+ return true;
11035
+ };
11036
+
11037
+ const getAncestorOpenType = (ancestor) => {
11038
+ if (ancestor === document) {
11039
+ return "document";
11040
+ }
11041
+ if (ancestor.tagName === "DIALOG") {
11042
+ return "dialog";
11043
+ }
11044
+ if (ancestor.hasAttribute("popover")) {
11045
+ return "popover";
11046
+ }
11047
+ if (ancestor.tagName === "DETAILS") {
11048
+ return "details";
11049
+ }
11050
+ if (ancestor.hasAttribute("aria-expanded")) {
11051
+ return `${ancestor.tagName}[aria-expanded]`;
11052
+ }
11053
+ return `${ancestor.tagName}`;
10881
11054
  };
10882
11055
 
10883
11056
  /**
10884
- * Like `getPositionedParent`, but aware of `element` itself being promoted
10885
- * to the top layer: an element with a `popover` attribute, or a `<dialog>`,
10886
- * always uses the initial containing block (the viewport) once shown,
10887
- * regardless of `position` or DOM ancestry walking up its own parent
10888
- * chain looking for a positioned ancestor (what `getPositionedParent` does)
10889
- * would give the wrong answer for these two specifically, since their real
10890
- * DOM position becomes irrelevant to their own containing block the moment
10891
- * they're actually open.
10892
- *
10893
- * Returns `null` to mean "the viewport" (matching how a real anchor
10894
- * resolves to `null`/no-anchor callers already treat that as a request for
10895
- * viewport-relative positioning) for a popover/dialog element;
10896
- * `getPositionedParent(element)` otherwise.
11057
+ * Notifies `callback({ isOpen, ancestor, ancestorType, toggleEvent })` the
11058
+ * moment `ancestor`'s open state changes, in either direction timed to
11059
+ * land strictly before the browser's next paint, so a caller reacting to it
11060
+ * (measurement, visibility tracking, layout) never flashes the stale state
11061
+ * first. Plain object, not a CustomEvent there's no real DOM event behind
11062
+ * most of these transitions (see `toggleEvent` below), so wrapping the info
11063
+ * in one would mostly be manufacturing a fake event for no benefit.
11064
+ *
11065
+ * We deliberately do NOT use the native `toggle` event as the primary
11066
+ * signal, even though every <dialog>/<details>/[popover] fires one: per the
11067
+ * WHATWG spec it's dispatched via a *queued task* ("queue a popover toggle
11068
+ * event task"), not synchronously and not as a microtask. The element's
11069
+ * shown state itself (showPopover()/showModal()) still flips synchronously,
11070
+ * so the browser can — and does — paint it in its default, uncorrected
11071
+ * state before that queued task ever runs. Relying on `toggle` alone means
11072
+ * a reaction to it always arrives one paint late.
11073
+ *
11074
+ * Instead we watch `open`/`aria-expanded` via MutationObserver:
11075
+ * - <dialog>/<details> reflect `open` themselves, natively, synchronously.
11076
+ * - navi's own Popover.jsx sets `aria-expanded` synchronously in the same
11077
+ * call stack as showPopover() (see popover.jsx's own aria-expanded
11078
+ * comments) — not part of any web standard, just that library's own
11079
+ * convention, but reliable for anything built through it.
11080
+ * MutationObserver callbacks run as a microtask, strictly before paint —
11081
+ * exactly the timing needed, no ambiguity. `toggleEvent` is `undefined` on
11082
+ * this path (there's no native event to report — a mutation record isn't
11083
+ * one).
11084
+ *
11085
+ * The `toggle` listener is kept as a fallback, attached ONLY where the
11086
+ * MutationObserver above has no chance of ever firing: a bare [popover]
11087
+ * element with no `aria-expanded` of its own — i.e. one not built through
11088
+ * navi's own Popover.jsx (the only thing that reliably sets it). That's the
11089
+ * one case with no other synchronously-observable signal at all. It still
11090
+ * arrives a paint late, but a late correction beats none. `toggleEvent` is
11091
+ * the real `toggle` event on this path.
11092
+ *
11093
+ * @param {Element} ancestor
11094
+ * @param {(info: { isOpen: boolean, ancestor: Element, ancestorType: string, toggleEvent: Event | undefined }) => void} callback
11095
+ * @returns {() => void} cleanup — removes the observer/listener
10897
11096
  */
10898
- const getPositioningContainer = (element) => {
10899
- if (element.hasAttribute("popover") || element.tagName === "DIALOG") {
10900
- return null;
11097
+ const observeAncestorOpenState = (ancestor, callback) => {
11098
+ const ancestorType = getAncestorOpenType(ancestor);
11099
+ const needsToggleFallback =
11100
+ ancestor.hasAttribute("popover") && !ancestor.hasAttribute("aria-expanded");
11101
+ if (needsToggleFallback) {
11102
+ const onToggle = (toggleEvent) => {
11103
+ callback({
11104
+ isOpen: isAncestorOpen(ancestor),
11105
+ ancestor,
11106
+ ancestorType,
11107
+ toggleEvent,
11108
+ });
11109
+ };
11110
+ ancestor.addEventListener("toggle", onToggle);
11111
+ return () => {
11112
+ ancestor.removeEventListener("toggle", onToggle);
11113
+ };
10901
11114
  }
10902
- return getPositionedParent(element);
11115
+
11116
+ // Edge-triggered on purpose: some consumers (e.g. Popover.jsx) set
11117
+ // aria-expanded both imperatively (in their own openEffect, for precise
11118
+ // ordering relative to forced reflows/transitions) AND declaratively via a
11119
+ // JSX prop derived from the same open state — the latter is a deliberate
11120
+ // "always reflect current truth" prop, but Preact diffs against its own
11121
+ // previous *rendered* value, not the live DOM, so any later re-render that
11122
+ // happens to occur while already open re-applies the same "true" value as
11123
+ // a genuinely new attribute mutation. Tracking wasOpen here collapses that
11124
+ // redundant open→open (or close→close) mutation instead of notifying
11125
+ // callback a second time for the same state.
11126
+ let wasOpen = isAncestorOpen(ancestor);
11127
+ const observer = new MutationObserver(() => {
11128
+ const isOpen = isAncestorOpen(ancestor);
11129
+ if (isOpen === wasOpen) {
11130
+ return;
11131
+ }
11132
+ wasOpen = isOpen;
11133
+ callback({
11134
+ isOpen,
11135
+ ancestor,
11136
+ ancestorType,
11137
+ toggleEvent: undefined,
11138
+ });
11139
+ });
11140
+ observer.observe(ancestor, {
11141
+ attributes: true,
11142
+ attributeFilter: ["open", "aria-expanded"],
11143
+ });
11144
+ return () => {
11145
+ observer.disconnect();
11146
+ };
11147
+ };
11148
+
11149
+ const onAncestorReopen = (el, callback) => {
11150
+ const nearestOpenableAncestor = closestOpenableAncestor(el);
11151
+ if (!nearestOpenableAncestor) {
11152
+ return () => {};
11153
+ }
11154
+ return observeAncestorOpenState(nearestOpenableAncestor, ({ isOpen }) => {
11155
+ if (!isOpen) {
11156
+ return;
11157
+ }
11158
+ callback();
11159
+ });
10903
11160
  };
10904
11161
 
10905
11162
  const getHeight = (element) => {
@@ -11296,10 +11553,10 @@ const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
11296
11553
  * Is 0 when ancestorClosed is true.
11297
11554
  *
11298
11555
  * @typedef {Object} VisibleRectInfo
11299
- * @property {Event} event - The DOM event (or CustomEvent) that triggered the check.
11300
- * @property {number} width - Raw getBoundingClientRect() width of the element.
11301
- * @property {number} height - Raw getBoundingClientRect() height of the element.
11302
- * @property {boolean} ancestorClosed - True when a popover, dialog, or details ancestor is
11556
+ * @property {Event} event - The DOM event (or CustomEvent) that triggered the check.
11557
+ * @property {number} width - Raw getBoundingClientRect() width of the element.
11558
+ * @property {number} height - Raw getBoundingClientRect() height of the element.
11559
+ * @property {boolean} ancestorClosed - True when a popover, dialog, or details ancestor is
11303
11560
  * currently closed so the element is not rendered. All visibleRect values are 0 in that case.
11304
11561
  * update() is called immediately on ancestor close and again (with false) on reopen.
11305
11562
  *
@@ -11307,6 +11564,7 @@ const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
11307
11564
  * - Once synchronously on initialization (event.type = "initialization")
11308
11565
  * - On document/container scroll, window resize, element resize, intersection changes, touch move
11309
11566
  * - Immediately when an ancestor popover/dialog/details opens or closes
11567
+ * - Immediately when an ancestor popover/dialog starts or stops repositioning itself
11310
11568
  *
11311
11569
  * A bit like https://tetherjs.dev/ but different
11312
11570
  */
@@ -11332,8 +11590,72 @@ const visibleRectEffect = (
11332
11590
  let lastMeasuredWidth;
11333
11591
  let lastMeasuredHeight;
11334
11592
  let ancestorClosedCount = 0;
11593
+ // Every ResizeObserver this effect owns (its own element-resize watcher
11594
+ // below, plus one per observeSize() call) unobserves itself the moment an
11595
+ // ancestor closes, reobserving once it reopens (see on_ancestor_events) —
11596
+ // closing a dialog/popover containing several watched elements can make
11597
+ // them all collapse to zero size in the same reflow, which is what trips
11598
+ // the browser's "ResizeObserver loop completed with undelivered
11599
+ // notifications" warning. Proactively unobserving avoids generating those
11600
+ // notifications instead of just reacting differently to them.
11601
+ // Set while an ancestor is itself mid-repositioning — on_ancestor_events'
11602
+ // own onNaviPositionTransition already drives this element's position
11603
+ // every frame for that duration, more accurately than the direct
11604
+ // window/visualViewport resize reaction below (on_resize) could. Gates
11605
+ // that reaction so it doesn't also fire mid-transition and animate its
11606
+ // own competing move toward a target computed from the anchor's still
11607
+ // mid-flight rect, racing the frame-by-frame follow loop.
11608
+ let ancestorRepositioningCount = 0;
11609
+ // check() runs on every scroll/resize/frame of an ancestor's own
11610
+ // transition, but plenty of those land on an identical result — skip
11611
+ // calling update() again when neither snapshot changed. Two snapshots,
11612
+ // not one, because pickPositionRelativeTo depends on both separately:
11613
+ // - lastVisibleRect: left/top/width/height, plus visibilityRatio (which
11614
+ // can change on its own — see its own ratio formula further down —
11615
+ // without any of the other four moving).
11616
+ // - lastViewportRect: not part of visibleRect at all, but an on-screen
11617
+ // keyboard opening/closing can shrink the viewport without moving
11618
+ // this element's own visibleRect by a single pixel, and
11619
+ // pickPositionRelativeTo's available space depends on it too.
11620
+ let lastVisibleRect = null;
11621
+ let lastViewportRect = null;
11622
+ let resizeWatchingPaused = false;
11623
+ const [publishResizeWatchingPausedChange, onResizeWatchingPausedChange] =
11624
+ createPubSub();
11625
+ const pauseResizeWatching = () => {
11626
+ if (resizeWatchingPaused) {
11627
+ return;
11628
+ }
11629
+ resizeWatchingPaused = true;
11630
+ publishResizeWatchingPausedChange(true);
11631
+ };
11632
+ const resumeResizeWatching = () => {
11633
+ if (!resizeWatchingPaused) {
11634
+ return;
11635
+ }
11636
+ resizeWatchingPaused = false;
11637
+ publishResizeWatchingPausedChange(false);
11638
+ };
11335
11639
  const check = (event) => {
11336
11640
 
11641
+ // visualViewport, not window.innerWidth/Height: the layout viewport
11642
+ // doesn't shrink when the on-screen keyboard opens (same reasoning as
11643
+ // pickPositionRelativeTo's own identical choice). offsetLeft/Top matter
11644
+ // too, for pinch-zoom/pan. Computed here regardless of scroll container
11645
+ // (not just where the non-document branch below needs it) because a
11646
+ // keyboard opening can change pickPositionRelativeTo's available space
11647
+ // without moving this element's own visibleRect at all — see
11648
+ // viewportRectChanged further down.
11649
+ const visualViewport = window.visualViewport;
11650
+ const viewportWidth = visualViewport
11651
+ ? visualViewport.width
11652
+ : window.innerWidth;
11653
+ const viewportHeight = visualViewport
11654
+ ? visualViewport.height
11655
+ : window.innerHeight;
11656
+ const viewportOffsetLeft = visualViewport ? visualViewport.offsetLeft : 0;
11657
+ const viewportOffsetTop = visualViewport ? visualViewport.offsetTop : 0;
11658
+
11337
11659
  // 1. Calculate element position relative to scrollable parent
11338
11660
  const { scrollLeft, scrollTop } = scrollContainer;
11339
11661
  const visibleAreaLeft = scrollLeft;
@@ -11434,22 +11756,26 @@ const visibleRectEffect = (
11434
11756
  if (scrollContainerIsDocument) {
11435
11757
  visibilityRatio = (widthVisible * heightVisible) / (width * height);
11436
11758
  } else {
11437
- // widthVisible/heightVisible are already clipped to the scroll container.
11438
- // Now clip their viewport-relative counterparts against the viewport.
11439
- const viewportWidth = window.innerWidth;
11440
- const viewportHeight = window.innerHeight;
11759
+ // widthVisible/heightVisible are already clipped to the scroll
11760
+ // container. Now clip their viewport-relative counterparts against
11761
+ // the viewport (viewportWidth/Height/OffsetLeft/OffsetTop computed
11762
+ // once, at the top of check() — see their own comment there).
11441
11763
  // Container-clipped visible rect in viewport coordinates
11442
11764
  const visibleLeft = overlayLeft;
11443
11765
  const visibleTop = overlayTop;
11444
11766
  const visibleRight = overlayLeft + widthVisible;
11445
11767
  const visibleBottom = overlayTop + heightVisible;
11446
11768
  // Intersect with viewport
11447
- const clippedLeft = visibleLeft < 0 ? 0 : visibleLeft;
11448
- const clippedTop = visibleTop < 0 ? 0 : visibleTop;
11769
+ const clippedLeft =
11770
+ visibleLeft < viewportOffsetLeft ? viewportOffsetLeft : visibleLeft;
11771
+ const clippedTop =
11772
+ visibleTop < viewportOffsetTop ? viewportOffsetTop : visibleTop;
11773
+ const viewportRight = viewportOffsetLeft + viewportWidth;
11774
+ const viewportBottom = viewportOffsetTop + viewportHeight;
11449
11775
  const clippedRight =
11450
- visibleRight > viewportWidth ? viewportWidth : visibleRight;
11776
+ visibleRight > viewportRight ? viewportRight : visibleRight;
11451
11777
  const clippedBottom =
11452
- visibleBottom > viewportHeight ? viewportHeight : visibleBottom;
11778
+ visibleBottom > viewportBottom ? viewportBottom : visibleBottom;
11453
11779
  const clippedWidth =
11454
11780
  clippedRight > clippedLeft ? clippedRight - clippedLeft : 0;
11455
11781
  const clippedHeight =
@@ -11466,12 +11792,49 @@ const visibleRectEffect = (
11466
11792
  height: heightVisible,
11467
11793
  visibilityRatio,
11468
11794
  };
11469
- update(visibleRect, {
11470
- event,
11471
- width,
11472
- height,
11473
- ancestorClosed: ancestorClosedCount > 0,
11474
- });
11795
+ // Not part of visibleRect itself, tracked only so viewportRectChanged
11796
+ // below can catch a keyboard opening/closing even when it doesn't move
11797
+ // this element's own visibleRect.
11798
+ const viewportRect = {
11799
+ viewportWidth,
11800
+ viewportHeight,
11801
+ viewportOffsetLeft,
11802
+ viewportOffsetTop,
11803
+ };
11804
+ const notify = (reason) => {
11805
+ update(visibleRect, {
11806
+ event,
11807
+ width,
11808
+ height,
11809
+ ancestorClosed: ancestorClosedCount > 0,
11810
+ });
11811
+ };
11812
+
11813
+ const visibleRectChanged =
11814
+ !lastVisibleRect ||
11815
+ lastVisibleRect.left !== visibleRect.left ||
11816
+ lastVisibleRect.top !== visibleRect.top ||
11817
+ lastVisibleRect.width !== visibleRect.width ||
11818
+ lastVisibleRect.height !== visibleRect.height ||
11819
+ lastVisibleRect.visibilityRatio !== visibleRect.visibilityRatio;
11820
+ if (visibleRectChanged) {
11821
+ lastVisibleRect = visibleRect;
11822
+ lastViewportRect = viewportRect;
11823
+ notify();
11824
+ return;
11825
+ }
11826
+ const viewportRectChanged =
11827
+ !lastViewportRect ||
11828
+ lastViewportRect.viewportWidth !== viewportRect.viewportWidth ||
11829
+ lastViewportRect.viewportHeight !== viewportRect.viewportHeight ||
11830
+ lastViewportRect.viewportOffsetLeft !== viewportRect.viewportOffsetLeft ||
11831
+ lastViewportRect.viewportOffsetTop !== viewportRect.viewportOffsetTop;
11832
+ if (viewportRectChanged) {
11833
+ lastVisibleRect = visibleRect;
11834
+ lastViewportRect = viewportRect;
11835
+ notify();
11836
+ return;
11837
+ }
11475
11838
  };
11476
11839
 
11477
11840
  check(initialEvent);
@@ -11549,8 +11912,24 @@ const visibleRectEffect = (
11549
11912
  {
11550
11913
  // See window_size.js's own module comment for why both of these go
11551
11914
  // through their shared debounce instead of each keeping its own timer.
11552
- addTeardown(subscribeVisualViewportResizeSettled(autoCheck));
11553
- addTeardown(subscribeWindowResizeSettled(autoCheck));
11915
+ const onWindowOrViewportResize = (event) => {
11916
+ // An ancestor's own navi_position_transition follow loop (see
11917
+ // on_ancestor_events below) is already re-checking this element's
11918
+ // position every frame, tracking the ancestor's live in-flight
11919
+ // position — more accurately than this debounced, ~100ms-after-the-
11920
+ // fact check could. Reacting here too would race it: a real
11921
+ // "resize" event makes shouldTransition true, so this would animate
11922
+ // its own competing move toward a target computed from the
11923
+ // anchor's current (still mid-flight) rect.
11924
+ if (ancestorRepositioningCount > 0) {
11925
+ return;
11926
+ }
11927
+ autoCheck(event);
11928
+ };
11929
+ addTeardown(
11930
+ subscribeVisualViewportResizeSettled(onWindowOrViewportResize),
11931
+ );
11932
+ addTeardown(subscribeWindowResizeSettled(onWindowOrViewportResize));
11554
11933
  }
11555
11934
  on_element_resize: {
11556
11935
  if (skipElementResize) {
@@ -11585,16 +11964,30 @@ const visibleRectEffect = (
11585
11964
  handlingResize = false;
11586
11965
  });
11587
11966
  resizeObserver.observe(element);
11967
+ const unsubscribeResizeWatchingPausedChange =
11968
+ onResizeWatchingPausedChange((paused) => {
11969
+ if (paused) {
11970
+ resizeObserver.unobserve(element);
11971
+ } else {
11972
+ resizeObserver.observe(element);
11973
+ }
11974
+ });
11588
11975
  // Temporarily disconnect ResizeObserver to prevent feedback loops eventually caused by update function
11589
11976
  onBeforeAutoCheck(() => {
11590
11977
  resizeObserver.unobserve(element);
11591
11978
  return () => {
11592
- // This triggers a new call to the resive observer that will be ignored thanks to
11593
- // the widthDiff/heightDiff early return
11594
- resizeObserver.observe(element);
11979
+ // Not reobserved at all while an ancestor is closed (see
11980
+ // pauseResizeWatching/resumeResizeWatching above) — resumeResizeWatching's
11981
+ // own publish is what reobserves once it reopens instead.
11982
+ if (!resizeWatchingPaused) {
11983
+ // This triggers a new call to the resive observer that will be ignored thanks to
11984
+ // the widthDiff/heightDiff early return
11985
+ resizeObserver.observe(element);
11986
+ }
11595
11987
  };
11596
11988
  });
11597
11989
  addTeardown(() => {
11990
+ unsubscribeResizeWatchingPausedChange();
11598
11991
  resizeObserver.disconnect();
11599
11992
  });
11600
11993
  }
@@ -11648,29 +12041,27 @@ const visibleRectEffect = (
11648
12041
  });
11649
12042
  }
11650
12043
  {
11651
- let current = element.parentElement;
11652
- while (current) {
11653
- if (
11654
- current.hasAttribute("popover") ||
11655
- current.tagName === "DIALOG" ||
11656
- current.tagName === "DETAILS"
11657
- ) {
11658
- const ancestor = current;
11659
- const isInitiallyClosed =
11660
- ancestor.tagName === "DIALOG" || ancestor.tagName === "DETAILS"
11661
- ? !ancestor.open
11662
- : !ancestor.matches(":popover-open");
11663
- if (isInitiallyClosed) {
11664
- ancestorClosedCount++;
11665
- }
12044
+ let currentOpenableAncestor = closestOpenableAncestor(element);
12045
+ while (currentOpenableAncestor) {
12046
+ const openableAncestor = currentOpenableAncestor;
12047
+ if (!isAncestorOpen(openableAncestor)) {
12048
+ ancestorClosedCount++;
12049
+ pauseResizeWatching();
12050
+ }
12051
+ const removeOpenStateObserver = observeAncestorOpenState(
12052
+ openableAncestor,
11666
12053
  // eslint-disable-next-line no-loop-func
11667
- const onToggle = (e) => {
11668
- const isClosed =
11669
- ancestor.tagName === "DETAILS"
11670
- ? !ancestor.open
11671
- : e.newState === "closed";
11672
- if (isClosed) {
12054
+ ({ isOpen, toggleEvent }) => {
12055
+ if (!isOpen) {
11673
12056
  ancestorClosedCount++;
12057
+ pauseResizeWatching();
12058
+ // Invalidates check()'s own "did anything actually change"
12059
+ // caches — without this, reopening onto the exact same
12060
+ // geometry/viewport as before closing would look unchanged to
12061
+ // check() and it would skip calling update() again, leaving a
12062
+ // consumer stuck showing this closed/zeroed state.
12063
+ lastVisibleRect = null;
12064
+ lastViewportRect = null;
11674
12065
  update(
11675
12066
  {
11676
12067
  left: 0,
@@ -11681,35 +12072,91 @@ const visibleRectEffect = (
11681
12072
  height: 0,
11682
12073
  visibilityRatio: 0,
11683
12074
  },
11684
- { event: e, width: 0, height: 0, ancestorClosed: true },
12075
+ {
12076
+ event: toggleEvent ?? new CustomEvent("ancestor_close"),
12077
+ width: 0,
12078
+ height: 0,
12079
+ ancestorClosed: true,
12080
+ },
11685
12081
  );
11686
- } else {
11687
- if (ancestorClosedCount > 0) {
11688
- ancestorClosedCount--;
11689
- }
11690
- if (ancestorClosedCount === 0) {
11691
- check(e);
11692
- }
12082
+ return;
11693
12083
  }
11694
- };
11695
- ancestor.addEventListener("toggle", onToggle);
12084
+ if (ancestorClosedCount > 0) {
12085
+ ancestorClosedCount--;
12086
+ }
12087
+ if (ancestorClosedCount === 0) {
12088
+ resumeResizeWatching();
12089
+ check(toggleEvent ?? new CustomEvent("ancestor_open"));
12090
+ }
12091
+ },
12092
+ );
11696
12093
 
11697
- const onNaviPositionChange = (e) => {
12094
+ const onNaviPositionChange = (e) => {
12095
+ autoCheck(e);
12096
+ };
12097
+ openableAncestor.addEventListener(
12098
+ "navi_position_change",
12099
+ onNaviPositionChange,
12100
+ );
12101
+ // Dispatched by applyNewPosition's own notifyPositionTransition
12102
+ // around this ancestor's own left/top animation (distinct from
12103
+ // navi_position_change, fired once with the final target, not per
12104
+ // frame). The anchor this element is positioned against may live
12105
+ // inside that ancestor and be moving right now — rather than hiding
12106
+ // for the duration (an opacity flicker once it settles reads worse
12107
+ // than a slightly-behind position), autoCheck() every frame for as
12108
+ // long as the animation runs, so this element stays in lockstep.
12109
+ // autoCheck, not check directly, so this element's own
12110
+ // ResizeObserver(s) stay unobserved for the loop's duration too —
12111
+ // repositioning every frame can itself cause reflows. e.detail.onEnd
12112
+ // stops the loop and settles on one final check once it ends.
12113
+ let positionTransitionRafId = null;
12114
+ let isTrackingPositionTransition = false;
12115
+ // ancestorRepositioningCount is intentionally shared across every
12116
+ // ancestor level (declared once, outside this loop) — it's a
12117
+ // single "is this element's position currently being driven by
12118
+ // some ancestor's transition" flag for the element itself, not
12119
+ // per-ancestor state.
12120
+ // eslint-disable-next-line no-loop-func
12121
+ const onNaviPositionTransition = (e) => {
12122
+ cancelAnimationFrame(positionTransitionRafId);
12123
+ if (!isTrackingPositionTransition) {
12124
+ isTrackingPositionTransition = true;
12125
+ ancestorRepositioningCount++;
12126
+ }
12127
+ const loop = () => {
11698
12128
  autoCheck(e);
12129
+ positionTransitionRafId = requestAnimationFrame(loop);
11699
12130
  };
11700
- ancestor.addEventListener(
12131
+ loop();
12132
+ e.detail.onEnd(() => {
12133
+ cancelAnimationFrame(positionTransitionRafId);
12134
+ if (isTrackingPositionTransition) {
12135
+ isTrackingPositionTransition = false;
12136
+ ancestorRepositioningCount--;
12137
+ }
12138
+ autoCheck(e);
12139
+ });
12140
+ };
12141
+ openableAncestor.addEventListener(
12142
+ "navi_position_transition",
12143
+ onNaviPositionTransition,
12144
+ );
12145
+ addTeardown(() => {
12146
+ removeOpenStateObserver();
12147
+ cancelAnimationFrame(positionTransitionRafId);
12148
+ openableAncestor.removeEventListener(
11701
12149
  "navi_position_change",
11702
12150
  onNaviPositionChange,
11703
12151
  );
11704
- addTeardown(() => {
11705
- ancestor.removeEventListener("toggle", onToggle);
11706
- ancestor.removeEventListener(
11707
- "navi_position_change",
11708
- onNaviPositionChange,
11709
- );
11710
- });
11711
- }
11712
- current = current.parentElement;
12152
+ openableAncestor.removeEventListener(
12153
+ "navi_position_transition",
12154
+ onNaviPositionTransition,
12155
+ );
12156
+ });
12157
+ currentOpenableAncestor = closestOpenableAncestor(
12158
+ currentOpenableAncestor,
12159
+ );
11713
12160
  }
11714
12161
  }
11715
12162
  }
@@ -11774,20 +12221,45 @@ const visibleRectEffect = (
11774
12221
  });
11775
12222
  });
11776
12223
  resizeObserver.observe(elementToObserve);
12224
+ // An ancestor may already be closed by the time a consumer calls
12225
+ // observeSize (e.g. Callout's own observeSize(calloutMessageElement)
12226
+ // call happens after visibleRectEffect itself returns) — keep this new
12227
+ // observer consistent with that already-paused state instead of
12228
+ // observing it only to immediately generate a closed-container
12229
+ // notification.
12230
+ if (resizeWatchingPaused) {
12231
+ resizeObserver.unobserve(elementToObserve);
12232
+ }
12233
+ const unsubscribeResizeWatchingPausedChange = onResizeWatchingPausedChange(
12234
+ (paused) => {
12235
+ if (paused) {
12236
+ resizeObserver.unobserve(elementToObserve);
12237
+ } else {
12238
+ resizeObserver.observe(elementToObserve);
12239
+ }
12240
+ },
12241
+ );
11777
12242
  const cleanupAutoCheck = onBeforeAutoCheck(() => {
11778
12243
  resizeObserver.unobserve(elementToObserve);
11779
12244
  return () => {
11780
- resizeObserver.observe(elementToObserve);
12245
+ // Not reobserved at all while an ancestor is closed (see
12246
+ // pauseResizeWatching/resumeResizeWatching) — resumeResizeWatching's
12247
+ // own publish is what reobserves once it reopens instead.
12248
+ if (!resizeWatchingPaused) {
12249
+ resizeObserver.observe(elementToObserve);
12250
+ }
11781
12251
  };
11782
12252
  });
11783
12253
  addTeardown(() => {
11784
12254
  if (pendingFrame !== null) {
11785
12255
  cancelAnimationFrame(pendingFrame);
11786
12256
  }
12257
+ unsubscribeResizeWatchingPausedChange();
11787
12258
  resizeObserver.disconnect();
11788
12259
  });
11789
12260
  return () => {
11790
12261
  cleanupAutoCheck();
12262
+ unsubscribeResizeWatchingPausedChange();
11791
12263
  if (pendingFrame !== null) {
11792
12264
  cancelAnimationFrame(pendingFrame);
11793
12265
  }
@@ -11984,17 +12456,18 @@ const toContainerAlignedPosition = (value) => {
11984
12456
  * there's a real `anchor`, since `element` can be container-relative either way (e.g. the
11985
12457
  * custom renderer in popover.jsx, always relative to its own positioned ancestor whether
11986
12458
  * or not it also has a real anchor). Whenever not explicitly given, this is always
11987
- * resolved automatically via `getPositioningContainer(element)` instead — regardless of
12459
+ * resolved automatically via `getPositionedParent(element)` instead — regardless of
11988
12460
  * `hasValidAnchor` — so a caller that never thinks about `container` at all still gets the
11989
- * right behavior on its own: `null` from `getPositioningContainer` (an `element` with a
11990
- * `popover` attribute, or a `<dialog>`e.g. Callout's own element) falls back to the
11991
- * traditional document-relative path below, exactly as if `container` genuinely didn't
11992
- * apply; anything else `getPositioningContainer` finds (a real positioned ancestor) is
11993
- * used the same way an explicit `container` would be. A container that resolves to
11994
- * `document.documentElement` (the viewport) produces identical output to the plain
11995
- * document-relative path either way, since the document's own scroll and the viewport's
11996
- * own origin already coincide with what this generically computes for any other container
11997
- * element. When there's a real container (explicit or resolved) either way: the final
12461
+ * right behavior on its own: `document.documentElement` from `getPositionedParent` (an
12462
+ * `element` promoted to the top layer a `[popover]` while shown, or a `<dialog>` while
12463
+ * actually modal or one with no positioned ancestor at all, e.g. Callout's own element)
12464
+ * falls back to the traditional document-relative path below, exactly as if `container`
12465
+ * genuinely didn't apply; anything else `getPositionedParent` finds (a real positioned
12466
+ * ancestor) is used the same way an explicit `container` would be. A container that
12467
+ * resolves to `document.documentElement` (the viewport) produces identical output to the
12468
+ * plain document-relative path either way, since the document's own scroll and the
12469
+ * viewport's own origin already coincide with what this generically computes for any other
12470
+ * container element. When there's a real container (explicit or resolved) either way: the final
11998
12471
  * `left`/`top` (and the returned `anchorLeft/Top/Right/Bottom`) are expressed relative to
11999
12472
  * its own padding-box origin plus its own scroll, instead of the document's — `element`'s
12000
12473
  * own computed `position` is *not* consulted in that case, unlike the traditional path.
@@ -12040,9 +12513,11 @@ const pickPositionRelativeTo = (
12040
12513
  // never gets offered more room (anchor-too-big check, flip decisions,
12041
12514
  // clamp) than its own container — resolvedContainer's own padding-box
12042
12515
  // edges when there is one — actually has.
12043
- const resolvedContainer = container ?? getPositioningContainer(element);
12044
- const hasRealContainer =
12045
- resolvedContainer && resolvedContainer !== document.documentElement;
12516
+ // Always a real element now (never null/undefined) — getPositionedParent
12517
+ // itself never returns anything falsy, document.documentElement (the
12518
+ // viewport) included.
12519
+ const resolvedContainer = container ?? getPositionedParent(element);
12520
+ const hasRealContainer = resolvedContainer !== document.documentElement;
12046
12521
  const containerRect = hasRealContainer
12047
12522
  ? resolvedContainer.getBoundingClientRect()
12048
12523
  : null;
@@ -12118,14 +12593,13 @@ const pickPositionRelativeTo = (
12118
12593
  positionXFixed = positionX;
12119
12594
  positionYFixed = positionY;
12120
12595
  }
12121
- // resolvedContainer was already resolved above. `null` from
12122
- // getPositioningContainer (a popover/dialog element, e.g. Callout's own)
12123
- // falls through to the traditional document-relative path below all the
12124
- // same, so an existing caller that never thinks about `container` at all
12125
- // keeps behaving exactly as before.
12126
- const effectiveAnchor = hasValidAnchor
12127
- ? anchor
12128
- : resolvedContainer || document.documentElement;
12596
+ // resolvedContainer was already resolved above. document.documentElement
12597
+ // from getPositionedParent (a popover/dialog element, e.g. Callout's own,
12598
+ // or one with no positioned ancestor at all) falls through to the
12599
+ // traditional document-relative path below all the same, so an existing
12600
+ // caller that never thinks about `container` at all keeps behaving
12601
+ // exactly as before.
12602
+ const effectiveAnchor = hasValidAnchor ? anchor : resolvedContainer;
12129
12603
  // document.documentElement is used as a sentinel "the viewport" value: an
12130
12604
  // anchorless popup should center/place itself against the visual
12131
12605
  // viewport, not against <html>'s own box — which, unlike the viewport,
@@ -12317,7 +12791,14 @@ const pickPositionRelativeTo = (
12317
12791
  if (currentFitsEnough) {
12318
12792
  finalX = activeX;
12319
12793
  } else {
12320
- finalX = oppositeX[activeX];
12794
+ // Only flip if the opposite side has more space — avoids oscillation
12795
+ // when neither side has enough room (both fail the ratio). Mirrors
12796
+ // the Y-axis branch above; missing here was the actual cause of a
12797
+ // real left/right flicker on a narrow viewport (neither side ever
12798
+ // "fits enough", so this branch ran on every reposition).
12799
+ const opposite = oppositeX[activeX];
12800
+ const oppositeHasMoreSpace = spaceFor(opposite) > spaceFor(activeX);
12801
+ finalX = oppositeHasMoreSpace ? opposite : activeX;
12321
12802
  }
12322
12803
  }
12323
12804
  }
@@ -12549,14 +13030,110 @@ const pickPositionRelativeTo = (
12549
13030
  };
12550
13031
  };
12551
13032
 
13033
+ // Per-element bookkeeping for the currently in-flight, self-driven position
13034
+ // transition, if any — see notifyPositionTransition's own doc for why this
13035
+ // is animation-driven rather than listening for the browser's own
13036
+ // transitionrun/transitionend: element -> { animation, endCallbacks }.
13037
+ const pendingPositionTransitions = new WeakMap();
13038
+
13039
+ // Reads `cssVarName` off `element` (getComputedStyle, so it's whatever the
13040
+ // cascade resolves to — a consumer can set it inline, in its own CSS rule,
13041
+ // or not at all) and converts it to milliseconds: "0.25s" -> 250, "250ms" ->
13042
+ // 250. Falls back to `fallbackMs` when unset/empty/unparsable, so a caller
13043
+ // never has to declare the CSS var itself just to get a sane default
13044
+ // duration — it only needs to when it actually wants to override it.
13045
+ const parseTransitionDurationMs = (element, cssVarName, fallbackMs) => {
13046
+ const trimmed = getStyle(element, cssVarName).trim();
13047
+ if (!trimmed) {
13048
+ return fallbackMs;
13049
+ }
13050
+ if (trimmed.endsWith("ms")) {
13051
+ return parseFloat(trimmed);
13052
+ }
13053
+ if (trimmed.endsWith("s")) {
13054
+ return parseFloat(trimmed) * 1000;
13055
+ }
13056
+ const parsed = parseFloat(trimmed);
13057
+ return Number.isNaN(parsed) ? fallbackMs : parsed;
13058
+ };
13059
+
12552
13060
  /**
12553
- * Applies a `pickPositionRelativeTo` result to `element`. Drives
12554
- * `--popup-position-transition-duration` (0s unless `shouldTransition`) so
12555
- * a scroll-triggered reposition stays instant while a resize-triggered one
12556
- * eases in set via a CSS var rather than `transitionProperty` directly so
12557
- * it doesn't clobber Popover/Dialog's own opacity/scale transition on the
12558
- * same element; consumers declare `transition-duration:
12559
- * var(--popup-position-transition-duration, 0s)` on `left`/`top` in CSS.
13061
+ * Dispatches a single "navi_position_transition" event on `element`,
13062
+ * self-driven rather than confirmed by the browser's own `transitionrun`
13063
+ * `applyNewPosition` calls this exactly when it knows it just started a
13064
+ * left/top `animation`, so there's nothing to wait for. transitionrun was
13065
+ * tried first and dropped: it reacts to *any* transition sharing the
13066
+ * element (a scale/opacity entrance would wrongly hide a descendant too),
13067
+ * and filtering by `propertyName` is unreliable (observed firing for "top"
13068
+ * instead of "left" in practice, despite the transition-property order).
13069
+ * A dedicated `Animation` sidesteps both.
13070
+ *
13071
+ * A descendant anchored inside `element` (see on_ancestor_events)
13072
+ * re-checks its own position every frame for as long as this animation
13073
+ * runs, instead of showing a stale position. `event.detail.onEnd(callback)`
13074
+ * is how it learns when the animation actually ends.
13075
+ *
13076
+ * A second reposition landing mid-animation cancels the pending one and
13077
+ * flushes its own registered callbacks immediately (same spirit as a real
13078
+ * `transitioncancel`), so nothing is left waiting on a superseded `onEnd`.
13079
+ *
13080
+ * `commitStyles()` below isn't what makes the final position correct —
13081
+ * `applyNewPosition` already sets the specified `left`/`top` before this
13082
+ * animation starts, so it takes back over once the active duration elapses
13083
+ * regardless. It just makes that explicit instead of relying on `fill:
13084
+ * "none"` timing, and drops the finished Animation instead of leaving it.
13085
+ */
13086
+ const notifyPositionTransition = (element, animation) => {
13087
+ const pending = pendingPositionTransitions.get(element);
13088
+ if (pending) {
13089
+ pending.animation.cancel();
13090
+ for (const callback of pending.endCallbacks) {
13091
+ callback();
13092
+ }
13093
+ }
13094
+ const endCallbacks = [];
13095
+ dispatchCustomEvent(element, "navi_position_transition", {
13096
+ onEnd: (callback) => {
13097
+ endCallbacks.push(callback);
13098
+ },
13099
+ });
13100
+ const current = { animation, endCallbacks };
13101
+ pendingPositionTransitions.set(element, current);
13102
+ animation.finished
13103
+ .then(() => {
13104
+ if (pendingPositionTransitions.get(element) === current) {
13105
+ pendingPositionTransitions.delete(element);
13106
+ }
13107
+ try {
13108
+ animation.commitStyles();
13109
+ } catch {
13110
+ // Element no longer rendered (removed/hidden mid-animation) —
13111
+ // nothing to commit to, and left/top were already final anyway.
13112
+ }
13113
+ animation.cancel();
13114
+ for (const callback of endCallbacks) {
13115
+ callback();
13116
+ }
13117
+ })
13118
+ .catch(() => {
13119
+ // Cancelled by a subsequent reposition — already flushed above.
13120
+ });
13121
+ };
13122
+
13123
+ /**
13124
+ * Applies a `pickPositionRelativeTo` result to `element`. `left`/`top` are
13125
+ * set instantly (a scroll-triggered reposition should never lag its
13126
+ * target); when `shouldTransition` is set (a resize-triggered reposition),
13127
+ * the visual move is played out via `element.animate()` instead — kept
13128
+ * independent of Popover/Dialog/Callout's own opacity/scale/display CSS
13129
+ * transition on the same element, so neither can clobber the other (see
13130
+ * notifyPositionTransition's own doc for why a dedicated Animation over a
13131
+ * CSS one). Duration comes from `--popup-position-transition-duration`
13132
+ * (parseTransitionDurationMs), falling back to 180ms unset.
13133
+ * Dispatches navi_position_transition when it starts such an animation, and
13134
+ * navi_position_change unconditionally — every caller (Dialog, Popover,
13135
+ * Callout) wants both, so a descendant anchored inside `element` can always
13136
+ * recheck its own position whenever `element` moves.
12560
13137
  */
12561
13138
  const applyNewPosition = (
12562
13139
  element,
@@ -12571,15 +13148,7 @@ const applyNewPosition = (
12571
13148
  spaceAbove,
12572
13149
  spaceBelow,
12573
13150
  },
12574
- { transitionDuration = "0.25s" } = {},
12575
13151
  ) => {
12576
- element.style.setProperty(
12577
- "--popup-position-transition-duration",
12578
- shouldTransition ? transitionDuration : "0s",
12579
- );
12580
- element.style.left = `${left}px`;
12581
- element.style.top = `${top}px`;
12582
-
12583
13152
  if (positionY === "top" || positionY === "inset-bottom") {
12584
13153
  element.style.setProperty(
12585
13154
  "--container-position-remaining-height",
@@ -12606,6 +13175,42 @@ const applyNewPosition = (
12606
13175
  } else {
12607
13176
  element.style.removeProperty("--container-position-remaining-width");
12608
13177
  }
13178
+
13179
+ // A single implicit keyframe turned out not to work here: the WAAPI
13180
+ // "neutral" start keyframe isn't frozen at `animate()` call time, it's
13181
+ // resolved from the underlying value when the animation is first
13182
+ // *sampled* (the next frame) — by then `element.style.left`/`top` below
13183
+ // has already been overwritten with the new target, so start === end and
13184
+ // nothing visibly moves (observed as the dialog just jumping). Reading
13185
+ // the previous value ourselves, before overwriting it, and passing both
13186
+ // keyframes explicitly sidesteps that entirely.
13187
+ const previousLeft = parseFloat(element.style.left) || left;
13188
+ const previousTop = parseFloat(element.style.top) || top;
13189
+ if (shouldTransition) {
13190
+ const animation = element.animate(
13191
+ [
13192
+ { left: `${previousLeft}px`, top: `${previousTop}px` },
13193
+ { left: `${left}px`, top: `${top}px` },
13194
+ ],
13195
+ {
13196
+ duration: parseTransitionDurationMs(
13197
+ element,
13198
+ "--popup-position-transition-duration",
13199
+ 250,
13200
+ ),
13201
+ easing: "ease",
13202
+ },
13203
+ );
13204
+ notifyPositionTransition(element, animation);
13205
+ }
13206
+ // The specified `left`/`top` are set to their final target right away,
13207
+ // regardless of `shouldTransition` — the animation above only plays the
13208
+ // visual move from the old position, it never becomes the actual
13209
+ // specified style (see notifyPositionTransition's own commitStyles for
13210
+ // why that matters once it ends).
13211
+ element.style.left = `${left}px`;
13212
+ element.style.top = `${top}px`;
13213
+ dispatchCustomEvent(element, "navi_position_change");
12609
13214
  };
12610
13215
 
12611
13216
  const [publishDebugger, subscribeDebugger] = createPubSub();
@@ -15858,4 +16463,4 @@ const useResizeStatus = (elementRef, { as = "number" } = {}) => {
15858
16463
  };
15859
16464
  };
15860
16465
 
15861
- export { EASING, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles, applyNewPosition, 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, getPositioningContainer, getPositioningScrollOffset, 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, 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 };
16466
+ 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "description": "DOM utilities for writing frontend code",
6
6
  "repository": {
@@ -40,8 +40,8 @@
40
40
  "@jsenv/core": "../../../",
41
41
  "@jsenv/navi": "../navi",
42
42
  "@jsenv/snapshot": "../../tooling/snapshot",
43
- "@preact/signals": "2.9.3",
44
- "preact": "11.0.0-beta.1"
43
+ "@preact/signals": "2.9.4",
44
+ "preact": "11.0.0-beta.2"
45
45
  },
46
46
  "publishConfig": {
47
47
  "access": "public"