@jsenv/dom 0.9.4 → 0.10.1
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 +593 -191
- package/package.json +8 -8
package/dist/jsenv_dom.js
CHANGED
|
@@ -117,13 +117,16 @@ const getElementSignature = (element) => {
|
|
|
117
117
|
return "<html>";
|
|
118
118
|
}
|
|
119
119
|
const elementId = element.id;
|
|
120
|
-
|
|
120
|
+
const className = element.className;
|
|
121
|
+
if (elementId && !looksLikeGeneratedId(elementId)) {
|
|
121
122
|
return `${tagName}#${elementId}`;
|
|
122
123
|
}
|
|
123
|
-
const className = element.className;
|
|
124
124
|
if (className) {
|
|
125
125
|
return `${tagName}.${className.split(" ").join(".")}`;
|
|
126
126
|
}
|
|
127
|
+
if (elementId) {
|
|
128
|
+
return `${tagName}#${elementId}`;
|
|
129
|
+
}
|
|
127
130
|
|
|
128
131
|
const parentSignature = getElementSignature(element.parentElement);
|
|
129
132
|
return `${parentSignature} > ${tagName}`;
|
|
@@ -131,6 +134,13 @@ const getElementSignature = (element) => {
|
|
|
131
134
|
return String(element);
|
|
132
135
|
};
|
|
133
136
|
|
|
137
|
+
// Generated ids from frameworks (Preact useId, React useId, etc.) look like
|
|
138
|
+
// "P0-0", ":r0:", "P1-3" — short alphanumeric tokens with dashes or colons.
|
|
139
|
+
// If an id matches this pattern we prefer className over it.
|
|
140
|
+
const looksLikeGeneratedId = (id) => {
|
|
141
|
+
return /^[A-Z][0-9]+-[0-9]+$|^:[a-z][0-9]*:$/.test(id);
|
|
142
|
+
};
|
|
143
|
+
|
|
134
144
|
const createIterableWeakSet = () => {
|
|
135
145
|
const objectWeakRefSet = new Set();
|
|
136
146
|
|
|
@@ -309,6 +319,7 @@ const elementIsWindow = (a) => a.window === a;
|
|
|
309
319
|
const elementIsDocument = (a) => a.nodeType === 9;
|
|
310
320
|
const elementIsDetails = ({ nodeName }) => nodeName === "DETAILS";
|
|
311
321
|
const elementIsSummary = ({ nodeName }) => nodeName === "SUMMARY";
|
|
322
|
+
const elementIsDialog = ({ nodeName }) => nodeName === "DIALOG";
|
|
312
323
|
|
|
313
324
|
// should be used ONLY when an element is related to other elements that are not descendants of this element
|
|
314
325
|
const getAssociatedElements = (element) => {
|
|
@@ -3849,6 +3860,16 @@ const getFocusVisibilityInfo = (node) => {
|
|
|
3849
3860
|
}
|
|
3850
3861
|
// Continue checking ancestors
|
|
3851
3862
|
}
|
|
3863
|
+
if (elementIsDialog(nodeOrAncestor) && !nodeOrAncestor.open) {
|
|
3864
|
+
return { visible: false, reason: "inside closed dialog element" };
|
|
3865
|
+
}
|
|
3866
|
+
if (
|
|
3867
|
+
nodeOrAncestor.popover !== null &&
|
|
3868
|
+
nodeOrAncestor.popover !== undefined &&
|
|
3869
|
+
!nodeOrAncestor.matches(":popover-open")
|
|
3870
|
+
) {
|
|
3871
|
+
return { visible: false, reason: "inside closed popover element" };
|
|
3872
|
+
}
|
|
3852
3873
|
nodeOrAncestor = nodeOrAncestor.parentNode;
|
|
3853
3874
|
}
|
|
3854
3875
|
return { visible: true, reason: "no reason to be hidden" };
|
|
@@ -3995,16 +4016,38 @@ const findFocusable = (element) => {
|
|
|
3995
4016
|
return focusableDescendant;
|
|
3996
4017
|
};
|
|
3997
4018
|
|
|
4019
|
+
// Input types where ArrowUp/Down natively change the value — don't intercept them
|
|
4020
|
+
const INPUT_TYPES_WITH_ARROW_MEANING = new Set([
|
|
4021
|
+
"number",
|
|
4022
|
+
"range",
|
|
4023
|
+
"date",
|
|
4024
|
+
"time",
|
|
4025
|
+
"datetime-local",
|
|
4026
|
+
"month",
|
|
4027
|
+
"week",
|
|
4028
|
+
]);
|
|
4029
|
+
const INPUT_ALLOWED_KEYS = new Set(["Home", "End", "Escape", "Enter"]);
|
|
4030
|
+
const INPUT_ARROW_KEYS = new Set(["ArrowDown", "ArrowUp"]);
|
|
4031
|
+
const TEXTAREA_ALLOWED_KEYS = new Set(["Escape"]);
|
|
4032
|
+
|
|
3998
4033
|
const canInterceptKeys = (event) => {
|
|
3999
4034
|
const target = event.target;
|
|
4000
|
-
//
|
|
4035
|
+
// Allow specific keys on input/textarea/contenteditable elements
|
|
4036
|
+
if (target.tagName === "INPUT") {
|
|
4037
|
+
if (INPUT_ALLOWED_KEYS.has(event.key)) {
|
|
4038
|
+
return true;
|
|
4039
|
+
}
|
|
4040
|
+
if (INPUT_ARROW_KEYS.has(event.key)) {
|
|
4041
|
+
return !INPUT_TYPES_WITH_ARROW_MEANING.has(target.type);
|
|
4042
|
+
}
|
|
4043
|
+
return false;
|
|
4044
|
+
}
|
|
4001
4045
|
if (
|
|
4002
|
-
target.tagName === "INPUT" ||
|
|
4003
4046
|
target.tagName === "TEXTAREA" ||
|
|
4004
4047
|
target.contentEditable === "true" ||
|
|
4005
4048
|
target.isContentEditable
|
|
4006
4049
|
) {
|
|
4007
|
-
return
|
|
4050
|
+
return TEXTAREA_ALLOWED_KEYS.has(event.key);
|
|
4008
4051
|
}
|
|
4009
4052
|
// Don't handle shortcuts when select dropdown is open
|
|
4010
4053
|
if (target.tagName === "SELECT") {
|
|
@@ -4639,7 +4682,11 @@ const getNextTablePosition = (
|
|
|
4639
4682
|
|
|
4640
4683
|
const performTabNavigation = (
|
|
4641
4684
|
event,
|
|
4642
|
-
{
|
|
4685
|
+
{
|
|
4686
|
+
rootElement = document.body,
|
|
4687
|
+
outsideOfElement = null,
|
|
4688
|
+
debug = () => {},
|
|
4689
|
+
} = {},
|
|
4643
4690
|
) => {
|
|
4644
4691
|
if (!isTabEvent$1(event)) {
|
|
4645
4692
|
return false;
|
|
@@ -4651,29 +4698,20 @@ const performTabNavigation = (
|
|
|
4651
4698
|
}
|
|
4652
4699
|
const isForward = !event.shiftKey;
|
|
4653
4700
|
const onTargetToFocus = (targetToFocus) => {
|
|
4654
|
-
|
|
4701
|
+
debug(
|
|
4655
4702
|
`Tab navigation: ${isForward ? "forward" : "backward"} from`,
|
|
4656
|
-
activeElement,
|
|
4703
|
+
getElementSignature(activeElement),
|
|
4657
4704
|
"to",
|
|
4658
|
-
targetToFocus,
|
|
4705
|
+
getElementSignature(targetToFocus),
|
|
4659
4706
|
);
|
|
4660
4707
|
event.preventDefault();
|
|
4661
4708
|
markFocusNav(event);
|
|
4662
4709
|
targetToFocus.focus();
|
|
4663
4710
|
};
|
|
4664
4711
|
|
|
4665
|
-
{
|
|
4666
|
-
console.debug(
|
|
4667
|
-
`Tab navigation: ${isForward ? "forward" : "backward"} from,`,
|
|
4668
|
-
activeElement,
|
|
4669
|
-
);
|
|
4670
|
-
}
|
|
4671
|
-
|
|
4672
4712
|
const predicate = (candidate) => {
|
|
4673
4713
|
const canBeFocusedByTab = isFocusableByTab(candidate);
|
|
4674
|
-
{
|
|
4675
|
-
console.debug(`Testing`, candidate, `${canBeFocusedByTab ? "✓" : "✗"}`);
|
|
4676
|
-
}
|
|
4714
|
+
// debug(`Testing`, candidate, `${canBeFocusedByTab ? "✓" : "✗"}`);
|
|
4677
4715
|
return canBeFocusedByTab;
|
|
4678
4716
|
};
|
|
4679
4717
|
|
|
@@ -4698,7 +4736,8 @@ const performTabNavigation = (
|
|
|
4698
4736
|
if (nextFocusableElement) {
|
|
4699
4737
|
return onTargetToFocus(nextFocusableElement);
|
|
4700
4738
|
}
|
|
4701
|
-
|
|
4739
|
+
// Wrap around: go back to the first focusable element in root.
|
|
4740
|
+
const firstFocusableElement = findDescendant(rootElement, predicate, {
|
|
4702
4741
|
skipRoot: outsideOfElement,
|
|
4703
4742
|
});
|
|
4704
4743
|
if (firstFocusableElement) {
|
|
@@ -4729,7 +4768,8 @@ const performTabNavigation = (
|
|
|
4729
4768
|
if (previousFocusableElement) {
|
|
4730
4769
|
return onTargetToFocus(previousFocusableElement);
|
|
4731
4770
|
}
|
|
4732
|
-
|
|
4771
|
+
// Wrap around: go back to the last focusable element in root.
|
|
4772
|
+
const lastFocusableElement = findLastDescendant(rootElement, predicate, {
|
|
4733
4773
|
skipRoot: outsideOfElement,
|
|
4734
4774
|
});
|
|
4735
4775
|
if (lastFocusableElement) {
|
|
@@ -4854,7 +4894,35 @@ const preventFocusNavViaKeyboard = (keyboardEvent) => {
|
|
|
4854
4894
|
return false;
|
|
4855
4895
|
};
|
|
4856
4896
|
|
|
4857
|
-
|
|
4897
|
+
/**
|
|
4898
|
+
* Traps keyboard focus and mouse clicks inside `element`.
|
|
4899
|
+
*
|
|
4900
|
+
* Once active:
|
|
4901
|
+
* - **Tab / Shift+Tab** cycle through focusable descendants of `element`,
|
|
4902
|
+
* wrapping from last → first and first → last. If no focusable element
|
|
4903
|
+
* exists, the default browser Tab action is suppressed so focus cannot
|
|
4904
|
+
* escape.
|
|
4905
|
+
* - **Mouse clicks** outside `element` are only blocked when `pointerTrap`
|
|
4906
|
+
* is `true`. Backdrop clicks (on `<dialog>` elements) still propagate even
|
|
4907
|
+
* then, so the dialog can close itself.
|
|
4908
|
+
*
|
|
4909
|
+
* Multiple traps can be stacked. When a new trap is activated the previous
|
|
4910
|
+
* one is paused; when the new trap is released the previous one resumes.
|
|
4911
|
+
* Traps must be released in LIFO order (the reverse of activation order).
|
|
4912
|
+
*
|
|
4913
|
+
* @param {HTMLElement} element - The root element to trap focus inside.
|
|
4914
|
+
* @param {object} [options]
|
|
4915
|
+
* @param {boolean} [options.pointerTrap=false] - When true, mouse clicks outside `element`
|
|
4916
|
+
* are cancelled so the user cannot move focus away by clicking the backdrop.
|
|
4917
|
+
* Backdrop clicks (target is a `<dialog>` element) only receive `preventDefault`
|
|
4918
|
+
* and still propagate, allowing the dialog to react to them (e.g. close itself).
|
|
4919
|
+
* @param {Function} [options.debug] - Optional debug logger passed to tab navigation.
|
|
4920
|
+
* @returns {() => void} Cleanup function — call it to release the trap.
|
|
4921
|
+
*/
|
|
4922
|
+
const trapFocusInside = (
|
|
4923
|
+
element,
|
|
4924
|
+
{ debug, pointerTrap = false } = {},
|
|
4925
|
+
) => {
|
|
4858
4926
|
if (element.nodeType === 3) {
|
|
4859
4927
|
console.warn("cannot trap focus inside a text node");
|
|
4860
4928
|
return () => {};
|
|
@@ -4869,39 +4937,67 @@ const trapFocusInside = (element) => {
|
|
|
4869
4937
|
}
|
|
4870
4938
|
|
|
4871
4939
|
const isEventOutside = (event) => {
|
|
4872
|
-
if (event.target === element)
|
|
4873
|
-
|
|
4940
|
+
if (event.target === element) {
|
|
4941
|
+
return false;
|
|
4942
|
+
}
|
|
4943
|
+
if (element.contains(event.target)) {
|
|
4944
|
+
return false;
|
|
4945
|
+
}
|
|
4874
4946
|
return true;
|
|
4875
4947
|
};
|
|
4876
4948
|
|
|
4877
4949
|
const lock = () => {
|
|
4878
|
-
const onmousedown =
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4950
|
+
const onmousedown = pointerTrap
|
|
4951
|
+
? (event) => {
|
|
4952
|
+
if (!isEventOutside(event)) {
|
|
4953
|
+
return;
|
|
4954
|
+
}
|
|
4955
|
+
event.preventDefault();
|
|
4956
|
+
// Backdrop clicks (e.g. clicking a <dialog>'s ::backdrop) must still
|
|
4957
|
+
// propagate so the dialog/popover can react to them (e.g. close itself).
|
|
4958
|
+
// A backdrop click is detected when the target is a <dialog> element —
|
|
4959
|
+
// the ::backdrop pseudo-element is not in the DOM, so the event target
|
|
4960
|
+
// becomes the dialog element itself when its content area is not hit.
|
|
4961
|
+
const isBackdropClick =
|
|
4962
|
+
event.target.tagName === "DIALOG" ||
|
|
4963
|
+
event.target.className.includes("backdrop");
|
|
4964
|
+
if (!isBackdropClick) {
|
|
4965
|
+
event.stopImmediatePropagation();
|
|
4966
|
+
}
|
|
4967
|
+
}
|
|
4968
|
+
: null;
|
|
4884
4969
|
|
|
4885
4970
|
const onkeydown = (event) => {
|
|
4886
4971
|
if (isTabEvent(event)) {
|
|
4887
|
-
performTabNavigation(event, {
|
|
4972
|
+
const handled = performTabNavigation(event, {
|
|
4973
|
+
rootElement: element,
|
|
4974
|
+
debug,
|
|
4975
|
+
});
|
|
4976
|
+
if (!handled) {
|
|
4977
|
+
// No focusable target found — prevent the browser from moving focus outside the trap.
|
|
4978
|
+
event.preventDefault();
|
|
4979
|
+
}
|
|
4888
4980
|
}
|
|
4889
4981
|
};
|
|
4890
4982
|
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4983
|
+
if (onmousedown) {
|
|
4984
|
+
document.addEventListener("mousedown", onmousedown, {
|
|
4985
|
+
capture: true,
|
|
4986
|
+
passive: false,
|
|
4987
|
+
});
|
|
4988
|
+
}
|
|
4895
4989
|
document.addEventListener("keydown", onkeydown, {
|
|
4896
4990
|
capture: true,
|
|
4897
4991
|
passive: false,
|
|
4898
4992
|
});
|
|
4899
4993
|
|
|
4900
4994
|
return () => {
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4995
|
+
if (onmousedown) {
|
|
4996
|
+
document.removeEventListener("mousedown", onmousedown, {
|
|
4997
|
+
capture: true,
|
|
4998
|
+
passive: false,
|
|
4999
|
+
});
|
|
5000
|
+
}
|
|
4905
5001
|
document.removeEventListener("keydown", onkeydown, {
|
|
4906
5002
|
capture: true,
|
|
4907
5003
|
passive: false,
|
|
@@ -5763,11 +5859,228 @@ const getScrollbarState = (
|
|
|
5763
5859
|
return { x, y, availableWidth, availableHeight };
|
|
5764
5860
|
};
|
|
5765
5861
|
|
|
5862
|
+
/**
|
|
5863
|
+
* Scrolls el into view within a specific container only — does NOT scroll
|
|
5864
|
+
* any ancestor beyond that container (document, popover backdrop, etc.).
|
|
5865
|
+
*
|
|
5866
|
+
* Why not just use scrollIntoView({ container: "nearest" })?
|
|
5867
|
+
* It finds the nearest scrollable ancestor and stops there ONLY IF that
|
|
5868
|
+
* ancestor has visible scrollbar, otherwise browser walks further up,
|
|
5869
|
+
* potentially scrolling the document.
|
|
5870
|
+
* This is exactly the wrong behavior inside a popover or fixed panel.
|
|
5871
|
+
* scrollIntoViewScoped avoids this by targeting one container explicitly.
|
|
5872
|
+
*
|
|
5873
|
+
* Uses scrollTo() so CSS scroll-behavior:smooth on the container is respected.
|
|
5874
|
+
* Respects scroll-margin-* on the element.
|
|
5875
|
+
*
|
|
5876
|
+
* @param {Element} el - The element to scroll into view.
|
|
5877
|
+
* @param {object} options
|
|
5878
|
+
* @param {Element} [options.container] - The scroll container to scroll. Defaults to getScrollContainer(el).
|
|
5879
|
+
* @param {"start"|"center"|"end"|"nearest"} [options.block="nearest"] - Vertical alignment.
|
|
5880
|
+
* @param {"start"|"center"|"end"|"nearest"} [options.inline="nearest"] - Horizontal alignment.
|
|
5881
|
+
*/
|
|
5882
|
+
const scrollIntoViewScoped = (
|
|
5883
|
+
el,
|
|
5884
|
+
{
|
|
5885
|
+
container = getScrollContainer(el),
|
|
5886
|
+
block = "nearest",
|
|
5887
|
+
inline = "nearest",
|
|
5888
|
+
} = {},
|
|
5889
|
+
) => {
|
|
5890
|
+
if (!container) {
|
|
5891
|
+
return;
|
|
5892
|
+
}
|
|
5893
|
+
|
|
5894
|
+
const containerRect = container.getBoundingClientRect();
|
|
5895
|
+
const elRect = el.getBoundingClientRect();
|
|
5896
|
+
const style = getComputedStyle(el);
|
|
5897
|
+
|
|
5898
|
+
const scrollMarginTop = parseFloat(style.scrollMarginTop) || 0;
|
|
5899
|
+
const scrollMarginBottom = parseFloat(style.scrollMarginBottom) || 0;
|
|
5900
|
+
const scrollMarginLeft = parseFloat(style.scrollMarginLeft) || 0;
|
|
5901
|
+
const scrollMarginRight = parseFloat(style.scrollMarginRight) || 0;
|
|
5902
|
+
|
|
5903
|
+
const currentScrollTop = container.scrollTop;
|
|
5904
|
+
const currentScrollLeft = container.scrollLeft;
|
|
5905
|
+
const containerHeight = containerRect.height;
|
|
5906
|
+
const containerWidth = containerRect.width;
|
|
5907
|
+
|
|
5908
|
+
// Element position relative to the container's scroll origin.
|
|
5909
|
+
const elTop =
|
|
5910
|
+
elRect.top - containerRect.top + currentScrollTop - scrollMarginTop;
|
|
5911
|
+
const elBottom = elTop + elRect.height + scrollMarginTop + scrollMarginBottom;
|
|
5912
|
+
const elLeft =
|
|
5913
|
+
elRect.left - containerRect.left + currentScrollLeft - scrollMarginLeft;
|
|
5914
|
+
const elRight = elLeft + elRect.width + scrollMarginLeft + scrollMarginRight;
|
|
5915
|
+
|
|
5916
|
+
let newScrollTop = currentScrollTop;
|
|
5917
|
+
if (block === "start") {
|
|
5918
|
+
newScrollTop = elTop;
|
|
5919
|
+
} else if (block === "end") {
|
|
5920
|
+
newScrollTop = elBottom - containerHeight;
|
|
5921
|
+
} else if (block === "center") {
|
|
5922
|
+
newScrollTop = elTop + (elRect.height - containerHeight) / 2;
|
|
5923
|
+
} else {
|
|
5924
|
+
// nearest: scroll only if partially or fully out of view.
|
|
5925
|
+
// When the element is taller than the container, only scroll if it is
|
|
5926
|
+
// completely out of view — otherwise it is already as visible as possible.
|
|
5927
|
+
const scrollBottom = currentScrollTop + containerHeight;
|
|
5928
|
+
const elHeight = elBottom - elTop;
|
|
5929
|
+
if (elHeight <= containerHeight) {
|
|
5930
|
+
if (elTop < currentScrollTop) {
|
|
5931
|
+
newScrollTop = elTop;
|
|
5932
|
+
} else if (elBottom > scrollBottom) {
|
|
5933
|
+
newScrollTop = elBottom - containerHeight;
|
|
5934
|
+
}
|
|
5935
|
+
} else if (elBottom < currentScrollTop) {
|
|
5936
|
+
newScrollTop = elBottom - containerHeight;
|
|
5937
|
+
} else if (elTop > scrollBottom) {
|
|
5938
|
+
newScrollTop = elTop;
|
|
5939
|
+
}
|
|
5940
|
+
}
|
|
5941
|
+
|
|
5942
|
+
let newScrollLeft = currentScrollLeft;
|
|
5943
|
+
if (inline === "start") {
|
|
5944
|
+
newScrollLeft = elLeft;
|
|
5945
|
+
} else if (inline === "end") {
|
|
5946
|
+
newScrollLeft = elRight - containerWidth;
|
|
5947
|
+
} else if (inline === "center") {
|
|
5948
|
+
newScrollLeft = elLeft + (elRect.width - containerWidth) / 2;
|
|
5949
|
+
} else {
|
|
5950
|
+
// nearest: scroll only if partially or fully out of view.
|
|
5951
|
+
// When the element is wider than the container, only scroll if it is
|
|
5952
|
+
// completely out of view — otherwise it is already as visible as possible.
|
|
5953
|
+
const scrollRight = currentScrollLeft + containerWidth;
|
|
5954
|
+
const elWidth = elRight - elLeft;
|
|
5955
|
+
if (elWidth <= containerWidth) {
|
|
5956
|
+
if (elLeft < currentScrollLeft) {
|
|
5957
|
+
newScrollLeft = elLeft;
|
|
5958
|
+
} else if (elRight > scrollRight) {
|
|
5959
|
+
newScrollLeft = elRight - containerWidth;
|
|
5960
|
+
}
|
|
5961
|
+
} else if (elRight < currentScrollLeft) {
|
|
5962
|
+
newScrollLeft = elRight - containerWidth;
|
|
5963
|
+
} else if (elLeft > scrollRight) {
|
|
5964
|
+
newScrollLeft = elLeft;
|
|
5965
|
+
}
|
|
5966
|
+
}
|
|
5967
|
+
|
|
5968
|
+
container.scrollTo({
|
|
5969
|
+
left: newScrollLeft,
|
|
5970
|
+
top: newScrollTop,
|
|
5971
|
+
});
|
|
5972
|
+
};
|
|
5973
|
+
|
|
5974
|
+
/**
|
|
5975
|
+
* DON'T USE THIS, use scroll-padding-top/bottom in CSS instead
|
|
5976
|
+
* better in every aspect
|
|
5977
|
+
*/
|
|
5978
|
+
|
|
5979
|
+
|
|
5980
|
+
/**
|
|
5981
|
+
* Scrolls el into view (using the native "nearest" block behavior) and then
|
|
5982
|
+
* corrects for any sticky element that visually covers el inside its scroll
|
|
5983
|
+
* container.
|
|
5984
|
+
*
|
|
5985
|
+
* After the native scroll, this function iterates the siblings of el (children
|
|
5986
|
+
* of el's parent) and checks whether any of them uses `position: sticky` and
|
|
5987
|
+
* overlaps el. The largest overlap on each side is used to nudge scrollTop:
|
|
5988
|
+
* - sticky-top (top !== auto): subtract overlap so el appears below the header
|
|
5989
|
+
* - sticky-bottom (bottom !== auto): add overlap so el appears above the footer
|
|
5990
|
+
*
|
|
5991
|
+
* If el happens to be covered on both sides at once (extremely unlikely) the
|
|
5992
|
+
* correction picks whichever side was covered — the result may not be perfect
|
|
5993
|
+
* but avoids an infinite correction loop.
|
|
5994
|
+
*
|
|
5995
|
+
* @param {Element} el - The element to scroll into view.
|
|
5996
|
+
*/
|
|
5997
|
+
const scrollIntoViewWithStickyAwareness = (
|
|
5998
|
+
el,
|
|
5999
|
+
{ behavior, block = "nearest", inline, container } = {},
|
|
6000
|
+
) => {
|
|
6001
|
+
el.scrollIntoView({ behavior, block, inline, container });
|
|
6002
|
+
const scrollContainer = getScrollContainer(el);
|
|
6003
|
+
if (!scrollContainer) {
|
|
6004
|
+
return;
|
|
6005
|
+
}
|
|
6006
|
+
const elRect = el.getBoundingClientRect();
|
|
6007
|
+
let topCover = 0;
|
|
6008
|
+
let bottomCover = 0;
|
|
6009
|
+
for (const sibling of el.parentNode.children) {
|
|
6010
|
+
const style = getComputedStyle(sibling);
|
|
6011
|
+
if (style.position !== "sticky") {
|
|
6012
|
+
continue;
|
|
6013
|
+
}
|
|
6014
|
+
const rect = sibling.getBoundingClientRect();
|
|
6015
|
+
if (style.top !== "auto") {
|
|
6016
|
+
// Sticky-top: covers el from above — track the largest overlap.
|
|
6017
|
+
const overlap = rect.bottom - elRect.top;
|
|
6018
|
+
if (overlap > topCover) {
|
|
6019
|
+
topCover = overlap;
|
|
6020
|
+
}
|
|
6021
|
+
} else if (style.bottom !== "auto") {
|
|
6022
|
+
// Sticky-bottom: covers el from below — track the largest overlap.
|
|
6023
|
+
// Only checked when top is "auto" so each element is attributed to one
|
|
6024
|
+
// side only; both sides are still accumulated across all children.
|
|
6025
|
+
const overlap = elRect.bottom - rect.top;
|
|
6026
|
+
if (overlap > bottomCover) {
|
|
6027
|
+
bottomCover = overlap;
|
|
6028
|
+
}
|
|
6029
|
+
}
|
|
6030
|
+
if (topCover > 0 && bottomCover > 0) {
|
|
6031
|
+
// Both sides already have coverage — no point checking further children.
|
|
6032
|
+
break;
|
|
6033
|
+
}
|
|
6034
|
+
}
|
|
6035
|
+
if (topCover > 0) {
|
|
6036
|
+
// For block="center" the element is visually centered in the full viewport.
|
|
6037
|
+
// A sticky header of height H shifts the available center upward by H/2,
|
|
6038
|
+
// so we only need to correct by half the overlap to keep the element
|
|
6039
|
+
// centered in the visible (uncovered) area.
|
|
6040
|
+
scrollContainer.scrollTop -= block === "center" ? topCover / 2 : topCover;
|
|
6041
|
+
}
|
|
6042
|
+
if (bottomCover > 0) {
|
|
6043
|
+
scrollContainer.scrollTop +=
|
|
6044
|
+
block === "center" ? bottomCover / 2 : bottomCover;
|
|
6045
|
+
}
|
|
6046
|
+
};
|
|
6047
|
+
|
|
6048
|
+
/**
|
|
6049
|
+
* Prevents scrolling on all scrollable containers that are ancestors of (or
|
|
6050
|
+
* siblings preceding) `element`. Used when an overlay (popover, dialog) is
|
|
6051
|
+
* open and background scroll should be disabled.
|
|
6052
|
+
*
|
|
6053
|
+
* **Why padding instead of scrollbar-gutter?**
|
|
6054
|
+
* `scrollbar-gutter: stable` would be the modern, CSS-native way to reserve
|
|
6055
|
+
* the scrollbar lane before hiding overflow so the layout doesn't shift.
|
|
6056
|
+
* However it only works well when the element's design already accounts for
|
|
6057
|
+
* that reserved space. On arbitrary containers we can't assume that, so we
|
|
6058
|
+
* measure the actual scrollbar size and compensate with padding — a technique
|
|
6059
|
+
* that works regardless of how the element is styled.
|
|
6060
|
+
*
|
|
6061
|
+
* **What if the element already uses scrollbar-gutter?**
|
|
6062
|
+
* A non-"auto" `scrollbar-gutter` value signals that the element has its own
|
|
6063
|
+
* scrollbar-gutter strategy in place. In that case we skip the padding
|
|
6064
|
+
* compensation and rely on that strategy instead — adding padding on top of an
|
|
6065
|
+
* already-reserved gutter would double-count the space.
|
|
6066
|
+
*
|
|
6067
|
+
* @param {HTMLElement} element - The overlay element being shown. Its preceding
|
|
6068
|
+
* siblings and all ancestor scroll containers will be scroll-locked.
|
|
6069
|
+
* @returns {() => void} Cleanup function that restores all modified styles.
|
|
6070
|
+
*/
|
|
5766
6071
|
const trapScrollInside = (element) => {
|
|
5767
6072
|
const cleanupCallbackSet = new Set();
|
|
5768
6073
|
const lockScroll = (el) => {
|
|
6074
|
+
const scrollbarGutter = getStyle(el, "scrollbar-gutter");
|
|
6075
|
+
const hasScrollbarGutterStrategy =
|
|
6076
|
+
scrollbarGutter && scrollbarGutter !== "auto";
|
|
6077
|
+
if (hasScrollbarGutterStrategy) {
|
|
6078
|
+
// The element manages its own gutter — just hide overflow, no padding needed.
|
|
6079
|
+
const removeScrollLockStyles = setStyles(el, { overflow: "hidden" });
|
|
6080
|
+
cleanupCallbackSet.add(removeScrollLockStyles);
|
|
6081
|
+
return;
|
|
6082
|
+
}
|
|
5769
6083
|
const [scrollbarWidth, scrollbarHeight] = measureScrollbar(el);
|
|
5770
|
-
// scrollbar-gutter would work but would display an empty blank space
|
|
5771
6084
|
const paddingRight = parseInt(getStyle(el, "padding-right"), 0);
|
|
5772
6085
|
const paddingTop = parseInt(getStyle(el, "padding-top"), 0);
|
|
5773
6086
|
const removeScrollLockStyles = setStyles(el, {
|
|
@@ -5775,9 +6088,7 @@ const trapScrollInside = (element) => {
|
|
|
5775
6088
|
"padding-top": `${paddingTop + scrollbarHeight}px`,
|
|
5776
6089
|
"overflow": "hidden",
|
|
5777
6090
|
});
|
|
5778
|
-
cleanupCallbackSet.add(
|
|
5779
|
-
removeScrollLockStyles();
|
|
5780
|
-
});
|
|
6091
|
+
cleanupCallbackSet.add(removeScrollLockStyles);
|
|
5781
6092
|
};
|
|
5782
6093
|
let previous = element.previousSibling;
|
|
5783
6094
|
while (previous) {
|
|
@@ -9215,24 +9526,27 @@ const stickyAsRelativeCoords = (
|
|
|
9215
9526
|
return [leftPosition, topPosition];
|
|
9216
9527
|
};
|
|
9217
9528
|
|
|
9218
|
-
|
|
9219
|
-
|
|
9220
|
-
|
|
9221
|
-
|
|
9222
|
-
|
|
9223
|
-
|
|
9224
|
-
|
|
9225
|
-
|
|
9226
|
-
|
|
9227
|
-
|
|
9228
|
-
|
|
9229
|
-
|
|
9529
|
+
/**
|
|
9530
|
+
* Tracks how much of an element is visible within its scrollable parent and within the
|
|
9531
|
+
* document viewport. Calls update() on initialization and whenever visibility changes
|
|
9532
|
+
* (scroll, resize, intersection changes).
|
|
9533
|
+
*
|
|
9534
|
+
* The update callback receives a visibleRect object with:
|
|
9535
|
+
* - left, top, right, bottom, width, height: the visible portion of the element,
|
|
9536
|
+
* clipped to its scroll container's bounds and expressed in overlay coordinates
|
|
9537
|
+
* - visibilityRatio: fraction of the element's area that is truly visible on screen (0–1).
|
|
9538
|
+
* For document scroll containers this is the viewport-clipped fraction.
|
|
9539
|
+
* For custom containers this is the fraction clipped by both the container AND the viewport
|
|
9540
|
+
* (so an element scrolled out of its container correctly reports 0, not 1).
|
|
9541
|
+
*
|
|
9542
|
+
* A bit like https://tetherjs.dev/ but different
|
|
9543
|
+
*/
|
|
9230
9544
|
const visibleRectEffect = (element, update) => {
|
|
9231
9545
|
const [teardown, addTeardown] = createPubSub();
|
|
9232
9546
|
const scrollContainer = getScrollContainer(element);
|
|
9233
9547
|
const scrollContainerIsDocument =
|
|
9234
9548
|
scrollContainer === document.documentElement;
|
|
9235
|
-
const check = (
|
|
9549
|
+
const check = (event) => {
|
|
9236
9550
|
|
|
9237
9551
|
// 1. Calculate element position relative to scrollable parent
|
|
9238
9552
|
const { scrollLeft, scrollTop } = scrollContainer;
|
|
@@ -9324,27 +9638,35 @@ const visibleRectEffect = (element, update) => {
|
|
|
9324
9638
|
}
|
|
9325
9639
|
}
|
|
9326
9640
|
|
|
9327
|
-
// Calculate
|
|
9328
|
-
|
|
9329
|
-
|
|
9330
|
-
//
|
|
9331
|
-
let
|
|
9641
|
+
// Calculate visibilityRatio: fraction of element area truly visible on screen.
|
|
9642
|
+
// For custom containers we intersect the container-clipped visible size (widthVisible x
|
|
9643
|
+
// heightVisible) with the viewport bounds, so an element scrolled out of its container
|
|
9644
|
+
// correctly reports 0 rather than the raw viewport intersection of its bounding rect.
|
|
9645
|
+
let visibilityRatio;
|
|
9332
9646
|
if (scrollContainerIsDocument) {
|
|
9333
|
-
|
|
9647
|
+
visibilityRatio = (widthVisible * heightVisible) / (width * height);
|
|
9334
9648
|
} else {
|
|
9335
|
-
//
|
|
9336
|
-
|
|
9649
|
+
// widthVisible/heightVisible are already clipped to the scroll container.
|
|
9650
|
+
// Now clip their viewport-relative counterparts against the viewport.
|
|
9337
9651
|
const viewportWidth = window.innerWidth;
|
|
9338
9652
|
const viewportHeight = window.innerHeight;
|
|
9339
|
-
//
|
|
9340
|
-
const
|
|
9341
|
-
const
|
|
9342
|
-
const
|
|
9343
|
-
const
|
|
9344
|
-
|
|
9345
|
-
const
|
|
9346
|
-
|
|
9347
|
-
|
|
9653
|
+
// Container-clipped visible rect in viewport coordinates
|
|
9654
|
+
const visibleLeft = overlayLeft;
|
|
9655
|
+
const visibleTop = overlayTop;
|
|
9656
|
+
const visibleRight = overlayLeft + widthVisible;
|
|
9657
|
+
const visibleBottom = overlayTop + heightVisible;
|
|
9658
|
+
// Intersect with viewport
|
|
9659
|
+
const clippedLeft = visibleLeft < 0 ? 0 : visibleLeft;
|
|
9660
|
+
const clippedTop = visibleTop < 0 ? 0 : visibleTop;
|
|
9661
|
+
const clippedRight =
|
|
9662
|
+
visibleRight > viewportWidth ? viewportWidth : visibleRight;
|
|
9663
|
+
const clippedBottom =
|
|
9664
|
+
visibleBottom > viewportHeight ? viewportHeight : visibleBottom;
|
|
9665
|
+
const clippedWidth =
|
|
9666
|
+
clippedRight > clippedLeft ? clippedRight - clippedLeft : 0;
|
|
9667
|
+
const clippedHeight =
|
|
9668
|
+
clippedBottom > clippedTop ? clippedBottom - clippedTop : 0;
|
|
9669
|
+
visibilityRatio = (clippedWidth * clippedHeight) / (width * height);
|
|
9348
9670
|
}
|
|
9349
9671
|
|
|
9350
9672
|
const visibleRect = {
|
|
@@ -9354,22 +9676,22 @@ const visibleRectEffect = (element, update) => {
|
|
|
9354
9676
|
bottom: overlayTop + heightVisible,
|
|
9355
9677
|
width: widthVisible,
|
|
9356
9678
|
height: heightVisible,
|
|
9357
|
-
visibilityRatio
|
|
9358
|
-
scrollVisibilityRatio,
|
|
9679
|
+
visibilityRatio,
|
|
9359
9680
|
};
|
|
9360
9681
|
update(visibleRect, {
|
|
9682
|
+
event,
|
|
9361
9683
|
width,
|
|
9362
9684
|
height,
|
|
9363
9685
|
});
|
|
9364
9686
|
};
|
|
9365
9687
|
|
|
9366
|
-
check();
|
|
9688
|
+
check(new CustomEvent("initialization"));
|
|
9367
9689
|
|
|
9368
9690
|
const [publishBeforeAutoCheck, onBeforeAutoCheck] = createPubSub();
|
|
9369
9691
|
{
|
|
9370
|
-
const autoCheck = (
|
|
9371
|
-
const beforeCheckResults = publishBeforeAutoCheck(
|
|
9372
|
-
check();
|
|
9692
|
+
const autoCheck = (event) => {
|
|
9693
|
+
const beforeCheckResults = publishBeforeAutoCheck(event);
|
|
9694
|
+
check(event);
|
|
9373
9695
|
for (const beforeCheckResult of beforeCheckResults) {
|
|
9374
9696
|
if (typeof beforeCheckResult === "function") {
|
|
9375
9697
|
beforeCheckResult();
|
|
@@ -9390,8 +9712,8 @@ const visibleRectEffect = (element, update) => {
|
|
|
9390
9712
|
{
|
|
9391
9713
|
// If scrollable parent is not document, also listen to document scroll
|
|
9392
9714
|
// to update UI position when the scrollable parent moves in viewport
|
|
9393
|
-
const onDocumentScroll = () => {
|
|
9394
|
-
autoCheck(
|
|
9715
|
+
const onDocumentScroll = (e) => {
|
|
9716
|
+
autoCheck(e);
|
|
9395
9717
|
};
|
|
9396
9718
|
document.addEventListener("scroll", onDocumentScroll, {
|
|
9397
9719
|
passive: true,
|
|
@@ -9402,8 +9724,8 @@ const visibleRectEffect = (element, update) => {
|
|
|
9402
9724
|
});
|
|
9403
9725
|
});
|
|
9404
9726
|
if (!scrollContainerIsDocument) {
|
|
9405
|
-
const onScroll = () => {
|
|
9406
|
-
autoCheck(
|
|
9727
|
+
const onScroll = (e) => {
|
|
9728
|
+
autoCheck(e);
|
|
9407
9729
|
};
|
|
9408
9730
|
scrollContainer.addEventListener("scroll", onScroll, {
|
|
9409
9731
|
passive: true,
|
|
@@ -9416,8 +9738,8 @@ const visibleRectEffect = (element, update) => {
|
|
|
9416
9738
|
}
|
|
9417
9739
|
}
|
|
9418
9740
|
{
|
|
9419
|
-
const onWindowResize = () => {
|
|
9420
|
-
autoCheck(
|
|
9741
|
+
const onWindowResize = (e) => {
|
|
9742
|
+
autoCheck(e);
|
|
9421
9743
|
};
|
|
9422
9744
|
window.addEventListener("resize", onWindowResize);
|
|
9423
9745
|
addTeardown(() => {
|
|
@@ -9447,7 +9769,9 @@ const visibleRectEffect = (element, update) => {
|
|
|
9447
9769
|
{
|
|
9448
9770
|
const documentIntersectionObserver = new IntersectionObserver(
|
|
9449
9771
|
() => {
|
|
9450
|
-
autoCheck(
|
|
9772
|
+
autoCheck(
|
|
9773
|
+
new CustomEvent("element_intersection_with_document_change"),
|
|
9774
|
+
);
|
|
9451
9775
|
},
|
|
9452
9776
|
{
|
|
9453
9777
|
root: null,
|
|
@@ -9462,7 +9786,9 @@ const visibleRectEffect = (element, update) => {
|
|
|
9462
9786
|
if (!scrollContainerIsDocument) {
|
|
9463
9787
|
const scrollIntersectionObserver = new IntersectionObserver(
|
|
9464
9788
|
() => {
|
|
9465
|
-
autoCheck(
|
|
9789
|
+
autoCheck(
|
|
9790
|
+
new CustomEvent("element_intersection_with_scroll_change"),
|
|
9791
|
+
);
|
|
9466
9792
|
},
|
|
9467
9793
|
{
|
|
9468
9794
|
root: scrollContainer,
|
|
@@ -9477,8 +9803,8 @@ const visibleRectEffect = (element, update) => {
|
|
|
9477
9803
|
}
|
|
9478
9804
|
}
|
|
9479
9805
|
{
|
|
9480
|
-
const onWindowTouchMove = () => {
|
|
9481
|
-
autoCheck(
|
|
9806
|
+
const onWindowTouchMove = (e) => {
|
|
9807
|
+
autoCheck(e);
|
|
9482
9808
|
};
|
|
9483
9809
|
window.addEventListener("touchmove", onWindowTouchMove, {
|
|
9484
9810
|
passive: true,
|
|
@@ -9500,14 +9826,50 @@ const visibleRectEffect = (element, update) => {
|
|
|
9500
9826
|
};
|
|
9501
9827
|
};
|
|
9502
9828
|
|
|
9829
|
+
/**
|
|
9830
|
+
* Places element adjacent to anchor using one of 9 compass positions.
|
|
9831
|
+
*
|
|
9832
|
+
* ```
|
|
9833
|
+
* top-left | top | top-right
|
|
9834
|
+
* ----------+---------+----------
|
|
9835
|
+
* left | center | right
|
|
9836
|
+
* ----------+---------+----------
|
|
9837
|
+
* bottom-left| bottom |bottom-right
|
|
9838
|
+
* ```
|
|
9839
|
+
*
|
|
9840
|
+
* All positions except "center" place element outside the anchor:
|
|
9841
|
+
* - "top" → element.bottom = anchor.top, horizontally centered
|
|
9842
|
+
* - "bottom" → element.top = anchor.bottom, horizontally centered (default)
|
|
9843
|
+
* - "left" → element.right = anchor.left, vertically centered
|
|
9844
|
+
* - "right" → element.left = anchor.right, vertically centered
|
|
9845
|
+
* - "top-left" → element.bottom = anchor.top, element.right = anchor.left
|
|
9846
|
+
* - "top-right" → element.bottom = anchor.top, element.left = anchor.right
|
|
9847
|
+
* - "bottom-left" → element.top = anchor.bottom, element.right = anchor.left
|
|
9848
|
+
* - "bottom-right" → element.top = anchor.bottom, element.left = anchor.right
|
|
9849
|
+
* - "center" → element centered on anchor (overlapping)
|
|
9850
|
+
*
|
|
9851
|
+
* @param {HTMLElement} element - The element to position (must be document-relative)
|
|
9852
|
+
* @param {HTMLElement} anchor - The anchor element to position against
|
|
9853
|
+
* @param {object} [options]
|
|
9854
|
+
* @param {string} [options.positionTry="bottom"] - Preferred position. Mimics CSS position-try.
|
|
9855
|
+
* If it does not fit, the logical opposite is tried automatically:
|
|
9856
|
+
* top↔bottom, left↔right, top-left↔bottom-right, top-right↔bottom-left.
|
|
9857
|
+
* The element's data-position-try attribute takes precedence over this param;
|
|
9858
|
+
* the last resolved position is persisted as data-position-current to avoid flickering.
|
|
9859
|
+
* @param {string} [options.position] - Force a specific position, skipping the fit-check.
|
|
9860
|
+
* @param {number} [options.alignToViewportEdgeWhenAnchorNearEdge=0] - Snap to viewport left
|
|
9861
|
+
* edge when anchor is within this many px of the left edge and element is wider than anchor.
|
|
9862
|
+
* @param {number} [options.minLeft=0] - Minimum left coordinate (document-relative).
|
|
9863
|
+
* @returns {{ position, left, top, width, height, anchorLeft, anchorTop, anchorRight, anchorBottom, spaceAbove, spaceBelow }}
|
|
9864
|
+
*/
|
|
9503
9865
|
const pickPositionRelativeTo = (
|
|
9504
9866
|
element,
|
|
9505
|
-
|
|
9867
|
+
anchor,
|
|
9506
9868
|
{
|
|
9507
|
-
|
|
9869
|
+
positionTry = "bottom",
|
|
9870
|
+
position,
|
|
9871
|
+
alignToViewportEdgeWhenAnchorNearEdge = 0,
|
|
9508
9872
|
minLeft = 0,
|
|
9509
|
-
positionPreference,
|
|
9510
|
-
forcePosition,
|
|
9511
9873
|
} = {},
|
|
9512
9874
|
) => {
|
|
9513
9875
|
|
|
@@ -9515,7 +9877,7 @@ const pickPositionRelativeTo = (
|
|
|
9515
9877
|
const viewportHeight = document.documentElement.clientHeight;
|
|
9516
9878
|
// Get viewport-relative positions
|
|
9517
9879
|
const elementRect = element.getBoundingClientRect();
|
|
9518
|
-
const
|
|
9880
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
9519
9881
|
const {
|
|
9520
9882
|
left: elementLeft,
|
|
9521
9883
|
right: elementRight,
|
|
@@ -9523,50 +9885,97 @@ const pickPositionRelativeTo = (
|
|
|
9523
9885
|
bottom: elementBottom,
|
|
9524
9886
|
} = elementRect;
|
|
9525
9887
|
const {
|
|
9526
|
-
left:
|
|
9527
|
-
right:
|
|
9528
|
-
top:
|
|
9529
|
-
bottom:
|
|
9530
|
-
} =
|
|
9888
|
+
left: anchorLeft,
|
|
9889
|
+
right: anchorRight,
|
|
9890
|
+
top: anchorTop,
|
|
9891
|
+
bottom: anchorBottom,
|
|
9892
|
+
} = anchorRect;
|
|
9531
9893
|
const elementWidth = elementRight - elementLeft;
|
|
9532
9894
|
const elementHeight = elementBottom - elementTop;
|
|
9533
|
-
const
|
|
9895
|
+
const anchorWidth = anchorRight - anchorLeft;
|
|
9896
|
+
const anchorHeight = anchorBottom - anchorTop;
|
|
9897
|
+
|
|
9898
|
+
// Determine the active position: position wins, then data-position-current (last resolved),
|
|
9899
|
+
// then data-position-try attribute (user preference), then positionTry param
|
|
9900
|
+
let activePosition;
|
|
9901
|
+
if (position) {
|
|
9902
|
+
activePosition = position;
|
|
9903
|
+
} else {
|
|
9904
|
+
const positionCurrentFromAttribute = element.getAttribute(
|
|
9905
|
+
"data-position-current",
|
|
9906
|
+
);
|
|
9907
|
+
const positionTryFromAttribute = element.getAttribute("data-position-try");
|
|
9908
|
+
activePosition =
|
|
9909
|
+
positionCurrentFromAttribute || positionTryFromAttribute || positionTry;
|
|
9910
|
+
}
|
|
9911
|
+
|
|
9912
|
+
const spaceAbove = anchorTop;
|
|
9913
|
+
const spaceBelow = viewportHeight - anchorBottom;
|
|
9914
|
+
|
|
9915
|
+
// Resolve vertical axis, falling back to opposite if the tried position does not fit
|
|
9916
|
+
const { isTop, isBottom, isLeft, isRight, isCenter } =
|
|
9917
|
+
decomposePosition(activePosition);
|
|
9918
|
+
const isCenterX = !isLeft && !isRight; // top / bottom / center
|
|
9919
|
+
const isCenterY = !isTop && !isBottom; // left / right / center
|
|
9920
|
+
|
|
9921
|
+
let resolvedVertical; // "top" | "bottom" | "center-y"
|
|
9922
|
+
if (isCenter || isCenterY) {
|
|
9923
|
+
resolvedVertical = "center-y";
|
|
9924
|
+
} else if (position) {
|
|
9925
|
+
resolvedVertical = isTop ? "top" : "bottom";
|
|
9926
|
+
} else if (isTop) {
|
|
9927
|
+
const minContentVisibilityRatio = 0.6;
|
|
9928
|
+
const fitsAbove = spaceAbove / elementHeight >= minContentVisibilityRatio;
|
|
9929
|
+
if (fitsAbove) {
|
|
9930
|
+
resolvedVertical = "top";
|
|
9931
|
+
} else {
|
|
9932
|
+
resolvedVertical = "bottom"; // opposite of top
|
|
9933
|
+
}
|
|
9934
|
+
} else {
|
|
9935
|
+
// isBottom
|
|
9936
|
+
const elementFitsBelow = spaceBelow >= elementHeight;
|
|
9937
|
+
if (elementFitsBelow) {
|
|
9938
|
+
resolvedVertical = "bottom";
|
|
9939
|
+
} else {
|
|
9940
|
+
resolvedVertical = "top"; // opposite of bottom
|
|
9941
|
+
}
|
|
9942
|
+
}
|
|
9534
9943
|
|
|
9535
9944
|
// Calculate horizontal position (viewport-relative)
|
|
9536
9945
|
let elementPositionLeft;
|
|
9537
9946
|
{
|
|
9538
|
-
|
|
9539
|
-
|
|
9540
|
-
if (
|
|
9541
|
-
|
|
9542
|
-
const targetRightIsVisible = targetRight <= viewportWidth;
|
|
9543
|
-
|
|
9544
|
-
if (!targetLeftIsVisible && targetRightIsVisible) {
|
|
9545
|
-
// Target extends beyond left edge but right side is visible
|
|
9546
|
-
const viewportCenter = viewportWidth / 2;
|
|
9547
|
-
const distanceFromRightEdge = viewportWidth - targetRight;
|
|
9548
|
-
elementPositionLeft =
|
|
9549
|
-
viewportCenter - distanceFromRightEdge / 2 - elementWidth / 2;
|
|
9550
|
-
} else if (targetLeftIsVisible && !targetRightIsVisible) {
|
|
9551
|
-
// Target extends beyond right edge but left side is visible
|
|
9552
|
-
const viewportCenter = viewportWidth / 2;
|
|
9553
|
-
const distanceFromLeftEdge = -targetLeft;
|
|
9554
|
-
elementPositionLeft =
|
|
9555
|
-
viewportCenter - distanceFromLeftEdge / 2 - elementWidth / 2;
|
|
9556
|
-
} else {
|
|
9557
|
-
// Target extends beyond both edges or is fully visible (center in viewport)
|
|
9558
|
-
elementPositionLeft = viewportWidth / 2 - elementWidth / 2;
|
|
9559
|
-
}
|
|
9947
|
+
if (isLeft) {
|
|
9948
|
+
elementPositionLeft = anchorLeft - elementWidth;
|
|
9949
|
+
} else if (isRight) {
|
|
9950
|
+
elementPositionLeft = anchorRight;
|
|
9560
9951
|
} else {
|
|
9561
|
-
//
|
|
9562
|
-
|
|
9563
|
-
|
|
9564
|
-
|
|
9565
|
-
const
|
|
9566
|
-
|
|
9567
|
-
|
|
9568
|
-
|
|
9569
|
-
elementPositionLeft =
|
|
9952
|
+
// centered horizontally on anchor
|
|
9953
|
+
const anchorIsWiderThanViewport = anchorWidth > viewportWidth;
|
|
9954
|
+
if (anchorIsWiderThanViewport) {
|
|
9955
|
+
const anchorLeftIsVisible = anchorLeft >= 0;
|
|
9956
|
+
const anchorRightIsVisible = anchorRight <= viewportWidth;
|
|
9957
|
+
if (!anchorLeftIsVisible && anchorRightIsVisible) {
|
|
9958
|
+
const viewportCenter = viewportWidth / 2;
|
|
9959
|
+
const distanceFromRightEdge = viewportWidth - anchorRight;
|
|
9960
|
+
elementPositionLeft =
|
|
9961
|
+
viewportCenter - distanceFromRightEdge / 2 - elementWidth / 2;
|
|
9962
|
+
} else if (anchorLeftIsVisible && !anchorRightIsVisible) {
|
|
9963
|
+
const viewportCenter = viewportWidth / 2;
|
|
9964
|
+
const distanceFromLeftEdge = -anchorLeft;
|
|
9965
|
+
elementPositionLeft =
|
|
9966
|
+
viewportCenter - distanceFromLeftEdge / 2 - elementWidth / 2;
|
|
9967
|
+
} else {
|
|
9968
|
+
elementPositionLeft = viewportWidth / 2 - elementWidth / 2;
|
|
9969
|
+
}
|
|
9970
|
+
} else {
|
|
9971
|
+
elementPositionLeft = anchorLeft + anchorWidth / 2 - elementWidth / 2;
|
|
9972
|
+
if (alignToViewportEdgeWhenAnchorNearEdge) {
|
|
9973
|
+
const elementIsWiderThanAnchor = elementWidth > anchorWidth;
|
|
9974
|
+
const anchorIsNearLeftEdge =
|
|
9975
|
+
anchorLeft < alignToViewportEdgeWhenAnchorNearEdge;
|
|
9976
|
+
if (elementIsWiderThanAnchor && anchorIsNearLeftEdge) {
|
|
9977
|
+
elementPositionLeft = minLeft;
|
|
9978
|
+
}
|
|
9570
9979
|
}
|
|
9571
9980
|
}
|
|
9572
9981
|
}
|
|
@@ -9579,83 +9988,76 @@ const pickPositionRelativeTo = (
|
|
|
9579
9988
|
}
|
|
9580
9989
|
|
|
9581
9990
|
// Calculate vertical position (viewport-relative)
|
|
9582
|
-
let
|
|
9583
|
-
|
|
9584
|
-
|
|
9585
|
-
|
|
9586
|
-
if (
|
|
9587
|
-
|
|
9588
|
-
|
|
9589
|
-
|
|
9590
|
-
|
|
9591
|
-
|
|
9592
|
-
|
|
9593
|
-
|
|
9594
|
-
const preferredPosition = positionPreference || elementPreferredPosition;
|
|
9595
|
-
|
|
9596
|
-
if (preferredPosition) {
|
|
9597
|
-
// Element has a preferred position - try to keep it unless we really struggle
|
|
9598
|
-
const visibleRatio =
|
|
9599
|
-
preferredPosition === "above"
|
|
9600
|
-
? spaceAboveTarget / elementHeight
|
|
9601
|
-
: spaceBelowTarget / elementHeight;
|
|
9602
|
-
const canShowMinimumContent = visibleRatio >= minContentVisibilityRatio;
|
|
9603
|
-
if (canShowMinimumContent) {
|
|
9604
|
-
position = preferredPosition;
|
|
9605
|
-
break determine_position;
|
|
9606
|
-
}
|
|
9607
|
-
}
|
|
9608
|
-
// No preferred position - use original logic (prefer below, fallback to above if more space)
|
|
9609
|
-
const elementFitsBelow = spaceBelowTarget >= elementHeight;
|
|
9610
|
-
if (elementFitsBelow) {
|
|
9611
|
-
position = "below";
|
|
9612
|
-
break determine_position;
|
|
9991
|
+
let elementPositionTop;
|
|
9992
|
+
{
|
|
9993
|
+
if (resolvedVertical === "center-y") {
|
|
9994
|
+
elementPositionTop = anchorTop + anchorHeight / 2 - elementHeight / 2;
|
|
9995
|
+
} else if (resolvedVertical === "bottom") {
|
|
9996
|
+
const idealTop = anchorBottom;
|
|
9997
|
+
elementPositionTop =
|
|
9998
|
+
idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
|
|
9999
|
+
} else {
|
|
10000
|
+
// "top"
|
|
10001
|
+
const idealTop = anchorTop - elementHeight;
|
|
10002
|
+
elementPositionTop = idealTop < 0 ? 0 : idealTop;
|
|
9613
10003
|
}
|
|
9614
|
-
const hasMoreSpaceBelow = spaceBelowTarget >= spaceAboveTarget;
|
|
9615
|
-
position = hasMoreSpaceBelow ? "below" : "above";
|
|
9616
10004
|
}
|
|
9617
10005
|
|
|
9618
|
-
let
|
|
10006
|
+
let finalPosition;
|
|
9619
10007
|
{
|
|
9620
|
-
|
|
9621
|
-
|
|
9622
|
-
|
|
9623
|
-
|
|
9624
|
-
|
|
9625
|
-
|
|
9626
|
-
|
|
10008
|
+
const vertPart = resolvedVertical === "center-y" ? "" : resolvedVertical;
|
|
10009
|
+
const horzPart = isCenterX ? "" : isLeft ? "left" : "right";
|
|
10010
|
+
if (vertPart && horzPart) {
|
|
10011
|
+
finalPosition = `${vertPart}-${horzPart}`;
|
|
10012
|
+
} else if (vertPart) {
|
|
10013
|
+
finalPosition = vertPart;
|
|
10014
|
+
} else if (horzPart) {
|
|
10015
|
+
finalPosition = horzPart;
|
|
9627
10016
|
} else {
|
|
9628
|
-
|
|
9629
|
-
const idealTopWhenAbove = targetTop - elementHeight;
|
|
9630
|
-
const minimumTopInViewport = 0;
|
|
9631
|
-
elementPositionTop =
|
|
9632
|
-
idealTopWhenAbove < minimumTopInViewport
|
|
9633
|
-
? minimumTopInViewport
|
|
9634
|
-
: idealTopWhenAbove;
|
|
10017
|
+
finalPosition = "center";
|
|
9635
10018
|
}
|
|
9636
10019
|
}
|
|
9637
10020
|
|
|
10021
|
+
// Persist the resolved position on the element so subsequent calls start from it
|
|
10022
|
+
// (avoids flickering between positions when the element is near the threshold).
|
|
10023
|
+
// position is not persisted — it is always explicit.
|
|
10024
|
+
|
|
10025
|
+
if (!position) {
|
|
10026
|
+
element.setAttribute("data-position-current", finalPosition);
|
|
10027
|
+
}
|
|
10028
|
+
|
|
9638
10029
|
// Get document scroll for final coordinate conversion
|
|
9639
10030
|
const { scrollLeft, scrollTop } = document.documentElement;
|
|
9640
10031
|
const elementDocumentLeft = elementPositionLeft + scrollLeft;
|
|
9641
10032
|
const elementDocumentTop = elementPositionTop + scrollTop;
|
|
9642
|
-
const
|
|
9643
|
-
const
|
|
9644
|
-
const
|
|
9645
|
-
const
|
|
10033
|
+
const anchorDocumentLeft = anchorLeft + scrollLeft;
|
|
10034
|
+
const anchorDocumentTop = anchorTop + scrollTop;
|
|
10035
|
+
const anchorDocumentRight = anchorRight + scrollLeft;
|
|
10036
|
+
const anchorDocumentBottom = anchorBottom + scrollTop;
|
|
9646
10037
|
|
|
9647
10038
|
return {
|
|
9648
|
-
position,
|
|
10039
|
+
position: finalPosition,
|
|
9649
10040
|
left: elementDocumentLeft,
|
|
9650
10041
|
top: elementDocumentTop,
|
|
9651
10042
|
width: elementWidth,
|
|
9652
10043
|
height: elementHeight,
|
|
9653
|
-
|
|
9654
|
-
|
|
9655
|
-
|
|
9656
|
-
|
|
9657
|
-
|
|
9658
|
-
|
|
10044
|
+
anchorLeft: anchorDocumentLeft,
|
|
10045
|
+
anchorTop: anchorDocumentTop,
|
|
10046
|
+
anchorRight: anchorDocumentRight,
|
|
10047
|
+
anchorBottom: anchorDocumentBottom,
|
|
10048
|
+
spaceAbove,
|
|
10049
|
+
spaceBelow,
|
|
10050
|
+
};
|
|
10051
|
+
};
|
|
10052
|
+
// Decompose position flags
|
|
10053
|
+
const decomposePosition = (pos) => {
|
|
10054
|
+
return {
|
|
10055
|
+
isTop: pos === "top" || pos === "top-left" || pos === "top-right",
|
|
10056
|
+
isBottom:
|
|
10057
|
+
pos === "bottom" || pos === "bottom-left" || pos === "bottom-right",
|
|
10058
|
+
isLeft: pos === "left" || pos === "top-left" || pos === "bottom-left",
|
|
10059
|
+
isRight: pos === "right" || pos === "top-right" || pos === "bottom-right",
|
|
10060
|
+
isCenter: pos === "center",
|
|
9659
10061
|
};
|
|
9660
10062
|
};
|
|
9661
10063
|
|
|
@@ -12782,4 +13184,4 @@ const useResizeStatus = (elementRef, { as = "number" } = {}) => {
|
|
|
12782
13184
|
};
|
|
12783
13185
|
};
|
|
12784
13186
|
|
|
12785
|
-
export { EASING, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles, canInterceptKeys, captureScrollState, contrastColor, createBackgroundColorTransition, createBackgroundTransition, createBorderRadiusTransition, createBorderTransition, createDragGestureController, createDragToMoveGestureController, createGroupTransitionController, createHeightTransition, createIterableWeakSet, createOpacityTransition, createPubSub, createStyleController, createTimelineTransition, createTransition, createTranslateXTransition, createValueEffect, createWidthTransition, cubicBezier, dragAfterThreshold, elementIsFocusable, elementIsVisibleForFocus, elementIsVisuallyVisible, findAfter, findAncestor, findBefore, findDescendant, findFocusable, getAvailableHeight, getAvailableWidth, getBackground, getBackgroundColor, getBorder, getBorderRadius, getBorderSizes, getContrastRatio, getDefaultStyles, getDragCoordinates, getDropTargetInfo, getElementSignature, getFirstVisuallyVisibleAncestor, getFocusVisibilityInfo, getHeight, getHeightWithoutTransition, getInnerHeight, getInnerWidth, 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, measureScrollbar, mergeOneStyle, mergeTwoStyles, normalizeStyles, parseStyle, pickPositionRelativeTo, prefersDarkColors, prefersLightColors, preventFocusNav, preventFocusNavViaKeyboard, preventIntermediateScrollbar, resolveCSSColor, resolveCSSSize, resolveColorLuminance, setAttribute, setAttributes, setStyles, startDragToResizeGesture, stickyAsRelativeCoords, stringifyStyle, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect };
|
|
13187
|
+
export { EASING, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles, canInterceptKeys, captureScrollState, contrastColor, createBackgroundColorTransition, createBackgroundTransition, createBorderRadiusTransition, createBorderTransition, createDragGestureController, createDragToMoveGestureController, createGroupTransitionController, createHeightTransition, createIterableWeakSet, createOpacityTransition, createPubSub, createStyleController, createTimelineTransition, createTransition, createTranslateXTransition, createValueEffect, createWidthTransition, cubicBezier, dragAfterThreshold, elementIsFocusable, elementIsVisibleForFocus, elementIsVisuallyVisible, findAfter, findAncestor, findBefore, findDescendant, findFocusable, getAvailableHeight, getAvailableWidth, getBackground, getBackgroundColor, getBorder, getBorderRadius, getBorderSizes, getContrastRatio, getDefaultStyles, getDragCoordinates, getDropTargetInfo, getElementSignature, getFirstVisuallyVisibleAncestor, getFocusVisibilityInfo, getHeight, getHeightWithoutTransition, getInnerHeight, getInnerWidth, 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, measureScrollbar, mergeOneStyle, mergeTwoStyles, normalizeStyles, parseStyle, pickPositionRelativeTo, prefersDarkColors, prefersLightColors, preventFocusNav, preventFocusNavViaKeyboard, preventIntermediateScrollbar, resolveCSSColor, resolveCSSSize, resolveColorLuminance, scrollIntoViewScoped, scrollIntoViewWithStickyAwareness, setAttribute, setAttributes, setStyles, startDragToResizeGesture, stickyAsRelativeCoords, stringifyStyle, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect };
|
package/package.json
CHANGED
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/dom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
|
+
"type": "module",
|
|
4
5
|
"description": "DOM utilities for writing frontend code",
|
|
5
6
|
"repository": {
|
|
6
7
|
"type": "git",
|
|
7
8
|
"url": "https://github.com/jsenv/core",
|
|
8
9
|
"directory": "packages/frontend/dom"
|
|
9
10
|
},
|
|
10
|
-
"license": "MIT",
|
|
11
11
|
"author": {
|
|
12
12
|
"name": "dmail",
|
|
13
13
|
"email": "dmaillard06@gmail.com"
|
|
14
14
|
},
|
|
15
|
-
"
|
|
16
|
-
"./dist/jsenv_dom.js"
|
|
17
|
-
],
|
|
18
|
-
"type": "module",
|
|
15
|
+
"license": "MIT",
|
|
19
16
|
"exports": {
|
|
20
17
|
".": {
|
|
21
18
|
"node": {
|
|
@@ -43,9 +40,12 @@
|
|
|
43
40
|
"@jsenv/navi": "../navi",
|
|
44
41
|
"@jsenv/snapshot": "../../tooling/snapshot",
|
|
45
42
|
"@preact/signals": "2.9.0",
|
|
46
|
-
"preact": "11.0.0-beta.
|
|
43
|
+
"preact": "11.0.0-beta.1"
|
|
47
44
|
},
|
|
48
45
|
"publishConfig": {
|
|
49
46
|
"access": "public"
|
|
50
|
-
}
|
|
47
|
+
},
|
|
48
|
+
"sideEffects": [
|
|
49
|
+
"./dist/jsenv_dom.js"
|
|
50
|
+
]
|
|
51
51
|
}
|