@codecademy/gamut

68.2.268.2.3-alpha.eb3f22.0
dist/PopoverContainer/utils.js
~dist/PopoverContainer/utils.jsModified
+44−24
Index: package/dist/PopoverContainer/utils.js
===================================================================
--- package/dist/PopoverContainer/utils.js
+++ package/dist/PopoverContainer/utils.js
@@ -1,5 +1,28 @@
 import { percentageOrAbsolute as percent } from '@codecademy/variance';
+/**
+ * Mirrors placement on the inline axis when the target is RTL so e.g. `bottom-left`
+ * uses the same geometry as `bottom-right` in LTR.
+ */
+export const mirrorAlignment = (alignment, isRtl) => {
+  if (!isRtl) return alignment;
+  switch (alignment) {
+    case 'top-left':
+      return 'top-right';
+    case 'top-right':
+      return 'top-left';
+    case 'bottom-left':
+      return 'bottom-right';
+    case 'bottom-right':
+      return 'bottom-left';
+    case 'left':
+      return 'right';
+    case 'right':
+      return 'left';
+    default:
+      return alignment;
+  }
+};
 const getWindowDimensions = () => ({
   height: window.innerHeight || document.documentElement.clientHeight,
   width: window.innerWidth || document.documentElement.clientWidth
 });
@@ -137,21 +160,18 @@
 
 /**
  * Computes the absolute position styles for a popover relative to a target element.
  *
- * Returns two style objects:
- * - `styles`: position edge values (left/right/top/bottom) passed as variance props so
- *   they are converted to logical properties (inset-inline-start etc.) when
- *   `useLogicalProperties` is enabled. Corner and edge alignments automatically flip
- *   sides in RTL layouts as a result.
- * - `physicalStyles`: applied as an inline `style` prop, bypassing logical conversion.
- *   Used for transforms (CSS transforms have no logical equivalent — `translate(-100%, 0)`
- *   always shifts physically left) and for centered alignments whose `left` value is a
- *   physical screen coordinate that must not flip in RTL.
+ * When `isRtl` is true, {@link mirrorAlignment} maps the requested placement to the
+ * mirrored corner/edge on the inline axis before computing offsets (same viewport math
+ * as LTR).
  *
- * When `invertAxis` is set and `isRtl` is true, the x-transform coefficient is negated
- * so that the shift moves toward the target rather than away from it after logical
- * positions have flipped the element to the opposite physical side.
+ * Returns two fragments that callers merge into inline `style` (they are not Gamut variance
+ * props on the host, so nothing here is swapped to logical properties via `system.positioning`).
+ *
+ * - `styles`: corner/edge inset lengths (`left` / `right` / `top` / `bottom`).
+ * - `dirNeutralStyles`: transforms/coords not further remapped for RTL/logical placement
+ *   after {@link mirrorAlignment}; merged after `styles`.
  */
 export const getPosition = ({
   alignment,
   container,
@@ -160,8 +180,9 @@
   y = 0,
   invertAxis,
   isRtl = false
 }) => {
+  const layoutAlignment = mirrorAlignment(alignment, isRtl);
   const {
     top,
     left,
     bottom,
@@ -170,28 +191,27 @@
     width
   } = container;
   const xOffset = width + offset + x;
   const yOffset = height + offset + y;
-  const alignments = alignment.split('-');
+  const alignments = layoutAlignment.split('-');
   const styles = {};
-  const physicalStyles = {};
+  const dirNeutralStyles = {};
   if (alignments.length === 1) {
     const [direction] = alignments;
     const isVertical = direction === 'top' || direction === 'bottom';
     if (isVertical) {
-      // Center x is a physical screen coordinate — this should not flip in RTL.
-      physicalStyles.left = left + width / 2;
-      physicalStyles.transform = 'translate(-50%, 0)';
+      // Center x uses viewport/layout coords — stays literal after mirrorAlignment under RTL.
+      dirNeutralStyles.left = left + width / 2;
+      dirNeutralStyles.transform = 'translate(-50%, 0)';
     } else {
       styles.top = top + height / 2;
-      physicalStyles.transform = 'translate(0, -50%)';
+      dirNeutralStyles.transform = 'translate(0, -50%)';
     }
   } else {
     const coef = AXIS[invertAxis ?? 'none'];
     const [yAxis, xAxis] = alignments;
-    // Negate x coefficient in RTL so invertAxis shifts toward the target.
-    const xCoef = isRtl ? -coef[xAxis] : coef[xAxis];
-    physicalStyles.transform = `translate(${percent(xCoef)}, ${percent(coef[yAxis])})`;
+    const xCoef = coef[xAxis];
+    dirNeutralStyles.transform = `translate(${percent(xCoef)}, ${percent(coef[yAxis])})`;
   }
   const alignmentOffsets = {
     left: {
       position: 'right',
@@ -209,18 +229,18 @@
       position: 'top',
       value: top + yOffset
     }
   };
-  alignments.forEach(alignment => {
+  alignments.forEach(edge => {
     const {
       position,
       value
-    } = alignmentOffsets[alignment];
+    } = alignmentOffsets[edge];
     styles[position] = value;
   });
   return {
     styles,
-    physicalStyles: Object.keys(physicalStyles).length ? physicalStyles : undefined
+    dirNeutralStyles: Object.keys(dirNeutralStyles).length ? dirNeutralStyles : undefined
   };
 };
 export const getContainers = (target, inline = false, scroll) => {
   const viewport = target.getBoundingClientRect();