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