@jsenv/dom 0.10.2 → 0.10.3

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 +200 -109
  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",
@@ -6131,10 +6133,10 @@ const trapScrollInside = (element) => {
6131
6133
  }
6132
6134
  const [scrollbarWidth, scrollbarHeight] = measureScrollbar(el);
6133
6135
  const paddingRight = parseInt(getStyle(el, "padding-right"), 0);
6134
- const paddingTop = parseInt(getStyle(el, "padding-top"), 0);
6136
+ const paddingBottom = parseInt(getStyle(el, "padding-bottom"), 0);
6135
6137
  const removeScrollLockStyles = setStyles(el, {
6136
6138
  "padding-right": `${paddingRight + scrollbarWidth}px`,
6137
- "padding-top": `${paddingTop + scrollbarHeight}px`,
6139
+ "padding-bottom": `${paddingBottom + scrollbarHeight}px`,
6138
6140
  "overflow": "hidden",
6139
6141
  });
6140
6142
  cleanupCallbackSet.add(removeScrollLockStyles);
@@ -9575,6 +9577,10 @@ const stickyAsRelativeCoords = (
9575
9577
  return [leftPosition, topPosition];
9576
9578
  };
9577
9579
 
9580
+ // Minimum fraction of element width/height that must be visible on the preferred side
9581
+ // before flipping to the opposite side. Prevents flickering near the flip threshold.
9582
+ const MIN_CONTENT_VISIBILITY_RATIO = 0.6;
9583
+
9578
9584
  /**
9579
9585
  * Tracks how much of an element is visible within its scrollable parent and within the
9580
9586
  * document viewport. Calls update() on initialization and whenever visibility changes
@@ -9876,47 +9882,52 @@ const visibleRectEffect = (element, update) => {
9876
9882
  };
9877
9883
 
9878
9884
  /**
9879
- * Places element adjacent to anchor using one of 9 compass positions.
9885
+ * Places element relative to anchor with independent control of horizontal and vertical axes.
9880
9886
  *
9881
- * ```
9882
- * top-left | top | top-right
9883
- * ----------+---------+----------
9884
- * left | center | right
9885
- * ----------+---------+----------
9886
- * bottom-left| bottom |bottom-right
9887
- * ```
9887
+ * Horizontal axis — positionX / positionXFixed (left → right):
9888
+ * "to-the-left" element.right = anchor.left (sits entirely to the left of anchor)
9889
+ * "left-aligned" element.left = anchor.left (left edges aligned)
9890
+ * "center" element centered horizontally over anchor (default)
9891
+ * "right-aligned" element.right = anchor.right (right edges aligned)
9892
+ * "to-the-right" element.left = anchor.right (sits entirely to the right of anchor)
9893
+ *
9894
+ * Vertical axis — positionY / positionYFixed (top → bottom):
9895
+ * "above" element.bottom = anchor.top (sits above, no overlap)
9896
+ * "above-overlap" element.bottom = anchor.bottom (sits above, overlapping anchor)
9897
+ * "center" element centered vertically over anchor
9898
+ * "below-overlap" element.top = anchor.top (sits below, overlapping anchor)
9899
+ * "below" element.top = anchor.bottom (sits below, no overlap) (default)
9888
9900
  *
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)
9901
+ * positionX / positionY attempt the requested placement and automatically flip to the
9902
+ * logical opposite when the element does not fit in the viewport:
9903
+ * above below, above-overlap below-overlap
9904
+ *
9905
+ * positionXFixed / positionYFixed skip the fit check entirely.
9906
+ *
9907
+ * The resolved X and Y are persisted as data-position-x-current / data-position-y-current
9908
+ * on the element so subsequent calls start from the last resolved position (avoids
9909
+ * flickering when the element is near the flip threshold). Fixed axes are not persisted.
9899
9910
  *
9900
9911
  * @param {HTMLElement} element - The element to position (must be document-relative)
9901
9912
  * @param {HTMLElement} anchor - The anchor element to position against
9902
9913
  * @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.
9914
+ * @param {string} [options.positionX="center"] - Preferred X placement, with viewport fallback.
9915
+ * @param {string} [options.positionY="below"] - Preferred Y placement, with viewport fallback.
9916
+ * @param {string} [options.positionXFixed] - Force X placement, skipping the fit-check.
9917
+ * @param {string} [options.positionYFixed] - Force Y placement, skipping the fit-check.
9909
9918
  * @param {number} [options.alignToViewportEdgeWhenAnchorNearEdge=0] - Snap to viewport left
9910
9919
  * edge when anchor is within this many px of the left edge and element is wider than anchor.
9911
9920
  * @param {number} [options.minLeft=0] - Minimum left coordinate (document-relative).
9912
- * @returns {{ position, left, top, width, height, anchorLeft, anchorTop, anchorRight, anchorBottom, spaceAbove, spaceBelow }}
9921
+ * @returns {{ positionX, positionY, left, top, width, height, anchorLeft, anchorTop, anchorRight, anchorBottom, spaceLeft, spaceRight, spaceAbove, spaceBelow }}
9913
9922
  */
9914
9923
  const pickPositionRelativeTo = (
9915
9924
  element,
9916
9925
  anchor,
9917
9926
  {
9918
- positionTry = "bottom",
9919
- position,
9927
+ positionX = "center",
9928
+ positionY = "below",
9929
+ positionXFixed,
9930
+ positionYFixed,
9920
9931
  alignToViewportEdgeWhenAnchorNearEdge = 0,
9921
9932
  minLeft = 0,
9922
9933
  } = {},
@@ -9944,61 +9955,151 @@ const pickPositionRelativeTo = (
9944
9955
  const anchorWidth = anchorRight - anchorLeft;
9945
9956
  const anchorHeight = anchorBottom - anchorTop;
9946
9957
 
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
9958
  const spaceAbove = anchorTop;
9962
9959
  const spaceBelow = viewportHeight - anchorBottom;
9960
+ const spaceLeft = anchorLeft;
9961
+ const spaceRight = viewportWidth - anchorRight;
9962
+
9963
+ // Resolve active X and Y, and whether each is fixed (no flip fallback)
9964
+ let activeX;
9965
+ let activeY;
9966
+ const xIsFixed = Boolean(positionXFixed);
9967
+ const yIsFixed = Boolean(positionYFixed);
9968
+ const hasStoredY = Boolean(element.getAttribute("data-position-y-current"));
9969
+ const hasStoredX = Boolean(element.getAttribute("data-position-x-current"));
9970
+ if (xIsFixed) {
9971
+ activeX = positionXFixed;
9972
+ } else {
9973
+ const storedX = element.getAttribute("data-position-x-current");
9974
+ activeX = storedX ?? positionX;
9975
+ }
9976
+ if (yIsFixed) {
9977
+ activeY = positionYFixed;
9978
+ } else {
9979
+ const storedY = element.getAttribute("data-position-y-current");
9980
+ activeY = storedY ?? positionY;
9981
+ }
9963
9982
 
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";
9983
+ // Resolve final Y
9984
+ let finalY;
9985
+ {
9986
+ const oppositeY = {
9987
+ "above": "below",
9988
+ "below": "above",
9989
+ "above-overlap": "below-overlap",
9990
+ "below-overlap": "above-overlap",
9991
+ };
9992
+ // Compute effective space for a given Y value
9993
+ const spaceFor = (y) => {
9994
+ if (y === "above") {
9995
+ return spaceAbove;
9996
+ }
9997
+ if (y === "above-overlap") {
9998
+ return spaceAbove + anchorHeight;
9999
+ }
10000
+ if (y === "below") {
10001
+ return spaceBelow;
10002
+ }
10003
+ if (y === "below-overlap") {
10004
+ return spaceBelow + anchorHeight;
10005
+ }
10006
+ return Infinity; // center
10007
+ };
10008
+ if (yIsFixed || activeY === "center") {
10009
+ finalY = activeY;
10010
+ } else if (!hasStoredY) {
10011
+ // Never positioned before — pick the best side from scratch.
10012
+ const preferred = positionY;
10013
+ const opposite = oppositeY[preferred];
10014
+ const preferredFits = spaceFor(preferred) >= elementHeight;
10015
+ const oppositeFits = spaceFor(opposite) >= elementHeight;
10016
+ if (preferredFits) {
10017
+ // Preferred fits completely — use it (even if opposite also fits)
10018
+ finalY = preferred;
10019
+ } else if (oppositeFits) {
10020
+ // Only opposite fits completely — flip
10021
+ finalY = opposite;
10022
+ } else {
10023
+ // Neither fits completely — use whichever meets the minimum ratio
10024
+ const preferredMeetsRatio =
10025
+ spaceFor(preferred) / elementHeight >= MIN_CONTENT_VISIBILITY_RATIO;
10026
+ finalY = preferredMeetsRatio ? preferred : opposite;
10027
+ }
9980
10028
  } else {
9981
- resolvedVertical = "bottom"; // opposite of top
10029
+ // Previously positioned stay as long as current side meets minimum ratio
10030
+ const currentFitsEnough =
10031
+ spaceFor(activeY) / elementHeight >= MIN_CONTENT_VISIBILITY_RATIO;
10032
+ if (currentFitsEnough) {
10033
+ finalY = activeY;
10034
+ } else {
10035
+ finalY = oppositeY[activeY];
10036
+ }
9982
10037
  }
9983
- } else {
9984
- // isBottom
9985
- const elementFitsBelow = spaceBelow >= elementHeight;
9986
- if (elementFitsBelow) {
9987
- resolvedVertical = "bottom";
10038
+ }
10039
+
10040
+ // Resolve final X
10041
+ let finalX;
10042
+ {
10043
+ const oppositeX = {
10044
+ "to-the-left": "to-the-right",
10045
+ "to-the-right": "to-the-left",
10046
+ "left-aligned": "right-aligned",
10047
+ "right-aligned": "left-aligned",
10048
+ };
10049
+ // Compute effective space for a given X value
10050
+ const spaceFor = (x) => {
10051
+ if (x === "to-the-left") {
10052
+ return spaceLeft;
10053
+ }
10054
+ if (x === "left-aligned") {
10055
+ return viewportWidth - anchorLeft;
10056
+ }
10057
+ if (x === "right-aligned") {
10058
+ return anchorRight;
10059
+ }
10060
+ if (x === "to-the-right") {
10061
+ return spaceRight;
10062
+ }
10063
+ return Infinity; // center
10064
+ };
10065
+ if (xIsFixed || activeX === "center") {
10066
+ finalX = activeX;
10067
+ } else if (!hasStoredX) {
10068
+ // Never positioned before — pick the best side from scratch.
10069
+ const preferred = positionX;
10070
+ const opposite = oppositeX[preferred];
10071
+ const preferredFits = spaceFor(preferred) >= elementWidth;
10072
+ const oppositeFits = spaceFor(opposite) >= elementWidth;
10073
+ if (preferredFits) {
10074
+ finalX = preferred;
10075
+ } else if (oppositeFits) {
10076
+ finalX = opposite;
10077
+ } else {
10078
+ const preferredMeetsRatio =
10079
+ spaceFor(preferred) / elementWidth >= MIN_CONTENT_VISIBILITY_RATIO;
10080
+ finalX = preferredMeetsRatio ? preferred : opposite;
10081
+ }
9988
10082
  } else {
9989
- resolvedVertical = "top"; // opposite of bottom
10083
+ // Previously positioned stay as long as current side meets minimum ratio
10084
+ const currentFitsEnough =
10085
+ spaceFor(activeX) / elementWidth >= MIN_CONTENT_VISIBILITY_RATIO;
10086
+ if (currentFitsEnough) {
10087
+ finalX = activeX;
10088
+ } else {
10089
+ finalX = oppositeX[activeX];
10090
+ }
9990
10091
  }
9991
10092
  }
9992
10093
 
9993
10094
  // Calculate horizontal position (viewport-relative)
9994
10095
  let elementPositionLeft;
9995
10096
  {
9996
- if (isLeft) {
10097
+ if (finalX === "to-the-left") {
9997
10098
  elementPositionLeft = anchorLeft - elementWidth;
9998
- } else if (isRight) {
9999
- elementPositionLeft = anchorRight;
10000
- } else {
10001
- // centered horizontally on anchor
10099
+ } else if (finalX === "left-aligned") {
10100
+ elementPositionLeft = anchorLeft;
10101
+ } else if (finalX === "center") {
10102
+ // Complex logic handles wide anchors and viewport-edge snapping
10002
10103
  const anchorIsWiderThanViewport = anchorWidth > viewportWidth;
10003
10104
  if (anchorIsWiderThanViewport) {
10004
10105
  const anchorLeftIsVisible = anchorLeft >= 0;
@@ -10027,6 +10128,11 @@ const pickPositionRelativeTo = (
10027
10128
  }
10028
10129
  }
10029
10130
  }
10131
+ } else if (finalX === "right-aligned") {
10132
+ elementPositionLeft = anchorRight - elementWidth;
10133
+ } else {
10134
+ // "to-the-right"
10135
+ elementPositionLeft = anchorRight;
10030
10136
  }
10031
10137
  // Constrain horizontal position to viewport boundaries
10032
10138
  if (elementPositionLeft < 0) {
@@ -10039,40 +10145,33 @@ const pickPositionRelativeTo = (
10039
10145
  // Calculate vertical position (viewport-relative)
10040
10146
  let elementPositionTop;
10041
10147
  {
10042
- if (resolvedVertical === "center-y") {
10148
+ if (finalY === "above") {
10149
+ const idealTop = anchorTop - elementHeight;
10150
+ elementPositionTop = idealTop < 0 ? 0 : idealTop;
10151
+ } else if (finalY === "above-overlap") {
10152
+ const idealTop = anchorBottom - elementHeight;
10153
+ elementPositionTop = idealTop < 0 ? 0 : idealTop;
10154
+ } else if (finalY === "center") {
10043
10155
  elementPositionTop = anchorTop + anchorHeight / 2 - elementHeight / 2;
10044
- } else if (resolvedVertical === "bottom") {
10045
- const idealTop = anchorBottom;
10156
+ } else if (finalY === "below-overlap") {
10157
+ const idealTop = anchorTop;
10046
10158
  elementPositionTop =
10047
10159
  idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
10048
10160
  } else {
10049
- // "top"
10050
- const idealTop = anchorTop - elementHeight;
10051
- elementPositionTop = idealTop < 0 ? 0 : idealTop;
10161
+ // "below"
10162
+ const idealTop = anchorBottom;
10163
+ elementPositionTop =
10164
+ idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
10052
10165
  }
10053
10166
  }
10054
10167
 
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
- }
10168
+ // Persist resolved X/Y so subsequent calls start from here (avoids flickering).
10169
+ // Fixed axes are not persisted.
10170
+ if (!xIsFixed) {
10171
+ element.setAttribute("data-position-x-current", finalX);
10068
10172
  }
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);
10173
+ if (!yIsFixed) {
10174
+ element.setAttribute("data-position-y-current", finalY);
10076
10175
  }
10077
10176
 
10078
10177
  // Get document scroll for final coordinate conversion
@@ -10085,7 +10184,8 @@ const pickPositionRelativeTo = (
10085
10184
  const anchorDocumentBottom = anchorBottom + scrollTop;
10086
10185
 
10087
10186
  return {
10088
- position: finalPosition,
10187
+ positionX: finalX,
10188
+ positionY: finalY,
10089
10189
  left: elementDocumentLeft,
10090
10190
  top: elementDocumentTop,
10091
10191
  width: elementWidth,
@@ -10094,21 +10194,12 @@ const pickPositionRelativeTo = (
10094
10194
  anchorTop: anchorDocumentTop,
10095
10195
  anchorRight: anchorDocumentRight,
10096
10196
  anchorBottom: anchorDocumentBottom,
10197
+ spaceLeft,
10198
+ spaceRight,
10097
10199
  spaceAbove,
10098
10200
  spaceBelow,
10099
10201
  };
10100
10202
  };
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",
10110
- };
10111
- };
10112
10203
 
10113
10204
  const [publishDebugger, subscribeDebugger] = createPubSub();
10114
10205
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "type": "module",
5
5
  "description": "DOM utilities for writing frontend code",
6
6
  "repository": {