@atlaskit/top-layer 0.1.5 → 0.3.0

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 (52) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/cjs/arrow/index.js +1 -1
  3. package/dist/cjs/internal/anchor-positioning-fallback.js +82 -22
  4. package/dist/cjs/internal/resolve-css-length-to-pixels.js +53 -0
  5. package/dist/cjs/internal/resolve-css-length.js +18 -0
  6. package/dist/cjs/internal/resolve-placement.js +47 -6
  7. package/dist/cjs/internal/use-anchor-positioning.js +180 -36
  8. package/dist/cjs/placement-map/index.js +31 -3
  9. package/dist/cjs/popup/popup-content.js +0 -2
  10. package/dist/cjs/testing/polyfill.js +347 -0
  11. package/dist/cjs/testing/to-be-visible.js +92 -0
  12. package/dist/es2019/arrow/index.js +22 -3
  13. package/dist/es2019/internal/anchor-positioning-fallback.js +80 -17
  14. package/dist/es2019/internal/resolve-css-length-to-pixels.js +48 -0
  15. package/dist/es2019/internal/resolve-css-length.js +13 -0
  16. package/dist/es2019/internal/resolve-placement.js +48 -6
  17. package/dist/es2019/internal/use-anchor-positioning.js +173 -28
  18. package/dist/es2019/placement-map/index.js +25 -3
  19. package/dist/es2019/popup/popup-content.js +0 -2
  20. package/dist/es2019/testing/polyfill.js +351 -0
  21. package/dist/es2019/testing/to-be-visible.js +85 -0
  22. package/dist/esm/arrow/index.js +1 -1
  23. package/dist/esm/internal/anchor-positioning-fallback.js +82 -22
  24. package/dist/esm/internal/resolve-css-length-to-pixels.js +47 -0
  25. package/dist/esm/internal/resolve-css-length.js +12 -0
  26. package/dist/esm/internal/resolve-placement.js +48 -6
  27. package/dist/esm/internal/use-anchor-positioning.js +179 -36
  28. package/dist/esm/placement-map/index.js +30 -3
  29. package/dist/esm/popup/popup-content.js +0 -2
  30. package/dist/esm/testing/polyfill.js +345 -0
  31. package/dist/esm/testing/to-be-visible.js +89 -0
  32. package/dist/types/internal/anchor-positioning-fallback.d.ts +11 -9
  33. package/dist/types/internal/resolve-css-length-to-pixels.d.ts +22 -0
  34. package/dist/types/internal/resolve-css-length.d.ts +8 -0
  35. package/dist/types/internal/resolve-placement.d.ts +47 -5
  36. package/dist/types/internal/use-anchor-positioning.d.ts +18 -7
  37. package/dist/types/placement-map/index.d.ts +11 -5
  38. package/dist/types/popup/types.d.ts +15 -30
  39. package/dist/types/testing/polyfill.d.ts +48 -0
  40. package/dist/types/testing/to-be-visible.d.ts +23 -0
  41. package/dist/types-ts4.5/internal/anchor-positioning-fallback.d.ts +11 -9
  42. package/dist/types-ts4.5/internal/resolve-css-length-to-pixels.d.ts +22 -0
  43. package/dist/types-ts4.5/internal/resolve-css-length.d.ts +8 -0
  44. package/dist/types-ts4.5/internal/resolve-placement.d.ts +47 -5
  45. package/dist/types-ts4.5/internal/use-anchor-positioning.d.ts +18 -7
  46. package/dist/types-ts4.5/placement-map/index.d.ts +14 -5
  47. package/dist/types-ts4.5/popup/types.d.ts +15 -30
  48. package/dist/types-ts4.5/testing/polyfill.d.ts +48 -0
  49. package/dist/types-ts4.5/testing/to-be-visible.d.ts +23 -0
  50. package/package.json +3 -2
  51. package/testing/polyfill/package.json +17 -0
  52. package/testing/to-be-visible/package.json +17 -0
@@ -1,6 +1,20 @@
1
+ /**
2
+ * JS fallback positioning math. Used only when the browser does not
3
+ * support CSS Anchor Positioning (less than 6% of users).
4
+ *
5
+ * Consumer-supplied `offset.gap` and `offset.crossAxisShift` are
6
+ * honored: the caller (`useAnchorPosition`) resolves CSS length strings
7
+ * (tokens, calc, var, etc) to pixels via a DOM probe before passing them
8
+ * in. The caller also keeps the popover hidden (`opacity: 0`) until the
9
+ * first measurement completes, so the popover never paints at the wrong
10
+ * position.
11
+ */
12
+
1
13
  /**
2
14
  * Computes the base {top, left} position based on the primary edge,
3
15
  * flipping to the opposite side when there is not enough space.
16
+ *
17
+ * `gap` is applied along the main axis between trigger and popover.
4
18
  */
5
19
  function computeEdgePosition({
6
20
  axis,
@@ -9,14 +23,16 @@ function computeEdgePosition({
9
23
  popoverWidth,
10
24
  popoverHeight,
11
25
  viewport,
12
- offset
26
+ gap
13
27
  }) {
14
28
  if (axis === 'block' && edge === 'end') {
15
29
  // Below trigger
16
30
  const spaceBelow = viewport.height - triggerRect.bottom;
17
31
  const spaceAbove = triggerRect.top;
32
+ const fitsBelow = spaceBelow >= popoverHeight + gap;
33
+ const useBelow = fitsBelow || spaceBelow >= spaceAbove;
18
34
  return {
19
- top: spaceBelow >= popoverHeight + offset || spaceBelow >= spaceAbove ? triggerRect.bottom + offset : triggerRect.top - popoverHeight - offset,
35
+ top: useBelow ? triggerRect.bottom + gap : triggerRect.top - popoverHeight - gap,
20
36
  left: triggerRect.left + triggerRect.width / 2 - popoverWidth / 2
21
37
  };
22
38
  }
@@ -24,8 +40,10 @@ function computeEdgePosition({
24
40
  // Above trigger
25
41
  const spaceAbove = triggerRect.top;
26
42
  const spaceBelow = viewport.height - triggerRect.bottom;
43
+ const fitsAbove = spaceAbove >= popoverHeight + gap;
44
+ const useAbove = fitsAbove || spaceAbove >= spaceBelow;
27
45
  return {
28
- top: spaceAbove >= popoverHeight + offset || spaceAbove >= spaceBelow ? triggerRect.top - popoverHeight - offset : triggerRect.bottom + offset,
46
+ top: useAbove ? triggerRect.top - popoverHeight - gap : triggerRect.bottom + gap,
29
47
  left: triggerRect.left + triggerRect.width / 2 - popoverWidth / 2
30
48
  };
31
49
  }
@@ -33,24 +51,28 @@ function computeEdgePosition({
33
51
  // Right of trigger
34
52
  const spaceRight = viewport.width - triggerRect.right;
35
53
  const spaceLeft = triggerRect.left;
54
+ const fitsRight = spaceRight >= popoverWidth + gap;
55
+ const useRight = fitsRight || spaceRight >= spaceLeft;
36
56
  return {
37
57
  top: triggerRect.top + triggerRect.height / 2 - popoverHeight / 2,
38
- left: spaceRight >= popoverWidth + offset || spaceRight >= spaceLeft ? triggerRect.right + offset : triggerRect.left - popoverWidth - offset
58
+ left: useRight ? triggerRect.right + gap : triggerRect.left - popoverWidth - gap
39
59
  };
40
60
  }
41
61
  if (axis === 'inline' && edge === 'start') {
42
62
  // Left of trigger
43
63
  const spaceLeft = triggerRect.left;
44
64
  const spaceRight = viewport.width - triggerRect.right;
65
+ const fitsLeft = spaceLeft >= popoverWidth + gap;
66
+ const useLeft = fitsLeft || spaceLeft >= spaceRight;
45
67
  return {
46
68
  top: triggerRect.top + triggerRect.height / 2 - popoverHeight / 2,
47
- left: spaceLeft >= popoverWidth + offset || spaceLeft >= spaceRight ? triggerRect.left - popoverWidth - offset : triggerRect.right + offset
69
+ left: useLeft ? triggerRect.left - popoverWidth - gap : triggerRect.right + gap
48
70
  };
49
71
  }
50
72
 
51
73
  // Unreachable for valid placements; defaults to below trigger
52
74
  return {
53
- top: triggerRect.bottom + offset,
75
+ top: triggerRect.bottom + gap,
54
76
  left: triggerRect.left + triggerRect.width / 2 - popoverWidth / 2
55
77
  };
56
78
  }
@@ -102,21 +124,55 @@ function applyAlignment({
102
124
  }
103
125
 
104
126
  /**
105
- * JS fallback: compute popover position from trigger rect and viewport.
127
+ * Applies the cross-axis shift offset.
106
128
  *
107
- * Placement algorithm (from the document):
108
- * - "block" placement: show above/below, picking the side with more space if flipping needed
109
- * - "inline" placement: show left/right, picking the side with more space if flipping needed
110
- * - "auto" placement: show on the edge with the most available space
129
+ * The sign mirrors the CSS path (see `crossAxisShiftMargin` in
130
+ * `use-anchor-positioning.tsx`):
131
+ * - `align: 'start' | 'center'` + `forwards` → push toward end (positive)
132
+ * - `align: 'end'` + `forwards` → push toward start (negative)
133
+ * - `backwards` flips the sign in both cases.
134
+ */
135
+ function applyCrossAxisShift({
136
+ position,
137
+ axis,
138
+ align,
139
+ crossAxisShift
140
+ }) {
141
+ if (crossAxisShift.value === 0) {
142
+ return position;
143
+ }
144
+ const directionSign = crossAxisShift.direction === 'forwards' ? 1 : -1;
145
+ const sideSign = align === 'end' ? -1 : 1;
146
+ const delta = crossAxisShift.value * directionSign * sideSign;
147
+
148
+ // Cross-axis is the OPPOSITE of the placement axis.
149
+ if (axis === 'block') {
150
+ return {
151
+ ...position,
152
+ left: position.left + delta
153
+ };
154
+ }
155
+ return {
156
+ ...position,
157
+ top: position.top + delta
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Computes popover position from trigger rect and viewport, flipping to
163
+ * the side with more space when needed. Re-runs on scroll/resize.
111
164
  *
112
- * Re-runs on scroll (capture on window) and resize events.
165
+ * `gap` and `crossAxisShift` are pre-resolved to pixels by the
166
+ * caller (see `resolveCssLengthToPixels`), so this function stays purely
167
+ * numeric.
113
168
  */
114
169
  export function computeFallbackPosition({
115
170
  triggerRect,
116
171
  popoverEl,
117
172
  placement,
118
173
  viewport,
119
- offset
174
+ gap,
175
+ crossAxisShift
120
176
  }) {
121
177
  const {
122
178
  axis,
@@ -124,7 +180,8 @@ export function computeFallbackPosition({
124
180
  align
125
181
  } = placement;
126
182
 
127
- // Measure popover dimensions (it might not have layout yet if just shown)
183
+ // Measure popover dimensions. Callers must ensure the popover has
184
+ // laid out before calling this (it should not be `display: none`).
128
185
  const popoverWidth = popoverEl.offsetWidth;
129
186
  const popoverHeight = popoverEl.offsetHeight;
130
187
  const basePosition = computeEdgePosition({
@@ -134,7 +191,7 @@ export function computeFallbackPosition({
134
191
  popoverWidth,
135
192
  popoverHeight,
136
193
  viewport,
137
- offset
194
+ gap
138
195
  });
139
196
  const alignedPosition = applyAlignment({
140
197
  position: basePosition,
@@ -144,10 +201,16 @@ export function computeFallbackPosition({
144
201
  popoverWidth,
145
202
  popoverHeight
146
203
  });
204
+ const shiftedPosition = applyCrossAxisShift({
205
+ position: alignedPosition,
206
+ axis,
207
+ align,
208
+ crossAxisShift
209
+ });
147
210
 
148
211
  // Clamp to viewport so the popover is never offscreen
149
212
  return {
150
- top: Math.max(0, Math.min(alignedPosition.top, viewport.height - popoverHeight)),
151
- left: Math.max(0, Math.min(alignedPosition.left, viewport.width - popoverWidth))
213
+ top: Math.max(0, Math.min(shiftedPosition.top, viewport.height - popoverHeight)),
214
+ left: Math.max(0, Math.min(shiftedPosition.left, viewport.width - popoverWidth))
152
215
  };
153
216
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Resolves a CSS length value to a pixel number using a hidden DOM probe.
3
+ *
4
+ * Used by the JS positioning fallback: consumer offsets can be numbers,
5
+ * `${n}px` strings, tokenised `var(--ds-space-100, 8px)` strings, `calc()`
6
+ * expressions, viewport units, etc. Parsing arbitrary CSS in JS is fragile
7
+ * (and impossible for `calc(var(...))` without a layout context), so we let
8
+ * the browser do the math.
9
+ *
10
+ * The probe is appended to `container` so it inherits the same containing
11
+ * block, font size, and custom-property scope as the popover. We use `width`
12
+ * because it accepts any CSS length, including negative `calc()` results
13
+ * (negative widths are clamped to 0 — see note below).
14
+ *
15
+ * Cost: one synchronous reflow per call. The fallback only resolves on
16
+ * placement/offset changes, not on every scroll/resize update, so this is
17
+ * not a hot path.
18
+ */
19
+ export function resolveCssLengthToPixels({
20
+ value,
21
+ container
22
+ }) {
23
+ if (typeof value === 'number') {
24
+ return value;
25
+ }
26
+
27
+ // Fast path: bare `${n}px` strings come from the consumer normalisation
28
+ // in `getPlacement`. Avoid a reflow when we can.
29
+ const pxMatch = value.match(/^(-?\d+(?:\.\d+)?)px$/);
30
+ if (pxMatch) {
31
+ return Number(pxMatch[1]);
32
+ }
33
+ const probe = container.ownerDocument.createElement('div');
34
+ probe.style.position = 'absolute';
35
+ probe.style.visibility = 'hidden';
36
+ probe.style.pointerEvents = 'none';
37
+ probe.style.height = '0';
38
+ probe.style.padding = '0';
39
+ probe.style.border = '0';
40
+ // `width` clamps negatives to 0; use `margin-left` instead so signs are
41
+ // preserved. `getComputedStyle` returns the resolved pixel value for
42
+ // margin-* properties even when the source was a token / calc / var.
43
+ probe.style.marginLeft = value;
44
+ container.appendChild(probe);
45
+ const resolved = parseFloat(getComputedStyle(probe).marginLeft);
46
+ container.removeChild(probe);
47
+ return Number.isFinite(resolved) ? resolved : 0;
48
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Normalizes an offset to a CSS length string. Numbers become `${n}px`;
3
+ * strings pass through. Apply at the API boundary so internal code only
4
+ * deals with strings.
5
+ */
6
+ export function toCssLengthString({
7
+ value
8
+ }) {
9
+ if (typeof value === 'number') {
10
+ return `${value}px`;
11
+ }
12
+ return value;
13
+ }
@@ -1,19 +1,61 @@
1
+ import { toCssLengthString } from './resolve-css-length';
2
+
3
+ /**
4
+ * Default gap between trigger and popover. Resolves to a design
5
+ * token (`space.100`) so the value follows the design system's spacing
6
+ * scale. Used when `placement.offset.gap` is not provided.
7
+ */
8
+ const DEFAULT_GAP = "var(--ds-space-100, 8px)";
9
+
10
+ /**
11
+ * Cross-axis shift. `value` is a CSS length string (consumer numbers are
12
+ * normalized to `${n}px`). `direction` is `'forwards'` (toward end) or
13
+ * `'backwards'` (toward start).
14
+ */
15
+
16
+ /**
17
+ * Fully-resolved placement (internal). Use `getPlacement` for partial input.
18
+ * `offset.gap` and `offset.crossAxisShift.value` are always CSS
19
+ * length strings.
20
+ */
21
+
1
22
  /**
2
- * Fully-resolved placement (internal). All fields are required.
3
- * Use getPlacement({ placement: partial }) to obtain from partial input.
23
+ * Partial placement options. `offset.gap` and
24
+ * `offset.crossAxisShift.value` accept either a number (pixels) or a
25
+ * CSS length string (e.g. `token('space.100')`).
26
+ *
27
+ * The two offset axes are named for clarity at the call site:
28
+ * - `gap` — distance away from the trigger along the placement axis
29
+ * - `crossAxisShift` — nudge along the trigger edge (perpendicular to the placement axis)
4
30
  */
5
31
 
6
32
  /**
7
- * Resolves a partial placement to its fully-specified form.
8
- * Defaults: `axis: 'block'`, `edge: 'end'`, `align: 'center'`.
33
+ * Resolves a partial placement to its fully-specified form. Number offsets
34
+ * are normalized to `${n}px` strings.
35
+ *
36
+ * Defaults: `axis: 'block'`, `edge: 'end'`, `align: 'center'`,
37
+ * `offset.gap: token('space.100', '8px')`,
38
+ * `offset.crossAxisShift: { value: '0px', direction: 'forwards' }`.
9
39
  */
10
40
  export function getPlacement({
11
41
  placement
12
42
  }) {
13
- var _placement$axis, _placement$edge, _placement$align;
43
+ var _placement$offset, _placement$axis, _placement$edge, _placement$align, _placement$offset$cro, _placement$offset2, _placement$offset2$cr, _placement$offset$cro2, _placement$offset3, _placement$offset3$cr;
44
+ const consumerGap = (_placement$offset = placement.offset) === null || _placement$offset === void 0 ? void 0 : _placement$offset.gap;
14
45
  return {
15
46
  axis: (_placement$axis = placement.axis) !== null && _placement$axis !== void 0 ? _placement$axis : 'block',
16
47
  edge: (_placement$edge = placement.edge) !== null && _placement$edge !== void 0 ? _placement$edge : 'end',
17
- align: (_placement$align = placement.align) !== null && _placement$align !== void 0 ? _placement$align : 'center'
48
+ align: (_placement$align = placement.align) !== null && _placement$align !== void 0 ? _placement$align : 'center',
49
+ offset: {
50
+ gap: consumerGap === undefined ? DEFAULT_GAP : toCssLengthString({
51
+ value: consumerGap
52
+ }),
53
+ crossAxisShift: {
54
+ value: toCssLengthString({
55
+ value: (_placement$offset$cro = (_placement$offset2 = placement.offset) === null || _placement$offset2 === void 0 ? void 0 : (_placement$offset2$cr = _placement$offset2.crossAxisShift) === null || _placement$offset2$cr === void 0 ? void 0 : _placement$offset2$cr.value) !== null && _placement$offset$cro !== void 0 ? _placement$offset$cro : 0
56
+ }),
57
+ direction: (_placement$offset$cro2 = (_placement$offset3 = placement.offset) === null || _placement$offset3 === void 0 ? void 0 : (_placement$offset3$cr = _placement$offset3.crossAxisShift) === null || _placement$offset3$cr === void 0 ? void 0 : _placement$offset3$cr.direction) !== null && _placement$offset$cro2 !== void 0 ? _placement$offset$cro2 : 'forwards'
58
+ }
59
+ }
18
60
  };
19
61
  }
@@ -1,9 +1,24 @@
1
+ /**
2
+ * Anchor positioning for top-layer popovers. See
3
+ * `notes/architecture/positioning.md` for the full model.
4
+ *
5
+ * Two runtime paths: CSS Anchor Positioning (modern browsers) and a JS
6
+ * fallback that measures the trigger and writes `top`/`left`.
7
+ *
8
+ * Both paths honour `offset.gap` and `offset.crossAxisShift`. The
9
+ * CSS path passes the values through as strings; the JS fallback resolves
10
+ * them to pixels via a hidden DOM probe (see `resolveCssLengthToPixels`)
11
+ * so tokens, `calc()`, `var()`, etc all work. The JS fallback hides the
12
+ * popover with `opacity: 0` until the first measurement completes, so it
13
+ * is never painted at the wrong location.
14
+ */
1
15
  import { useId, useLayoutEffect } from 'react';
2
16
  import { bind } from 'bind-event-listener';
3
17
  import rafSchedule from 'raf-schd';
4
18
  import once from '@atlaskit/ds-lib/once';
5
19
  import { computeFallbackPosition } from './anchor-positioning-fallback';
6
20
  import { combine } from './combine';
21
+ import { resolveCssLengthToPixels } from './resolve-css-length-to-pixels';
7
22
  import { getPlacement } from './resolve-placement';
8
23
  import { setStyle } from './set-style';
9
24
  export { getPlacement } from './resolve-placement';
@@ -91,6 +106,19 @@ export function placementToTryFallbacks({
91
106
  return [`${sameEdge} span-${crossAxis}-end`, `${sameEdge} span-${crossAxis}-start`, flipKeyword, `${oppositeEdge} span-${crossAxis}-end`, `${oppositeEdge} span-${crossAxis}-start`].join(', ');
92
107
  }
93
108
 
109
+ /**
110
+ * Returns true when the popover is currently open. Wrapped because the
111
+ * `:popover-open` pseudo-class is not implemented in some test environments
112
+ * (e.g. jsdom), where `matches` throws on unknown selectors.
113
+ */
114
+ function isPopoverOpen(popover) {
115
+ try {
116
+ return popover.matches(':popover-open');
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
94
122
  /**
95
123
  * Detects whether the browser supports CSS Anchor Positioning.
96
124
  * Uses `once()` for lazy evaluation — safe for SSR and avoids hydration mismatches.
@@ -102,12 +130,6 @@ const supportsAnchorPositioning = once(() => {
102
130
  return CSS.supports('anchor-name', '--a');
103
131
  });
104
132
 
105
- /**
106
- * Default gap between the popover and its trigger (in pixels).
107
- * Maps to `space.100` (8px) in the design token scale.
108
- */
109
- const DEFAULT_OFFSET = 8;
110
-
111
133
  /**
112
134
  * Returns the CSS margin declaration that creates a gap between the
113
135
  * popover and its trigger on the side facing the anchor.
@@ -123,7 +145,7 @@ function edgeMargin({
123
145
  axis,
124
146
  edge
125
147
  } = placement;
126
- const value = `${offset}px`;
148
+ const value = offset;
127
149
  if (axis === 'block' && edge === 'end') {
128
150
  return {
129
151
  property: 'margin-block-start',
@@ -154,6 +176,36 @@ function edgeMargin({
154
176
  };
155
177
  }
156
178
 
179
+ /**
180
+ * Cross-axis shift margin. Margin side matches the popover's anchored
181
+ * cross-axis edge: `align: 'start'` uses the START margin, `align: 'end'`
182
+ * uses the END margin with the sign inverted (so `forwards` is always
183
+ * positive), `align: 'center'` uses START. Margin on the un-anchored side
184
+ * has no effect under CSS Anchor Positioning.
185
+ */
186
+ function crossAxisShiftMargin({
187
+ placement,
188
+ crossAxisShiftCssValue,
189
+ direction
190
+ }) {
191
+ const {
192
+ axis,
193
+ align
194
+ } = placement;
195
+ const directionSign = direction === 'forwards' ? 1 : -1;
196
+ const crossAxis = axis === 'block' ? 'inline' : 'block';
197
+ const useEndSide = align === 'end';
198
+ const side = useEndSide ? 'end' : 'start';
199
+ const sideSign = useEndSide ? -1 : 1;
200
+ const finalSign = directionSign * sideSign;
201
+ // Wrap any string in calc() with the sign factor. CSS handles the math.
202
+ const value = finalSign === 1 ? crossAxisShiftCssValue : `calc(-1 * ${crossAxisShiftCssValue})`;
203
+ return {
204
+ property: `margin-${crossAxis}-${side}`,
205
+ value
206
+ };
207
+ }
208
+
157
209
  /**
158
210
  * Hook that positions an element relative to an anchor element using
159
211
  * CSS Anchor Positioning (with a JS fallback for older browsers).
@@ -174,7 +226,6 @@ export function useAnchorPosition({
174
226
  anchorRef,
175
227
  popoverRef,
176
228
  placement,
177
- offset = DEFAULT_OFFSET,
178
229
  forceFallbackPositioning = false,
179
230
  arrow
180
231
  }) {
@@ -189,11 +240,18 @@ export function useAnchorPosition({
189
240
  const resolvedPlacement = getPlacement({
190
241
  placement
191
242
  });
243
+ const gapCssValue = resolvedPlacement.offset.gap;
244
+ const crossAxisShiftCssValue = resolvedPlacement.offset.crossAxisShift.value;
192
245
  const shouldForceFallback = Boolean(forceFallbackPositioning);
193
246
  if (supportsAnchorPositioning() && !shouldForceFallback) {
194
247
  const gap = edgeMargin({
195
248
  placement: resolvedPlacement,
196
- offset
249
+ offset: gapCssValue
250
+ });
251
+ const crossAxisShift = crossAxisShiftMargin({
252
+ placement: resolvedPlacement,
253
+ crossAxisShiftCssValue,
254
+ direction: resolvedPlacement.offset.crossAxisShift.direction
197
255
  });
198
256
  const popoverStyles = [{
199
257
  property: 'position-anchor',
@@ -220,17 +278,62 @@ export function useAnchorPosition({
220
278
  property: 'inset',
221
279
  value: 'auto'
222
280
  },
223
- // Gap between the popover and its trigger
281
+ // Main-axis gap between the popover and its trigger
224
282
  {
225
283
  property: gap.property,
226
284
  value: gap.value
285
+ },
286
+ // Cross-axis shift
287
+ {
288
+ property: crossAxisShift.property,
289
+ value: crossAxisShift.value
227
290
  }];
291
+
292
+ // Surface the active cross-axis shift value as a custom property so
293
+ // each named arrow @position-try rule can re-apply it after a flip.
294
+ // The four properties cover (cross-axis, side); only one is non-zero
295
+ // at a time.
296
+ popoverStyles.push({
297
+ property: '--ds-cross-axis-shift-margin-start',
298
+ value: '0px'
299
+ });
300
+ popoverStyles.push({
301
+ property: '--ds-cross-axis-shift-margin-end',
302
+ value: '0px'
303
+ });
304
+ popoverStyles.push({
305
+ property: '--ds-cross-axis-shift-margin-block-start',
306
+ value: '0px'
307
+ });
308
+ popoverStyles.push({
309
+ property: '--ds-cross-axis-shift-margin-block-end',
310
+ value: '0px'
311
+ });
312
+ const crossAxisShiftActive = crossAxisShiftMargin({
313
+ placement: resolvedPlacement,
314
+ crossAxisShiftCssValue,
315
+ direction: resolvedPlacement.offset.crossAxisShift.direction
316
+ });
317
+ // Map margin property to its corresponding custom property name.
318
+ const customPropertyByMarginProperty = {
319
+ 'margin-inline-start': '--ds-cross-axis-shift-margin-start',
320
+ 'margin-inline-end': '--ds-cross-axis-shift-margin-end',
321
+ 'margin-block-start': '--ds-cross-axis-shift-margin-block-start',
322
+ 'margin-block-end': '--ds-cross-axis-shift-margin-block-end'
323
+ };
324
+ const crossAxisShiftCustomProperty = customPropertyByMarginProperty[crossAxisShiftActive.property];
325
+ if (crossAxisShiftCustomProperty) {
326
+ popoverStyles.push({
327
+ property: crossAxisShiftCustomProperty,
328
+ value: crossAxisShiftActive.value
329
+ });
330
+ }
228
331
  if (arrow) {
229
332
  // Set the arrow size variable used by the injected arrow CSS
230
333
  // and the @position-try rules for margin values.
231
334
  popoverStyles.push({
232
335
  property: '--ds-arrow-size',
233
- value: `${offset}px`
336
+ value: gapCssValue
234
337
  });
235
338
  // Mark the popover root as the arrow host. The ARROW_CSS targets
236
339
  // [data-ds-popover-arrow] to apply clip-path: inset(0) margin-box
@@ -266,6 +369,7 @@ export function useAnchorPosition({
266
369
  }]
267
370
  });
268
371
  function update() {
372
+ var _popover$parentElemen;
269
373
  if (!trigger || !popover) {
270
374
  return;
271
375
  }
@@ -274,6 +378,22 @@ export function useAnchorPosition({
274
378
  width: window.innerWidth,
275
379
  height: window.innerHeight
276
380
  };
381
+
382
+ // Resolve consumer-supplied CSS length strings (tokens, calc, var,
383
+ // px, rem, etc) to pixels. The probe is mounted next to the popover
384
+ // so it inherits the same custom-property scope and font size.
385
+ // `popover.parentElement` is guaranteed to exist for any rendered
386
+ // popover (the popover itself is appended somewhere in the tree),
387
+ // but fall back to the popover element if not for safety.
388
+ const probeContainer = (_popover$parentElemen = popover.parentElement) !== null && _popover$parentElemen !== void 0 ? _popover$parentElemen : popover;
389
+ const gapPx = resolveCssLengthToPixels({
390
+ value: gapCssValue,
391
+ container: probeContainer
392
+ });
393
+ const crossAxisShiftPx = resolveCssLengthToPixels({
394
+ value: crossAxisShiftCssValue,
395
+ container: probeContainer
396
+ });
277
397
  const {
278
398
  top,
279
399
  left
@@ -282,34 +402,61 @@ export function useAnchorPosition({
282
402
  popoverEl: popover,
283
403
  placement: resolvedPlacement,
284
404
  viewport,
285
- offset
405
+ gap: gapPx,
406
+ crossAxisShift: {
407
+ value: crossAxisShiftPx,
408
+ direction: resolvedPlacement.offset.crossAxisShift.direction
409
+ }
286
410
  });
287
411
  popover.style.setProperty('top', `${top}px`);
288
412
  popover.style.setProperty('left', `${left}px`);
413
+ // Reveal the popover only after it has been positioned.
414
+ // See the toggle listener below for why we hide on open.
415
+ popover.style.removeProperty('opacity');
289
416
  }
290
417
 
291
418
  // Throttle scroll/resize updates to one per animation frame
292
419
  const scheduledUpdate = rafSchedule(update);
293
- let pendingFrame = null;
420
+
421
+ // Wait for the FIRST valid layout before measuring: the popover
422
+ // is `display: none` until `showPopover()`, and some browsers
423
+ // fire `toggle` before layout, so RAF-after-toggle is unreliable.
424
+ // ResizeObserver fires once the browser has real dimensions.
425
+ // Self-disconnects after one valid measurement; ongoing
426
+ // scroll/resize is handled by the window listeners below.
427
+ const resizeObserver = new ResizeObserver(() => {
428
+ if (popover.offsetWidth > 0 && popover.offsetHeight > 0) {
429
+ update();
430
+ resizeObserver.disconnect();
431
+ }
432
+ });
433
+
434
+ // If the popover is already open by the time this effect runs (for
435
+ // example, the parent `useAnchorPosition` effect runs after the child
436
+ // `Popover` effect that called `showPopover()`), the initial `toggle`
437
+ // event has already fired and our listener below would miss it. Start
438
+ // observing immediately so the first measurement still happens.
439
+ if (isPopoverOpen(popover)) {
440
+ popover.style.setProperty('opacity', '0');
441
+ resizeObserver.observe(popover);
442
+ }
294
443
  const undoPositioning = combine(cleanupBaseStyles,
295
- // Position when the popover transitions to visible.
296
- // The popover starts hidden (`display: none` per the popover
297
- // UA stylesheet), so `offsetWidth`/`offsetHeight` are 0 until
298
- // `showPopover()` is called. We defer positioning to the next
299
- // animation frame because some browsers (Firefox) fire the
300
- // `toggle` event synchronously during `showPopover()`, before
301
- // the element has been laid out — so dimensions are still 0.
302
- // Waiting one frame guarantees layout has run and measurements
303
- // are accurate.
444
+ // On every open: hide synchronously so the user never sees
445
+ // the UA-default position, then re-observe for a fresh
446
+ // measurement. We use `opacity: 0` rather than
447
+ // `visibility: hidden` because Firefox skips visibility-hidden
448
+ // elements during `<dialog>` initial-focus traversal — see
449
+ // `form-in-popup.spec.tsx` on `desktop-firefox`.
304
450
  bind(popover, {
305
451
  type: 'toggle',
306
452
  listener: event => {
307
453
  const toggleEvent = event;
308
454
  if (toggleEvent.newState === 'open') {
309
- pendingFrame = requestAnimationFrame(update);
455
+ popover.style.setProperty('opacity', '0');
456
+ resizeObserver.observe(popover);
310
457
  }
311
458
  }
312
- }), bind(window, {
459
+ }), () => resizeObserver.disconnect(), bind(window, {
313
460
  type: 'scroll',
314
461
  listener: scheduledUpdate,
315
462
  options: {
@@ -324,12 +471,10 @@ export function useAnchorPosition({
324
471
  }
325
472
  }), () => {
326
473
  scheduledUpdate.cancel();
327
- if (pendingFrame !== null) {
328
- cancelAnimationFrame(pendingFrame);
329
- }
330
474
  popover.style.removeProperty('top');
331
475
  popover.style.removeProperty('left');
476
+ popover.style.removeProperty('opacity');
332
477
  });
333
478
  return undoPositioning;
334
- }, [anchorRef, popoverRef, placement, anchorName, offset, forceFallbackPositioning, arrow]);
479
+ }, [anchorRef, popoverRef, placement, anchorName, forceFallbackPositioning, arrow]);
335
480
  }
@@ -128,12 +128,34 @@ const placementMapping = {
128
128
  };
129
129
 
130
130
  /**
131
- * Convert a legacy Popper.js placement to the new CSS `position-area`-based Placement.
131
+ * Convert a legacy Popper.js placement to the new `position-area`-based Placement.
132
+ * Optional `offset` is the legacy Popper `[along, away]` tuple (also known as
133
+ * `[skidding, distance]`); negative `along` becomes `direction: 'backwards'`.
134
+ *
135
+ * The tuple keeps the historical popper names so call sites that already pass
136
+ * `[along, away]` migrate without thinking; the new internal field names
137
+ * (`gap` / `crossAxisShift`) are produced inside this adapter.
132
138
  */
133
139
  export function fromLegacyPlacement({
134
- legacy
140
+ legacy,
141
+ offset
135
142
  }) {
136
- return placementMapping[legacy];
143
+ const basePlacement = placementMapping[legacy];
144
+ if (!offset) {
145
+ return basePlacement;
146
+ }
147
+ const [along, away] = offset;
148
+ return {
149
+ ...basePlacement,
150
+ offset: {
151
+ gap: away,
152
+ crossAxisShift: {
153
+ // Sign is captured in `direction`; `value` is the magnitude.
154
+ value: Math.abs(along),
155
+ direction: along >= 0 ? 'forwards' : 'backwards'
156
+ }
157
+ }
158
+ };
137
159
  }
138
160
 
139
161
  /**