@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
@@ -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
  /**
@@ -30,7 +30,6 @@ export const PopupContent = /*#__PURE__*/forwardRef(function PopupContent({
30
30
  placement: placementProp,
31
31
  onClose: onCloseProp,
32
32
  testId: testIdProp,
33
- offset: offsetProp,
34
33
  forceFallbackPositioning: forceFallbackPositioningProp
35
34
  }, ref) {
36
35
  var _ref, _ref2, _ctx$popoverRef;
@@ -129,7 +128,6 @@ export const PopupContent = /*#__PURE__*/forwardRef(function PopupContent({
129
128
  },
130
129
  popoverRef,
131
130
  placement,
132
- offset: offsetProp,
133
131
  forceFallbackPositioning,
134
132
  arrow: arrowPreset !== null && arrowPreset !== void 0 ? arrowPreset : undefined
135
133
  });
@@ -19,7 +19,7 @@ import { getPlacement, placementToPositionArea } from '../internal/use-anchor-po
19
19
  *
20
20
  * @see https://goulet.dev/posts/tooltip-with-popover-and-anchor-positioning/
21
21
  */
22
- var ARROW_CSS = "\n[data-ds-popover-arrow] {\n clip-path: inset(var(--ds-arrow-offset, 0px)) margin-box;\n box-shadow: none;\n}\n\n/* Top and Bottom arrows (vertical hexagon) */\n[data-ds-popover-arrow]::before {\n content: \"\";\n position: absolute;\n z-index: -1;\n background: inherit;\n left: 50%;\n transform: translateX(-50%);\n width: calc(var(--ds-arrow-size, 8px) * 2);\n height: calc(100% + var(--ds-arrow-size, 8px) * 2);\n top: calc(var(--ds-arrow-size, 8px) * -1);\n clip-path: polygon(\n 0 var(--ds-arrow-size, 8px),\n 50% 0,\n 100% var(--ds-arrow-size, 8px),\n 100% calc(100% - var(--ds-arrow-size, 8px)),\n 50% 100%,\n 0 calc(100% - var(--ds-arrow-size, 8px))\n );\n}\n\n/* Left and Right arrows (horizontal hexagon) */\n[data-ds-popover-arrow]::after {\n content: \"\";\n position: absolute;\n z-index: -1;\n background: inherit;\n top: 50%;\n transform: translateY(-50%);\n height: calc(var(--ds-arrow-size, 8px) * 2);\n width: calc(100% + var(--ds-arrow-size, 8px) * 2);\n left: calc(var(--ds-arrow-size, 8px) * -1);\n clip-path: polygon(\n var(--ds-arrow-size, 8px) 0,\n calc(100% - var(--ds-arrow-size, 8px)) 0,\n 100% 50%,\n calc(100% - var(--ds-arrow-size, 8px)) 100%,\n var(--ds-arrow-size, 8px) 100%,\n 0 50%\n );\n}\n\n/* \u2500\u2500 @position-try rules \u2500\u2500\n * One rule per possible position-area value.\n * Each sets the position-area and the correct margin direction\n * so the arrow on the anchor-facing side is revealed.\n *\n * Margin direction per edge:\n * block-start (above anchor) \u2192 margin-block-end (bottom, facing anchor)\n * block-end (below anchor) \u2192 margin-block-start (top, facing anchor)\n * inline-start (left of anchor) \u2192 margin-inline-end (right, facing anchor)\n * inline-end (right of anchor) \u2192 margin-inline-start (left, facing anchor)\n */\n\n/* Single-axis centered */\n@position-try --ds-arrow-block-start {\n position-area: block-start;\n margin: 0;\n margin-block-end: var(--ds-arrow-size, 8px);\n}\n@position-try --ds-arrow-block-end {\n position-area: block-end;\n margin: 0;\n margin-block-start: var(--ds-arrow-size, 8px);\n}\n@position-try --ds-arrow-inline-start {\n position-area: inline-start;\n margin: 0;\n margin-inline-end: var(--ds-arrow-size, 8px);\n}\n@position-try --ds-arrow-inline-end {\n position-area: inline-end;\n margin: 0;\n margin-inline-start: var(--ds-arrow-size, 8px);\n}\n\n/* Block edge + inline span */\n@position-try --ds-arrow-block-start-span-inline-start {\n position-area: block-start span-inline-start;\n margin: 0;\n margin-block-end: var(--ds-arrow-size, 8px);\n}\n@position-try --ds-arrow-block-start-span-inline-end {\n position-area: block-start span-inline-end;\n margin: 0;\n margin-block-end: var(--ds-arrow-size, 8px);\n}\n@position-try --ds-arrow-block-end-span-inline-start {\n position-area: block-end span-inline-start;\n margin: 0;\n margin-block-start: var(--ds-arrow-size, 8px);\n}\n@position-try --ds-arrow-block-end-span-inline-end {\n position-area: block-end span-inline-end;\n margin: 0;\n margin-block-start: var(--ds-arrow-size, 8px);\n}\n\n/* Inline edge + block span */\n@position-try --ds-arrow-inline-start-span-block-start {\n position-area: inline-start span-block-start;\n margin: 0;\n margin-inline-end: var(--ds-arrow-size, 8px);\n}\n@position-try --ds-arrow-inline-start-span-block-end {\n position-area: inline-start span-block-end;\n margin: 0;\n margin-inline-end: var(--ds-arrow-size, 8px);\n}\n@position-try --ds-arrow-inline-end-span-block-start {\n position-area: inline-end span-block-start;\n margin: 0;\n margin-inline-start: var(--ds-arrow-size, 8px);\n}\n@position-try --ds-arrow-inline-end-span-block-end {\n position-area: inline-end span-block-end;\n margin: 0;\n margin-inline-start: var(--ds-arrow-size, 8px);\n}\n";
22
+ var ARROW_CSS = "\n[data-ds-popover-arrow] {\n clip-path: inset(var(--ds-arrow-offset, 0px)) margin-box;\n box-shadow: none;\n}\n\n/* Top and Bottom arrows (vertical hexagon) */\n[data-ds-popover-arrow]::before {\n content: \"\";\n position: absolute;\n z-index: -1;\n background: inherit;\n left: 50%;\n transform: translateX(-50%);\n width: calc(var(--ds-arrow-size, 8px) * 2);\n height: calc(100% + var(--ds-arrow-size, 8px) * 2);\n top: calc(var(--ds-arrow-size, 8px) * -1);\n clip-path: polygon(\n 0 var(--ds-arrow-size, 8px),\n 50% 0,\n 100% var(--ds-arrow-size, 8px),\n 100% calc(100% - var(--ds-arrow-size, 8px)),\n 50% 100%,\n 0 calc(100% - var(--ds-arrow-size, 8px))\n );\n}\n\n/* Left and Right arrows (horizontal hexagon) */\n[data-ds-popover-arrow]::after {\n content: \"\";\n position: absolute;\n z-index: -1;\n background: inherit;\n top: 50%;\n transform: translateY(-50%);\n height: calc(var(--ds-arrow-size, 8px) * 2);\n width: calc(100% + var(--ds-arrow-size, 8px) * 2);\n left: calc(var(--ds-arrow-size, 8px) * -1);\n clip-path: polygon(\n var(--ds-arrow-size, 8px) 0,\n calc(100% - var(--ds-arrow-size, 8px)) 0,\n 100% 50%,\n calc(100% - var(--ds-arrow-size, 8px)) 100%,\n var(--ds-arrow-size, 8px) 100%,\n 0 50%\n );\n}\n\n/* \u2500\u2500 @position-try rules \u2500\u2500\n * One rule per possible position-area value.\n * Each sets the position-area and the correct margin direction\n * so the arrow on the anchor-facing side is revealed.\n *\n * Margin direction per edge:\n * block-start (above anchor) \u2192 margin-block-end (bottom, facing anchor)\n * block-end (below anchor) \u2192 margin-block-start (top, facing anchor)\n * inline-start (left of anchor) \u2192 margin-inline-end (right, facing anchor)\n * inline-end (right of anchor) \u2192 margin-inline-start (left, facing anchor)\n */\n\n/* Each rule sets two margins: the anchor-facing margin for the gap\n (via --ds-arrow-size) and one cross-axis margin for the shift\n (via one of --ds-cross-axis-shift-margin-start, --ds-cross-axis-shift-margin-end,\n --ds-cross-axis-shift-margin-block-start, or --ds-cross-axis-shift-margin-block-end). The\n side matches the popover's anchored cross-axis edge per align. Only\n one of the four properties is non-zero at a time; useAnchorPosition\n picks it. See notes/architecture/positioning.md for details. */\n/* Single-axis centered (align: center) */\n@position-try --ds-arrow-block-start {\n position-area: block-start;\n margin: 0;\n margin-block-end: var(--ds-arrow-size, 8px);\n margin-inline-start: var(--ds-cross-axis-shift-margin-start, 0px);\n}\n@position-try --ds-arrow-block-end {\n position-area: block-end;\n margin: 0;\n margin-block-start: var(--ds-arrow-size, 8px);\n margin-inline-start: var(--ds-cross-axis-shift-margin-start, 0px);\n}\n@position-try --ds-arrow-inline-start {\n position-area: inline-start;\n margin: 0;\n margin-inline-end: var(--ds-arrow-size, 8px);\n margin-block-start: var(--ds-cross-axis-shift-margin-block-start, 0px);\n}\n@position-try --ds-arrow-inline-end {\n position-area: inline-end;\n margin: 0;\n margin-inline-start: var(--ds-arrow-size, 8px);\n margin-block-start: var(--ds-cross-axis-shift-margin-block-start, 0px);\n}\n\n/* Block edge + inline span (align: start uses span-inline-end; align: end uses span-inline-start) */\n@position-try --ds-arrow-block-start-span-inline-start {\n position-area: block-start span-inline-start;\n margin: 0;\n margin-block-end: var(--ds-arrow-size, 8px);\n margin-inline-end: var(--ds-cross-axis-shift-margin-end, 0px);\n}\n@position-try --ds-arrow-block-start-span-inline-end {\n position-area: block-start span-inline-end;\n margin: 0;\n margin-block-end: var(--ds-arrow-size, 8px);\n margin-inline-start: var(--ds-cross-axis-shift-margin-start, 0px);\n}\n@position-try --ds-arrow-block-end-span-inline-start {\n position-area: block-end span-inline-start;\n margin: 0;\n margin-block-start: var(--ds-arrow-size, 8px);\n margin-inline-end: var(--ds-cross-axis-shift-margin-end, 0px);\n}\n@position-try --ds-arrow-block-end-span-inline-end {\n position-area: block-end span-inline-end;\n margin: 0;\n margin-block-start: var(--ds-arrow-size, 8px);\n margin-inline-start: var(--ds-cross-axis-shift-margin-start, 0px);\n}\n\n/* Inline edge + block span (align: start uses span-block-end; align: end uses span-block-start) */\n@position-try --ds-arrow-inline-start-span-block-start {\n position-area: inline-start span-block-start;\n margin: 0;\n margin-inline-end: var(--ds-arrow-size, 8px);\n margin-block-end: var(--ds-cross-axis-shift-margin-block-end, 0px);\n}\n@position-try --ds-arrow-inline-start-span-block-end {\n position-area: inline-start span-block-end;\n margin: 0;\n margin-inline-end: var(--ds-arrow-size, 8px);\n margin-block-start: var(--ds-cross-axis-shift-margin-block-start, 0px);\n}\n@position-try --ds-arrow-inline-end-span-block-start {\n position-area: inline-end span-block-start;\n margin: 0;\n margin-inline-start: var(--ds-arrow-size, 8px);\n margin-block-end: var(--ds-cross-axis-shift-margin-block-end, 0px);\n}\n@position-try --ds-arrow-inline-end-span-block-end {\n position-area: inline-end span-block-end;\n margin: 0;\n margin-inline-start: var(--ds-arrow-size, 8px);\n margin-block-start: var(--ds-cross-axis-shift-margin-block-start, 0px);\n}\n";
23
23
 
24
24
  // ── Placement utilities ──
25
25