@jsenv/dom 0.14.7 → 0.15.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 +858 -206
- 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
|
});
|
|
@@ -6640,6 +6728,24 @@ const viewportPosToScrollRelativePos = (
|
|
|
6640
6728
|
];
|
|
6641
6729
|
};
|
|
6642
6730
|
|
|
6731
|
+
// position: fixed is already viewport-relative, so no scroll offset is
|
|
6732
|
+
// needed to place it correctly — adding one would double-count the scroll.
|
|
6733
|
+
// position: absolute (assumed relative to the initial containing block, the
|
|
6734
|
+
// common case for a document-relative absolutely positioned element) needs
|
|
6735
|
+
// the current scroll offset added to convert a viewport-relative coordinate
|
|
6736
|
+
// into one it can be set to directly. Read the element's own computed style
|
|
6737
|
+
// rather than assuming one or the other, since callers may use either.
|
|
6738
|
+
const getPositioningScrollOffset = (element) => {
|
|
6739
|
+
const isFixed = getComputedStyle(element).position === "fixed";
|
|
6740
|
+
if (isFixed) {
|
|
6741
|
+
return { scrollLeft: 0, scrollTop: 0 };
|
|
6742
|
+
}
|
|
6743
|
+
return {
|
|
6744
|
+
scrollLeft: documentElement$1.scrollLeft,
|
|
6745
|
+
scrollTop: documentElement$1.scrollTop,
|
|
6746
|
+
};
|
|
6747
|
+
};
|
|
6748
|
+
|
|
6643
6749
|
const addScrollToRect = (scrollRelativeRect) => {
|
|
6644
6750
|
const { left, top, width, height, scrollLeft, scrollTop } =
|
|
6645
6751
|
scrollRelativeRect;
|
|
@@ -10774,6 +10880,28 @@ const getPositionedParent = (element) => {
|
|
|
10774
10880
|
return document.body;
|
|
10775
10881
|
};
|
|
10776
10882
|
|
|
10883
|
+
/**
|
|
10884
|
+
* Like `getPositionedParent`, but aware of `element` itself being promoted
|
|
10885
|
+
* to the top layer: an element with a `popover` attribute, or a `<dialog>`,
|
|
10886
|
+
* always uses the initial containing block (the viewport) once shown,
|
|
10887
|
+
* regardless of `position` or DOM ancestry — walking up its own parent
|
|
10888
|
+
* chain looking for a positioned ancestor (what `getPositionedParent` does)
|
|
10889
|
+
* would give the wrong answer for these two specifically, since their real
|
|
10890
|
+
* DOM position becomes irrelevant to their own containing block the moment
|
|
10891
|
+
* they're actually open.
|
|
10892
|
+
*
|
|
10893
|
+
* Returns `null` to mean "the viewport" (matching how a real anchor
|
|
10894
|
+
* resolves to `null`/no-anchor callers already treat that as a request for
|
|
10895
|
+
* viewport-relative positioning) for a popover/dialog element;
|
|
10896
|
+
* `getPositionedParent(element)` otherwise.
|
|
10897
|
+
*/
|
|
10898
|
+
const getPositioningContainer = (element) => {
|
|
10899
|
+
if (element.hasAttribute("popover") || element.tagName === "DIALOG") {
|
|
10900
|
+
return null;
|
|
10901
|
+
}
|
|
10902
|
+
return getPositionedParent(element);
|
|
10903
|
+
};
|
|
10904
|
+
|
|
10777
10905
|
const getHeight = (element) => {
|
|
10778
10906
|
const { height } = element.getBoundingClientRect();
|
|
10779
10907
|
return height;
|
|
@@ -11100,6 +11228,49 @@ const stickyAsRelativeCoords = (
|
|
|
11100
11228
|
return [leftPosition, topPosition];
|
|
11101
11229
|
};
|
|
11102
11230
|
|
|
11231
|
+
// Both "resize" sources fire transiently on mobile (keyboard/UI chrome
|
|
11232
|
+
// briefly shifting when focus moves between inputs) — debounced so
|
|
11233
|
+
// consumers skip that in-between state. One shared timer per source (not
|
|
11234
|
+
// one per subscriber) so everything settles on the same tick.
|
|
11235
|
+
const RESIZE_SETTLE_MS = 100;
|
|
11236
|
+
|
|
11237
|
+
// Set while a visualViewport resize is debouncing, cleared once it settles —
|
|
11238
|
+
// read by the window resize listener below.
|
|
11239
|
+
let visualViewportResizePending = false;
|
|
11240
|
+
|
|
11241
|
+
const [publishVisualViewportResize, subscribeVisualViewportResizeSettled] =
|
|
11242
|
+
createPubSub();
|
|
11243
|
+
const [publishWindowResize, subscribeWindowResizeSettled] = createPubSub();
|
|
11244
|
+
|
|
11245
|
+
if (window.visualViewport) {
|
|
11246
|
+
let timeoutId;
|
|
11247
|
+
window.visualViewport.addEventListener("resize", (event) => {
|
|
11248
|
+
visualViewportResizePending = true;
|
|
11249
|
+
clearTimeout(timeoutId);
|
|
11250
|
+
timeoutId = setTimeout(() => {
|
|
11251
|
+
visualViewportResizePending = false;
|
|
11252
|
+
publishVisualViewportResize(event);
|
|
11253
|
+
}, RESIZE_SETTLE_MS);
|
|
11254
|
+
});
|
|
11255
|
+
}
|
|
11256
|
+
|
|
11257
|
+
let windowResizeTimeoutId;
|
|
11258
|
+
window.addEventListener("resize", (event) => {
|
|
11259
|
+
clearTimeout(windowResizeTimeoutId);
|
|
11260
|
+
// Mobile browsers appear to dispatch visualViewport resize, then window
|
|
11261
|
+
// resize, then visualViewport resize again for the same keyboard/UI-chrome
|
|
11262
|
+
// shift — debounce the same way only when it looks like part of that
|
|
11263
|
+
// sequence (a visualViewport resize is already pending); otherwise react
|
|
11264
|
+
// immediately, so a genuine window resize isn't delayed for nothing.
|
|
11265
|
+
if (!visualViewportResizePending) {
|
|
11266
|
+
publishWindowResize(event);
|
|
11267
|
+
return;
|
|
11268
|
+
}
|
|
11269
|
+
windowResizeTimeoutId = setTimeout(() => {
|
|
11270
|
+
publishWindowResize(event);
|
|
11271
|
+
}, RESIZE_SETTLE_MS);
|
|
11272
|
+
});
|
|
11273
|
+
|
|
11103
11274
|
// Minimum fraction of element width/height that must be visible on the preferred side
|
|
11104
11275
|
// before flipping to the opposite side. Prevents flickering near the flip threshold.
|
|
11105
11276
|
const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
|
|
@@ -11148,7 +11319,14 @@ const visibleRectEffect = (
|
|
|
11148
11319
|
} = {},
|
|
11149
11320
|
) => {
|
|
11150
11321
|
const [teardown, addTeardown] = createPubSub();
|
|
11151
|
-
|
|
11322
|
+
// getScrollContainer(document.documentElement) returns null specifically
|
|
11323
|
+
// when the document itself has no overflow to scroll (e.g. a small
|
|
11324
|
+
// dialog/popover on an otherwise short page) — document.documentElement
|
|
11325
|
+
// is still a perfectly valid fallback in that case (scrollLeft/scrollTop
|
|
11326
|
+
// are just 0), so this never needs to crash the way a bare
|
|
11327
|
+
// `getScrollContainer(element)` result would below.
|
|
11328
|
+
const scrollContainer =
|
|
11329
|
+
getScrollContainer(element) ?? document.documentElement;
|
|
11152
11330
|
const scrollContainerIsDocument =
|
|
11153
11331
|
scrollContainer === document.documentElement;
|
|
11154
11332
|
let lastMeasuredWidth;
|
|
@@ -11299,16 +11477,16 @@ const visibleRectEffect = (
|
|
|
11299
11477
|
check(initialEvent);
|
|
11300
11478
|
|
|
11301
11479
|
const [publishBeforeAutoCheck, onBeforeAutoCheck] = createPubSub();
|
|
11302
|
-
{
|
|
11303
|
-
const
|
|
11304
|
-
|
|
11305
|
-
|
|
11306
|
-
|
|
11307
|
-
|
|
11308
|
-
beforeCheckResult();
|
|
11309
|
-
}
|
|
11480
|
+
const autoCheck = (event) => {
|
|
11481
|
+
const beforeCheckResults = publishBeforeAutoCheck(event);
|
|
11482
|
+
check(event);
|
|
11483
|
+
for (const beforeCheckResult of beforeCheckResults) {
|
|
11484
|
+
if (typeof beforeCheckResult === "function") {
|
|
11485
|
+
beforeCheckResult();
|
|
11310
11486
|
}
|
|
11311
|
-
}
|
|
11487
|
+
}
|
|
11488
|
+
};
|
|
11489
|
+
{
|
|
11312
11490
|
// let rafId = null;
|
|
11313
11491
|
// const scheduleCheck = (reason) => {
|
|
11314
11492
|
// cancelAnimationFrame(rafId);
|
|
@@ -11369,40 +11547,10 @@ const visibleRectEffect = (
|
|
|
11369
11547
|
}
|
|
11370
11548
|
}
|
|
11371
11549
|
{
|
|
11372
|
-
|
|
11373
|
-
|
|
11374
|
-
|
|
11375
|
-
|
|
11376
|
-
// focus and the keyboard reopens. Debouncing prevents repositioning the
|
|
11377
|
-
// during that transient state, which would cause a visible flicker.
|
|
11378
|
-
let resizeTimeout;
|
|
11379
|
-
const cancelDelayedAutoCheck = () => {
|
|
11380
|
-
clearTimeout(resizeTimeout);
|
|
11381
|
-
};
|
|
11382
|
-
const onVisualViewportResize = (e) => {
|
|
11383
|
-
cancelDelayedAutoCheck();
|
|
11384
|
-
resizeTimeout = setTimeout(() => {
|
|
11385
|
-
autoCheck(e);
|
|
11386
|
-
}, 100);
|
|
11387
|
-
};
|
|
11388
|
-
window.visualViewport.addEventListener(
|
|
11389
|
-
"resize",
|
|
11390
|
-
onVisualViewportResize,
|
|
11391
|
-
);
|
|
11392
|
-
addTeardown(() => {
|
|
11393
|
-
window.visualViewport.removeEventListener(
|
|
11394
|
-
"resize",
|
|
11395
|
-
onVisualViewportResize,
|
|
11396
|
-
);
|
|
11397
|
-
});
|
|
11398
|
-
}
|
|
11399
|
-
const onWindowResize = (e) => {
|
|
11400
|
-
autoCheck(e);
|
|
11401
|
-
};
|
|
11402
|
-
window.addEventListener("resize", onWindowResize);
|
|
11403
|
-
addTeardown(() => {
|
|
11404
|
-
window.removeEventListener("resize", onWindowResize);
|
|
11405
|
-
});
|
|
11550
|
+
// See window_size.js's own module comment for why both of these go
|
|
11551
|
+
// through their shared debounce instead of each keeping its own timer.
|
|
11552
|
+
addTeardown(subscribeVisualViewportResizeSettled(autoCheck));
|
|
11553
|
+
addTeardown(subscribeWindowResizeSettled(autoCheck));
|
|
11406
11554
|
}
|
|
11407
11555
|
on_element_resize: {
|
|
11408
11556
|
if (skipElementResize) {
|
|
@@ -11546,18 +11694,18 @@ const visibleRectEffect = (
|
|
|
11546
11694
|
};
|
|
11547
11695
|
ancestor.addEventListener("toggle", onToggle);
|
|
11548
11696
|
|
|
11549
|
-
const
|
|
11697
|
+
const onNaviPositionChange = (e) => {
|
|
11550
11698
|
autoCheck(e);
|
|
11551
11699
|
};
|
|
11552
11700
|
ancestor.addEventListener(
|
|
11553
|
-
"
|
|
11554
|
-
|
|
11701
|
+
"navi_position_change",
|
|
11702
|
+
onNaviPositionChange,
|
|
11555
11703
|
);
|
|
11556
11704
|
addTeardown(() => {
|
|
11557
11705
|
ancestor.removeEventListener("toggle", onToggle);
|
|
11558
11706
|
ancestor.removeEventListener(
|
|
11559
|
-
"
|
|
11560
|
-
|
|
11707
|
+
"navi_position_change",
|
|
11708
|
+
onNaviPositionChange,
|
|
11561
11709
|
);
|
|
11562
11710
|
});
|
|
11563
11711
|
}
|
|
@@ -11566,97 +11714,451 @@ const visibleRectEffect = (
|
|
|
11566
11714
|
}
|
|
11567
11715
|
}
|
|
11568
11716
|
|
|
11717
|
+
// Re-checks whenever `elementToObserve` (some other element than the one
|
|
11718
|
+
// this effect tracks — e.g. a popover/callout's own content) changes size,
|
|
11719
|
+
// not just when `element` itself is scrolled/resized/re-anchored. Useful
|
|
11720
|
+
// when the tracked element's *position* depends on a size that lives
|
|
11721
|
+
// elsewhere (a callout re-measuring itself against its message body, a
|
|
11722
|
+
// popover reconsidering "top" vs "bottom" once its own content grows).
|
|
11723
|
+
// Can be called more than once, once per element worth watching.
|
|
11724
|
+
const observeSize = (elementToObserve) => {
|
|
11725
|
+
let lastWidth;
|
|
11726
|
+
let lastHeight;
|
|
11727
|
+
// Set right before a deferred check() runs, read right after — see
|
|
11728
|
+
// below for why a pending frame needs to be cancelable.
|
|
11729
|
+
let pendingFrame = null;
|
|
11730
|
+
const resizeObserver = new ResizeObserver((entries) => {
|
|
11731
|
+
const [entry] = entries;
|
|
11732
|
+
const { width, height } = entry.contentRect;
|
|
11733
|
+
// Debounce tiny changes that are likely sub-pixel rounding.
|
|
11734
|
+
if (lastWidth !== undefined) {
|
|
11735
|
+
const widthDiff = Math.abs(width - lastWidth);
|
|
11736
|
+
const heightDiff = Math.abs(height - lastHeight);
|
|
11737
|
+
const threshold = 1;
|
|
11738
|
+
if (widthDiff < threshold && heightDiff < threshold) {
|
|
11739
|
+
return;
|
|
11740
|
+
}
|
|
11741
|
+
}
|
|
11742
|
+
lastWidth = width;
|
|
11743
|
+
lastHeight = height;
|
|
11744
|
+
// Deferred to the next frame rather than calling check() here
|
|
11745
|
+
// directly: check() (via update()) commonly mutates
|
|
11746
|
+
// elementToObserve's own size again as a side effect of repositioning
|
|
11747
|
+
// it (e.g. a popover clearing then re-setting its own max-height
|
|
11748
|
+
// while reconsidering "top" vs "bottom" once it no longer fits where
|
|
11749
|
+
// it was) — when elementToObserve is the very element this observer
|
|
11750
|
+
// watches (a popover watching its own content, not some other
|
|
11751
|
+
// element), doing that synchronously from inside this callback is a
|
|
11752
|
+
// same-frame observer-triggers-itself loop, which the browser detects
|
|
11753
|
+
// and reports as "ResizeObserver loop completed with undelivered
|
|
11754
|
+
// notifications." The debounce above only guards against oscillation
|
|
11755
|
+
// across separate ResizeObserver deliveries — it does nothing for
|
|
11756
|
+
// this single legitimate resize-causes-a-reposition-causes-another-
|
|
11757
|
+
// resize step, since each individual size change here is real, not
|
|
11758
|
+
// sub-pixel noise. Deferring one frame breaks the synchronous chain:
|
|
11759
|
+
// by the time the reposition runs, this callback has already
|
|
11760
|
+
// returned, so any size change it causes is observed as a fresh,
|
|
11761
|
+
// later delivery instead of a nested one. Cancels/replaces any
|
|
11762
|
+
// still-pending frame from an earlier, superseded delivery, so only
|
|
11763
|
+
// the latest size ever actually gets checked.
|
|
11764
|
+
if (pendingFrame !== null) {
|
|
11765
|
+
cancelAnimationFrame(pendingFrame);
|
|
11766
|
+
}
|
|
11767
|
+
pendingFrame = requestAnimationFrame(() => {
|
|
11768
|
+
pendingFrame = null;
|
|
11769
|
+
check(
|
|
11770
|
+
new CustomEvent("observed_element_size_change", {
|
|
11771
|
+
detail: { width, height },
|
|
11772
|
+
}),
|
|
11773
|
+
);
|
|
11774
|
+
});
|
|
11775
|
+
});
|
|
11776
|
+
resizeObserver.observe(elementToObserve);
|
|
11777
|
+
const cleanupAutoCheck = onBeforeAutoCheck(() => {
|
|
11778
|
+
resizeObserver.unobserve(elementToObserve);
|
|
11779
|
+
return () => {
|
|
11780
|
+
resizeObserver.observe(elementToObserve);
|
|
11781
|
+
};
|
|
11782
|
+
});
|
|
11783
|
+
addTeardown(() => {
|
|
11784
|
+
if (pendingFrame !== null) {
|
|
11785
|
+
cancelAnimationFrame(pendingFrame);
|
|
11786
|
+
}
|
|
11787
|
+
resizeObserver.disconnect();
|
|
11788
|
+
});
|
|
11789
|
+
return () => {
|
|
11790
|
+
cleanupAutoCheck();
|
|
11791
|
+
if (pendingFrame !== null) {
|
|
11792
|
+
cancelAnimationFrame(pendingFrame);
|
|
11793
|
+
}
|
|
11794
|
+
resizeObserver.disconnect();
|
|
11795
|
+
};
|
|
11796
|
+
};
|
|
11797
|
+
|
|
11569
11798
|
return {
|
|
11570
11799
|
check,
|
|
11571
11800
|
onBeforeAutoCheck,
|
|
11801
|
+
observeSize,
|
|
11572
11802
|
disconnect: () => {
|
|
11573
11803
|
teardown();
|
|
11574
11804
|
},
|
|
11575
11805
|
};
|
|
11576
11806
|
};
|
|
11577
11807
|
|
|
11808
|
+
/**
|
|
11809
|
+
* The `positionArea` grammar `pickPositionRelativeTo` accepts (also reused
|
|
11810
|
+
* as-is by `@jsenv/navi`'s Popover/Dialog/Callout): a single compass token
|
|
11811
|
+
* (loosely inspired by CSS `position-area`'s own naming), optionally wrapped
|
|
11812
|
+
* in `inset(...)` when the element should overlap the anchor instead of
|
|
11813
|
+
* sitting fully to one side of it. Resolves internally to a { y, x } pair —
|
|
11814
|
+
* y: top/inset-top/center/inset-bottom/bottom, x: left/inset-left/center/
|
|
11815
|
+
* inset-right/right — the same vocabulary the rest of this file's
|
|
11816
|
+
* positioning math (spaceFor, oppositeX/Y, etc.) actually operates on: a
|
|
11817
|
+
* bare `top`/`bottom`/`left`/`right` means outside the anchor (no overlap on
|
|
11818
|
+
* that axis), `inset-*` means flush against/overlapping it.
|
|
11819
|
+
*
|
|
11820
|
+
* Outside the anchor (bare token — element placed fully to one side, no
|
|
11821
|
+
* overlap on that side's axis):
|
|
11822
|
+
*
|
|
11823
|
+
* top-left top-start top top-end top-right
|
|
11824
|
+
* right-start right right-end
|
|
11825
|
+
* bottom-right bottom-end bottom bottom-start bottom-left
|
|
11826
|
+
* left-end left left-start
|
|
11827
|
+
*
|
|
11828
|
+
* A corner token fixes one axis outside (top/bottom/left/right) and the
|
|
11829
|
+
* other the same way (a true corner, no cross-axis overlap at all).
|
|
11830
|
+
* "-start"/"-end" keep one axis outside but align the cross axis flush with
|
|
11831
|
+
* the anchor's near/far edge instead (`top-start` is above the anchor,
|
|
11832
|
+
* left-edges flush). The bare direction word centers the cross axis on the
|
|
11833
|
+
* anchor.
|
|
11834
|
+
*
|
|
11835
|
+
* Overlapping the anchor (wrapped in `inset(...)`, the classic 3×3 grid):
|
|
11836
|
+
*
|
|
11837
|
+
* inset(top-left) inset(top) inset(top-right)
|
|
11838
|
+
* inset(left) center inset(right)
|
|
11839
|
+
* inset(bottom-left) inset(bottom) inset(bottom-right)
|
|
11840
|
+
*
|
|
11841
|
+
* `center` and `inset(center)` are equivalent aliases for dead-center.
|
|
11842
|
+
*/
|
|
11843
|
+
const OUTSIDE_POSITION_AREA_TOKENS = {
|
|
11844
|
+
"top-left": { y: "top", x: "left" },
|
|
11845
|
+
"top-start": { y: "top", x: "inset-left" },
|
|
11846
|
+
"top": { y: "top", x: "center" },
|
|
11847
|
+
"top-end": { y: "top", x: "inset-right" },
|
|
11848
|
+
"top-right": { y: "top", x: "right" },
|
|
11849
|
+
|
|
11850
|
+
"right-start": { y: "inset-top", x: "right" },
|
|
11851
|
+
"right": { y: "center", x: "right" },
|
|
11852
|
+
"right-end": { y: "inset-bottom", x: "right" },
|
|
11853
|
+
|
|
11854
|
+
"bottom-right": { y: "bottom", x: "right" },
|
|
11855
|
+
"bottom-end": { y: "bottom", x: "inset-right" },
|
|
11856
|
+
"bottom": { y: "bottom", x: "center" },
|
|
11857
|
+
"bottom-start": { y: "bottom", x: "inset-left" },
|
|
11858
|
+
"bottom-left": { y: "bottom", x: "left" },
|
|
11859
|
+
|
|
11860
|
+
"left-end": { y: "inset-bottom", x: "left" },
|
|
11861
|
+
"left": { y: "center", x: "left" },
|
|
11862
|
+
"left-start": { y: "inset-top", x: "left" },
|
|
11863
|
+
|
|
11864
|
+
"center": { y: "center", x: "center" },
|
|
11865
|
+
};
|
|
11866
|
+
const INSET_POSITION_AREA_TOKENS = {
|
|
11867
|
+
"top-left": { y: "inset-top", x: "inset-left" },
|
|
11868
|
+
"top": { y: "inset-top", x: "center" },
|
|
11869
|
+
"top-right": { y: "inset-top", x: "inset-right" },
|
|
11870
|
+
|
|
11871
|
+
"right": { y: "center", x: "inset-right" },
|
|
11872
|
+
|
|
11873
|
+
"bottom-right": { y: "inset-bottom", x: "inset-right" },
|
|
11874
|
+
"bottom": { y: "inset-bottom", x: "center" },
|
|
11875
|
+
"bottom-left": { y: "inset-bottom", x: "inset-left" },
|
|
11876
|
+
|
|
11877
|
+
"left": { y: "center", x: "inset-left" },
|
|
11878
|
+
|
|
11879
|
+
"center": { y: "center", x: "center" },
|
|
11880
|
+
};
|
|
11881
|
+
const INSET_TOKEN_RE = /^inset\(\s*([a-z-]+)\s*\)$/;
|
|
11882
|
+
|
|
11883
|
+
/**
|
|
11884
|
+
* Parses a positionArea string into a { y, x } pair, or null if it's not a
|
|
11885
|
+
* recognized token.
|
|
11886
|
+
*/
|
|
11887
|
+
const parsePositionArea = (value) => {
|
|
11888
|
+
const insetMatch = INSET_TOKEN_RE.exec(value);
|
|
11889
|
+
if (insetMatch) {
|
|
11890
|
+
const parsed = INSET_POSITION_AREA_TOKENS[insetMatch[1]];
|
|
11891
|
+
return parsed ? { ...parsed } : null;
|
|
11892
|
+
}
|
|
11893
|
+
const parsed = OUTSIDE_POSITION_AREA_TOKENS[value];
|
|
11894
|
+
return parsed ? { ...parsed } : null;
|
|
11895
|
+
};
|
|
11896
|
+
|
|
11897
|
+
/**
|
|
11898
|
+
* Collapses a bare position value ("top"/"bottom"/"left"/"right") to its
|
|
11899
|
+
* "inset-*" equivalent — "inset-*"/"center" values pass through unchanged.
|
|
11900
|
+
* Only used by pickPositionRelativeTo's own no-anchor (container-docked)
|
|
11901
|
+
* mode — see its own doc for why.
|
|
11902
|
+
*/
|
|
11903
|
+
const toContainerAlignedPosition = (value) => {
|
|
11904
|
+
if (value === "top") {
|
|
11905
|
+
return "inset-top";
|
|
11906
|
+
}
|
|
11907
|
+
if (value === "bottom") {
|
|
11908
|
+
return "inset-bottom";
|
|
11909
|
+
}
|
|
11910
|
+
if (value === "left") {
|
|
11911
|
+
return "inset-left";
|
|
11912
|
+
}
|
|
11913
|
+
if (value === "right") {
|
|
11914
|
+
return "inset-right";
|
|
11915
|
+
}
|
|
11916
|
+
return value;
|
|
11917
|
+
};
|
|
11918
|
+
|
|
11578
11919
|
/**
|
|
11579
11920
|
* Places element relative to anchor with independent control of horizontal and vertical axes.
|
|
11580
11921
|
*
|
|
11581
|
-
*
|
|
11582
|
-
*
|
|
11583
|
-
*
|
|
11584
|
-
*
|
|
11585
|
-
* "
|
|
11586
|
-
* "
|
|
11587
|
-
*
|
|
11588
|
-
*
|
|
11589
|
-
* "
|
|
11590
|
-
*
|
|
11591
|
-
*
|
|
11592
|
-
* "
|
|
11593
|
-
* "
|
|
11594
|
-
*
|
|
11595
|
-
*
|
|
11922
|
+
* `positionArea` (see its own doc above `parsePositionArea`) is a single
|
|
11923
|
+
* compass token that resolves to a { y, x } pair internally:
|
|
11924
|
+
*
|
|
11925
|
+
* Horizontal (x) axis:
|
|
11926
|
+
* "left" element.right = anchor.left (sits entirely to the left of anchor)
|
|
11927
|
+
* "inset-left" element.left = anchor.left (left edges aligned, overlapping)
|
|
11928
|
+
* "center" element centered horizontally over anchor
|
|
11929
|
+
* "inset-right" element.right = anchor.right (right edges aligned, overlapping)
|
|
11930
|
+
* "right" element.left = anchor.right (sits entirely to the right of anchor)
|
|
11931
|
+
*
|
|
11932
|
+
* Vertical (y) axis:
|
|
11933
|
+
* "top" element.bottom = anchor.top (sits above, no overlap)
|
|
11934
|
+
* "inset-top" element.top = anchor.top (top edges aligned, overlapping)
|
|
11935
|
+
* "center" element centered vertically over anchor
|
|
11936
|
+
* "inset-bottom" element.bottom = anchor.bottom (bottom edges aligned, overlapping)
|
|
11937
|
+
* "bottom" element.top = anchor.bottom (sits below, no overlap)
|
|
11938
|
+
*
|
|
11939
|
+
* The resolved x/y attempt the requested placement and automatically flip to the
|
|
11596
11940
|
* logical opposite when the element does not fit in the viewport:
|
|
11597
|
-
*
|
|
11941
|
+
* top ↔ bottom, inset-top ↔ inset-bottom, left ↔ right, inset-left ↔ inset-right
|
|
11598
11942
|
*
|
|
11599
|
-
*
|
|
11943
|
+
* `positionAreaFixed` skips the fit check entirely on both axes.
|
|
11600
11944
|
*
|
|
11601
11945
|
* The resolved X and Y are persisted as data-position-x-current / data-position-y-current
|
|
11602
11946
|
* on the element so subsequent calls start from the last resolved position (avoids
|
|
11603
|
-
* flickering when the element is near the flip threshold)
|
|
11604
|
-
*
|
|
11605
|
-
*
|
|
11606
|
-
*
|
|
11947
|
+
* flickering when the element is near the flip threshold) and so other CSS/JS can read
|
|
11948
|
+
* "which side is this on right now" — including for a fixed axis, even though a fixed
|
|
11949
|
+
* axis never reads the attribute back itself (`positionAreaFixed` always wins).
|
|
11950
|
+
*
|
|
11951
|
+
* @param {HTMLElement} element - The element to position (position: absolute or
|
|
11952
|
+
* fixed — detected from its own computed style, see the scroll offset comment below)
|
|
11953
|
+
* @param {HTMLElement} [anchor] - The anchor element to position against. Omit (or pass
|
|
11954
|
+
* `null`/`undefined`) when there's no real anchor to dock `element` against a *container*
|
|
11955
|
+
* instead — see `container` below; in that mode, "top"/"bottom"/"left"/"right" are
|
|
11956
|
+
* collapsed to their "inset-*" equivalent internally (docking has no "float away with
|
|
11957
|
+
* a gap" concept the way a real anchor does) and x/y always behave as if
|
|
11958
|
+
* `positionAreaFixed` were set (a docked edge/corner never flips to the other side —
|
|
11959
|
+
* there's no "other side" of a container the way there is of a real anchor).
|
|
11607
11960
|
* @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
|
-
*
|
|
11961
|
+
* @param {string} [options.positionArea="bottom"] - Preferred placement, with viewport
|
|
11962
|
+
* fallback — see `parsePositionArea`'s own doc for the full token grammar (a single
|
|
11963
|
+
* compass token, optionally `inset(...)`-wrapped).
|
|
11964
|
+
* @param {string} [options.positionAreaFixed] - Forces this placement, skipping the
|
|
11965
|
+
* fit-check on both axes. Same grammar as `positionArea`.
|
|
11966
|
+
* @param {string} [options.positionAreaWhenAnchorIsInvalid="center"] - `positionArea`
|
|
11967
|
+
* used instead, as a plain no-anchor dock, whenever the anchor is too big to leave
|
|
11968
|
+
* room on the axis `positionArea` places it outside of. `hasValidAnchor` in the return
|
|
11969
|
+
* value reports which way it went.
|
|
11970
|
+
* @param {Event|CustomEvent} [options.event] - The event that triggered this particular
|
|
11971
|
+
* reposition (a scroll/resize/etc. handler simply forwarding whatever it was itself
|
|
11972
|
+
* called with) — purely informational, never changes the computed `left`/`top`
|
|
11973
|
+
* themselves, only `shouldTransition` in the return value (see `applyNewPosition`'s
|
|
11974
|
+
* own doc for how that's meant to be used).
|
|
11975
|
+
* @param {number} [options.alignToContainerEdgeWhenAnchorNearEdge=0] - When centering
|
|
11976
|
+
* (positionArea's x is "center") an element wider than its anchor, snap to the available area's own
|
|
11977
|
+
* left edge (the page viewport normally, or the container's edge — see `container` below —
|
|
11978
|
+
* whenever there's no real `anchor`) instead of centering, once the anchor is within this
|
|
11979
|
+
* many px of that same edge — avoids the (wider) element overflowing past it. 0 disables
|
|
11980
|
+
* the snap entirely.
|
|
11624
11981
|
* @param {number} [options.minLeft=0] - Minimum left coordinate (document-relative).
|
|
11625
|
-
* @
|
|
11982
|
+
* @param {HTMLElement|null} [options.container] - The container `element` is genuinely
|
|
11983
|
+
* `position: absolute` relative to (its own containing block) — decoupled from whether
|
|
11984
|
+
* there's a real `anchor`, since `element` can be container-relative either way (e.g. the
|
|
11985
|
+
* custom renderer in popover.jsx, always relative to its own positioned ancestor whether
|
|
11986
|
+
* or not it also has a real anchor). Whenever not explicitly given, this is always
|
|
11987
|
+
* resolved automatically via `getPositioningContainer(element)` instead — regardless of
|
|
11988
|
+
* `hasValidAnchor` — so a caller that never thinks about `container` at all still gets the
|
|
11989
|
+
* right behavior on its own: `null` from `getPositioningContainer` (an `element` with a
|
|
11990
|
+
* `popover` attribute, or a `<dialog>` — e.g. Callout's own element) falls back to the
|
|
11991
|
+
* traditional document-relative path below, exactly as if `container` genuinely didn't
|
|
11992
|
+
* apply; anything else `getPositioningContainer` finds (a real positioned ancestor) is
|
|
11993
|
+
* used the same way an explicit `container` would be. A container that resolves to
|
|
11994
|
+
* `document.documentElement` (the viewport) produces identical output to the plain
|
|
11995
|
+
* document-relative path either way, since the document's own scroll and the viewport's
|
|
11996
|
+
* own origin already coincide with what this generically computes for any other container
|
|
11997
|
+
* element. When there's a real container (explicit or resolved) either way: the final
|
|
11998
|
+
* `left`/`top` (and the returned `anchorLeft/Top/Right/Bottom`) are expressed relative to
|
|
11999
|
+
* its own padding-box origin plus its own scroll, instead of the document's — `element`'s
|
|
12000
|
+
* own computed `position` is *not* consulted in that case, unlike the traditional path.
|
|
12001
|
+
* When `anchor` is also omitted (no real anchor at all), the container additionally
|
|
12002
|
+
* becomes what's positioned against, and the boundary clamp uses its own (padding-box)
|
|
12003
|
+
* edges instead of the page viewport's, on both axes (the Y axis otherwise has no such
|
|
12004
|
+
* clamp at all — see the clamp's own comment) — that part *is* gated on `hasValidAnchor`,
|
|
12005
|
+
* unlike the coordinate-space conversion itself.
|
|
12006
|
+
* @returns {{ hasValidAnchor, shouldTransition, positionX, positionY, left, top, width, height, anchorLeft, anchorTop, anchorRight, anchorBottom, spaceLeft, spaceRight, spaceAbove, spaceBelow }}
|
|
11626
12007
|
*/
|
|
11627
12008
|
const pickPositionRelativeTo = (
|
|
11628
12009
|
element,
|
|
11629
12010
|
anchor,
|
|
11630
12011
|
{
|
|
11631
|
-
|
|
11632
|
-
|
|
11633
|
-
|
|
11634
|
-
|
|
11635
|
-
|
|
12012
|
+
positionArea = "bottom",
|
|
12013
|
+
positionAreaFixed,
|
|
12014
|
+
positionAreaWhenAnchorIsInvalid = "center",
|
|
12015
|
+
event,
|
|
12016
|
+
alignToContainerEdgeWhenAnchorNearEdge = 0,
|
|
11636
12017
|
minLeft = 0,
|
|
11637
|
-
|
|
12018
|
+
marginWithAnchor = 0,
|
|
11638
12019
|
alignToAnchorBox = "border-box",
|
|
11639
|
-
|
|
12020
|
+
marginWithContainer = 0,
|
|
12021
|
+
container,
|
|
11640
12022
|
} = {},
|
|
11641
12023
|
) => {
|
|
11642
|
-
|
|
11643
|
-
|
|
11644
|
-
|
|
12024
|
+
// Needed before hasValidAnchor below. visualViewport, not
|
|
12025
|
+
// document.documentElement.clientWidth/Height: the layout viewport
|
|
12026
|
+
// doesn't shrink when the on-screen keyboard opens, only the visual one
|
|
12027
|
+
// does.
|
|
12028
|
+
const visualViewport = window.visualViewport;
|
|
12029
|
+
const viewportWidth = visualViewport
|
|
12030
|
+
? visualViewport.width
|
|
12031
|
+
: document.documentElement.clientWidth;
|
|
12032
|
+
const viewportHeight = visualViewport
|
|
12033
|
+
? visualViewport.height
|
|
12034
|
+
: document.documentElement.clientHeight;
|
|
12035
|
+
const viewportLeft = visualViewport ? visualViewport.offsetLeft : 0;
|
|
12036
|
+
const viewportTop = visualViewport ? visualViewport.offsetTop : 0;
|
|
12037
|
+
|
|
12038
|
+
// Resolved early: everything below that would otherwise reach for
|
|
12039
|
+
// viewportLeft/Top/Width/Height instead uses these, so a "local" popover
|
|
12040
|
+
// never gets offered more room (anchor-too-big check, flip decisions,
|
|
12041
|
+
// clamp) than its own container — resolvedContainer's own padding-box
|
|
12042
|
+
// edges when there is one — actually has.
|
|
12043
|
+
const resolvedContainer = container ?? getPositioningContainer(element);
|
|
12044
|
+
const hasRealContainer =
|
|
12045
|
+
resolvedContainer && resolvedContainer !== document.documentElement;
|
|
12046
|
+
const containerRect = hasRealContainer
|
|
12047
|
+
? resolvedContainer.getBoundingClientRect()
|
|
12048
|
+
: null;
|
|
12049
|
+
const containerBorders = hasRealContainer
|
|
12050
|
+
? getBorderSizes(resolvedContainer)
|
|
12051
|
+
: { left: 0, top: 0, right: 0, bottom: 0 };
|
|
12052
|
+
const availableLeft = hasRealContainer
|
|
12053
|
+
? snapToPixel(containerRect.left) + containerBorders.left
|
|
12054
|
+
: viewportLeft;
|
|
12055
|
+
const availableTop = hasRealContainer
|
|
12056
|
+
? snapToPixel(containerRect.top) + containerBorders.top
|
|
12057
|
+
: viewportTop;
|
|
12058
|
+
const availableRight = hasRealContainer
|
|
12059
|
+
? snapToPixel(containerRect.right) - containerBorders.right
|
|
12060
|
+
: viewportLeft + viewportWidth;
|
|
12061
|
+
const availableBottom = hasRealContainer
|
|
12062
|
+
? snapToPixel(containerRect.bottom) - containerBorders.bottom
|
|
12063
|
+
: viewportTop + viewportHeight;
|
|
12064
|
+
const availableWidth = availableRight - availableLeft;
|
|
12065
|
+
const availableHeight = availableBottom - availableTop;
|
|
12066
|
+
|
|
12067
|
+
// Rejected only on the axis positionArea actually places `element`
|
|
12068
|
+
// outside of ("left"/"right" or "top"/"bottom") — that's the only axis
|
|
12069
|
+
// where the anchor's own size eats into the room available. Docks via
|
|
12070
|
+
// positionAreaWhenAnchorIsInvalid instead of `positionArea` once rejected.
|
|
12071
|
+
const requestedPositionArea = parsePositionArea(positionArea);
|
|
12072
|
+
const anchorRejected =
|
|
12073
|
+
Boolean(anchor) &&
|
|
12074
|
+
(() => {
|
|
12075
|
+
const rect = anchor.getBoundingClientRect();
|
|
12076
|
+
const { x, y } = requestedPositionArea ?? {};
|
|
12077
|
+
if (
|
|
12078
|
+
(y === "top" || y === "bottom") &&
|
|
12079
|
+
rect.height > availableHeight - 50
|
|
12080
|
+
) {
|
|
12081
|
+
return true;
|
|
12082
|
+
}
|
|
12083
|
+
if ((x === "left" || x === "right") && rect.width > availableWidth - 50) {
|
|
12084
|
+
return true;
|
|
12085
|
+
}
|
|
12086
|
+
return false;
|
|
12087
|
+
})();
|
|
12088
|
+
const hasValidAnchor = Boolean(anchor) && !anchorRejected;
|
|
12089
|
+
const effectivePositionArea = anchorRejected
|
|
12090
|
+
? positionAreaWhenAnchorIsInvalid
|
|
12091
|
+
: positionArea;
|
|
12092
|
+
|
|
12093
|
+
const parsedPositionArea = parsePositionArea(effectivePositionArea);
|
|
12094
|
+
if (!parsedPositionArea) {
|
|
12095
|
+
console.warn(
|
|
12096
|
+
`pickPositionRelativeTo: invalid positionArea="${effectivePositionArea}"`,
|
|
12097
|
+
);
|
|
12098
|
+
}
|
|
12099
|
+
let positionX = parsedPositionArea ? parsedPositionArea.x : "center";
|
|
12100
|
+
let positionY = parsedPositionArea ? parsedPositionArea.y : "bottom";
|
|
12101
|
+
let positionXFixed;
|
|
12102
|
+
let positionYFixed;
|
|
12103
|
+
if (positionAreaFixed) {
|
|
12104
|
+
const parsedPositionAreaFixed = parsePositionArea(positionAreaFixed);
|
|
12105
|
+
if (!parsedPositionAreaFixed) {
|
|
12106
|
+
console.warn(
|
|
12107
|
+
`pickPositionRelativeTo: invalid positionAreaFixed="${positionAreaFixed}"`,
|
|
12108
|
+
);
|
|
12109
|
+
} else {
|
|
12110
|
+
positionXFixed = parsedPositionAreaFixed.x;
|
|
12111
|
+
positionYFixed = parsedPositionAreaFixed.y;
|
|
12112
|
+
}
|
|
12113
|
+
}
|
|
12114
|
+
// No real anchor (or a rejected one): dock against a container instead.
|
|
12115
|
+
if (!hasValidAnchor) {
|
|
12116
|
+
positionX = toContainerAlignedPosition(positionX);
|
|
12117
|
+
positionY = toContainerAlignedPosition(positionY);
|
|
12118
|
+
positionXFixed = positionX;
|
|
12119
|
+
positionYFixed = positionY;
|
|
12120
|
+
}
|
|
12121
|
+
// resolvedContainer was already resolved above. `null` from
|
|
12122
|
+
// getPositioningContainer (a popover/dialog element, e.g. Callout's own)
|
|
12123
|
+
// falls through to the traditional document-relative path below all the
|
|
12124
|
+
// same, so an existing caller that never thinks about `container` at all
|
|
12125
|
+
// keeps behaving exactly as before.
|
|
12126
|
+
const effectiveAnchor = hasValidAnchor
|
|
12127
|
+
? anchor
|
|
12128
|
+
: resolvedContainer || document.documentElement;
|
|
12129
|
+
// document.documentElement is used as a sentinel "the viewport" value: an
|
|
12130
|
+
// anchorless popup should center/place itself against the visual
|
|
12131
|
+
// viewport, not against <html>'s own box — which, unlike the viewport,
|
|
12132
|
+
// grows with document content and can be far taller than what's on
|
|
12133
|
+
// screen (its top is also negative once the page is scrolled). Using the
|
|
12134
|
+
// viewport rect here fixes that; the scroll offset is still applied
|
|
12135
|
+
// below like any other case (see getPositioningScrollOffset).
|
|
12136
|
+
const anchorIsViewport = effectiveAnchor === document.documentElement;
|
|
11645
12137
|
// Get viewport-relative positions
|
|
11646
|
-
const
|
|
11647
|
-
|
|
11648
|
-
|
|
11649
|
-
|
|
11650
|
-
|
|
11651
|
-
|
|
11652
|
-
|
|
11653
|
-
|
|
12138
|
+
const anchorRect = anchorIsViewport
|
|
12139
|
+
? {
|
|
12140
|
+
left: viewportLeft,
|
|
12141
|
+
top: viewportTop,
|
|
12142
|
+
right: viewportLeft + viewportWidth,
|
|
12143
|
+
bottom: viewportTop + viewportHeight,
|
|
12144
|
+
}
|
|
12145
|
+
: effectiveAnchor.getBoundingClientRect();
|
|
11654
12146
|
const anchorLeft = snapToPixel(anchorRect.left);
|
|
11655
12147
|
const anchorTop = snapToPixel(anchorRect.top);
|
|
11656
12148
|
const anchorRight = snapToPixel(anchorRect.right);
|
|
11657
12149
|
const anchorBottom = snapToPixel(anchorRect.bottom);
|
|
11658
|
-
|
|
11659
|
-
const
|
|
12150
|
+
// Horizontal clamp bounds — see availableLeft/availableRight above.
|
|
12151
|
+
const clampLeftBound = availableLeft;
|
|
12152
|
+
const clampRightBound = availableRight;
|
|
12153
|
+
// offsetWidth/offsetHeight (layout box), not getBoundingClientRect() (the
|
|
12154
|
+
// painted/transformed box): the element being positioned may have an
|
|
12155
|
+
// active CSS `scale`/`translate` transform mid-animation (e.g. a popover
|
|
12156
|
+
// using animation="scale"/"grow", still at its @starting-style value the
|
|
12157
|
+
// instant it's first shown) — getBoundingClientRect() would then report
|
|
12158
|
+
// its *shrunk* transformed size, throwing off any math that centers/fits
|
|
12159
|
+
// against the element's own dimensions.
|
|
12160
|
+
const elementWidth = element.offsetWidth;
|
|
12161
|
+
const elementHeight = element.offsetHeight;
|
|
11660
12162
|
const anchorWidth = anchorRight - anchorLeft;
|
|
11661
12163
|
const anchorHeight = anchorBottom - anchorTop;
|
|
11662
12164
|
|
|
@@ -11671,19 +12173,19 @@ const pickPositionRelativeTo = (
|
|
|
11671
12173
|
let insetLeft = 0;
|
|
11672
12174
|
let insetRight = 0;
|
|
11673
12175
|
if (alignToAnchorBox === "content-box") {
|
|
11674
|
-
const anchorBorderSizes = getBorderSizes(
|
|
11675
|
-
const anchorPaddingSizes = getPaddingSizes(
|
|
12176
|
+
const anchorBorderSizes = getBorderSizes(effectiveAnchor);
|
|
12177
|
+
const anchorPaddingSizes = getPaddingSizes(effectiveAnchor);
|
|
11676
12178
|
insetTop = anchorBorderSizes.top + anchorPaddingSizes.top;
|
|
11677
12179
|
insetBottom = anchorBorderSizes.bottom + anchorPaddingSizes.bottom;
|
|
11678
12180
|
insetLeft = anchorBorderSizes.left + anchorPaddingSizes.left;
|
|
11679
12181
|
insetRight = anchorBorderSizes.right + anchorPaddingSizes.right;
|
|
11680
12182
|
}
|
|
11681
|
-
const spaceAbove = anchorTop + insetTop;
|
|
11682
|
-
const spaceBelow =
|
|
12183
|
+
const spaceAbove = anchorTop + insetTop - availableTop;
|
|
12184
|
+
const spaceBelow = availableBottom - anchorBottom + insetBottom;
|
|
11683
12185
|
const effectiveAnchorLeft = anchorLeft + insetLeft;
|
|
11684
12186
|
const effectiveAnchorRight = anchorRight - insetRight;
|
|
11685
|
-
const spaceLeft = anchorLeft + insetLeft;
|
|
11686
|
-
const spaceRight =
|
|
12187
|
+
const spaceLeft = anchorLeft + insetLeft - availableLeft;
|
|
12188
|
+
const spaceRight = availableRight - anchorRight + insetRight;
|
|
11687
12189
|
|
|
11688
12190
|
// Resolve active X and Y, and whether each is fixed (no flip fallback)
|
|
11689
12191
|
let activeX;
|
|
@@ -11709,24 +12211,24 @@ const pickPositionRelativeTo = (
|
|
|
11709
12211
|
let finalY;
|
|
11710
12212
|
{
|
|
11711
12213
|
const oppositeY = {
|
|
11712
|
-
"
|
|
11713
|
-
"
|
|
11714
|
-
"
|
|
11715
|
-
"
|
|
12214
|
+
"top": "bottom",
|
|
12215
|
+
"bottom": "top",
|
|
12216
|
+
"inset-top": "inset-bottom",
|
|
12217
|
+
"inset-bottom": "inset-top",
|
|
11716
12218
|
};
|
|
11717
12219
|
// Compute effective space for a given Y value
|
|
11718
12220
|
const spaceFor = (y) => {
|
|
11719
|
-
if (y === "
|
|
11720
|
-
return spaceAbove -
|
|
12221
|
+
if (y === "top") {
|
|
12222
|
+
return spaceAbove - marginWithAnchor - marginWithContainer;
|
|
11721
12223
|
}
|
|
11722
|
-
if (y === "
|
|
11723
|
-
return spaceAbove + anchorHeight -
|
|
12224
|
+
if (y === "inset-bottom") {
|
|
12225
|
+
return spaceAbove + anchorHeight - marginWithContainer;
|
|
11724
12226
|
}
|
|
11725
|
-
if (y === "
|
|
11726
|
-
return spaceBelow -
|
|
12227
|
+
if (y === "bottom") {
|
|
12228
|
+
return spaceBelow - marginWithAnchor - marginWithContainer;
|
|
11727
12229
|
}
|
|
11728
|
-
if (y === "
|
|
11729
|
-
return spaceBelow + anchorHeight -
|
|
12230
|
+
if (y === "inset-top") {
|
|
12231
|
+
return spaceBelow + anchorHeight - marginWithContainer;
|
|
11730
12232
|
}
|
|
11731
12233
|
return Infinity; // center
|
|
11732
12234
|
};
|
|
@@ -11770,24 +12272,24 @@ const pickPositionRelativeTo = (
|
|
|
11770
12272
|
let finalX;
|
|
11771
12273
|
{
|
|
11772
12274
|
const oppositeX = {
|
|
11773
|
-
"
|
|
11774
|
-
"
|
|
11775
|
-
"left
|
|
11776
|
-
"right
|
|
12275
|
+
"left": "right",
|
|
12276
|
+
"right": "left",
|
|
12277
|
+
"inset-left": "inset-right",
|
|
12278
|
+
"inset-right": "inset-left",
|
|
11777
12279
|
};
|
|
11778
12280
|
// Compute effective space for a given X value
|
|
11779
12281
|
const spaceFor = (x) => {
|
|
11780
|
-
if (x === "
|
|
11781
|
-
return spaceLeft -
|
|
12282
|
+
if (x === "left") {
|
|
12283
|
+
return spaceLeft - marginWithAnchor - marginWithContainer;
|
|
11782
12284
|
}
|
|
11783
|
-
if (x === "left
|
|
11784
|
-
return
|
|
12285
|
+
if (x === "inset-left") {
|
|
12286
|
+
return availableRight - anchorLeft - marginWithContainer;
|
|
11785
12287
|
}
|
|
11786
|
-
if (x === "right
|
|
11787
|
-
return anchorRight -
|
|
12288
|
+
if (x === "inset-right") {
|
|
12289
|
+
return anchorRight - availableLeft - marginWithContainer;
|
|
11788
12290
|
}
|
|
11789
|
-
if (x === "
|
|
11790
|
-
return spaceRight -
|
|
12291
|
+
if (x === "right") {
|
|
12292
|
+
return spaceRight - marginWithAnchor - marginWithContainer;
|
|
11791
12293
|
}
|
|
11792
12294
|
return Infinity; // center
|
|
11793
12295
|
};
|
|
@@ -11823,101 +12325,178 @@ const pickPositionRelativeTo = (
|
|
|
11823
12325
|
// Calculate horizontal position (viewport-relative)
|
|
11824
12326
|
let elementPositionLeft;
|
|
11825
12327
|
{
|
|
11826
|
-
if (finalX === "
|
|
11827
|
-
elementPositionLeft =
|
|
11828
|
-
|
|
12328
|
+
if (finalX === "left") {
|
|
12329
|
+
elementPositionLeft =
|
|
12330
|
+
effectiveAnchorLeft - elementWidth - marginWithAnchor;
|
|
12331
|
+
} else if (finalX === "inset-left") {
|
|
11829
12332
|
elementPositionLeft = effectiveAnchorLeft;
|
|
11830
12333
|
} else if (finalX === "center") {
|
|
11831
|
-
// Complex logic handles wide anchors and
|
|
11832
|
-
const
|
|
11833
|
-
if (
|
|
11834
|
-
const anchorLeftIsVisible = effectiveAnchorLeft >=
|
|
11835
|
-
const anchorRightIsVisible = effectiveAnchorRight <=
|
|
12334
|
+
// Complex logic handles wide anchors and container-edge snapping
|
|
12335
|
+
const anchorIsWiderThanAvailable = anchorWidth > availableWidth;
|
|
12336
|
+
if (anchorIsWiderThanAvailable) {
|
|
12337
|
+
const anchorLeftIsVisible = effectiveAnchorLeft >= availableLeft;
|
|
12338
|
+
const anchorRightIsVisible = effectiveAnchorRight <= availableRight;
|
|
11836
12339
|
if (!anchorLeftIsVisible && anchorRightIsVisible) {
|
|
11837
|
-
const
|
|
11838
|
-
const distanceFromRightEdge =
|
|
12340
|
+
const availableCenter = availableLeft + availableWidth / 2;
|
|
12341
|
+
const distanceFromRightEdge = availableRight - effectiveAnchorRight;
|
|
11839
12342
|
elementPositionLeft =
|
|
11840
|
-
|
|
12343
|
+
availableCenter - distanceFromRightEdge / 2 - elementWidth / 2;
|
|
11841
12344
|
} else if (anchorLeftIsVisible && !anchorRightIsVisible) {
|
|
11842
|
-
const
|
|
11843
|
-
const distanceFromLeftEdge = -effectiveAnchorLeft;
|
|
12345
|
+
const availableCenter = availableLeft + availableWidth / 2;
|
|
12346
|
+
const distanceFromLeftEdge = availableLeft - effectiveAnchorLeft;
|
|
11844
12347
|
elementPositionLeft =
|
|
11845
|
-
|
|
12348
|
+
availableCenter - distanceFromLeftEdge / 2 - elementWidth / 2;
|
|
11846
12349
|
} else {
|
|
11847
|
-
elementPositionLeft =
|
|
12350
|
+
elementPositionLeft =
|
|
12351
|
+
availableLeft + availableWidth / 2 - elementWidth / 2;
|
|
11848
12352
|
}
|
|
11849
12353
|
} else {
|
|
11850
12354
|
elementPositionLeft =
|
|
11851
12355
|
effectiveAnchorLeft +
|
|
11852
12356
|
(effectiveAnchorRight - effectiveAnchorLeft) / 2 -
|
|
11853
12357
|
elementWidth / 2;
|
|
11854
|
-
if (
|
|
12358
|
+
if (alignToContainerEdgeWhenAnchorNearEdge) {
|
|
11855
12359
|
const effectiveAnchorWidth =
|
|
11856
12360
|
effectiveAnchorRight - effectiveAnchorLeft;
|
|
11857
12361
|
const elementIsWiderThanAnchor = elementWidth > effectiveAnchorWidth;
|
|
11858
|
-
const
|
|
11859
|
-
effectiveAnchorLeft <
|
|
11860
|
-
|
|
11861
|
-
|
|
12362
|
+
const anchorIsNearContainerEdge =
|
|
12363
|
+
effectiveAnchorLeft - clampLeftBound <
|
|
12364
|
+
alignToContainerEdgeWhenAnchorNearEdge;
|
|
12365
|
+
if (elementIsWiderThanAnchor && anchorIsNearContainerEdge) {
|
|
12366
|
+
elementPositionLeft = clampLeftBound + minLeft;
|
|
11862
12367
|
}
|
|
11863
12368
|
}
|
|
11864
12369
|
}
|
|
11865
|
-
} else if (finalX === "right
|
|
12370
|
+
} else if (finalX === "inset-right") {
|
|
11866
12371
|
elementPositionLeft = effectiveAnchorRight - elementWidth;
|
|
11867
12372
|
} else {
|
|
11868
|
-
// "
|
|
11869
|
-
elementPositionLeft = effectiveAnchorRight +
|
|
12373
|
+
// "right"
|
|
12374
|
+
elementPositionLeft = effectiveAnchorRight + marginWithAnchor;
|
|
11870
12375
|
}
|
|
11871
|
-
// Constrain horizontal position to
|
|
11872
|
-
|
|
11873
|
-
|
|
12376
|
+
// Constrain horizontal position to the available area's boundaries
|
|
12377
|
+
// (with marginWithContainer margin).
|
|
12378
|
+
if (elementPositionLeft < clampLeftBound + marginWithContainer) {
|
|
12379
|
+
elementPositionLeft = clampLeftBound + marginWithContainer;
|
|
11874
12380
|
} else if (
|
|
11875
12381
|
elementPositionLeft + elementWidth >
|
|
11876
|
-
|
|
12382
|
+
clampRightBound - marginWithContainer
|
|
11877
12383
|
) {
|
|
11878
|
-
elementPositionLeft =
|
|
12384
|
+
elementPositionLeft =
|
|
12385
|
+
clampRightBound - marginWithContainer - elementWidth;
|
|
11879
12386
|
}
|
|
11880
12387
|
}
|
|
11881
12388
|
|
|
11882
12389
|
// Calculate vertical position (viewport-relative)
|
|
11883
12390
|
let elementPositionTop;
|
|
11884
12391
|
{
|
|
11885
|
-
if (finalY === "
|
|
11886
|
-
// top is always anchorTop + insetTop - elementHeight -
|
|
11887
|
-
const idealTop = anchorTop + insetTop - elementHeight -
|
|
12392
|
+
if (finalY === "top") {
|
|
12393
|
+
// top is always anchorTop + insetTop - elementHeight - marginWithAnchor — max-height truncates if needed.
|
|
12394
|
+
const idealTop = anchorTop + insetTop - elementHeight - marginWithAnchor;
|
|
11888
12395
|
elementPositionTop =
|
|
11889
|
-
idealTop <
|
|
11890
|
-
} else if (finalY === "
|
|
12396
|
+
idealTop < marginWithContainer ? marginWithContainer : idealTop;
|
|
12397
|
+
} else if (finalY === "inset-bottom") {
|
|
11891
12398
|
const idealTop = anchorBottom - elementHeight;
|
|
11892
12399
|
elementPositionTop =
|
|
11893
|
-
idealTop <
|
|
12400
|
+
idealTop < marginWithContainer ? marginWithContainer : idealTop;
|
|
11894
12401
|
} else if (finalY === "center") {
|
|
11895
12402
|
elementPositionTop = anchorTop + anchorHeight / 2 - elementHeight / 2;
|
|
11896
|
-
} else if (finalY === "
|
|
12403
|
+
} else if (finalY === "inset-top") {
|
|
11897
12404
|
const idealTop = anchorTop;
|
|
11898
12405
|
elementPositionTop =
|
|
11899
12406
|
idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
|
|
11900
12407
|
} else {
|
|
11901
|
-
// "
|
|
11902
|
-
// top is always anchorBottom - insetBottom +
|
|
12408
|
+
// "bottom"
|
|
12409
|
+
// top is always anchorBottom - insetBottom + marginWithAnchor — max-height (via --container-position-remaining-height) truncates
|
|
11903
12410
|
// the element height so it doesn't overflow the viewport bottom.
|
|
11904
|
-
const idealTop = anchorBottom - insetBottom +
|
|
12411
|
+
const idealTop = anchorBottom - insetBottom + marginWithAnchor;
|
|
11905
12412
|
elementPositionTop =
|
|
11906
12413
|
idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
|
|
11907
12414
|
}
|
|
11908
|
-
|
|
11909
|
-
|
|
11910
|
-
|
|
11911
|
-
|
|
11912
|
-
|
|
11913
|
-
|
|
11914
|
-
|
|
11915
|
-
|
|
11916
|
-
|
|
11917
|
-
|
|
11918
|
-
|
|
11919
|
-
|
|
11920
|
-
|
|
12415
|
+
// Unlike the horizontal clamp above, there's normally no universal
|
|
12416
|
+
// vertical boundary clamp at all — "top"/"bottom" already clamp their
|
|
12417
|
+
// own idealTop inline, "inset-*"/"center" don't, and changing that
|
|
12418
|
+
// for every existing consumer (real-anchor "bottom" near the viewport
|
|
12419
|
+
// bottom relies on --container-position-remaining-height/max-height truncation instead of
|
|
12420
|
+
// repositioning) is out of scope here. Scoped strictly to the no-anchor
|
|
12421
|
+
// (container-docked) case, where it's new and safe: a container is
|
|
12422
|
+
// always meant to be respected on both axes.
|
|
12423
|
+
if (!hasValidAnchor) {
|
|
12424
|
+
if (elementPositionTop < availableTop + marginWithContainer) {
|
|
12425
|
+
elementPositionTop = availableTop + marginWithContainer;
|
|
12426
|
+
} else if (
|
|
12427
|
+
elementPositionTop + elementHeight >
|
|
12428
|
+
availableBottom - marginWithContainer
|
|
12429
|
+
) {
|
|
12430
|
+
elementPositionTop =
|
|
12431
|
+
availableBottom - marginWithContainer - elementHeight;
|
|
12432
|
+
}
|
|
12433
|
+
}
|
|
12434
|
+
}
|
|
12435
|
+
|
|
12436
|
+
// Persist resolved X/Y so subsequent calls start from here (avoids
|
|
12437
|
+
// flickering) — and so CSS consumers (e.g. Popover's "clip" animation,
|
|
12438
|
+
// which reads data-position-y-current to pick which edge to reveal from)
|
|
12439
|
+
// can rely on it always reflecting the current side, fixed or not. A fixed
|
|
12440
|
+
// axis is never read back from this attribute (xIsFixed/yIsFixed always
|
|
12441
|
+
// wins over the stored value above), so persisting it here is purely for
|
|
12442
|
+
// those outside readers, not for this function's own flip logic.
|
|
12443
|
+
element.setAttribute("data-position-x-current", finalX);
|
|
12444
|
+
element.setAttribute("data-position-y-current", finalY);
|
|
12445
|
+
|
|
12446
|
+
// Convert the viewport-relative math above into whatever coordinate space
|
|
12447
|
+
// `element.style.top/left` actually needs. This is decided independently
|
|
12448
|
+
// of whether there's a real anchor: `element` might be `position:
|
|
12449
|
+
// absolute` relative to some container regardless (e.g. the custom
|
|
12450
|
+
// renderer in popover.jsx, which is always relative to its own
|
|
12451
|
+
// positioned ancestor whether or not it also has a real anchor) — that's
|
|
12452
|
+
// what `resolvedContainer` (explicit or auto-resolved above) communicates
|
|
12453
|
+
// even when `anchor` is also given. The container to convert into is
|
|
12454
|
+
// `resolvedContainer` when there's a real anchor, or (in the no-anchor
|
|
12455
|
+
// case) `effectiveAnchor` itself, since there the container *is* what's
|
|
12456
|
+
// being positioned against.
|
|
12457
|
+
const coordinateContainer = hasValidAnchor
|
|
12458
|
+
? resolvedContainer
|
|
12459
|
+
: effectiveAnchor;
|
|
12460
|
+
let scrollLeft;
|
|
12461
|
+
let scrollTop;
|
|
12462
|
+
if (coordinateContainer && coordinateContainer !== document.documentElement) {
|
|
12463
|
+
// Reuse anchorRect/containerBorders when the coordinate container is
|
|
12464
|
+
// the same element already measured above (the no-anchor case);
|
|
12465
|
+
// otherwise (a real anchor positioned within a *different*, explicitly
|
|
12466
|
+
// given container) measure the container separately — the anchor's own
|
|
12467
|
+
// rect only matters for the positioning math above, not for this.
|
|
12468
|
+
const isSameAsEffectiveAnchor = coordinateContainer === effectiveAnchor;
|
|
12469
|
+
const coordinateRect = isSameAsEffectiveAnchor
|
|
12470
|
+
? anchorRect
|
|
12471
|
+
: coordinateContainer.getBoundingClientRect();
|
|
12472
|
+
const coordinateBorders = isSameAsEffectiveAnchor
|
|
12473
|
+
? containerBorders
|
|
12474
|
+
: getBorderSizes(coordinateContainer);
|
|
12475
|
+
scrollLeft =
|
|
12476
|
+
-coordinateRect.left -
|
|
12477
|
+
coordinateBorders.left +
|
|
12478
|
+
coordinateContainer.scrollLeft;
|
|
12479
|
+
scrollTop =
|
|
12480
|
+
-coordinateRect.top -
|
|
12481
|
+
coordinateBorders.top +
|
|
12482
|
+
coordinateContainer.scrollTop;
|
|
12483
|
+
} else {
|
|
12484
|
+
// No container to convert into (a plain real anchor, the common case
|
|
12485
|
+
// for Callout/Picker/Popover's own via-attribute renderer), or the
|
|
12486
|
+
// container is the viewport itself (Popover's via-attribute renderer
|
|
12487
|
+
// when docked, no real anchor) — either way, `element`'s own computed
|
|
12488
|
+
// `position` (fixed vs absolute, detected dynamically) decides whether
|
|
12489
|
+
// any scroll offset applies at all: none for position: fixed (already
|
|
12490
|
+
// viewport-relative — adding scroll would double-count it), the
|
|
12491
|
+
// document's own scroll for position: absolute (relative to the
|
|
12492
|
+
// initial containing block, i.e. document-relative) — including when
|
|
12493
|
+
// docked to the viewport, so the result lands at the visual center of
|
|
12494
|
+
// the viewport at its current scroll position.
|
|
12495
|
+
({ scrollLeft, scrollTop } = getPositioningScrollOffset(element));
|
|
12496
|
+
}
|
|
12497
|
+
// visibleRectEffect recomputes this on every scroll tick, which is what
|
|
12498
|
+
// keeps it looking anchored as the page (or the container) scrolls
|
|
12499
|
+
// either way.
|
|
11921
12500
|
const elementDocumentLeft = snapToPixel(elementPositionLeft + scrollLeft);
|
|
11922
12501
|
const elementDocumentTop = snapToPixel(elementPositionTop + scrollTop);
|
|
11923
12502
|
const anchorDocumentLeft = anchorLeft + scrollLeft;
|
|
@@ -11927,18 +12506,32 @@ const pickPositionRelativeTo = (
|
|
|
11927
12506
|
|
|
11928
12507
|
// For overlap variants the element starts at the anchor edge (not past it),
|
|
11929
12508
|
// so the usable space includes the anchor dimension.
|
|
11930
|
-
//
|
|
12509
|
+
// marginWithAnchor (gap between anchor and element) and marginWithContainer are subtracted
|
|
11931
12510
|
// so callers get the net usable space directly.
|
|
11932
12511
|
const effectiveSpaceAbove =
|
|
11933
|
-
(finalY === "
|
|
11934
|
-
(finalY === "
|
|
11935
|
-
|
|
12512
|
+
(finalY === "inset-bottom" ? spaceAbove + anchorHeight : spaceAbove) -
|
|
12513
|
+
(finalY === "top" ? marginWithAnchor : 0) -
|
|
12514
|
+
marginWithContainer;
|
|
11936
12515
|
const effectiveSpaceBelow =
|
|
11937
|
-
(finalY === "
|
|
11938
|
-
(finalY === "
|
|
11939
|
-
|
|
12516
|
+
(finalY === "inset-top" ? spaceBelow + anchorHeight : spaceBelow) -
|
|
12517
|
+
(finalY === "bottom" ? marginWithAnchor : 0) -
|
|
12518
|
+
marginWithContainer;
|
|
12519
|
+
const effectiveSpaceLeft =
|
|
12520
|
+
(finalX === "inset-right" ? spaceLeft + anchorWidth : spaceLeft) -
|
|
12521
|
+
(finalX === "left" ? marginWithAnchor : 0) -
|
|
12522
|
+
marginWithContainer;
|
|
12523
|
+
const effectiveSpaceRight =
|
|
12524
|
+
(finalX === "inset-left" ? spaceRight + anchorWidth : spaceRight) -
|
|
12525
|
+
(finalX === "right" ? marginWithAnchor : 0) -
|
|
12526
|
+
marginWithContainer;
|
|
11940
12527
|
|
|
11941
12528
|
return {
|
|
12529
|
+
// Whether a real anchor actually ended up used — false when there's no
|
|
12530
|
+
// `anchor`, or it was rejected as too big.
|
|
12531
|
+
hasValidAnchor,
|
|
12532
|
+
// True only when `event` is a "resize" — see applyNewPosition's own
|
|
12533
|
+
// doc for why only resize-triggered repositions are meant to animate.
|
|
12534
|
+
shouldTransition: event?.type === "resize",
|
|
11942
12535
|
positionX: finalX,
|
|
11943
12536
|
positionY: finalY,
|
|
11944
12537
|
left: elementDocumentLeft,
|
|
@@ -11949,13 +12542,72 @@ const pickPositionRelativeTo = (
|
|
|
11949
12542
|
anchorTop: anchorDocumentTop,
|
|
11950
12543
|
anchorRight: anchorDocumentRight,
|
|
11951
12544
|
anchorBottom: anchorDocumentBottom,
|
|
11952
|
-
spaceLeft:
|
|
11953
|
-
spaceRight:
|
|
12545
|
+
spaceLeft: effectiveSpaceLeft,
|
|
12546
|
+
spaceRight: effectiveSpaceRight,
|
|
11954
12547
|
spaceAbove: effectiveSpaceAbove,
|
|
11955
12548
|
spaceBelow: effectiveSpaceBelow,
|
|
11956
12549
|
};
|
|
11957
12550
|
};
|
|
11958
12551
|
|
|
12552
|
+
/**
|
|
12553
|
+
* Applies a `pickPositionRelativeTo` result to `element`. Drives
|
|
12554
|
+
* `--popup-position-transition-duration` (0s unless `shouldTransition`) so
|
|
12555
|
+
* a scroll-triggered reposition stays instant while a resize-triggered one
|
|
12556
|
+
* eases in — set via a CSS var rather than `transitionProperty` directly so
|
|
12557
|
+
* it doesn't clobber Popover/Dialog's own opacity/scale transition on the
|
|
12558
|
+
* same element; consumers declare `transition-duration:
|
|
12559
|
+
* var(--popup-position-transition-duration, 0s)` on `left`/`top` in CSS.
|
|
12560
|
+
*/
|
|
12561
|
+
const applyNewPosition = (
|
|
12562
|
+
element,
|
|
12563
|
+
{
|
|
12564
|
+
left,
|
|
12565
|
+
top,
|
|
12566
|
+
shouldTransition,
|
|
12567
|
+
positionX,
|
|
12568
|
+
positionY,
|
|
12569
|
+
spaceLeft,
|
|
12570
|
+
spaceRight,
|
|
12571
|
+
spaceAbove,
|
|
12572
|
+
spaceBelow,
|
|
12573
|
+
},
|
|
12574
|
+
{ transitionDuration = "0.25s" } = {},
|
|
12575
|
+
) => {
|
|
12576
|
+
element.style.setProperty(
|
|
12577
|
+
"--popup-position-transition-duration",
|
|
12578
|
+
shouldTransition ? transitionDuration : "0s",
|
|
12579
|
+
);
|
|
12580
|
+
element.style.left = `${left}px`;
|
|
12581
|
+
element.style.top = `${top}px`;
|
|
12582
|
+
|
|
12583
|
+
if (positionY === "top" || positionY === "inset-bottom") {
|
|
12584
|
+
element.style.setProperty(
|
|
12585
|
+
"--container-position-remaining-height",
|
|
12586
|
+
`${spaceAbove}px`,
|
|
12587
|
+
);
|
|
12588
|
+
} else if (positionY === "bottom" || positionY === "inset-top") {
|
|
12589
|
+
element.style.setProperty(
|
|
12590
|
+
"--container-position-remaining-height",
|
|
12591
|
+
`${spaceBelow}px`,
|
|
12592
|
+
);
|
|
12593
|
+
} else {
|
|
12594
|
+
element.style.removeProperty("--container-position-remaining-height");
|
|
12595
|
+
}
|
|
12596
|
+
if (positionX === "left" || positionX === "inset-right") {
|
|
12597
|
+
element.style.setProperty(
|
|
12598
|
+
"--container-position-remaining-width",
|
|
12599
|
+
`${spaceLeft}px`,
|
|
12600
|
+
);
|
|
12601
|
+
} else if (positionX === "right" || positionX === "inset-left") {
|
|
12602
|
+
element.style.setProperty(
|
|
12603
|
+
"--container-position-remaining-width",
|
|
12604
|
+
`${spaceRight}px`,
|
|
12605
|
+
);
|
|
12606
|
+
} else {
|
|
12607
|
+
element.style.removeProperty("--container-position-remaining-width");
|
|
12608
|
+
}
|
|
12609
|
+
};
|
|
12610
|
+
|
|
11959
12611
|
const [publishDebugger, subscribeDebugger] = createPubSub();
|
|
11960
12612
|
|
|
11961
12613
|
const notifyDebuggerStart = () => {
|
|
@@ -15206,4 +15858,4 @@ const useResizeStatus = (elementRef, { as = "number" } = {}) => {
|
|
|
15206
15858
|
};
|
|
15207
15859
|
};
|
|
15208
15860
|
|
|
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 };
|
|
15861
|
+
export { EASING, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles, applyNewPosition, captureScrollState, chainEvent, contrastColor, createBackgroundColorTransition, createBackgroundTransition, createBorderRadiusTransition, createBorderTransition, createDragGestureController, createDragToMoveGestureController, createEventGroupLogger, createGroupTransitionController, createHeightTransition, createIterableWeakSet, createOpacityTransition, createPubSub, createStyleController, createTimelineTransition, createTransition, createTranslateXTransition, createValueEffect, createWidthTransition, cubicBezier, dispatchCustomEvent, dispatchInternalCustomEvent, dispatchPublicCustomEvent, dragAfterThreshold, elementIsFocusable, elementIsVisibleForFocus, elementIsVisuallyVisible, findAfter, findAncestor, findBefore, findDescendant, findEvent, findFocusDelegateTarget, findFocusable, formatEventSideEffect, getAvailableHeight, getAvailableWidth, getBackground, getBackgroundColor, getBorder, getBorderRadius, getBorderSizes, getContrastRatio, getDefaultStyles, getDragCoordinates, getDropTargetInfo, getElementSignature, getFirstVisuallyVisibleAncestor, getFocusVisibilityInfo, getHeight, getHeightWithoutTransition, getInnerHeight, getInnerWidth, getKeyboardEventDefaultAction, getLuminance, getMarginSizes, getMaxHeight, getMaxWidth, getMinHeight, getMinWidth, getOpacity, getOpacityWithoutTransition, getPaddingSizes, getPositionedParent, getPositioningContainer, getPositioningScrollOffset, getPreferedColorScheme, getScrollBox, getScrollContainer, getScrollContainerSet, getScrollRelativeRect, getSelfAndAncestorScrolls, getStyle, getTranslateX, getTranslateXWithoutTransition, getTranslateY, getVisuallyVisibleInfo, getWidth, getWidthWithoutTransition, hasCSSSizeUnit, initFlexDetailsSet, initFocusGroup, initPositionSticky, isSameColor, isScrollable, measureLongestVisualLineWidth, measureScrollbar, measureWidestChildRow, mergeOneStyle, mergeTwoStyles, normalizeKeyboardKey, normalizeStyle, normalizeStyles, parsePositionArea, parseStyle, performTabNavigation, pickPositionRelativeTo, prefersDarkColors, prefersLightColors, preventFocusNav, preventFocusNavViaKeyboard, preventIntermediateScrollbar, resolveCSSColor, resolveCSSSize, resolveColorLuminance, resolveOklchLightness, scrollIntoViewScoped, scrollIntoViewWithStickyAwareness, setAttribute, setAttributes, setStyles, snapToPixel, startDragToReorder, startDragToResizeGesture, stickyAsRelativeCoords, stringifyStyle, subscribeVisualViewportResizeSettled, subscribeWindowResizeSettled, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect };
|