@atlaskit/top-layer 4.2.2 → 4.2.4

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.
@@ -62,6 +62,38 @@ const supportsAnchorPositioning = once(() => {
62
62
  return CSS.supports('anchor-name', '--a');
63
63
  });
64
64
 
65
+ /**
66
+ * Every logical margin property this module can write: one per axis, per side.
67
+ *
68
+ * Deriving the union from `TPlacementAxis` and `TPlacementEdge` rather than
69
+ * writing the four names out keeps it in step with the placement model, and
70
+ * makes `CROSS_AXIS_SHIFT_CUSTOM_PROPERTY` below exhaustive by construction.
71
+ */
72
+
73
+ /**
74
+ * The custom property mirroring each cross-axis shift margin.
75
+ *
76
+ * These are written for the planned named arrow `@position-try` rules. Nothing
77
+ * reads them yet, and the shift no longer depends on them: the antisymmetric
78
+ * margins written by `crossAxisShiftMargins` survive a cross-axis slide on
79
+ * their own.
80
+ *
81
+ * Keyed on `TLogicalMarginProperty` and not on `string`, so a margin property
82
+ * that has no custom property (or a custom property whose margin no longer
83
+ * exists) is a compile error rather than an `undefined` lookup that reaches
84
+ * `setStyle` as `{ property: undefined, value }`.
85
+ */
86
+ const CROSS_AXIS_SHIFT_CUSTOM_PROPERTY = {
87
+ 'margin-inline-start': '--ds-cross-axis-shift-margin-start',
88
+ 'margin-inline-end': '--ds-cross-axis-shift-margin-end',
89
+ 'margin-block-start': '--ds-cross-axis-shift-margin-block-start',
90
+ 'margin-block-end': '--ds-cross-axis-shift-margin-block-end'
91
+ };
92
+ const ZEROED_CROSS_AXIS_SHIFT_CUSTOM_PROPERTIES = Object.values(CROSS_AXIS_SHIFT_CUSTOM_PROPERTY).map(property => ({
93
+ property,
94
+ value: '0px'
95
+ }));
96
+
65
97
  /**
66
98
  * Returns the CSS margin declaration that creates a gap between the
67
99
  * popover and its trigger on the side facing the anchor.
@@ -77,65 +109,65 @@ function edgeMargin({
77
109
  axis,
78
110
  edge
79
111
  } = placement;
80
- const value = offset;
81
- if (axis === 'block' && edge === 'end') {
82
- return {
83
- property: 'margin-block-start',
84
- value
85
- };
86
- }
87
- if (axis === 'block' && edge === 'start') {
88
- return {
89
- property: 'margin-block-end',
90
- value
91
- };
92
- }
93
- if (axis === 'inline' && edge === 'end') {
94
- return {
95
- property: 'margin-inline-start',
96
- value
97
- };
98
- }
99
- if (axis === 'inline' && edge === 'start') {
100
- return {
101
- property: 'margin-inline-end',
102
- value
103
- };
104
- }
112
+ // The gap sits on the side FACING the anchor, which is always the side
113
+ // opposite the placement edge.
114
+ const anchorFacingEdge = edge === 'end' ? 'start' : 'end';
105
115
  return {
106
- property: 'margin-block-start',
107
- value
116
+ property: `margin-${axis}-${anchorFacingEdge}`,
117
+ value: offset
108
118
  };
109
119
  }
110
120
 
111
121
  /**
112
- * Cross-axis shift margin. Margin side matches the popover's anchored
113
- * cross-axis edge: `align: 'start'` uses the START margin, `align: 'end'`
114
- * uses the END margin with the sign inverted (so `forwards` is always
115
- * positive), `align: 'center'` uses START. Margin on the un-anchored side
116
- * has no effect under CSS Anchor Positioning.
122
+ * **The cross-axis shift margins.**
123
+ *
124
+ * The shift is written antisymmetrically on BOTH cross-axis sides
125
+ * (`start: +value`, `end: -value`) rather than as a single margin on the side
126
+ * picked from `align`. Two separate behaviours require it:
127
+ *
128
+ * 1. **`align: 'center'` is centered with `anchor-center`, which centers the
129
+ * popover's MARGIN box on the anchor.** A single-sided margin widens the
130
+ * margin box, so it displaces the border box by only half its value. An
131
+ * antisymmetric pair leaves the margin box width unchanged (`+value` and
132
+ * `-value` sum to zero) while moving its center by the full value.
133
+ * 2. **`position-try-fallbacks` can slide the popover onto the opposite
134
+ * cross-axis side.** A `<position-area>` fallback keeps the base style's
135
+ * margins, and margin on the un-anchored side has no effect, so a
136
+ * single-sided margin is silently dropped once the browser slides across the
137
+ * cross axis. With both sides written, whichever side ends up anchored moves
138
+ * the popover the same physical direction.
139
+ *
140
+ * Leaving the margin box width unchanged also means the shift does not move
141
+ * the point at which `position-try-fallbacks` decides the popover overflows.
142
+ *
143
+ * A `<try-tactic>` fallback (`flip-block`, `flip-inline`) behaves differently to
144
+ * a `<position-area>` one: it SWAPS the start and end margins rather than
145
+ * keeping them, which is what preserves the gap through a flip. So a flip across
146
+ * the cross axis mirrors the shift along with the rest of the placement, rather
147
+ * than preserving its physical direction. Only the diagonal fallback does that,
148
+ * and only for `align: 'start' | 'end'`. See `placementToTryFallbacks`.
149
+ *
150
+ * A positive value always moves the popover toward the cross-axis END, so
151
+ * `direction: 'forwards'` means the same thing for every `align` value. See
152
+ * `notes/decisions/placement-offset.md`.
117
153
  */
118
- function crossAxisShiftMargin({
154
+ function crossAxisShiftMargins({
119
155
  placement,
120
156
  crossAxisShiftCssValue,
121
157
  direction
122
158
  }) {
123
- const {
124
- axis,
125
- align
126
- } = placement;
127
- const directionSign = direction === 'forwards' ? 1 : -1;
128
- const crossAxis = axis === 'block' ? 'inline' : 'block';
129
- const useEndSide = align === 'end';
130
- const side = useEndSide ? 'end' : 'start';
131
- const sideSign = useEndSide ? -1 : 1;
132
- const finalSign = directionSign * sideSign;
133
- // Wrap any string in calc() with the sign factor. CSS handles the math.
134
- const value = finalSign === 1 ? crossAxisShiftCssValue : `calc(-1 * ${crossAxisShiftCssValue})`;
135
- return {
136
- property: `margin-${crossAxis}-${side}`,
137
- value
138
- };
159
+ const crossAxis = placement.axis === 'block' ? 'inline' : 'block';
160
+ // Wrap any string in calc() with the sign factor. CSS handles the math,
161
+ // so opaque values such as design tokens negate correctly.
162
+ const negated = `calc(-1 * ${crossAxisShiftCssValue})`;
163
+ const isForwards = direction === 'forwards';
164
+ return [{
165
+ property: `margin-${crossAxis}-start`,
166
+ value: isForwards ? crossAxisShiftCssValue : negated
167
+ }, {
168
+ property: `margin-${crossAxis}-end`,
169
+ value: isForwards ? negated : crossAxisShiftCssValue
170
+ }];
139
171
  }
140
172
 
141
173
  /**
@@ -216,10 +248,9 @@ export function useAnchorPosition({
216
248
  placement: stablePlacement,
217
249
  offset: gapCssValue
218
250
  });
219
- // Computed once; reused below to populate the active
220
- // `--ds-cross-axis-shift-margin-*` custom property for the
221
- // named arrow @position-try rules.
222
- const crossAxisShift = crossAxisShiftMargin({
251
+ // Computed once; also mirrored into the
252
+ // `--ds-cross-axis-shift-margin-*` custom properties below.
253
+ const crossAxisShift = crossAxisShiftMargins({
223
254
  placement: stablePlacement,
224
255
  crossAxisShiftCssValue,
225
256
  direction: stablePlacement.offset.crossAxisShift.direction
@@ -246,48 +277,16 @@ export function useAnchorPosition({
246
277
  }, {
247
278
  property: 'inset',
248
279
  value: 'auto'
249
- }, {
250
- property: gap.property,
251
- value: gap.value
252
- }, {
253
- property: crossAxisShift.property,
254
- value: crossAxisShift.value
255
- }];
256
-
257
- // Expose the active cross-axis shift via custom properties so each
258
- // named arrow @position-try rule can re-apply it after a flip.
259
- // One of the four (cross-axis, side) properties is non-zero at a time.
260
- popoverStyles.push({
261
- property: '--ds-cross-axis-shift-margin-start',
262
- value: '0px'
263
- });
264
- popoverStyles.push({
265
- property: '--ds-cross-axis-shift-margin-end',
266
- value: '0px'
267
- });
268
- popoverStyles.push({
269
- property: '--ds-cross-axis-shift-margin-block-start',
270
- value: '0px'
271
- });
272
- popoverStyles.push({
273
- property: '--ds-cross-axis-shift-margin-block-end',
274
- value: '0px'
275
- });
276
- // `crossAxisShift` IS the active margin; reuse instead of recomputing.
277
- const crossAxisShiftActive = crossAxisShift;
278
- const customPropertyByMarginProperty = {
279
- 'margin-inline-start': '--ds-cross-axis-shift-margin-start',
280
- 'margin-inline-end': '--ds-cross-axis-shift-margin-end',
281
- 'margin-block-start': '--ds-cross-axis-shift-margin-block-start',
282
- 'margin-block-end': '--ds-cross-axis-shift-margin-block-end'
283
- };
284
- const crossAxisShiftCustomProperty = customPropertyByMarginProperty[crossAxisShiftActive.property];
285
- if (crossAxisShiftCustomProperty) {
286
- popoverStyles.push({
287
- property: crossAxisShiftCustomProperty,
288
- value: crossAxisShiftActive.value
289
- });
290
- }
280
+ }, gap, ...crossAxisShift,
281
+ // Zero every side first so the two sides that are NOT on the
282
+ // active cross axis do not keep a value from a previous placement.
283
+ ...ZEROED_CROSS_AXIS_SHIFT_CUSTOM_PROPERTIES, ...crossAxisShift.map(({
284
+ property,
285
+ value
286
+ }) => ({
287
+ property: CROSS_AXIS_SHIFT_CUSTOM_PROPERTY[property],
288
+ value
289
+ }))];
291
290
 
292
291
  /**
293
292
  * **We are never cleaning up anchor names**
@@ -1,18 +1,44 @@
1
1
  import { useLayoutEffect } from 'react';
2
+ import once from '@atlaskit/ds-lib/once';
2
3
  import { setStyle } from './set-style';
3
4
 
5
+ /**
6
+ * Probes anchor SIZING, separately from `anchor-name` (see
7
+ * `supportsAnchorPositioning`): some browsers have positioning but not sizing.
8
+ * `once()` for SSR safety.
9
+ */
10
+ const supportsAnchorSize = once(() => {
11
+ if (typeof window === 'undefined' || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') {
12
+ return false;
13
+ }
14
+ return CSS.supports('min-inline-size', 'anchor-size(self-inline)');
15
+ });
16
+
17
+ /**
18
+ * The JS fallback's stand-in for `anchor-size(self-inline)`. `offsetWidth` / `offsetHeight` are
19
+ * physical, so the axis is picked from the popover's `writing-mode`. Anything not horizontal is
20
+ * treated as vertical, and an unknown or empty value falls back to `offsetWidth`.
21
+ */
22
+ function getFallbackAnchorInlineSize({
23
+ anchor,
24
+ popover
25
+ }) {
26
+ // `String()` because not every test environment implements `writing-mode`.
27
+ const writingMode = String(getComputedStyle(popover).writingMode);
28
+ const isVertical = writingMode.startsWith('vertical') || writingMode.startsWith('sideways');
29
+ return isVertical ? anchor.offsetHeight : anchor.offsetWidth;
30
+ }
31
+
4
32
  /**
5
33
  * Sets the width of a popover element relative to its anchor element.
6
34
  *
7
- * - `'match-anchor'` → `width: anchor-size(width)` - popover matches anchor width exactly.
8
- * - `'min-anchor'` → `min-width: anchor-size(width)` - popover is at least as wide as anchor,
9
- * but can grow wider.
10
- * - `'none'` → no explicit width is set (default behaviour)
35
+ * - `'match-anchor'` → `inline-size: anchor-size(self-inline)`, exact
36
+ * - `'min-anchor'` → `min-inline-size: anchor-size(self-inline)`, floor only
37
+ * - `'none'` → `min-inline-size: max-content`, content floor (default)
11
38
  *
12
- * Uses CSS `anchor-size(width)` when the browser supports it, falling back to a
13
- * one-off measurement of `anchor.offsetWidth`.
14
- *
15
- * Designed to be composed alongside `useAnchorPosition` for top-layer popovers.
39
+ * `self-inline` is the inline axis of the element USING the function (the popover), never the
40
+ * anchor, so it matches the axis `inline-size` constrains. See
41
+ * `notes/decisions/width-from-anchor-floors.md`.
16
42
  */
17
43
  export function useWidthFromAnchor({
18
44
  mode,
@@ -25,55 +51,41 @@ export function useWidthFromAnchor({
25
51
  if (!node) {
26
52
  return;
27
53
  }
28
-
29
- // Feature-detect `anchor-size()` specifically (not just `anchor-name`)
30
- // so the JS fallback fires correctly in browsers that support anchor
31
- // positioning but not anchor sizing.
32
- const supportsAnchorSize = typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('width', 'anchor-size(width)');
33
54
  if (mode === 'match-anchor') {
34
- // Set an exact width matching the anchor.
35
- // `min-inline-size` is intentionally NOT set here - it would override the
36
- // explicit width constraint, allowing the popover to grow past the anchor's width.
37
- if (supportsAnchorSize) {
55
+ // No floor here: it would let the popover grow past the anchor.
56
+ if (supportsAnchorSize()) {
38
57
  return setStyle({
39
58
  element: node,
40
59
  styles: [{
41
- property: 'width',
42
- value: 'anchor-size(width)'
60
+ property: 'inline-size',
61
+ value: 'anchor-size(self-inline)'
43
62
  }]
44
63
  });
45
64
  }
46
-
47
- // JS fallback: one-off read of the anchor's width.
48
65
  const anchor = anchorRef.current;
49
66
  if (anchor) {
50
67
  return setStyle({
51
68
  element: node,
52
69
  styles: [{
53
- property: 'width',
54
- value: `${anchor.offsetWidth}px`
70
+ property: 'inline-size',
71
+ value: `${getFallbackAnchorInlineSize({
72
+ anchor,
73
+ popover: node
74
+ })}px`
55
75
  }]
56
76
  });
57
77
  }
58
78
  return;
59
79
  }
60
-
61
- // For 'min-anchor' and 'none', apply `min-inline-size: max-content` to ensure
62
- // correct position-try-fallbacks behaviour (popover margin box overflows the
63
- // viewport when its span region is too narrow, rather than wrapping content).
64
- const minInlineSizeStyle = {
65
- property: 'min-inline-size',
66
- value: 'max-content'
67
- };
68
80
  if (mode === 'min-anchor') {
69
- // Set a minimum width matching the anchor. The popover can grow wider
70
- // if its content requires it.
71
- if (supportsAnchorSize) {
81
+ // One floor only. The content floor from mode 'none' deliberately does not also apply
82
+ // here, and cannot: it would need this same property. See the decision note.
83
+ if (supportsAnchorSize()) {
72
84
  return setStyle({
73
85
  element: node,
74
- styles: [minInlineSizeStyle, {
75
- property: 'min-width',
76
- value: 'anchor-size(width)'
86
+ styles: [{
87
+ property: 'min-inline-size',
88
+ value: 'anchor-size(self-inline)'
77
89
  }]
78
90
  });
79
91
  }
@@ -81,24 +93,28 @@ export function useWidthFromAnchor({
81
93
  if (anchor) {
82
94
  return setStyle({
83
95
  element: node,
84
- styles: [minInlineSizeStyle, {
85
- property: 'min-width',
86
- value: `${anchor.offsetWidth}px`
96
+ styles: [{
97
+ property: 'min-inline-size',
98
+ value: `${getFallbackAnchorInlineSize({
99
+ anchor,
100
+ popover: node
101
+ })}px`
87
102
  }]
88
103
  });
89
104
  }
90
105
 
91
- // No anchor available in JS fallback - still apply min-inline-size.
92
- return setStyle({
93
- element: node,
94
- styles: [minInlineSizeStyle]
95
- });
106
+ // Nothing to measure, so no floor.
107
+ return;
96
108
  }
97
109
 
98
- // 'none' mode: no explicit width, but still apply `min-inline-size: max-content`.
110
+ // Makes a too-narrow span overflow and drive `position-try-fallbacks` instead of wrapping.
111
+ // `@atlaskit/popper` resets this exact property to `0` for its fit-to-viewport mode.
99
112
  return setStyle({
100
113
  element: node,
101
- styles: [minInlineSizeStyle]
114
+ styles: [{
115
+ property: 'min-inline-size',
116
+ value: 'max-content'
117
+ }]
102
118
  });
103
119
  }, [mode, popoverRef, anchorRef, isOpen]);
104
120
  }
@@ -13,6 +13,19 @@ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t =
13
13
  * position.
14
14
  */
15
15
 
16
+ /**
17
+ * A viewport-relative position, in pixels. Physical rather than logical
18
+ * because it is written straight to `top` / `left`.
19
+ */
20
+
21
+ /**
22
+ * The cross-axis shift with its length already resolved to pixels.
23
+ *
24
+ * Mirrors `TCrossAxisShiftOffset`, whose `value` is an unresolved CSS length
25
+ * string. Only this module needs the pixel form, because only this module does
26
+ * the arithmetic itself.
27
+ */
28
+
16
29
  /**
17
30
  * Computes the base {top, left} position based on the primary edge,
18
31
  * flipping to the opposite side when there is not enough space.
@@ -123,23 +136,26 @@ function applyAlignment(_ref2) {
123
136
  /**
124
137
  * Applies the cross-axis shift offset.
125
138
  *
126
- * The sign mirrors the CSS path (see `crossAxisShiftMargin` in
127
- * `use-anchor-position.tsx`):
128
- * - `align: 'start' | 'center'` + `forwards` → push toward end (positive)
129
- * - `align: 'end'` + `forwards` → push toward start (negative)
130
- * - `backwards` flips the sign in both cases.
139
+ * `forwards` always moves the popover toward the cross-axis END and
140
+ * `backwards` toward the START, for every `align` value. See
141
+ * `notes/decisions/placement-offset.md`.
142
+ *
143
+ * This is a coordinate, so the direction is the sign and nothing else. There
144
+ * is deliberately no per-`align` sign inversion here: the CSS path inverts the
145
+ * sign only because a margin on the END side pushes a box the opposite way to
146
+ * a margin on the START side, which is a property of margins rather than of
147
+ * the shift. Applying that inversion to a coordinate moved `align: 'end'`
148
+ * popovers the wrong way.
131
149
  */
132
150
  function applyCrossAxisShift(_ref3) {
133
151
  var position = _ref3.position,
134
152
  axis = _ref3.axis,
135
- align = _ref3.align,
136
153
  crossAxisShift = _ref3.crossAxisShift;
137
154
  if (crossAxisShift.value === 0) {
138
155
  return position;
139
156
  }
140
157
  var directionSign = crossAxisShift.direction === 'forwards' ? 1 : -1;
141
- var sideSign = align === 'end' ? -1 : 1;
142
- var delta = crossAxisShift.value * directionSign * sideSign;
158
+ var delta = crossAxisShift.value * directionSign;
143
159
 
144
160
  // Cross-axis is the OPPOSITE of the placement axis.
145
161
  if (axis === 'block') {
@@ -195,7 +211,6 @@ export function computeFallbackPosition(_ref4) {
195
211
  var shiftedPosition = applyCrossAxisShift({
196
212
  position: alignedPosition,
197
213
  axis: axis,
198
- align: align,
199
214
  crossAxisShift: crossAxisShift
200
215
  });
201
216
 
@@ -44,8 +44,9 @@ var roleToAriaHasPopup = {
44
44
  * `menu → "menu"`, `tooltip → undefined`). React omits the attribute when
45
45
  * the value is `undefined`.
46
46
  * - `aria-expanded` - reflects the current open state.
47
- * - `aria-controls` - references the popover element by its id while the
48
- * popover is open, and is omitted while the popover is closed and unmounted.
47
+ * - `aria-controls` - references the popover element by its id, even
48
+ * while the popover is closed. The relationship is stable; `aria-expanded`
49
+ * carries the current visibility state.
49
50
  *
50
51
  * **This function is for click/keyboard-activated popovers only.** For
51
52
  * hover-driven tooltips, the trigger should use `aria-describedby` instead.
@@ -7,10 +7,15 @@ import { toCssLengthString } from './resolve-css-length';
7
7
  */
8
8
  var DEFAULT_GAP = "var(--ds-space-100, 8px)";
9
9
 
10
+ /**
11
+ * Which way along the cross axis a shift moves the popover. `'forwards'` is
12
+ * always toward the cross-axis END and `'backwards'` toward the START, for
13
+ * every `align` value. See `notes/decisions/placement-offset.md`.
14
+ */
15
+
10
16
  /**
11
17
  * 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).
18
+ * normalized to `${n}px`).
14
19
  */
15
20
 
16
21
  /**
@@ -1,3 +1,12 @@
1
+ /**
2
+ * A single inline style declaration.
3
+ *
4
+ * `TProperty` narrows the property name for callers that write a known, closed
5
+ * set of properties. Narrowing it lets a record keyed on those same names be
6
+ * checked for exhaustiveness at compile time, rather than silently resolving to
7
+ * `undefined` for a name that was never mapped.
8
+ */
9
+
1
10
  /**
2
11
  * Sets inline styles on an element and returns a cleanup function that
3
12
  * restores the prior inline values (so we do not stomp consumer styles).