@atlaskit/top-layer 0.1.5 → 0.2.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 (40) hide show
  1. package/CHANGELOG.md +24 -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/es2019/arrow/index.js +22 -3
  11. package/dist/es2019/internal/anchor-positioning-fallback.js +80 -17
  12. package/dist/es2019/internal/resolve-css-length-to-pixels.js +48 -0
  13. package/dist/es2019/internal/resolve-css-length.js +13 -0
  14. package/dist/es2019/internal/resolve-placement.js +48 -6
  15. package/dist/es2019/internal/use-anchor-positioning.js +173 -28
  16. package/dist/es2019/placement-map/index.js +25 -3
  17. package/dist/es2019/popup/popup-content.js +0 -2
  18. package/dist/esm/arrow/index.js +1 -1
  19. package/dist/esm/internal/anchor-positioning-fallback.js +82 -22
  20. package/dist/esm/internal/resolve-css-length-to-pixels.js +47 -0
  21. package/dist/esm/internal/resolve-css-length.js +12 -0
  22. package/dist/esm/internal/resolve-placement.js +48 -6
  23. package/dist/esm/internal/use-anchor-positioning.js +179 -36
  24. package/dist/esm/placement-map/index.js +30 -3
  25. package/dist/esm/popup/popup-content.js +0 -2
  26. package/dist/types/internal/anchor-positioning-fallback.d.ts +11 -9
  27. package/dist/types/internal/resolve-css-length-to-pixels.d.ts +22 -0
  28. package/dist/types/internal/resolve-css-length.d.ts +8 -0
  29. package/dist/types/internal/resolve-placement.d.ts +47 -5
  30. package/dist/types/internal/use-anchor-positioning.d.ts +18 -7
  31. package/dist/types/placement-map/index.d.ts +11 -5
  32. package/dist/types/popup/types.d.ts +15 -30
  33. package/dist/types-ts4.5/internal/anchor-positioning-fallback.d.ts +11 -9
  34. package/dist/types-ts4.5/internal/resolve-css-length-to-pixels.d.ts +22 -0
  35. package/dist/types-ts4.5/internal/resolve-css-length.d.ts +8 -0
  36. package/dist/types-ts4.5/internal/resolve-placement.d.ts +47 -5
  37. package/dist/types-ts4.5/internal/use-anchor-positioning.d.ts +18 -7
  38. package/dist/types-ts4.5/placement-map/index.d.ts +14 -5
  39. package/dist/types-ts4.5/popup/types.d.ts +15 -30
  40. package/package.json +2 -2
@@ -19,8 +19,24 @@ var _rafSchd = _interopRequireDefault(require("raf-schd"));
19
19
  var _once = _interopRequireDefault(require("@atlaskit/ds-lib/once"));
20
20
  var _anchorPositioningFallback = require("./anchor-positioning-fallback");
21
21
  var _combine = require("./combine");
22
+ var _resolveCssLengthToPixels = require("./resolve-css-length-to-pixels");
22
23
  var _resolvePlacement = require("./resolve-placement");
23
24
  var _setStyle = require("./set-style");
25
+ /**
26
+ * Anchor positioning for top-layer popovers. See
27
+ * `notes/architecture/positioning.md` for the full model.
28
+ *
29
+ * Two runtime paths: CSS Anchor Positioning (modern browsers) and a JS
30
+ * fallback that measures the trigger and writes `top`/`left`.
31
+ *
32
+ * Both paths honour `offset.gap` and `offset.crossAxisShift`. The
33
+ * CSS path passes the values through as strings; the JS fallback resolves
34
+ * them to pixels via a hidden DOM probe (see `resolveCssLengthToPixels`)
35
+ * so tokens, `calc()`, `var()`, etc all work. The JS fallback hides the
36
+ * popover with `opacity: 0` until the first measurement completes, so it
37
+ * is never painted at the wrong location.
38
+ */
39
+
24
40
  /**
25
41
  * Resolves placement options to CSS `position-area` syntax.
26
42
  *
@@ -100,6 +116,19 @@ function placementToTryFallbacks(_ref2) {
100
116
  return ["".concat(sameEdge, " span-").concat(crossAxis, "-end"), "".concat(sameEdge, " span-").concat(crossAxis, "-start"), flipKeyword, "".concat(oppositeEdge, " span-").concat(crossAxis, "-end"), "".concat(oppositeEdge, " span-").concat(crossAxis, "-start")].join(', ');
101
117
  }
102
118
 
119
+ /**
120
+ * Returns true when the popover is currently open. Wrapped because the
121
+ * `:popover-open` pseudo-class is not implemented in some test environments
122
+ * (e.g. jsdom), where `matches` throws on unknown selectors.
123
+ */
124
+ function isPopoverOpen(popover) {
125
+ try {
126
+ return popover.matches(':popover-open');
127
+ } catch (_unused) {
128
+ return false;
129
+ }
130
+ }
131
+
103
132
  /**
104
133
  * Detects whether the browser supports CSS Anchor Positioning.
105
134
  * Uses `once()` for lazy evaluation — safe for SSR and avoids hydration mismatches.
@@ -111,12 +140,6 @@ var supportsAnchorPositioning = (0, _once.default)(function () {
111
140
  return CSS.supports('anchor-name', '--a');
112
141
  });
113
142
 
114
- /**
115
- * Default gap between the popover and its trigger (in pixels).
116
- * Maps to `space.100` (8px) in the design token scale.
117
- */
118
- var DEFAULT_OFFSET = 8;
119
-
120
143
  /**
121
144
  * Returns the CSS margin declaration that creates a gap between the
122
145
  * popover and its trigger on the side facing the anchor.
@@ -129,7 +152,7 @@ function edgeMargin(_ref3) {
129
152
  offset = _ref3.offset;
130
153
  var axis = placement.axis,
131
154
  edge = placement.edge;
132
- var value = "".concat(offset, "px");
155
+ var value = offset;
133
156
  if (axis === 'block' && edge === 'end') {
134
157
  return {
135
158
  property: 'margin-block-start',
@@ -160,6 +183,33 @@ function edgeMargin(_ref3) {
160
183
  };
161
184
  }
162
185
 
186
+ /**
187
+ * Cross-axis shift margin. Margin side matches the popover's anchored
188
+ * cross-axis edge: `align: 'start'` uses the START margin, `align: 'end'`
189
+ * uses the END margin with the sign inverted (so `forwards` is always
190
+ * positive), `align: 'center'` uses START. Margin on the un-anchored side
191
+ * has no effect under CSS Anchor Positioning.
192
+ */
193
+ function crossAxisShiftMargin(_ref4) {
194
+ var placement = _ref4.placement,
195
+ crossAxisShiftCssValue = _ref4.crossAxisShiftCssValue,
196
+ direction = _ref4.direction;
197
+ var axis = placement.axis,
198
+ align = placement.align;
199
+ var directionSign = direction === 'forwards' ? 1 : -1;
200
+ var crossAxis = axis === 'block' ? 'inline' : 'block';
201
+ var useEndSide = align === 'end';
202
+ var side = useEndSide ? 'end' : 'start';
203
+ var sideSign = useEndSide ? -1 : 1;
204
+ var finalSign = directionSign * sideSign;
205
+ // Wrap any string in calc() with the sign factor. CSS handles the math.
206
+ var value = finalSign === 1 ? crossAxisShiftCssValue : "calc(-1 * ".concat(crossAxisShiftCssValue, ")");
207
+ return {
208
+ property: "margin-".concat(crossAxis, "-").concat(side),
209
+ value: value
210
+ };
211
+ }
212
+
163
213
  /**
164
214
  * Hook that positions an element relative to an anchor element using
165
215
  * CSS Anchor Positioning (with a JS fallback for older browsers).
@@ -176,15 +226,13 @@ function edgeMargin(_ref3) {
176
226
  * `position: fixed` with measured coordinates, re-running on scroll (capture)
177
227
  * and resize events.
178
228
  */
179
- function useAnchorPosition(_ref4) {
180
- var anchorRef = _ref4.anchorRef,
181
- popoverRef = _ref4.popoverRef,
182
- placement = _ref4.placement,
183
- _ref4$offset = _ref4.offset,
184
- offset = _ref4$offset === void 0 ? DEFAULT_OFFSET : _ref4$offset,
185
- _ref4$forceFallbackPo = _ref4.forceFallbackPositioning,
186
- forceFallbackPositioning = _ref4$forceFallbackPo === void 0 ? false : _ref4$forceFallbackPo,
187
- arrow = _ref4.arrow;
229
+ function useAnchorPosition(_ref5) {
230
+ var anchorRef = _ref5.anchorRef,
231
+ popoverRef = _ref5.popoverRef,
232
+ placement = _ref5.placement,
233
+ _ref5$forceFallbackPo = _ref5.forceFallbackPositioning,
234
+ forceFallbackPositioning = _ref5$forceFallbackPo === void 0 ? false : _ref5$forceFallbackPo,
235
+ arrow = _ref5.arrow;
188
236
  var id = (0, _react.useId)();
189
237
  var anchorName = "--anchor-".concat(id.replace(/:/g, ''));
190
238
  (0, _react.useLayoutEffect)(function () {
@@ -196,11 +244,18 @@ function useAnchorPosition(_ref4) {
196
244
  var resolvedPlacement = (0, _resolvePlacement.getPlacement)({
197
245
  placement: placement
198
246
  });
247
+ var gapCssValue = resolvedPlacement.offset.gap;
248
+ var crossAxisShiftCssValue = resolvedPlacement.offset.crossAxisShift.value;
199
249
  var shouldForceFallback = Boolean(forceFallbackPositioning);
200
250
  if (supportsAnchorPositioning() && !shouldForceFallback) {
201
251
  var gap = edgeMargin({
202
252
  placement: resolvedPlacement,
203
- offset: offset
253
+ offset: gapCssValue
254
+ });
255
+ var crossAxisShift = crossAxisShiftMargin({
256
+ placement: resolvedPlacement,
257
+ crossAxisShiftCssValue: crossAxisShiftCssValue,
258
+ direction: resolvedPlacement.offset.crossAxisShift.direction
204
259
  });
205
260
  var popoverStyles = [{
206
261
  property: 'position-anchor',
@@ -227,17 +282,62 @@ function useAnchorPosition(_ref4) {
227
282
  property: 'inset',
228
283
  value: 'auto'
229
284
  },
230
- // Gap between the popover and its trigger
285
+ // Main-axis gap between the popover and its trigger
231
286
  {
232
287
  property: gap.property,
233
288
  value: gap.value
289
+ },
290
+ // Cross-axis shift
291
+ {
292
+ property: crossAxisShift.property,
293
+ value: crossAxisShift.value
234
294
  }];
295
+
296
+ // Surface the active cross-axis shift value as a custom property so
297
+ // each named arrow @position-try rule can re-apply it after a flip.
298
+ // The four properties cover (cross-axis, side); only one is non-zero
299
+ // at a time.
300
+ popoverStyles.push({
301
+ property: '--ds-cross-axis-shift-margin-start',
302
+ value: '0px'
303
+ });
304
+ popoverStyles.push({
305
+ property: '--ds-cross-axis-shift-margin-end',
306
+ value: '0px'
307
+ });
308
+ popoverStyles.push({
309
+ property: '--ds-cross-axis-shift-margin-block-start',
310
+ value: '0px'
311
+ });
312
+ popoverStyles.push({
313
+ property: '--ds-cross-axis-shift-margin-block-end',
314
+ value: '0px'
315
+ });
316
+ var crossAxisShiftActive = crossAxisShiftMargin({
317
+ placement: resolvedPlacement,
318
+ crossAxisShiftCssValue: crossAxisShiftCssValue,
319
+ direction: resolvedPlacement.offset.crossAxisShift.direction
320
+ });
321
+ // Map margin property to its corresponding custom property name.
322
+ var customPropertyByMarginProperty = {
323
+ 'margin-inline-start': '--ds-cross-axis-shift-margin-start',
324
+ 'margin-inline-end': '--ds-cross-axis-shift-margin-end',
325
+ 'margin-block-start': '--ds-cross-axis-shift-margin-block-start',
326
+ 'margin-block-end': '--ds-cross-axis-shift-margin-block-end'
327
+ };
328
+ var crossAxisShiftCustomProperty = customPropertyByMarginProperty[crossAxisShiftActive.property];
329
+ if (crossAxisShiftCustomProperty) {
330
+ popoverStyles.push({
331
+ property: crossAxisShiftCustomProperty,
332
+ value: crossAxisShiftActive.value
333
+ });
334
+ }
235
335
  if (arrow) {
236
336
  // Set the arrow size variable used by the injected arrow CSS
237
337
  // and the @position-try rules for margin values.
238
338
  popoverStyles.push({
239
339
  property: '--ds-arrow-size',
240
- value: "".concat(offset, "px")
340
+ value: gapCssValue
241
341
  });
242
342
  // Mark the popover root as the arrow host. The ARROW_CSS targets
243
343
  // [data-ds-popover-arrow] to apply clip-path: inset(0) margin-box
@@ -275,6 +375,7 @@ function useAnchorPosition(_ref4) {
275
375
  }]
276
376
  });
277
377
  function update() {
378
+ var _popover$parentElemen;
278
379
  if (!trigger || !popover) {
279
380
  return;
280
381
  }
@@ -283,41 +384,86 @@ function useAnchorPosition(_ref4) {
283
384
  width: window.innerWidth,
284
385
  height: window.innerHeight
285
386
  };
387
+
388
+ // Resolve consumer-supplied CSS length strings (tokens, calc, var,
389
+ // px, rem, etc) to pixels. The probe is mounted next to the popover
390
+ // so it inherits the same custom-property scope and font size.
391
+ // `popover.parentElement` is guaranteed to exist for any rendered
392
+ // popover (the popover itself is appended somewhere in the tree),
393
+ // but fall back to the popover element if not for safety.
394
+ var probeContainer = (_popover$parentElemen = popover.parentElement) !== null && _popover$parentElemen !== void 0 ? _popover$parentElemen : popover;
395
+ var gapPx = (0, _resolveCssLengthToPixels.resolveCssLengthToPixels)({
396
+ value: gapCssValue,
397
+ container: probeContainer
398
+ });
399
+ var crossAxisShiftPx = (0, _resolveCssLengthToPixels.resolveCssLengthToPixels)({
400
+ value: crossAxisShiftCssValue,
401
+ container: probeContainer
402
+ });
286
403
  var _computeFallbackPosit = (0, _anchorPositioningFallback.computeFallbackPosition)({
287
404
  triggerRect: triggerRect,
288
405
  popoverEl: popover,
289
406
  placement: resolvedPlacement,
290
407
  viewport: viewport,
291
- offset: offset
408
+ gap: gapPx,
409
+ crossAxisShift: {
410
+ value: crossAxisShiftPx,
411
+ direction: resolvedPlacement.offset.crossAxisShift.direction
412
+ }
292
413
  }),
293
414
  top = _computeFallbackPosit.top,
294
415
  left = _computeFallbackPosit.left;
295
416
  popover.style.setProperty('top', "".concat(top, "px"));
296
417
  popover.style.setProperty('left', "".concat(left, "px"));
418
+ // Reveal the popover only after it has been positioned.
419
+ // See the toggle listener below for why we hide on open.
420
+ popover.style.removeProperty('opacity');
297
421
  }
298
422
 
299
423
  // Throttle scroll/resize updates to one per animation frame
300
424
  var scheduledUpdate = (0, _rafSchd.default)(update);
301
- var pendingFrame = null;
425
+
426
+ // Wait for the FIRST valid layout before measuring: the popover
427
+ // is `display: none` until `showPopover()`, and some browsers
428
+ // fire `toggle` before layout, so RAF-after-toggle is unreliable.
429
+ // ResizeObserver fires once the browser has real dimensions.
430
+ // Self-disconnects after one valid measurement; ongoing
431
+ // scroll/resize is handled by the window listeners below.
432
+ var resizeObserver = new ResizeObserver(function () {
433
+ if (popover.offsetWidth > 0 && popover.offsetHeight > 0) {
434
+ update();
435
+ resizeObserver.disconnect();
436
+ }
437
+ });
438
+
439
+ // If the popover is already open by the time this effect runs (for
440
+ // example, the parent `useAnchorPosition` effect runs after the child
441
+ // `Popover` effect that called `showPopover()`), the initial `toggle`
442
+ // event has already fired and our listener below would miss it. Start
443
+ // observing immediately so the first measurement still happens.
444
+ if (isPopoverOpen(popover)) {
445
+ popover.style.setProperty('opacity', '0');
446
+ resizeObserver.observe(popover);
447
+ }
302
448
  var undoPositioning = (0, _combine.combine)(cleanupBaseStyles,
303
- // Position when the popover transitions to visible.
304
- // The popover starts hidden (`display: none` per the popover
305
- // UA stylesheet), so `offsetWidth`/`offsetHeight` are 0 until
306
- // `showPopover()` is called. We defer positioning to the next
307
- // animation frame because some browsers (Firefox) fire the
308
- // `toggle` event synchronously during `showPopover()`, before
309
- // the element has been laid out — so dimensions are still 0.
310
- // Waiting one frame guarantees layout has run and measurements
311
- // are accurate.
449
+ // On every open: hide synchronously so the user never sees
450
+ // the UA-default position, then re-observe for a fresh
451
+ // measurement. We use `opacity: 0` rather than
452
+ // `visibility: hidden` because Firefox skips visibility-hidden
453
+ // elements during `<dialog>` initial-focus traversal — see
454
+ // `form-in-popup.spec.tsx` on `desktop-firefox`.
312
455
  (0, _bindEventListener.bind)(popover, {
313
456
  type: 'toggle',
314
457
  listener: function listener(event) {
315
458
  var toggleEvent = event;
316
459
  if (toggleEvent.newState === 'open') {
317
- pendingFrame = requestAnimationFrame(update);
460
+ popover.style.setProperty('opacity', '0');
461
+ resizeObserver.observe(popover);
318
462
  }
319
463
  }
320
- }), (0, _bindEventListener.bind)(window, {
464
+ }), function () {
465
+ return resizeObserver.disconnect();
466
+ }, (0, _bindEventListener.bind)(window, {
321
467
  type: 'scroll',
322
468
  listener: scheduledUpdate,
323
469
  options: {
@@ -332,12 +478,10 @@ function useAnchorPosition(_ref4) {
332
478
  }
333
479
  }), function () {
334
480
  scheduledUpdate.cancel();
335
- if (pendingFrame !== null) {
336
- cancelAnimationFrame(pendingFrame);
337
- }
338
481
  popover.style.removeProperty('top');
339
482
  popover.style.removeProperty('left');
483
+ popover.style.removeProperty('opacity');
340
484
  });
341
485
  return undoPositioning;
342
- }, [anchorRef, popoverRef, placement, anchorName, offset, forceFallbackPositioning, arrow]);
486
+ }, [anchorRef, popoverRef, placement, anchorName, forceFallbackPositioning, arrow]);
343
487
  }
@@ -1,10 +1,15 @@
1
1
  "use strict";
2
2
 
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
3
4
  Object.defineProperty(exports, "__esModule", {
4
5
  value: true
5
6
  });
6
7
  exports.fromLegacyPlacement = fromLegacyPlacement;
7
8
  exports.placementMapping = void 0;
9
+ var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
10
+ var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray"));
11
+ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
12
+ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
8
13
  /**
9
14
  * Legacy Popper.js placement values (used by `@atlaskit/popper`, `@atlaskit/popup`,
10
15
  * `@atlaskit/tooltip`, etc.).
@@ -135,11 +140,34 @@ var placementMapping = exports.placementMapping = {
135
140
  };
136
141
 
137
142
  /**
138
- * Convert a legacy Popper.js placement to the new CSS `position-area`-based Placement.
143
+ * Convert a legacy Popper.js placement to the new `position-area`-based Placement.
144
+ * Optional `offset` is the legacy Popper `[along, away]` tuple (also known as
145
+ * `[skidding, distance]`); negative `along` becomes `direction: 'backwards'`.
146
+ *
147
+ * The tuple keeps the historical popper names so call sites that already pass
148
+ * `[along, away]` migrate without thinking; the new internal field names
149
+ * (`gap` / `crossAxisShift`) are produced inside this adapter.
139
150
  */
140
151
  function fromLegacyPlacement(_ref) {
141
- var legacy = _ref.legacy;
142
- return placementMapping[legacy];
152
+ var legacy = _ref.legacy,
153
+ offset = _ref.offset;
154
+ var basePlacement = placementMapping[legacy];
155
+ if (!offset) {
156
+ return basePlacement;
157
+ }
158
+ var _offset = (0, _slicedToArray2.default)(offset, 2),
159
+ along = _offset[0],
160
+ away = _offset[1];
161
+ return _objectSpread(_objectSpread({}, basePlacement), {}, {
162
+ offset: {
163
+ gap: away,
164
+ crossAxisShift: {
165
+ // Sign is captured in `direction`; `value` is the magnitude.
166
+ value: Math.abs(along),
167
+ direction: along >= 0 ? 'forwards' : 'backwards'
168
+ }
169
+ }
170
+ });
143
171
  }
144
172
 
145
173
  /**
@@ -41,7 +41,6 @@ var PopupContent = exports.PopupContent = /*#__PURE__*/(0, _react.forwardRef)(fu
41
41
  placementProp = _ref.placement,
42
42
  onCloseProp = _ref.onClose,
43
43
  testIdProp = _ref.testId,
44
- offsetProp = _ref.offset,
45
44
  forceFallbackPositioningProp = _ref.forceFallbackPositioning;
46
45
  // When inside <Popup>, context provides defaults. Props always win.
47
46
  var ctx = (0, _popupContext.useMaybePopupContext)();
@@ -138,7 +137,6 @@ var PopupContent = exports.PopupContent = /*#__PURE__*/(0, _react.forwardRef)(fu
138
137
  },
139
138
  popoverRef: popoverRef,
140
139
  placement: placement,
141
- offset: offsetProp,
142
140
  forceFallbackPositioning: forceFallbackPositioning,
143
141
  arrow: arrowPreset !== null && arrowPreset !== void 0 ? arrowPreset : undefined
144
142
  });
@@ -79,70 +79,89 @@ const ARROW_CSS = `
79
79
  * inline-end (right of anchor) → margin-inline-start (left, facing anchor)
80
80
  */
81
81
 
82
- /* Single-axis centered */
82
+ /* Each rule sets two margins: the anchor-facing margin for the gap
83
+ (via --ds-arrow-size) and one cross-axis margin for the shift
84
+ (via one of --ds-cross-axis-shift-margin-start, --ds-cross-axis-shift-margin-end,
85
+ --ds-cross-axis-shift-margin-block-start, or --ds-cross-axis-shift-margin-block-end). The
86
+ side matches the popover's anchored cross-axis edge per align. Only
87
+ one of the four properties is non-zero at a time; useAnchorPosition
88
+ picks it. See notes/architecture/positioning.md for details. */
89
+ /* Single-axis centered (align: center) */
83
90
  @position-try --ds-arrow-block-start {
84
91
  position-area: block-start;
85
92
  margin: 0;
86
93
  margin-block-end: var(--ds-arrow-size, 8px);
94
+ margin-inline-start: var(--ds-cross-axis-shift-margin-start, 0px);
87
95
  }
88
96
  @position-try --ds-arrow-block-end {
89
97
  position-area: block-end;
90
98
  margin: 0;
91
99
  margin-block-start: var(--ds-arrow-size, 8px);
100
+ margin-inline-start: var(--ds-cross-axis-shift-margin-start, 0px);
92
101
  }
93
102
  @position-try --ds-arrow-inline-start {
94
103
  position-area: inline-start;
95
104
  margin: 0;
96
105
  margin-inline-end: var(--ds-arrow-size, 8px);
106
+ margin-block-start: var(--ds-cross-axis-shift-margin-block-start, 0px);
97
107
  }
98
108
  @position-try --ds-arrow-inline-end {
99
109
  position-area: inline-end;
100
110
  margin: 0;
101
111
  margin-inline-start: var(--ds-arrow-size, 8px);
112
+ margin-block-start: var(--ds-cross-axis-shift-margin-block-start, 0px);
102
113
  }
103
114
 
104
- /* Block edge + inline span */
115
+ /* Block edge + inline span (align: start uses span-inline-end; align: end uses span-inline-start) */
105
116
  @position-try --ds-arrow-block-start-span-inline-start {
106
117
  position-area: block-start span-inline-start;
107
118
  margin: 0;
108
119
  margin-block-end: var(--ds-arrow-size, 8px);
120
+ margin-inline-end: var(--ds-cross-axis-shift-margin-end, 0px);
109
121
  }
110
122
  @position-try --ds-arrow-block-start-span-inline-end {
111
123
  position-area: block-start span-inline-end;
112
124
  margin: 0;
113
125
  margin-block-end: var(--ds-arrow-size, 8px);
126
+ margin-inline-start: var(--ds-cross-axis-shift-margin-start, 0px);
114
127
  }
115
128
  @position-try --ds-arrow-block-end-span-inline-start {
116
129
  position-area: block-end span-inline-start;
117
130
  margin: 0;
118
131
  margin-block-start: var(--ds-arrow-size, 8px);
132
+ margin-inline-end: var(--ds-cross-axis-shift-margin-end, 0px);
119
133
  }
120
134
  @position-try --ds-arrow-block-end-span-inline-end {
121
135
  position-area: block-end span-inline-end;
122
136
  margin: 0;
123
137
  margin-block-start: var(--ds-arrow-size, 8px);
138
+ margin-inline-start: var(--ds-cross-axis-shift-margin-start, 0px);
124
139
  }
125
140
 
126
- /* Inline edge + block span */
141
+ /* Inline edge + block span (align: start uses span-block-end; align: end uses span-block-start) */
127
142
  @position-try --ds-arrow-inline-start-span-block-start {
128
143
  position-area: inline-start span-block-start;
129
144
  margin: 0;
130
145
  margin-inline-end: var(--ds-arrow-size, 8px);
146
+ margin-block-end: var(--ds-cross-axis-shift-margin-block-end, 0px);
131
147
  }
132
148
  @position-try --ds-arrow-inline-start-span-block-end {
133
149
  position-area: inline-start span-block-end;
134
150
  margin: 0;
135
151
  margin-inline-end: var(--ds-arrow-size, 8px);
152
+ margin-block-start: var(--ds-cross-axis-shift-margin-block-start, 0px);
136
153
  }
137
154
  @position-try --ds-arrow-inline-end-span-block-start {
138
155
  position-area: inline-end span-block-start;
139
156
  margin: 0;
140
157
  margin-inline-start: var(--ds-arrow-size, 8px);
158
+ margin-block-end: var(--ds-cross-axis-shift-margin-block-end, 0px);
141
159
  }
142
160
  @position-try --ds-arrow-inline-end-span-block-end {
143
161
  position-area: inline-end span-block-end;
144
162
  margin: 0;
145
163
  margin-inline-start: var(--ds-arrow-size, 8px);
164
+ margin-block-start: var(--ds-cross-axis-shift-margin-block-start, 0px);
146
165
  }
147
166
  `;
148
167
 
@@ -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
  }