@jsenv/dom 0.10.1 → 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 +290 -150
  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",
@@ -2084,6 +2086,9 @@ const normalizeStyle = (
2084
2086
  if (propertyName === "transform") {
2085
2087
  if (context === "js") {
2086
2088
  if (typeof value === "string") {
2089
+ if (isCSSKeyword(value)) {
2090
+ return value;
2091
+ }
2087
2092
  // For js context, prefer objects
2088
2093
  return parseCSSTransform(value, normalizeStyle);
2089
2094
  }
@@ -2121,6 +2126,9 @@ const normalizeStyle = (
2121
2126
  if (propertyName === "background") {
2122
2127
  if (context === "js") {
2123
2128
  if (typeof value === "string") {
2129
+ if (isCSSKeyword(value)) {
2130
+ return value;
2131
+ }
2124
2132
  // For js context, prefer objects
2125
2133
  return parseCSSBackground(value, {
2126
2134
  parseStyle,
@@ -2158,6 +2166,9 @@ const normalizeStyle = (
2158
2166
  if (propertyName === "border") {
2159
2167
  if (context === "js") {
2160
2168
  if (typeof value === "string") {
2169
+ if (isCSSKeyword(value)) {
2170
+ return value;
2171
+ }
2161
2172
  // For js context, prefer objects
2162
2173
  return parseCSSBorder(value, element);
2163
2174
  }
@@ -2285,10 +2296,20 @@ const normalizeStyle = (
2285
2296
  }
2286
2297
 
2287
2298
  if (colorPropertySet.has(propertyName)) {
2288
- if (typeof value === "string" && isCSSFunction(value)) {
2289
- return value;
2299
+ if (typeof value === "string") {
2300
+ if (isCSSKeyword(value)) {
2301
+ return value;
2302
+ }
2303
+ if (isCSSFunction(value)) {
2304
+ return value;
2305
+ }
2290
2306
  }
2291
2307
  const rgba = parseCSSColor(value, element);
2308
+ if (rgba === null) {
2309
+ // parseCSSColor could not parse the value (e.g. a CSS variable or unknown keyword)
2310
+ // return as-is so the original string reaches the DOM unchanged
2311
+ return value;
2312
+ }
2292
2313
  if (context === "js") {
2293
2314
  return rgba;
2294
2315
  }
@@ -2307,6 +2328,9 @@ const stringifyStyle = (value, propertyName, element) => {
2307
2328
  const isCSSFunction = (value) => {
2308
2329
  return /^[a-z-]+\(/.test(value);
2309
2330
  };
2331
+ const isCSSKeyword = (value) => {
2332
+ return globalCSSKeywordSet.has(value);
2333
+ };
2310
2334
  const normalizeNumber = (value, { unit, propertyName, preferedType }) => {
2311
2335
  if (typeof value === "string") {
2312
2336
  // CSS variables and CSS functions like calc() must be passed through as-is
@@ -3406,74 +3430,101 @@ const isSameColor = (color1, color2) => {
3406
3430
 
3407
3431
  /**
3408
3432
  * Returns `"white"` or `"black"`, whichever provides better contrast against
3409
- * the given background color mirroring the CSS `contrast-color()` function.
3433
+ * the given background color, using OKLCH lightness (perceptually uniform).
3410
3434
  *
3411
- * `"white"` is preferred when both colors yield the same contrast ratio.
3435
+ * Uses a threshold of 0.5 on the OKLCH L axis (0–1 scale).
3436
+ * Colors with L > threshold are considered light → return "black".
3437
+ * Colors with L ≤ threshold are considered dark → return "white".
3412
3438
  *
3413
3439
  * @param {string} backgroundColor - CSS color value (hex, rgb, hsl, CSS variable, …)
3414
3440
  * @param {Element} [element] - DOM element used to resolve CSS variables / computed styles
3441
+ * @param {number} [lightnessThreshold=0.5] - OKLCH L threshold (0–1). Below → "white", above → "black".
3415
3442
  * @returns {"white"|"black"}
3416
3443
  * @example
3417
- * contrastColor("#1a202c") // "white" (dark background)
3418
- * contrastColor("#f5f5f5") // "black" (light background)
3419
- * contrastColor("var(--bg)", el) // "white" or "black"
3444
+ * contrastColor("#1a202c") // "white" (dark background)
3445
+ * contrastColor("#f5f5f5") // "black" (light background)
3446
+ * contrastColor("#e91e8c") // "white" (vivid pink, perceptually dark)
3420
3447
  */
3421
-
3422
-
3423
- const contrastColor = (backgroundColor, element) => {
3448
+ const contrastColor = (
3449
+ backgroundColor,
3450
+ element,
3451
+ lightnessThreshold = 0.5,
3452
+ ) => {
3424
3453
  const resolvedBgColor = parseCSSColor(backgroundColor, element);
3425
3454
  if (!resolvedBgColor) {
3426
3455
  return "white";
3427
3456
  }
3428
-
3429
- // Composite against white when the background has transparency so the
3430
- // luminance reflects what the user actually sees.
3431
3457
  const [r, g, b] =
3432
3458
  resolvedBgColor[3] === 1
3433
3459
  ? resolvedBgColor
3434
3460
  : compositeColor(resolvedBgColor, WHITE_RGBA);
3435
-
3436
- const bgLuminance = getLuminance(r, g, b);
3437
-
3438
- // One luminance comparison replaces two full contrast-ratio computations.
3439
- // White wins (or ties) when bgLuminance <= the crossover point where both
3440
- // colors yield identical ratios:
3441
- // contrastWithWhite = contrastWithBlack
3442
- // 1.05 / (L + 0.05) = (L + 0.05) / 0.05
3443
- // L = √(1.05 × 0.05) − 0.05 ≈ 0.179
3444
- return bgLuminance <= EQUAL_CONTRAST_LUMINANCE ? "white" : "black";
3461
+ const L = rgbToOklchL(r, g, b);
3462
+ return L <= lightnessThreshold ? "white" : "black";
3445
3463
  };
3446
3464
 
3447
- // Luminance threshold at which white and black yield the same contrast ratio
3448
- // against a background. Below → white wins or ties; above → black wins.
3449
- const EQUAL_CONTRAST_LUMINANCE = Math.sqrt(1.05 * 0.05) - 0.05;
3450
- const WHITE_RGBA = [255, 255, 255, 1];
3451
-
3452
3465
  /**
3453
- * Resolves the luminance value of a CSS color
3466
+ * Resolves the OKLCH lightness of a CSS color (perceptually uniform, 0–1 scale).
3467
+ *
3454
3468
  * @param {string} color - CSS color value (hex, rgb, hsl, CSS variable, etc.)
3455
3469
  * @param {Element} [element] - DOM element to resolve CSS variables against
3456
- * @returns {number|undefined} Relative luminance (0-1) according to WCAG formula, or undefined if color cannot be resolved
3470
+ * @returns {number|null} OKLCH L value (01), or null if color cannot be resolved
3457
3471
  * @example
3458
- * // Get luminance of a hex color
3459
- * resolveColorLuminance("#ff0000") // returns ~0.213 (red)
3460
- *
3461
- * // Get luminance of a CSS variable
3462
- * resolveColorLuminance("var(--primary-color)", element) // returns luminance value or undefined
3463
- *
3464
- * // Use for light/dark classification
3465
- * const luminance = resolveColorLuminance("#2ecc71");
3466
- * const isLight = luminance > 0.3; // true for light colors, false for dark
3472
+ * resolveOklchLightness("#e91e8c") // ~0.56 (vivid pink feels medium-bright)
3473
+ * resolveOklchLightness("#4476ff") // ~0.53 (blue)
3474
+ * resolveOklchLightness("#1a202c") // ~0.22 (dark background)
3475
+ */
3476
+ const resolveOklchLightness = (color, element) => {
3477
+ const rgba = parseCSSColor(color, element);
3478
+ if (!rgba) {
3479
+ return null;
3480
+ }
3481
+ const [r, g, b] = rgba;
3482
+ return rgbToOklchL(r, g, b);
3483
+ };
3484
+
3485
+ /**
3486
+ * Resolves the WCAG relative luminance of a CSS color (kept for backwards compatibility).
3487
+ * @deprecated Prefer resolveOklchLightness for perceptually uniform results.
3467
3488
  */
3468
3489
  const resolveColorLuminance = (color, element) => {
3469
3490
  const rgba = parseCSSColor(color, element);
3470
3491
  if (!rgba) {
3471
- return undefined;
3492
+ return null;
3472
3493
  }
3473
3494
  const [r, g, b] = rgba;
3474
3495
  return getLuminance(r, g, b);
3475
3496
  };
3476
3497
 
3498
+ const WHITE_RGBA = [255, 255, 255, 1];
3499
+
3500
+ /**
3501
+ * Converts sRGB (0–255 each) to OKLCH lightness L (0–1).
3502
+ * Implements the sRGB → Linear sRGB → XYZ D65 → OKLab → L pipeline.
3503
+ */
3504
+ const rgbToOklchL = (r, g, b) => {
3505
+ // sRGB → linear
3506
+ const toLinear = (c) => {
3507
+ c = c / 255;
3508
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
3509
+ };
3510
+ const lr = toLinear(r);
3511
+ const lg = toLinear(g);
3512
+ const lb = toLinear(b);
3513
+
3514
+ // Linear sRGB → LMS (Oklab M1 matrix)
3515
+ const l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb;
3516
+ const m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb;
3517
+ const s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb;
3518
+
3519
+ // Cube root
3520
+ const l_ = Math.cbrt(l);
3521
+ const m_ = Math.cbrt(m);
3522
+ const s_ = Math.cbrt(s);
3523
+
3524
+ // LMS → OKLab L
3525
+ return 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
3526
+ };
3527
+
3477
3528
  /**
3478
3529
  * Calculates the contrast ratio between two RGBA colors
3479
3530
  * Based on WCAG 2.1 specification
@@ -6082,10 +6133,10 @@ const trapScrollInside = (element) => {
6082
6133
  }
6083
6134
  const [scrollbarWidth, scrollbarHeight] = measureScrollbar(el);
6084
6135
  const paddingRight = parseInt(getStyle(el, "padding-right"), 0);
6085
- const paddingTop = parseInt(getStyle(el, "padding-top"), 0);
6136
+ const paddingBottom = parseInt(getStyle(el, "padding-bottom"), 0);
6086
6137
  const removeScrollLockStyles = setStyles(el, {
6087
6138
  "padding-right": `${paddingRight + scrollbarWidth}px`,
6088
- "padding-top": `${paddingTop + scrollbarHeight}px`,
6139
+ "padding-bottom": `${paddingBottom + scrollbarHeight}px`,
6089
6140
  "overflow": "hidden",
6090
6141
  });
6091
6142
  cleanupCallbackSet.add(removeScrollLockStyles);
@@ -9526,6 +9577,10 @@ const stickyAsRelativeCoords = (
9526
9577
  return [leftPosition, topPosition];
9527
9578
  };
9528
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
+
9529
9584
  /**
9530
9585
  * Tracks how much of an element is visible within its scrollable parent and within the
9531
9586
  * document viewport. Calls update() on initialization and whenever visibility changes
@@ -9827,47 +9882,52 @@ const visibleRectEffect = (element, update) => {
9827
9882
  };
9828
9883
 
9829
9884
  /**
9830
- * 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.
9831
9886
  *
9832
- * ```
9833
- * top-left | top | top-right
9834
- * ----------+---------+----------
9835
- * left | center | right
9836
- * ----------+---------+----------
9837
- * bottom-left| bottom |bottom-right
9838
- * ```
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)
9900
+ *
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.
9839
9906
  *
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)
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.
9850
9910
  *
9851
9911
  * @param {HTMLElement} element - The element to position (must be document-relative)
9852
9912
  * @param {HTMLElement} anchor - The anchor element to position against
9853
9913
  * @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.
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.
9860
9918
  * @param {number} [options.alignToViewportEdgeWhenAnchorNearEdge=0] - Snap to viewport left
9861
9919
  * edge when anchor is within this many px of the left edge and element is wider than anchor.
9862
9920
  * @param {number} [options.minLeft=0] - Minimum left coordinate (document-relative).
9863
- * @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 }}
9864
9922
  */
9865
9923
  const pickPositionRelativeTo = (
9866
9924
  element,
9867
9925
  anchor,
9868
9926
  {
9869
- positionTry = "bottom",
9870
- position,
9927
+ positionX = "center",
9928
+ positionY = "below",
9929
+ positionXFixed,
9930
+ positionYFixed,
9871
9931
  alignToViewportEdgeWhenAnchorNearEdge = 0,
9872
9932
  minLeft = 0,
9873
9933
  } = {},
@@ -9895,61 +9955,151 @@ const pickPositionRelativeTo = (
9895
9955
  const anchorWidth = anchorRight - anchorLeft;
9896
9956
  const anchorHeight = anchorBottom - anchorTop;
9897
9957
 
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
9958
  const spaceAbove = anchorTop;
9913
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
+ }
9914
9982
 
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";
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
+ }
9931
10028
  } else {
9932
- 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
+ }
9933
10037
  }
9934
- } else {
9935
- // isBottom
9936
- const elementFitsBelow = spaceBelow >= elementHeight;
9937
- if (elementFitsBelow) {
9938
- 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
+ }
9939
10082
  } else {
9940
- 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
+ }
9941
10091
  }
9942
10092
  }
9943
10093
 
9944
10094
  // Calculate horizontal position (viewport-relative)
9945
10095
  let elementPositionLeft;
9946
10096
  {
9947
- if (isLeft) {
10097
+ if (finalX === "to-the-left") {
9948
10098
  elementPositionLeft = anchorLeft - elementWidth;
9949
- } else if (isRight) {
9950
- elementPositionLeft = anchorRight;
9951
- } else {
9952
- // 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
9953
10103
  const anchorIsWiderThanViewport = anchorWidth > viewportWidth;
9954
10104
  if (anchorIsWiderThanViewport) {
9955
10105
  const anchorLeftIsVisible = anchorLeft >= 0;
@@ -9978,6 +10128,11 @@ const pickPositionRelativeTo = (
9978
10128
  }
9979
10129
  }
9980
10130
  }
10131
+ } else if (finalX === "right-aligned") {
10132
+ elementPositionLeft = anchorRight - elementWidth;
10133
+ } else {
10134
+ // "to-the-right"
10135
+ elementPositionLeft = anchorRight;
9981
10136
  }
9982
10137
  // Constrain horizontal position to viewport boundaries
9983
10138
  if (elementPositionLeft < 0) {
@@ -9990,40 +10145,33 @@ const pickPositionRelativeTo = (
9990
10145
  // Calculate vertical position (viewport-relative)
9991
10146
  let elementPositionTop;
9992
10147
  {
9993
- 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") {
9994
10155
  elementPositionTop = anchorTop + anchorHeight / 2 - elementHeight / 2;
9995
- } else if (resolvedVertical === "bottom") {
9996
- const idealTop = anchorBottom;
10156
+ } else if (finalY === "below-overlap") {
10157
+ const idealTop = anchorTop;
9997
10158
  elementPositionTop =
9998
10159
  idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
9999
10160
  } else {
10000
- // "top"
10001
- const idealTop = anchorTop - elementHeight;
10002
- elementPositionTop = idealTop < 0 ? 0 : idealTop;
10161
+ // "below"
10162
+ const idealTop = anchorBottom;
10163
+ elementPositionTop =
10164
+ idealTop % 1 === 0 ? idealTop : Math.floor(idealTop) + 1;
10003
10165
  }
10004
10166
  }
10005
10167
 
10006
- let finalPosition;
10007
- {
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;
10016
- } else {
10017
- finalPosition = "center";
10018
- }
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);
10019
10172
  }
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);
10173
+ if (!yIsFixed) {
10174
+ element.setAttribute("data-position-y-current", finalY);
10027
10175
  }
10028
10176
 
10029
10177
  // Get document scroll for final coordinate conversion
@@ -10036,7 +10184,8 @@ const pickPositionRelativeTo = (
10036
10184
  const anchorDocumentBottom = anchorBottom + scrollTop;
10037
10185
 
10038
10186
  return {
10039
- position: finalPosition,
10187
+ positionX: finalX,
10188
+ positionY: finalY,
10040
10189
  left: elementDocumentLeft,
10041
10190
  top: elementDocumentTop,
10042
10191
  width: elementWidth,
@@ -10045,21 +10194,12 @@ const pickPositionRelativeTo = (
10045
10194
  anchorTop: anchorDocumentTop,
10046
10195
  anchorRight: anchorDocumentRight,
10047
10196
  anchorBottom: anchorDocumentBottom,
10197
+ spaceLeft,
10198
+ spaceRight,
10048
10199
  spaceAbove,
10049
10200
  spaceBelow,
10050
10201
  };
10051
10202
  };
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",
10061
- };
10062
- };
10063
10203
 
10064
10204
  const [publishDebugger, subscribeDebugger] = createPubSub();
10065
10205
 
@@ -13184,4 +13324,4 @@ const useResizeStatus = (elementRef, { as = "number" } = {}) => {
13184
13324
  };
13185
13325
  };
13186
13326
 
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 };
13327
+ 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "type": "module",
5
5
  "description": "DOM utilities for writing frontend code",
6
6
  "repository": {