@jsenv/dom 0.15.0 → 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 +681 -140
  2. package/package.json +1 -1
package/dist/jsenv_dom.js CHANGED
@@ -6323,7 +6323,10 @@ const getScrollContainer = (arg, { includeHidden } = {}) => {
6323
6323
  }
6324
6324
  return null;
6325
6325
  }
6326
- 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")) {
6327
6330
  return getScrollingElement(element.ownerDocument);
6328
6331
  }
6329
6332
  const position = getStyle(element, "position");
@@ -7363,6 +7366,74 @@ const applyWheelScrollThrough = (element, wheelEvent) => {
7363
7366
  });
7364
7367
  };
7365
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
+ */
7366
7437
  const findSelfOrAncestorFixedPosition = (element) => {
7367
7438
  let current = element;
7368
7439
  while (true) {
@@ -7460,14 +7531,18 @@ const createDragElementPositioner = (
7460
7531
  let scrollableTop;
7461
7532
  let convertScrollablePosition;
7462
7533
 
7463
- const positionedParent = elementToMove
7464
- ? elementToMove.offsetParent
7465
- : 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);
7466
7541
  const scrollContainer = getScrollContainer(element);
7467
7542
  const [getPositionOffsets, getScrollOffsets] = createGetOffsets({
7468
7543
  positionedParent,
7469
7544
  referencePositionedParent: referenceElement
7470
- ? referenceElement.offsetParent
7545
+ ? getPositionedParent(referenceElement)
7471
7546
  : positionedParent,
7472
7547
  scrollContainer,
7473
7548
  referenceScrollContainer: referenceElement
@@ -7721,7 +7796,7 @@ const isOverlayOf = (element, potentialTarget) => {
7721
7796
  if (overlayTarget === potentialTarget) {
7722
7797
  return true;
7723
7798
  }
7724
- const overlayTargetPositionedParent = overlayTarget.offsetParent;
7799
+ const overlayTargetPositionedParent = getPositionedParent(overlayTarget);
7725
7800
  if (overlayTargetPositionedParent === potentialTarget) {
7726
7801
  return true;
7727
7802
  }
@@ -10864,42 +10939,160 @@ const getResizeDirection = (element) => {
10864
10939
  return { x, y };
10865
10940
  };
10866
10941
 
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;
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;
10879
10956
  }
10880
- return document.body;
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}`;
10881
10990
  };
10882
10991
 
10883
10992
  /**
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.
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
10897
11032
  */
10898
- const getPositioningContainer = (element) => {
10899
- if (element.hasAttribute("popover") || element.tagName === "DIALOG") {
10900
- return null;
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
+ };
10901
11050
  }
10902
- return getPositionedParent(element);
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;
11067
+ }
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 () => {};
11089
+ }
11090
+ return observeAncestorOpenState(nearestOpenableAncestor, ({ isOpen }) => {
11091
+ if (!isOpen) {
11092
+ return;
11093
+ }
11094
+ callback();
11095
+ });
10903
11096
  };
10904
11097
 
10905
11098
  const getHeight = (element) => {
@@ -11296,10 +11489,10 @@ const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
11296
11489
  * Is 0 when ancestorClosed is true.
11297
11490
  *
11298
11491
  * @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
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
11303
11496
  * currently closed so the element is not rendered. All visibleRect values are 0 in that case.
11304
11497
  * update() is called immediately on ancestor close and again (with false) on reopen.
11305
11498
  *
@@ -11307,6 +11500,7 @@ const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
11307
11500
  * - Once synchronously on initialization (event.type = "initialization")
11308
11501
  * - On document/container scroll, window resize, element resize, intersection changes, touch move
11309
11502
  * - Immediately when an ancestor popover/dialog/details opens or closes
11503
+ * - Immediately when an ancestor popover/dialog starts or stops repositioning itself
11310
11504
  *
11311
11505
  * A bit like https://tetherjs.dev/ but different
11312
11506
  */
@@ -11332,8 +11526,72 @@ const visibleRectEffect = (
11332
11526
  let lastMeasuredWidth;
11333
11527
  let lastMeasuredHeight;
11334
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
+ };
11335
11575
  const check = (event) => {
11336
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
+
11337
11595
  // 1. Calculate element position relative to scrollable parent
11338
11596
  const { scrollLeft, scrollTop } = scrollContainer;
11339
11597
  const visibleAreaLeft = scrollLeft;
@@ -11434,22 +11692,26 @@ const visibleRectEffect = (
11434
11692
  if (scrollContainerIsDocument) {
11435
11693
  visibilityRatio = (widthVisible * heightVisible) / (width * height);
11436
11694
  } 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;
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).
11441
11699
  // Container-clipped visible rect in viewport coordinates
11442
11700
  const visibleLeft = overlayLeft;
11443
11701
  const visibleTop = overlayTop;
11444
11702
  const visibleRight = overlayLeft + widthVisible;
11445
11703
  const visibleBottom = overlayTop + heightVisible;
11446
11704
  // Intersect with viewport
11447
- const clippedLeft = visibleLeft < 0 ? 0 : visibleLeft;
11448
- 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;
11449
11711
  const clippedRight =
11450
- visibleRight > viewportWidth ? viewportWidth : visibleRight;
11712
+ visibleRight > viewportRight ? viewportRight : visibleRight;
11451
11713
  const clippedBottom =
11452
- visibleBottom > viewportHeight ? viewportHeight : visibleBottom;
11714
+ visibleBottom > viewportBottom ? viewportBottom : visibleBottom;
11453
11715
  const clippedWidth =
11454
11716
  clippedRight > clippedLeft ? clippedRight - clippedLeft : 0;
11455
11717
  const clippedHeight =
@@ -11466,12 +11728,49 @@ const visibleRectEffect = (
11466
11728
  height: heightVisible,
11467
11729
  visibilityRatio,
11468
11730
  };
11469
- update(visibleRect, {
11470
- event,
11471
- width,
11472
- height,
11473
- ancestorClosed: ancestorClosedCount > 0,
11474
- });
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
+ }
11475
11774
  };
11476
11775
 
11477
11776
  check(initialEvent);
@@ -11549,8 +11848,24 @@ const visibleRectEffect = (
11549
11848
  {
11550
11849
  // See window_size.js's own module comment for why both of these go
11551
11850
  // through their shared debounce instead of each keeping its own timer.
11552
- addTeardown(subscribeVisualViewportResizeSettled(autoCheck));
11553
- addTeardown(subscribeWindowResizeSettled(autoCheck));
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);
11864
+ };
11865
+ addTeardown(
11866
+ subscribeVisualViewportResizeSettled(onWindowOrViewportResize),
11867
+ );
11868
+ addTeardown(subscribeWindowResizeSettled(onWindowOrViewportResize));
11554
11869
  }
11555
11870
  on_element_resize: {
11556
11871
  if (skipElementResize) {
@@ -11585,16 +11900,30 @@ const visibleRectEffect = (
11585
11900
  handlingResize = false;
11586
11901
  });
11587
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
+ });
11588
11911
  // Temporarily disconnect ResizeObserver to prevent feedback loops eventually caused by update function
11589
11912
  onBeforeAutoCheck(() => {
11590
11913
  resizeObserver.unobserve(element);
11591
11914
  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);
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
+ }
11595
11923
  };
11596
11924
  });
11597
11925
  addTeardown(() => {
11926
+ unsubscribeResizeWatchingPausedChange();
11598
11927
  resizeObserver.disconnect();
11599
11928
  });
11600
11929
  }
@@ -11648,29 +11977,27 @@ const visibleRectEffect = (
11648
11977
  });
11649
11978
  }
11650
11979
  {
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
- }
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,
11666
11989
  // 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) {
11990
+ ({ isOpen, toggleEvent }) => {
11991
+ if (!isOpen) {
11673
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;
11674
12001
  update(
11675
12002
  {
11676
12003
  left: 0,
@@ -11681,35 +12008,91 @@ const visibleRectEffect = (
11681
12008
  height: 0,
11682
12009
  visibilityRatio: 0,
11683
12010
  },
11684
- { 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
+ },
11685
12017
  );
11686
- } else {
11687
- if (ancestorClosedCount > 0) {
11688
- ancestorClosedCount--;
11689
- }
11690
- if (ancestorClosedCount === 0) {
11691
- check(e);
11692
- }
12018
+ return;
11693
12019
  }
11694
- };
11695
- 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
+ );
11696
12029
 
11697
- const onNaviPositionChange = (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 = () => {
11698
12064
  autoCheck(e);
12065
+ positionTransitionRafId = requestAnimationFrame(loop);
11699
12066
  };
11700
- ancestor.addEventListener(
12067
+ loop();
12068
+ e.detail.onEnd(() => {
12069
+ cancelAnimationFrame(positionTransitionRafId);
12070
+ if (isTrackingPositionTransition) {
12071
+ isTrackingPositionTransition = false;
12072
+ ancestorRepositioningCount--;
12073
+ }
12074
+ autoCheck(e);
12075
+ });
12076
+ };
12077
+ openableAncestor.addEventListener(
12078
+ "navi_position_transition",
12079
+ onNaviPositionTransition,
12080
+ );
12081
+ addTeardown(() => {
12082
+ removeOpenStateObserver();
12083
+ cancelAnimationFrame(positionTransitionRafId);
12084
+ openableAncestor.removeEventListener(
11701
12085
  "navi_position_change",
11702
12086
  onNaviPositionChange,
11703
12087
  );
11704
- addTeardown(() => {
11705
- ancestor.removeEventListener("toggle", onToggle);
11706
- ancestor.removeEventListener(
11707
- "navi_position_change",
11708
- onNaviPositionChange,
11709
- );
11710
- });
11711
- }
11712
- current = current.parentElement;
12088
+ openableAncestor.removeEventListener(
12089
+ "navi_position_transition",
12090
+ onNaviPositionTransition,
12091
+ );
12092
+ });
12093
+ currentOpenableAncestor = closestOpenableAncestor(
12094
+ currentOpenableAncestor,
12095
+ );
11713
12096
  }
11714
12097
  }
11715
12098
  }
@@ -11774,20 +12157,45 @@ const visibleRectEffect = (
11774
12157
  });
11775
12158
  });
11776
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
+ );
11777
12178
  const cleanupAutoCheck = onBeforeAutoCheck(() => {
11778
12179
  resizeObserver.unobserve(elementToObserve);
11779
12180
  return () => {
11780
- resizeObserver.observe(elementToObserve);
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
+ }
11781
12187
  };
11782
12188
  });
11783
12189
  addTeardown(() => {
11784
12190
  if (pendingFrame !== null) {
11785
12191
  cancelAnimationFrame(pendingFrame);
11786
12192
  }
12193
+ unsubscribeResizeWatchingPausedChange();
11787
12194
  resizeObserver.disconnect();
11788
12195
  });
11789
12196
  return () => {
11790
12197
  cleanupAutoCheck();
12198
+ unsubscribeResizeWatchingPausedChange();
11791
12199
  if (pendingFrame !== null) {
11792
12200
  cancelAnimationFrame(pendingFrame);
11793
12201
  }
@@ -11984,17 +12392,18 @@ const toContainerAlignedPosition = (value) => {
11984
12392
  * there's a real `anchor`, since `element` can be container-relative either way (e.g. the
11985
12393
  * custom renderer in popover.jsx, always relative to its own positioned ancestor whether
11986
12394
  * or not it also has a real anchor). Whenever not explicitly given, this is always
11987
- * resolved automatically via `getPositioningContainer(element)` instead — regardless of
12395
+ * resolved automatically via `getPositionedParent(element)` instead — regardless of
11988
12396
  * `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
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
11998
12407
  * `left`/`top` (and the returned `anchorLeft/Top/Right/Bottom`) are expressed relative to
11999
12408
  * its own padding-box origin plus its own scroll, instead of the document's — `element`'s
12000
12409
  * own computed `position` is *not* consulted in that case, unlike the traditional path.
@@ -12040,9 +12449,11 @@ const pickPositionRelativeTo = (
12040
12449
  // never gets offered more room (anchor-too-big check, flip decisions,
12041
12450
  // clamp) than its own container — resolvedContainer's own padding-box
12042
12451
  // edges when there is one — actually has.
12043
- const resolvedContainer = container ?? getPositioningContainer(element);
12044
- const hasRealContainer =
12045
- resolvedContainer && resolvedContainer !== document.documentElement;
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;
12046
12457
  const containerRect = hasRealContainer
12047
12458
  ? resolvedContainer.getBoundingClientRect()
12048
12459
  : null;
@@ -12118,14 +12529,13 @@ const pickPositionRelativeTo = (
12118
12529
  positionXFixed = positionX;
12119
12530
  positionYFixed = positionY;
12120
12531
  }
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;
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;
12129
12539
  // document.documentElement is used as a sentinel "the viewport" value: an
12130
12540
  // anchorless popup should center/place itself against the visual
12131
12541
  // viewport, not against <html>'s own box — which, unlike the viewport,
@@ -12317,7 +12727,14 @@ const pickPositionRelativeTo = (
12317
12727
  if (currentFitsEnough) {
12318
12728
  finalX = activeX;
12319
12729
  } else {
12320
- 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;
12321
12738
  }
12322
12739
  }
12323
12740
  }
@@ -12549,14 +12966,110 @@ const pickPositionRelativeTo = (
12549
12966
  };
12550
12967
  };
12551
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
+
12552
12996
  /**
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.
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.
12560
13073
  */
12561
13074
  const applyNewPosition = (
12562
13075
  element,
@@ -12571,15 +13084,7 @@ const applyNewPosition = (
12571
13084
  spaceAbove,
12572
13085
  spaceBelow,
12573
13086
  },
12574
- { transitionDuration = "0.25s" } = {},
12575
13087
  ) => {
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
13088
  if (positionY === "top" || positionY === "inset-bottom") {
12584
13089
  element.style.setProperty(
12585
13090
  "--container-position-remaining-height",
@@ -12606,6 +13111,42 @@ const applyNewPosition = (
12606
13111
  } else {
12607
13112
  element.style.removeProperty("--container-position-remaining-width");
12608
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");
12609
13150
  };
12610
13151
 
12611
13152
  const [publishDebugger, subscribeDebugger] = createPubSub();
@@ -15858,4 +16399,4 @@ const useResizeStatus = (elementRef, { as = "number" } = {}) => {
15858
16399
  };
15859
16400
  };
15860
16401
 
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 };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "description": "DOM utilities for writing frontend code",
6
6
  "repository": {