@jsenv/dom 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/jsenv_dom.js +283 -145
  2. package/package.json +1 -1
package/dist/jsenv_dom.js CHANGED
@@ -1908,6 +1908,8 @@ const pxPropertySet = new Set([
1908
1908
  "paddingRight",
1909
1909
  "paddingBottom",
1910
1910
  "paddingLeft",
1911
+ "outlineWidth",
1912
+ "outlineOffset",
1911
1913
  "borderWidth",
1912
1914
  "borderTopWidth",
1913
1915
  "borderRightWidth",
@@ -5417,6 +5419,31 @@ const getScrollContainerSet = (element) => {
5417
5419
  return scrollContainerSet;
5418
5420
  };
5419
5421
 
5422
+ /**
5423
+ * Rounds a CSS pixel value to the nearest physical pixel boundary for the current display.
5424
+ *
5425
+ * At zoom levels other than 100%, `devicePixelRatio` is not an integer (e.g. 1.25, 1.5),
5426
+ * so fractional CSS pixel values from `getBoundingClientRect()` may not align to the physical
5427
+ * pixel grid. Setting `top`/`left` to such values causes the browser to interpolate across
5428
+ * pixels, resulting in blurry rendering or misalignment with adjacent elements.
5429
+ *
5430
+ * Snapping to the physical grid ensures the value falls exactly on a pixel boundary.
5431
+ *
5432
+ * @param {number} value - A CSS pixel value (e.g. from getBoundingClientRect or scroll offset).
5433
+ * @returns {number} The nearest physical-pixel-aligned CSS pixel value.
5434
+ * @example
5435
+ * // At devicePixelRatio 1.25, snapToPixel(154.4) → 154.4 (already on grid)
5436
+ * // At devicePixelRatio 1.25, snapToPixel(154.3) → 154.4
5437
+ */
5438
+ const snapToPixel = (value) => {
5439
+ return Math.round(value * devicePixelRatio) / devicePixelRatio;
5440
+ };
5441
+
5442
+ // Round a CSS-pixel value to the nearest physical pixel boundary.
5443
+ // At zoom levels other than 100%, devicePixelRatio is not an integer (e.g. 1.25, 1.5),
5444
+ // so CSS pixels don't align 1:1 with physical pixels. Rounding to the physical grid
5445
+ // ensures the browser can render the element without sub-pixel blurring.
5446
+
5420
5447
  const getBorderSizes = (element) => {
5421
5448
  const {
5422
5449
  borderLeftWidth,
@@ -5424,11 +5451,12 @@ const getBorderSizes = (element) => {
5424
5451
  borderTopWidth,
5425
5452
  borderBottomWidth,
5426
5453
  } = window.getComputedStyle(element, null);
5454
+
5427
5455
  return {
5428
- left: parseFloat(borderLeftWidth),
5429
- right: parseFloat(borderRightWidth),
5430
- top: parseFloat(borderTopWidth),
5431
- bottom: parseFloat(borderBottomWidth),
5456
+ left: snapToPixel(parseFloat(borderLeftWidth)),
5457
+ right: snapToPixel(parseFloat(borderRightWidth)),
5458
+ top: snapToPixel(parseFloat(borderTopWidth)),
5459
+ bottom: snapToPixel(parseFloat(borderBottomWidth)),
5432
5460
  };
5433
5461
  };
5434
5462
 
@@ -5780,8 +5808,8 @@ const measureScrollbar = (scrollableElement) => {
5780
5808
  const scrollbarHeight = scrollDiv.offsetHeight - scrollDiv.clientHeight;
5781
5809
  scrollableElement.removeChild(scrollDiv);
5782
5810
  return [
5783
- hasXScrollbar ? scrollbarWidth : 0,
5784
- hasYScrollbar ? scrollbarHeight : 0,
5811
+ hasXScrollbar ? snapToPixel(scrollbarWidth) : 0,
5812
+ hasYScrollbar ? snapToPixel(scrollbarHeight) : 0,
5785
5813
  ];
5786
5814
  };
5787
5815
 
@@ -6094,6 +6122,18 @@ const scrollIntoViewWithStickyAwareness = (
6094
6122
  }
6095
6123
  };
6096
6124
 
6125
+ const getPaddingSizes = (element) => {
6126
+ const { paddingLeft, paddingRight, paddingTop, paddingBottom } =
6127
+ window.getComputedStyle(element, null);
6128
+
6129
+ return {
6130
+ left: snapToPixel(parseFloat(paddingLeft)),
6131
+ right: snapToPixel(parseFloat(paddingRight)),
6132
+ top: snapToPixel(parseFloat(paddingTop)),
6133
+ bottom: snapToPixel(parseFloat(paddingBottom)),
6134
+ };
6135
+ };
6136
+
6097
6137
  /**
6098
6138
  * Prevents scrolling on all scrollable containers that are ancestors of (or
6099
6139
  * siblings preceding) `element`. Used when an overlay (popover, dialog) is
@@ -6130,11 +6170,10 @@ const trapScrollInside = (element) => {
6130
6170
  return;
6131
6171
  }
6132
6172
  const [scrollbarWidth, scrollbarHeight] = measureScrollbar(el);
6133
- const paddingRight = parseInt(getStyle(el, "padding-right"), 0);
6134
- const paddingTop = parseInt(getStyle(el, "padding-top"), 0);
6173
+ const { right, bottom } = getPaddingSizes(el);
6135
6174
  const removeScrollLockStyles = setStyles(el, {
6136
- "padding-right": `${paddingRight + scrollbarWidth}px`,
6137
- "padding-top": `${paddingTop + scrollbarHeight}px`,
6175
+ "padding-right": `${right + scrollbarWidth}px`,
6176
+ "padding-bottom": `${bottom + scrollbarHeight}px`,
6138
6177
  "overflow": "hidden",
6139
6178
  });
6140
6179
  cleanupCallbackSet.add(removeScrollLockStyles);
@@ -9575,6 +9614,10 @@ const stickyAsRelativeCoords = (
9575
9614
  return [leftPosition, topPosition];
9576
9615
  };
9577
9616
 
9617
+ // Minimum fraction of element width/height that must be visible on the preferred side
9618
+ // before flipping to the opposite side. Prevents flickering near the flip threshold.
9619
+ const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
9620
+
9578
9621
  /**
9579
9622
  * Tracks how much of an element is visible within its scrollable parent and within the
9580
9623
  * document viewport. Calls update() on initialization and whenever visibility changes
@@ -9876,49 +9919,56 @@ const visibleRectEffect = (element, update) => {
9876
9919
  };
9877
9920
 
9878
9921
  /**
9879
- * Places element adjacent to anchor using one of 9 compass positions.
9922
+ * Places element relative to anchor with independent control of horizontal and vertical axes.
9880
9923
  *
9881
- * ```
9882
- * top-left | top | top-right
9883
- * ----------+---------+----------
9884
- * left | center | right
9885
- * ----------+---------+----------
9886
- * bottom-left| bottom |bottom-right
9887
- * ```
9924
+ * Horizontal axis — positionX / positionXFixed (left → right):
9925
+ * "to-the-left" element.right = anchor.left (sits entirely to the left of anchor)
9926
+ * "left-aligned" element.left = anchor.left (left edges aligned)
9927
+ * "center" element centered horizontally over anchor (default)
9928
+ * "right-aligned" element.right = anchor.right (right edges aligned)
9929
+ * "to-the-right" element.left = anchor.right (sits entirely to the right of anchor)
9888
9930
  *
9889
- * All positions except "center" place element outside the anchor:
9890
- * - "top"element.bottom = anchor.top, horizontally centered
9891
- * - "bottom" → element.top = anchor.bottom, horizontally centered (default)
9892
- * - "left"element.right = anchor.left, vertically centered
9893
- * - "right" → element.left = anchor.right, vertically centered
9894
- * - "top-left"element.bottom = anchor.top, element.right = anchor.left
9895
- * - "top-right" → element.bottom = anchor.top, element.left = anchor.right
9896
- * - "bottom-left" → element.top = anchor.bottom, element.right = anchor.left
9897
- * - "bottom-right" element.top = anchor.bottom, element.left = anchor.right
9898
- * - "center" → element centered on anchor (overlapping)
9931
+ * Vertical axis positionY / positionYFixed (top bottom):
9932
+ * "above" element.bottom = anchor.top (sits above, no overlap)
9933
+ * "above-overlap" element.bottom = anchor.bottom (sits above, overlapping anchor)
9934
+ * "center" element centered vertically over anchor
9935
+ * "below-overlap" element.top = anchor.top (sits below, overlapping anchor)
9936
+ * "below" element.top = anchor.bottom (sits below, no overlap) (default)
9937
+ *
9938
+ * positionX / positionY attempt the requested placement and automatically flip to the
9939
+ * logical opposite when the element does not fit in the viewport:
9940
+ * above below, above-overlap below-overlap
9941
+ *
9942
+ * positionXFixed / positionYFixed skip the fit check entirely.
9943
+ *
9944
+ * The resolved X and Y are persisted as data-position-x-current / data-position-y-current
9945
+ * on the element so subsequent calls start from the last resolved position (avoids
9946
+ * flickering when the element is near the flip threshold). Fixed axes are not persisted.
9899
9947
  *
9900
9948
  * @param {HTMLElement} element - The element to position (must be document-relative)
9901
9949
  * @param {HTMLElement} anchor - The anchor element to position against
9902
9950
  * @param {object} [options]
9903
- * @param {string} [options.positionTry="bottom"] - Preferred position. Mimics CSS position-try.
9904
- * If it does not fit, the logical opposite is tried automatically:
9905
- * top↔bottom, left↔right, top-left↔bottom-right, top-right↔bottom-left.
9906
- * The element's data-position-try attribute takes precedence over this param;
9907
- * the last resolved position is persisted as data-position-current to avoid flickering.
9908
- * @param {string} [options.position] - Force a specific position, skipping the fit-check.
9951
+ * @param {string} [options.positionX="center"] - Preferred X placement, with viewport fallback.
9952
+ * @param {string} [options.positionY="below"] - Preferred Y placement, with viewport fallback.
9953
+ * @param {string} [options.positionXFixed] - Force X placement, skipping the fit-check.
9954
+ * @param {string} [options.positionYFixed] - Force Y placement, skipping the fit-check.
9909
9955
  * @param {number} [options.alignToViewportEdgeWhenAnchorNearEdge=0] - Snap to viewport left
9910
9956
  * edge when anchor is within this many px of the left edge and element is wider than anchor.
9911
9957
  * @param {number} [options.minLeft=0] - Minimum left coordinate (document-relative).
9912
- * @returns {{ position, left, top, width, height, anchorLeft, anchorTop, anchorRight, anchorBottom, spaceAbove, spaceBelow }}
9958
+ * @returns {{ positionX, positionY, left, top, width, height, anchorLeft, anchorTop, anchorRight, anchorBottom, spaceLeft, spaceRight, spaceAbove, spaceBelow }}
9913
9959
  */
9914
9960
  const pickPositionRelativeTo = (
9915
9961
  element,
9916
9962
  anchor,
9917
9963
  {
9918
- positionTry = "bottom",
9919
- position,
9964
+ positionX = "center",
9965
+ positionY = "below",
9966
+ positionXFixed,
9967
+ positionYFixed,
9920
9968
  alignToViewportEdgeWhenAnchorNearEdge = 0,
9921
9969
  minLeft = 0,
9970
+ spacing = 0,
9971
+ viewportSpacing = 0,
9922
9972
  } = {},
9923
9973
  ) => {
9924
9974
 
@@ -9933,72 +9983,160 @@ const pickPositionRelativeTo = (
9933
9983
  top: elementTop,
9934
9984
  bottom: elementBottom,
9935
9985
  } = elementRect;
9936
- const {
9937
- left: anchorLeft,
9938
- right: anchorRight,
9939
- top: anchorTop,
9940
- bottom: anchorBottom,
9941
- } = anchorRect;
9986
+ const anchorLeft = snapToPixel(anchorRect.left);
9987
+ const anchorTop = snapToPixel(anchorRect.top);
9988
+ const anchorRight = snapToPixel(anchorRect.right);
9989
+ const anchorBottom = snapToPixel(anchorRect.bottom);
9942
9990
  const elementWidth = elementRight - elementLeft;
9943
9991
  const elementHeight = elementBottom - elementTop;
9944
9992
  const anchorWidth = anchorRight - anchorLeft;
9945
9993
  const anchorHeight = anchorBottom - anchorTop;
9946
9994
 
9947
- // Determine the active position: position wins, then data-position-current (last resolved),
9948
- // then data-position-try attribute (user preference), then positionTry param
9949
- let activePosition;
9950
- if (position) {
9951
- activePosition = position;
9952
- } else {
9953
- const positionCurrentFromAttribute = element.getAttribute(
9954
- "data-position-current",
9955
- );
9956
- const positionTryFromAttribute = element.getAttribute("data-position-try");
9957
- activePosition =
9958
- positionCurrentFromAttribute || positionTryFromAttribute || positionTry;
9959
- }
9960
-
9961
9995
  const spaceAbove = anchorTop;
9962
9996
  const spaceBelow = viewportHeight - anchorBottom;
9997
+ const spaceLeft = anchorLeft;
9998
+ const spaceRight = viewportWidth - anchorRight;
9999
+
10000
+ // Resolve active X and Y, and whether each is fixed (no flip fallback)
10001
+ let activeX;
10002
+ let activeY;
10003
+ const xIsFixed = Boolean(positionXFixed);
10004
+ const yIsFixed = Boolean(positionYFixed);
10005
+ const hasStoredY = Boolean(element.getAttribute("data-position-y-current"));
10006
+ const hasStoredX = Boolean(element.getAttribute("data-position-x-current"));
10007
+ if (xIsFixed) {
10008
+ activeX = positionXFixed;
10009
+ } else {
10010
+ const storedX = element.getAttribute("data-position-x-current");
10011
+ activeX = storedX ?? positionX;
10012
+ }
10013
+ if (yIsFixed) {
10014
+ activeY = positionYFixed;
10015
+ } else {
10016
+ const storedY = element.getAttribute("data-position-y-current");
10017
+ activeY = storedY ?? positionY;
10018
+ }
9963
10019
 
9964
- // Resolve vertical axis, falling back to opposite if the tried position does not fit
9965
- const { isTop, isBottom, isLeft, isRight, isCenter } =
9966
- decomposePosition(activePosition);
9967
- const isCenterX = !isLeft && !isRight; // top / bottom / center
9968
- const isCenterY = !isTop && !isBottom; // left / right / center
9969
-
9970
- let resolvedVertical; // "top" | "bottom" | "center-y"
9971
- if (isCenter || isCenterY) {
9972
- resolvedVertical = "center-y";
9973
- } else if (position) {
9974
- resolvedVertical = isTop ? "top" : "bottom";
9975
- } else if (isTop) {
9976
- const minContentVisibilityRatio = 0.6;
9977
- const fitsAbove = spaceAbove / elementHeight >= minContentVisibilityRatio;
9978
- if (fitsAbove) {
9979
- resolvedVertical = "top";
10020
+ // Resolve final Y
10021
+ let finalY;
10022
+ {
10023
+ const oppositeY = {
10024
+ "above": "below",
10025
+ "below": "above",
10026
+ "above-overlap": "below-overlap",
10027
+ "below-overlap": "above-overlap",
10028
+ };
10029
+ // Compute effective space for a given Y value
10030
+ const spaceFor = (y) => {
10031
+ if (y === "above") {
10032
+ return spaceAbove - spacing - viewportSpacing;
10033
+ }
10034
+ if (y === "above-overlap") {
10035
+ return spaceAbove + anchorHeight - viewportSpacing;
10036
+ }
10037
+ if (y === "below") {
10038
+ return spaceBelow - spacing - viewportSpacing;
10039
+ }
10040
+ if (y === "below-overlap") {
10041
+ return spaceBelow + anchorHeight - viewportSpacing;
10042
+ }
10043
+ return Infinity; // center
10044
+ };
10045
+ if (yIsFixed || activeY === "center") {
10046
+ finalY = activeY;
10047
+ } else if (!hasStoredY) {
10048
+ // Never positioned before — pick the best side from scratch.
10049
+ const preferred = positionY;
10050
+ const opposite = oppositeY[preferred];
10051
+ const preferredFits = spaceFor(preferred) >= elementHeight;
10052
+ const oppositeFits = spaceFor(opposite) >= elementHeight;
10053
+ if (preferredFits) {
10054
+ // Preferred fits completely — use it (even if opposite also fits)
10055
+ finalY = preferred;
10056
+ } else if (oppositeFits) {
10057
+ // Only opposite fits completely — flip
10058
+ finalY = opposite;
10059
+ } else {
10060
+ // Neither fits completely — use whichever meets the minimum ratio
10061
+ const preferredMeetsRatio =
10062
+ spaceFor(preferred) / elementHeight >= MIN_CONTENT_VISIBILITY_RATIO;
10063
+ finalY = preferredMeetsRatio ? preferred : opposite;
10064
+ }
9980
10065
  } else {
9981
- resolvedVertical = "bottom"; // opposite of top
10066
+ // Previously positioned stay as long as current side meets minimum ratio
10067
+ const currentFitsEnough =
10068
+ spaceFor(activeY) / elementHeight >= MIN_CONTENT_VISIBILITY_RATIO;
10069
+ if (currentFitsEnough) {
10070
+ finalY = activeY;
10071
+ } else {
10072
+ finalY = oppositeY[activeY];
10073
+ }
9982
10074
  }
9983
- } else {
9984
- // isBottom
9985
- const elementFitsBelow = spaceBelow >= elementHeight;
9986
- if (elementFitsBelow) {
9987
- resolvedVertical = "bottom";
10075
+ }
10076
+
10077
+ // Resolve final X
10078
+ let finalX;
10079
+ {
10080
+ const oppositeX = {
10081
+ "to-the-left": "to-the-right",
10082
+ "to-the-right": "to-the-left",
10083
+ "left-aligned": "right-aligned",
10084
+ "right-aligned": "left-aligned",
10085
+ };
10086
+ // Compute effective space for a given X value
10087
+ const spaceFor = (x) => {
10088
+ if (x === "to-the-left") {
10089
+ return spaceLeft - spacing - viewportSpacing;
10090
+ }
10091
+ if (x === "left-aligned") {
10092
+ return viewportWidth - anchorLeft - viewportSpacing;
10093
+ }
10094
+ if (x === "right-aligned") {
10095
+ return anchorRight - viewportSpacing;
10096
+ }
10097
+ if (x === "to-the-right") {
10098
+ return spaceRight - spacing - viewportSpacing;
10099
+ }
10100
+ return Infinity; // center
10101
+ };
10102
+ if (xIsFixed || activeX === "center") {
10103
+ finalX = activeX;
10104
+ } else if (!hasStoredX) {
10105
+ // Never positioned before — pick the best side from scratch.
10106
+ const preferred = positionX;
10107
+ const opposite = oppositeX[preferred];
10108
+ const preferredFits = spaceFor(preferred) >= elementWidth;
10109
+ const oppositeFits = spaceFor(opposite) >= elementWidth;
10110
+ if (preferredFits) {
10111
+ finalX = preferred;
10112
+ } else if (oppositeFits) {
10113
+ finalX = opposite;
10114
+ } else {
10115
+ const preferredMeetsRatio =
10116
+ spaceFor(preferred) / elementWidth >= MIN_CONTENT_VISIBILITY_RATIO;
10117
+ finalX = preferredMeetsRatio ? preferred : opposite;
10118
+ }
9988
10119
  } else {
9989
- resolvedVertical = "top"; // opposite of bottom
10120
+ // Previously positioned stay as long as current side meets minimum ratio
10121
+ const currentFitsEnough =
10122
+ spaceFor(activeX) / elementWidth >= MIN_CONTENT_VISIBILITY_RATIO;
10123
+ if (currentFitsEnough) {
10124
+ finalX = activeX;
10125
+ } else {
10126
+ finalX = oppositeX[activeX];
10127
+ }
9990
10128
  }
9991
10129
  }
9992
10130
 
9993
10131
  // Calculate horizontal position (viewport-relative)
9994
10132
  let elementPositionLeft;
9995
10133
  {
9996
- if (isLeft) {
9997
- elementPositionLeft = anchorLeft - elementWidth;
9998
- } else if (isRight) {
9999
- elementPositionLeft = anchorRight;
10000
- } else {
10001
- // centered horizontally on anchor
10134
+ if (finalX === "to-the-left") {
10135
+ elementPositionLeft = anchorLeft - elementWidth - spacing;
10136
+ } else if (finalX === "left-aligned") {
10137
+ elementPositionLeft = anchorLeft;
10138
+ } else if (finalX === "center") {
10139
+ // Complex logic handles wide anchors and viewport-edge snapping
10002
10140
  const anchorIsWiderThanViewport = anchorWidth > viewportWidth;
10003
10141
  if (anchorIsWiderThanViewport) {
10004
10142
  const anchorLeftIsVisible = anchorLeft >= 0;
@@ -10027,65 +10165,85 @@ const pickPositionRelativeTo = (
10027
10165
  }
10028
10166
  }
10029
10167
  }
10168
+ } else if (finalX === "right-aligned") {
10169
+ elementPositionLeft = anchorRight - elementWidth;
10170
+ } else {
10171
+ // "to-the-right"
10172
+ elementPositionLeft = anchorRight + spacing;
10030
10173
  }
10031
- // Constrain horizontal position to viewport boundaries
10032
- if (elementPositionLeft < 0) {
10033
- elementPositionLeft = 0;
10034
- } else if (elementPositionLeft + elementWidth > viewportWidth) {
10035
- elementPositionLeft = viewportWidth - elementWidth;
10174
+ // Constrain horizontal position to viewport boundaries (with viewportSpacing margin)
10175
+ if (elementPositionLeft < viewportSpacing) {
10176
+ elementPositionLeft = viewportSpacing;
10177
+ } else if (
10178
+ elementPositionLeft + elementWidth >
10179
+ viewportWidth - viewportSpacing
10180
+ ) {
10181
+ elementPositionLeft = viewportWidth - viewportSpacing - elementWidth;
10036
10182
  }
10037
10183
  }
10038
10184
 
10039
10185
  // Calculate vertical position (viewport-relative)
10040
10186
  let elementPositionTop;
10041
10187
  {
10042
- if (resolvedVertical === "center-y") {
10188
+ if (finalY === "above") {
10189
+ // top is always anchorTop - elementHeight - spacing — max-height truncates if needed.
10190
+ const idealTop = anchorTop - elementHeight - spacing;
10191
+ elementPositionTop =
10192
+ idealTop < viewportSpacing ? viewportSpacing : idealTop;
10193
+ } else if (finalY === "above-overlap") {
10194
+ const idealTop = anchorBottom - elementHeight;
10195
+ elementPositionTop =
10196
+ idealTop < viewportSpacing ? viewportSpacing : idealTop;
10197
+ } else if (finalY === "center") {
10043
10198
  elementPositionTop = anchorTop + anchorHeight / 2 - elementHeight / 2;
10044
- } else if (resolvedVertical === "bottom") {
10045
- const idealTop = anchorBottom;
10199
+ } else if (finalY === "below-overlap") {
10200
+ const idealTop = anchorTop;
10046
10201
  elementPositionTop =
10047
10202
  idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
10048
10203
  } else {
10049
- // "top"
10050
- const idealTop = anchorTop - elementHeight;
10051
- elementPositionTop = idealTop < 0 ? 0 : idealTop;
10204
+ // "below"
10205
+ // top is always anchorBottom + spacing — max-height (via --space-available) truncates
10206
+ // the element height so it doesn't overflow the viewport bottom.
10207
+ const idealTop = anchorBottom + spacing;
10208
+ elementPositionTop =
10209
+ idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
10052
10210
  }
10053
10211
  }
10054
10212
 
10055
- let finalPosition;
10056
- {
10057
- const vertPart = resolvedVertical === "center-y" ? "" : resolvedVertical;
10058
- const horzPart = isCenterX ? "" : isLeft ? "left" : "right";
10059
- if (vertPart && horzPart) {
10060
- finalPosition = `${vertPart}-${horzPart}`;
10061
- } else if (vertPart) {
10062
- finalPosition = vertPart;
10063
- } else if (horzPart) {
10064
- finalPosition = horzPart;
10065
- } else {
10066
- finalPosition = "center";
10067
- }
10213
+ // Persist resolved X/Y so subsequent calls start from here (avoids flickering).
10214
+ // Fixed axes are not persisted.
10215
+ if (!xIsFixed) {
10216
+ element.setAttribute("data-position-x-current", finalX);
10068
10217
  }
10069
-
10070
- // Persist the resolved position on the element so subsequent calls start from it
10071
- // (avoids flickering between positions when the element is near the threshold).
10072
- // position is not persisted — it is always explicit.
10073
-
10074
- if (!position) {
10075
- element.setAttribute("data-position-current", finalPosition);
10218
+ if (!yIsFixed) {
10219
+ element.setAttribute("data-position-y-current", finalY);
10076
10220
  }
10077
10221
 
10078
10222
  // Get document scroll for final coordinate conversion
10079
10223
  const { scrollLeft, scrollTop } = document.documentElement;
10080
- const elementDocumentLeft = elementPositionLeft + scrollLeft;
10081
- const elementDocumentTop = elementPositionTop + scrollTop;
10224
+ const elementDocumentLeft = snapToPixel(elementPositionLeft + scrollLeft);
10225
+ const elementDocumentTop = snapToPixel(elementPositionTop + scrollTop);
10082
10226
  const anchorDocumentLeft = anchorLeft + scrollLeft;
10083
10227
  const anchorDocumentTop = anchorTop + scrollTop;
10084
10228
  const anchorDocumentRight = anchorRight + scrollLeft;
10085
10229
  const anchorDocumentBottom = anchorBottom + scrollTop;
10086
10230
 
10231
+ // For overlap variants the element starts at the anchor edge (not past it),
10232
+ // so the usable space includes the anchor dimension.
10233
+ // spacing (gap between anchor and element) and viewportSpacing are subtracted
10234
+ // so callers get the net usable space directly.
10235
+ const effectiveSpaceAbove =
10236
+ (finalY === "above-overlap" ? spaceAbove + anchorHeight : spaceAbove) -
10237
+ (finalY === "above" ? spacing : 0) -
10238
+ viewportSpacing;
10239
+ const effectiveSpaceBelow =
10240
+ (finalY === "below-overlap" ? spaceBelow + anchorHeight : spaceBelow) -
10241
+ (finalY === "below" ? spacing : 0) -
10242
+ viewportSpacing;
10243
+
10087
10244
  return {
10088
- position: finalPosition,
10245
+ positionX: finalX,
10246
+ positionY: finalY,
10089
10247
  left: elementDocumentLeft,
10090
10248
  top: elementDocumentTop,
10091
10249
  width: elementWidth,
@@ -10094,19 +10252,10 @@ const pickPositionRelativeTo = (
10094
10252
  anchorTop: anchorDocumentTop,
10095
10253
  anchorRight: anchorDocumentRight,
10096
10254
  anchorBottom: anchorDocumentBottom,
10097
- spaceAbove,
10098
- spaceBelow,
10099
- };
10100
- };
10101
- // Decompose position flags
10102
- const decomposePosition = (pos) => {
10103
- return {
10104
- isTop: pos === "top" || pos === "top-left" || pos === "top-right",
10105
- isBottom:
10106
- pos === "bottom" || pos === "bottom-left" || pos === "bottom-right",
10107
- isLeft: pos === "left" || pos === "top-left" || pos === "bottom-left",
10108
- isRight: pos === "right" || pos === "top-right" || pos === "bottom-right",
10109
- isCenter: pos === "center",
10255
+ spaceLeft: spaceLeft - viewportSpacing,
10256
+ spaceRight: spaceRight - viewportSpacing,
10257
+ spaceAbove: effectiveSpaceAbove,
10258
+ spaceBelow: effectiveSpaceBelow,
10110
10259
  };
10111
10260
  };
10112
10261
 
@@ -11882,17 +12031,6 @@ const getWidthWithoutTransition = (element) =>
11882
12031
  const getHeightWithoutTransition = (element) =>
11883
12032
  getHeight$1(element, transitionStyleController);
11884
12033
 
11885
- const getPaddingSizes = (element) => {
11886
- const { paddingLeft, paddingRight, paddingTop, paddingBottom } =
11887
- window.getComputedStyle(element, null);
11888
- return {
11889
- left: parseFloat(paddingLeft),
11890
- right: parseFloat(paddingRight),
11891
- top: parseFloat(paddingTop),
11892
- bottom: parseFloat(paddingBottom),
11893
- };
11894
- };
11895
-
11896
12034
  const getInnerHeight = (element) => {
11897
12035
  // Always subtract paddings and borders to get the content height
11898
12036
  const paddingSizes = getPaddingSizes(element);
@@ -13233,4 +13371,4 @@ const useResizeStatus = (elementRef, { as = "number" } = {}) => {
13233
13371
  };
13234
13372
  };
13235
13373
 
13236
- 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, resolveOklchLightness, scrollIntoViewScoped, scrollIntoViewWithStickyAwareness, setAttribute, setAttributes, setStyles, startDragToResizeGesture, stickyAsRelativeCoords, stringifyStyle, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect };
13374
+ 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, normalizeStyle, normalizeStyles, parseStyle, pickPositionRelativeTo, prefersDarkColors, prefersLightColors, preventFocusNav, preventFocusNavViaKeyboard, preventIntermediateScrollbar, resolveCSSColor, resolveCSSSize, resolveColorLuminance, resolveOklchLightness, scrollIntoViewScoped, scrollIntoViewWithStickyAwareness, setAttribute, setAttributes, setStyles, snapToPixel, startDragToResizeGesture, stickyAsRelativeCoords, stringifyStyle, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "type": "module",
5
5
  "description": "DOM utilities for writing frontend code",
6
6
  "repository": {