@atlaskit/top-layer 0.1.4 → 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 +32 -0
  2. package/dist/cjs/arrow/index.js +2 -2
  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 +181 -37
  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 +24 -5
  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 +174 -29
  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 +2 -2
  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 +180 -37
  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
@@ -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';
@@ -87,6 +102,19 @@ export function placementToTryFallbacks(_ref2) {
87
102
  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(', ');
88
103
  }
89
104
 
105
+ /**
106
+ * Returns true when the popover is currently open. Wrapped because the
107
+ * `:popover-open` pseudo-class is not implemented in some test environments
108
+ * (e.g. jsdom), where `matches` throws on unknown selectors.
109
+ */
110
+ function isPopoverOpen(popover) {
111
+ try {
112
+ return popover.matches(':popover-open');
113
+ } catch (_unused) {
114
+ return false;
115
+ }
116
+ }
117
+
90
118
  /**
91
119
  * Detects whether the browser supports CSS Anchor Positioning.
92
120
  * Uses `once()` for lazy evaluation — safe for SSR and avoids hydration mismatches.
@@ -98,12 +126,6 @@ var supportsAnchorPositioning = once(function () {
98
126
  return CSS.supports('anchor-name', '--a');
99
127
  });
100
128
 
101
- /**
102
- * Default gap between the popover and its trigger (in pixels).
103
- * Maps to `space.100` (8px) in the design token scale.
104
- */
105
- var DEFAULT_OFFSET = 8;
106
-
107
129
  /**
108
130
  * Returns the CSS margin declaration that creates a gap between the
109
131
  * popover and its trigger on the side facing the anchor.
@@ -116,7 +138,7 @@ function edgeMargin(_ref3) {
116
138
  offset = _ref3.offset;
117
139
  var axis = placement.axis,
118
140
  edge = placement.edge;
119
- var value = "".concat(offset, "px");
141
+ var value = offset;
120
142
  if (axis === 'block' && edge === 'end') {
121
143
  return {
122
144
  property: 'margin-block-start',
@@ -147,6 +169,33 @@ function edgeMargin(_ref3) {
147
169
  };
148
170
  }
149
171
 
172
+ /**
173
+ * Cross-axis shift margin. Margin side matches the popover's anchored
174
+ * cross-axis edge: `align: 'start'` uses the START margin, `align: 'end'`
175
+ * uses the END margin with the sign inverted (so `forwards` is always
176
+ * positive), `align: 'center'` uses START. Margin on the un-anchored side
177
+ * has no effect under CSS Anchor Positioning.
178
+ */
179
+ function crossAxisShiftMargin(_ref4) {
180
+ var placement = _ref4.placement,
181
+ crossAxisShiftCssValue = _ref4.crossAxisShiftCssValue,
182
+ direction = _ref4.direction;
183
+ var axis = placement.axis,
184
+ align = placement.align;
185
+ var directionSign = direction === 'forwards' ? 1 : -1;
186
+ var crossAxis = axis === 'block' ? 'inline' : 'block';
187
+ var useEndSide = align === 'end';
188
+ var side = useEndSide ? 'end' : 'start';
189
+ var sideSign = useEndSide ? -1 : 1;
190
+ var finalSign = directionSign * sideSign;
191
+ // Wrap any string in calc() with the sign factor. CSS handles the math.
192
+ var value = finalSign === 1 ? crossAxisShiftCssValue : "calc(-1 * ".concat(crossAxisShiftCssValue, ")");
193
+ return {
194
+ property: "margin-".concat(crossAxis, "-").concat(side),
195
+ value: value
196
+ };
197
+ }
198
+
150
199
  /**
151
200
  * Hook that positions an element relative to an anchor element using
152
201
  * CSS Anchor Positioning (with a JS fallback for older browsers).
@@ -163,15 +212,13 @@ function edgeMargin(_ref3) {
163
212
  * `position: fixed` with measured coordinates, re-running on scroll (capture)
164
213
  * and resize events.
165
214
  */
166
- export function useAnchorPosition(_ref4) {
167
- var anchorRef = _ref4.anchorRef,
168
- popoverRef = _ref4.popoverRef,
169
- placement = _ref4.placement,
170
- _ref4$offset = _ref4.offset,
171
- offset = _ref4$offset === void 0 ? DEFAULT_OFFSET : _ref4$offset,
172
- _ref4$forceFallbackPo = _ref4.forceFallbackPositioning,
173
- forceFallbackPositioning = _ref4$forceFallbackPo === void 0 ? false : _ref4$forceFallbackPo,
174
- arrow = _ref4.arrow;
215
+ export function useAnchorPosition(_ref5) {
216
+ var anchorRef = _ref5.anchorRef,
217
+ popoverRef = _ref5.popoverRef,
218
+ placement = _ref5.placement,
219
+ _ref5$forceFallbackPo = _ref5.forceFallbackPositioning,
220
+ forceFallbackPositioning = _ref5$forceFallbackPo === void 0 ? false : _ref5$forceFallbackPo,
221
+ arrow = _ref5.arrow;
175
222
  var id = useId();
176
223
  var anchorName = "--anchor-".concat(id.replace(/:/g, ''));
177
224
  useLayoutEffect(function () {
@@ -183,11 +230,18 @@ export function useAnchorPosition(_ref4) {
183
230
  var resolvedPlacement = getPlacement({
184
231
  placement: placement
185
232
  });
233
+ var gapCssValue = resolvedPlacement.offset.gap;
234
+ var crossAxisShiftCssValue = resolvedPlacement.offset.crossAxisShift.value;
186
235
  var shouldForceFallback = Boolean(forceFallbackPositioning);
187
236
  if (supportsAnchorPositioning() && !shouldForceFallback) {
188
237
  var gap = edgeMargin({
189
238
  placement: resolvedPlacement,
190
- offset: offset
239
+ offset: gapCssValue
240
+ });
241
+ var crossAxisShift = crossAxisShiftMargin({
242
+ placement: resolvedPlacement,
243
+ crossAxisShiftCssValue: crossAxisShiftCssValue,
244
+ direction: resolvedPlacement.offset.crossAxisShift.direction
191
245
  });
192
246
  var popoverStyles = [{
193
247
  property: 'position-anchor',
@@ -214,20 +268,65 @@ export function useAnchorPosition(_ref4) {
214
268
  property: 'inset',
215
269
  value: 'auto'
216
270
  },
217
- // Gap between the popover and its trigger
271
+ // Main-axis gap between the popover and its trigger
218
272
  {
219
273
  property: gap.property,
220
274
  value: gap.value
275
+ },
276
+ // Cross-axis shift
277
+ {
278
+ property: crossAxisShift.property,
279
+ value: crossAxisShift.value
221
280
  }];
281
+
282
+ // Surface the active cross-axis shift value as a custom property so
283
+ // each named arrow @position-try rule can re-apply it after a flip.
284
+ // The four properties cover (cross-axis, side); only one is non-zero
285
+ // at a time.
286
+ popoverStyles.push({
287
+ property: '--ds-cross-axis-shift-margin-start',
288
+ value: '0px'
289
+ });
290
+ popoverStyles.push({
291
+ property: '--ds-cross-axis-shift-margin-end',
292
+ value: '0px'
293
+ });
294
+ popoverStyles.push({
295
+ property: '--ds-cross-axis-shift-margin-block-start',
296
+ value: '0px'
297
+ });
298
+ popoverStyles.push({
299
+ property: '--ds-cross-axis-shift-margin-block-end',
300
+ value: '0px'
301
+ });
302
+ var crossAxisShiftActive = crossAxisShiftMargin({
303
+ placement: resolvedPlacement,
304
+ crossAxisShiftCssValue: crossAxisShiftCssValue,
305
+ direction: resolvedPlacement.offset.crossAxisShift.direction
306
+ });
307
+ // Map margin property to its corresponding custom property name.
308
+ var customPropertyByMarginProperty = {
309
+ 'margin-inline-start': '--ds-cross-axis-shift-margin-start',
310
+ 'margin-inline-end': '--ds-cross-axis-shift-margin-end',
311
+ 'margin-block-start': '--ds-cross-axis-shift-margin-block-start',
312
+ 'margin-block-end': '--ds-cross-axis-shift-margin-block-end'
313
+ };
314
+ var crossAxisShiftCustomProperty = customPropertyByMarginProperty[crossAxisShiftActive.property];
315
+ if (crossAxisShiftCustomProperty) {
316
+ popoverStyles.push({
317
+ property: crossAxisShiftCustomProperty,
318
+ value: crossAxisShiftActive.value
319
+ });
320
+ }
222
321
  if (arrow) {
223
322
  // Set the arrow size variable used by the injected arrow CSS
224
323
  // and the @position-try rules for margin values.
225
324
  popoverStyles.push({
226
325
  property: '--ds-arrow-size',
227
- value: "".concat(offset, "px")
326
+ value: gapCssValue
228
327
  });
229
328
  // Mark the popover root as the arrow host. The ARROW_CSS targets
230
- // [data-ds-popover-arrow] to apply clip-path: inset(1px) margin-box
329
+ // [data-ds-popover-arrow] to apply clip-path: inset(0) margin-box
231
330
  // and box-shadow: none. Must be on the popover root (not a child)
232
331
  // because that's where useAnchorPosition sets the margin gap.
233
332
  popover.setAttribute('data-ds-popover-arrow', '');
@@ -262,6 +361,7 @@ export function useAnchorPosition(_ref4) {
262
361
  }]
263
362
  });
264
363
  function update() {
364
+ var _popover$parentElemen;
265
365
  if (!trigger || !popover) {
266
366
  return;
267
367
  }
@@ -270,41 +370,86 @@ export function useAnchorPosition(_ref4) {
270
370
  width: window.innerWidth,
271
371
  height: window.innerHeight
272
372
  };
373
+
374
+ // Resolve consumer-supplied CSS length strings (tokens, calc, var,
375
+ // px, rem, etc) to pixels. The probe is mounted next to the popover
376
+ // so it inherits the same custom-property scope and font size.
377
+ // `popover.parentElement` is guaranteed to exist for any rendered
378
+ // popover (the popover itself is appended somewhere in the tree),
379
+ // but fall back to the popover element if not for safety.
380
+ var probeContainer = (_popover$parentElemen = popover.parentElement) !== null && _popover$parentElemen !== void 0 ? _popover$parentElemen : popover;
381
+ var gapPx = resolveCssLengthToPixels({
382
+ value: gapCssValue,
383
+ container: probeContainer
384
+ });
385
+ var crossAxisShiftPx = resolveCssLengthToPixels({
386
+ value: crossAxisShiftCssValue,
387
+ container: probeContainer
388
+ });
273
389
  var _computeFallbackPosit = computeFallbackPosition({
274
390
  triggerRect: triggerRect,
275
391
  popoverEl: popover,
276
392
  placement: resolvedPlacement,
277
393
  viewport: viewport,
278
- offset: offset
394
+ gap: gapPx,
395
+ crossAxisShift: {
396
+ value: crossAxisShiftPx,
397
+ direction: resolvedPlacement.offset.crossAxisShift.direction
398
+ }
279
399
  }),
280
400
  top = _computeFallbackPosit.top,
281
401
  left = _computeFallbackPosit.left;
282
402
  popover.style.setProperty('top', "".concat(top, "px"));
283
403
  popover.style.setProperty('left', "".concat(left, "px"));
404
+ // Reveal the popover only after it has been positioned.
405
+ // See the toggle listener below for why we hide on open.
406
+ popover.style.removeProperty('opacity');
284
407
  }
285
408
 
286
409
  // Throttle scroll/resize updates to one per animation frame
287
410
  var scheduledUpdate = rafSchedule(update);
288
- var pendingFrame = null;
411
+
412
+ // Wait for the FIRST valid layout before measuring: the popover
413
+ // is `display: none` until `showPopover()`, and some browsers
414
+ // fire `toggle` before layout, so RAF-after-toggle is unreliable.
415
+ // ResizeObserver fires once the browser has real dimensions.
416
+ // Self-disconnects after one valid measurement; ongoing
417
+ // scroll/resize is handled by the window listeners below.
418
+ var resizeObserver = new ResizeObserver(function () {
419
+ if (popover.offsetWidth > 0 && popover.offsetHeight > 0) {
420
+ update();
421
+ resizeObserver.disconnect();
422
+ }
423
+ });
424
+
425
+ // If the popover is already open by the time this effect runs (for
426
+ // example, the parent `useAnchorPosition` effect runs after the child
427
+ // `Popover` effect that called `showPopover()`), the initial `toggle`
428
+ // event has already fired and our listener below would miss it. Start
429
+ // observing immediately so the first measurement still happens.
430
+ if (isPopoverOpen(popover)) {
431
+ popover.style.setProperty('opacity', '0');
432
+ resizeObserver.observe(popover);
433
+ }
289
434
  var undoPositioning = combine(cleanupBaseStyles,
290
- // Position when the popover transitions to visible.
291
- // The popover starts hidden (`display: none` per the popover
292
- // UA stylesheet), so `offsetWidth`/`offsetHeight` are 0 until
293
- // `showPopover()` is called. We defer positioning to the next
294
- // animation frame because some browsers (Firefox) fire the
295
- // `toggle` event synchronously during `showPopover()`, before
296
- // the element has been laid out — so dimensions are still 0.
297
- // Waiting one frame guarantees layout has run and measurements
298
- // are accurate.
435
+ // On every open: hide synchronously so the user never sees
436
+ // the UA-default position, then re-observe for a fresh
437
+ // measurement. We use `opacity: 0` rather than
438
+ // `visibility: hidden` because Firefox skips visibility-hidden
439
+ // elements during `<dialog>` initial-focus traversal — see
440
+ // `form-in-popup.spec.tsx` on `desktop-firefox`.
299
441
  bind(popover, {
300
442
  type: 'toggle',
301
443
  listener: function listener(event) {
302
444
  var toggleEvent = event;
303
445
  if (toggleEvent.newState === 'open') {
304
- pendingFrame = requestAnimationFrame(update);
446
+ popover.style.setProperty('opacity', '0');
447
+ resizeObserver.observe(popover);
305
448
  }
306
449
  }
307
- }), bind(window, {
450
+ }), function () {
451
+ return resizeObserver.disconnect();
452
+ }, bind(window, {
308
453
  type: 'scroll',
309
454
  listener: scheduledUpdate,
310
455
  options: {
@@ -319,12 +464,10 @@ export function useAnchorPosition(_ref4) {
319
464
  }
320
465
  }), function () {
321
466
  scheduledUpdate.cancel();
322
- if (pendingFrame !== null) {
323
- cancelAnimationFrame(pendingFrame);
324
- }
325
467
  popover.style.removeProperty('top');
326
468
  popover.style.removeProperty('left');
469
+ popover.style.removeProperty('opacity');
327
470
  });
328
471
  return undoPositioning;
329
- }, [anchorRef, popoverRef, placement, anchorName, offset, forceFallbackPositioning, arrow]);
472
+ }, [anchorRef, popoverRef, placement, anchorName, forceFallbackPositioning, arrow]);
330
473
  }
@@ -1,3 +1,7 @@
1
+ import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
+ import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
3
+ 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; }
4
+ 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) { _defineProperty(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; }
1
5
  /**
2
6
  * Legacy Popper.js placement values (used by `@atlaskit/popper`, `@atlaskit/popup`,
3
7
  * `@atlaskit/tooltip`, etc.).
@@ -128,11 +132,34 @@ var placementMapping = {
128
132
  };
129
133
 
130
134
  /**
131
- * Convert a legacy Popper.js placement to the new CSS `position-area`-based Placement.
135
+ * Convert a legacy Popper.js placement to the new `position-area`-based Placement.
136
+ * Optional `offset` is the legacy Popper `[along, away]` tuple (also known as
137
+ * `[skidding, distance]`); negative `along` becomes `direction: 'backwards'`.
138
+ *
139
+ * The tuple keeps the historical popper names so call sites that already pass
140
+ * `[along, away]` migrate without thinking; the new internal field names
141
+ * (`gap` / `crossAxisShift`) are produced inside this adapter.
132
142
  */
133
143
  export function fromLegacyPlacement(_ref) {
134
- var legacy = _ref.legacy;
135
- return placementMapping[legacy];
144
+ var legacy = _ref.legacy,
145
+ offset = _ref.offset;
146
+ var basePlacement = placementMapping[legacy];
147
+ if (!offset) {
148
+ return basePlacement;
149
+ }
150
+ var _offset = _slicedToArray(offset, 2),
151
+ along = _offset[0],
152
+ away = _offset[1];
153
+ return _objectSpread(_objectSpread({}, basePlacement), {}, {
154
+ offset: {
155
+ gap: away,
156
+ crossAxisShift: {
157
+ // Sign is captured in `direction`; `value` is the magnitude.
158
+ value: Math.abs(along),
159
+ direction: along >= 0 ? 'forwards' : 'backwards'
160
+ }
161
+ }
162
+ });
136
163
  }
137
164
 
138
165
  /**
@@ -32,7 +32,6 @@ export var PopupContent = /*#__PURE__*/forwardRef(function PopupContent(_ref, re
32
32
  placementProp = _ref.placement,
33
33
  onCloseProp = _ref.onClose,
34
34
  testIdProp = _ref.testId,
35
- offsetProp = _ref.offset,
36
35
  forceFallbackPositioningProp = _ref.forceFallbackPositioning;
37
36
  // When inside <Popup>, context provides defaults. Props always win.
38
37
  var ctx = useMaybePopupContext();
@@ -129,7 +128,6 @@ export var PopupContent = /*#__PURE__*/forwardRef(function PopupContent(_ref, re
129
128
  },
130
129
  popoverRef: popoverRef,
131
130
  placement: placement,
132
- offset: offsetProp,
133
131
  forceFallbackPositioning: forceFallbackPositioning,
134
132
  arrow: arrowPreset !== null && arrowPreset !== void 0 ? arrowPreset : undefined
135
133
  });
@@ -1,15 +1,13 @@
1
1
  import { type TPlacement } from './resolve-placement';
2
2
  /**
3
- * JS fallback: compute popover position from trigger rect and viewport.
3
+ * Computes popover position from trigger rect and viewport, flipping to
4
+ * the side with more space when needed. Re-runs on scroll/resize.
4
5
  *
5
- * Placement algorithm (from the document):
6
- * - "block" placement: show above/below, picking the side with more space if flipping needed
7
- * - "inline" placement: show left/right, picking the side with more space if flipping needed
8
- * - "auto" placement: show on the edge with the most available space
9
- *
10
- * Re-runs on scroll (capture on window) and resize events.
6
+ * `gap` and `crossAxisShift` are pre-resolved to pixels by the
7
+ * caller (see `resolveCssLengthToPixels`), so this function stays purely
8
+ * numeric.
11
9
  */
12
- export declare function computeFallbackPosition({ triggerRect, popoverEl, placement, viewport, offset, }: {
10
+ export declare function computeFallbackPosition({ triggerRect, popoverEl, placement, viewport, gap, crossAxisShift, }: {
13
11
  triggerRect: DOMRect;
14
12
  popoverEl: HTMLElement;
15
13
  placement: TPlacement;
@@ -17,7 +15,11 @@ export declare function computeFallbackPosition({ triggerRect, popoverEl, placem
17
15
  width: number;
18
16
  height: number;
19
17
  };
20
- offset: number;
18
+ gap: number;
19
+ crossAxisShift: {
20
+ value: number;
21
+ direction: 'forwards' | 'backwards';
22
+ };
21
23
  }): {
22
24
  top: number;
23
25
  left: number;
@@ -0,0 +1,22 @@
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 declare function resolveCssLengthToPixels({ value, container, }: {
20
+ value: number | string;
21
+ container: HTMLElement;
22
+ }): number;
@@ -0,0 +1,8 @@
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 declare function toCssLengthString({ value }: {
7
+ value: number | string;
8
+ }): string;
@@ -1,16 +1,58 @@
1
1
  /**
2
- * Fully-resolved placement (internal). All fields are required.
3
- * Use getPlacement({ placement: partial }) to obtain from partial input.
2
+ * Cross-axis shift. `value` is a CSS length string (consumer numbers are
3
+ * normalized to `${n}px`). `direction` is `'forwards'` (toward end) or
4
+ * `'backwards'` (toward start).
5
+ */
6
+ export type TCrossAxisShiftOffset = {
7
+ value: string;
8
+ direction: 'forwards' | 'backwards';
9
+ };
10
+ /**
11
+ * Fully-resolved placement (internal). Use `getPlacement` for partial input.
12
+ * `offset.gap` and `offset.crossAxisShift.value` are always CSS
13
+ * length strings.
4
14
  */
5
15
  export type TPlacement = {
6
16
  axis: 'block' | 'inline';
7
17
  edge: 'start' | 'end';
8
18
  align: 'start' | 'center' | 'end';
19
+ offset: {
20
+ gap: string;
21
+ crossAxisShift: {
22
+ value: string;
23
+ direction: 'forwards' | 'backwards';
24
+ };
25
+ };
26
+ };
27
+ /**
28
+ * Partial placement options. `offset.gap` and
29
+ * `offset.crossAxisShift.value` accept either a number (pixels) or a
30
+ * CSS length string (e.g. `token('space.100')`).
31
+ *
32
+ * The two offset axes are named for clarity at the call site:
33
+ * - `gap` — distance away from the trigger along the placement axis
34
+ * - `crossAxisShift` — nudge along the trigger edge (perpendicular to the placement axis)
35
+ */
36
+ export type TPlacementOptions = {
37
+ axis?: 'block' | 'inline';
38
+ edge?: 'start' | 'end';
39
+ align?: 'start' | 'center' | 'end';
40
+ offset?: {
41
+ gap?: number | string;
42
+ crossAxisShift?: {
43
+ value?: number | string;
44
+ direction?: 'forwards' | 'backwards';
45
+ };
46
+ };
9
47
  };
10
48
  /**
11
- * Resolves a partial placement to its fully-specified form.
12
- * Defaults: `axis: 'block'`, `edge: 'end'`, `align: 'center'`.
49
+ * Resolves a partial placement to its fully-specified form. Number offsets
50
+ * are normalized to `${n}px` strings.
51
+ *
52
+ * Defaults: `axis: 'block'`, `edge: 'end'`, `align: 'center'`,
53
+ * `offset.gap: token('space.100', '8px')`,
54
+ * `offset.crossAxisShift: { value: '0px', direction: 'forwards' }`.
13
55
  */
14
56
  export declare function getPlacement({ placement }: {
15
- placement: Partial<TPlacement>;
57
+ placement: TPlacementOptions;
16
58
  }): TPlacement;
@@ -1,6 +1,20 @@
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 { type RefObject } from 'react';
2
16
  import { type TArrowPreset } from '../arrow/types';
3
- import { type TPlacementOptions } from '../popup/types';
17
+ import { type TPlacementOptions } from '../internal/resolve-placement';
4
18
  export { getPlacement } from './resolve-placement';
5
19
  /**
6
20
  * Resolves placement options to CSS `position-area` syntax.
@@ -59,7 +73,7 @@ export declare function placementToTryFallbacks({ placement }: {
59
73
  * `position: fixed` with measured coordinates, re-running on scroll (capture)
60
74
  * and resize events.
61
75
  */
62
- export declare function useAnchorPosition({ anchorRef, popoverRef, placement, offset, forceFallbackPositioning, arrow, }: {
76
+ export declare function useAnchorPosition({ anchorRef, popoverRef, placement, forceFallbackPositioning, arrow, }: {
63
77
  /**
64
78
  * Element to position relative to.
65
79
  */
@@ -70,13 +84,10 @@ export declare function useAnchorPosition({ anchorRef, popoverRef, placement, of
70
84
  popoverRef: RefObject<HTMLElement | null>;
71
85
  /**
72
86
  * Where to place the element relative to the anchor.
87
+ * `offset.gap` and `offset.crossAxisShift` are part of the
88
+ * placement object.
73
89
  */
74
90
  placement: TPlacementOptions;
75
- /**
76
- * Gap between the positioned element and the anchor, in pixels.
77
- * Defaults to 8 (`space.100`). Tooltip uses 4.
78
- */
79
- offset?: number;
80
91
  /**
81
92
  * Forces the JavaScript positioning fallback even when the browser
82
93
  * supports CSS Anchor Positioning. Useful for testing fallback
@@ -1,5 +1,4 @@
1
- import { type TPlacement } from '../internal/resolve-placement';
2
- import { type TPlacementOptions } from '../popup/types';
1
+ import { type TPlacementOptions } from '../internal/resolve-placement';
3
2
  /**
4
3
  * Legacy Popper.js placement values (used by `@atlaskit/popper`, `@atlaskit/popup`,
5
4
  * `@atlaskit/tooltip`, etc.).
@@ -41,12 +40,19 @@ export type TLegacyPlacement = 'auto' | 'auto-start' | 'auto-end' | 'top' | 'top
41
40
  * // newPlacement === { axis: 'block', edge: 'end', align: 'start' }
42
41
  * ```
43
42
  */
44
- declare const placementMapping: Record<TLegacyPlacement, TPlacement>;
43
+ declare const placementMapping: Record<TLegacyPlacement, TPlacementOptions>;
45
44
  /**
46
- * Convert a legacy Popper.js placement to the new CSS `position-area`-based Placement.
45
+ * Convert a legacy Popper.js placement to the new `position-area`-based Placement.
46
+ * Optional `offset` is the legacy Popper `[along, away]` tuple (also known as
47
+ * `[skidding, distance]`); negative `along` becomes `direction: 'backwards'`.
48
+ *
49
+ * The tuple keeps the historical popper names so call sites that already pass
50
+ * `[along, away]` migrate without thinking; the new internal field names
51
+ * (`gap` / `crossAxisShift`) are produced inside this adapter.
47
52
  */
48
- export declare function fromLegacyPlacement({ legacy }: {
53
+ export declare function fromLegacyPlacement({ legacy, offset, }: {
49
54
  legacy: TLegacyPlacement;
55
+ offset?: [along: number, away: number];
50
56
  }): TPlacementOptions;
51
57
  /**
52
58
  * The full mapping record, exposed for consumers that need to iterate or introspect.