@atlaskit/top-layer 0.4.0 → 0.5.1

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 (29) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/cjs/animations/presets.js +2 -2
  3. package/dist/cjs/arrow/index.js +6 -6
  4. package/dist/cjs/entry-points/use-anchor-position.js +2 -2
  5. package/dist/cjs/internal/anchor-positioning-fallback.js +1 -1
  6. package/dist/cjs/internal/{use-anchor-positioning.js → use-anchor-position.js} +68 -24
  7. package/dist/cjs/popup/popup-content.js +2 -2
  8. package/dist/cjs/testing/polyfill.js +2 -2
  9. package/dist/es2019/animations/presets.js +1 -1
  10. package/dist/es2019/arrow/index.js +1 -1
  11. package/dist/es2019/entry-points/use-anchor-position.js +1 -1
  12. package/dist/es2019/internal/anchor-positioning-fallback.js +1 -1
  13. package/dist/es2019/internal/{use-anchor-positioning.js → use-anchor-position.js} +68 -25
  14. package/dist/es2019/popup/popup-content.js +1 -1
  15. package/dist/es2019/testing/polyfill.js +2 -2
  16. package/dist/esm/animations/presets.js +1 -1
  17. package/dist/esm/arrow/index.js +1 -1
  18. package/dist/esm/entry-points/use-anchor-position.js +1 -1
  19. package/dist/esm/internal/anchor-positioning-fallback.js +1 -1
  20. package/dist/esm/internal/{use-anchor-positioning.js → use-anchor-position.js} +69 -25
  21. package/dist/esm/popup/popup-content.js +1 -1
  22. package/dist/esm/testing/polyfill.js +2 -2
  23. package/dist/types/entry-points/use-anchor-position.d.ts +1 -1
  24. package/dist/{types-ts4.5/internal/use-anchor-positioning.d.ts → types/internal/use-anchor-position.d.ts} +20 -1
  25. package/dist/types/testing/polyfill.d.ts +1 -1
  26. package/dist/types-ts4.5/entry-points/use-anchor-position.d.ts +1 -1
  27. package/dist/{types/internal/use-anchor-positioning.d.ts → types-ts4.5/internal/use-anchor-position.d.ts} +20 -1
  28. package/dist/types-ts4.5/testing/polyfill.d.ts +1 -1
  29. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @atlaskit/top-layer
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`693551fc3e8c4`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/693551fc3e8c4) -
8
+ useAnchorPosition no longer re-runs its positioning effect when a fresh placement object reference
9
+ with the same resolved shape is passed (eg an inline literal on every render). The hook
10
+ structurally compares the resolved placement and reuses the previous reference when the shape is
11
+ unchanged.
12
+
13
+ ## 0.5.0
14
+
15
+ ### Minor Changes
16
+
17
+ - [`6e656fce5ceee`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/6e656fce5ceee) -
18
+ Fix anchor positioning so multiple popovers can share the same trigger element. Previously the
19
+ second `useAnchorPosition` call on the same trigger would overwrite the first call's `anchor-name`
20
+ style, leaving the first popover unpositioned.
21
+
3
22
  ## 0.4.0
4
23
 
5
24
  ### Minor Changes
@@ -8,7 +8,7 @@ exports.dialogSlideUpAndFade = dialogSlideUpAndFade;
8
8
  exports.fade = fade;
9
9
  exports.scaleAndFade = scaleAndFade;
10
10
  exports.slideAndFade = slideAndFade;
11
- var _useAnchorPositioning = require("../internal/use-anchor-positioning");
11
+ var _useAnchorPosition = require("../internal/use-anchor-position");
12
12
  // ── slideAndFade ──
13
13
 
14
14
  function getSlideAndFadeProperties(_ref) {
@@ -16,7 +16,7 @@ function getSlideAndFadeProperties(_ref) {
16
16
  distance = _ref.distance;
17
17
  var pos = "".concat(distance, "px");
18
18
  var neg = "-".concat(distance, "px");
19
- var _getPlacement = (0, _useAnchorPositioning.getPlacement)({
19
+ var _getPlacement = (0, _useAnchorPosition.getPlacement)({
20
20
  placement: placement
21
21
  }),
22
22
  axis = _getPlacement.axis,
@@ -6,7 +6,7 @@ Object.defineProperty(exports, "__esModule", {
6
6
  });
7
7
  exports.arrow = void 0;
8
8
  var _once = _interopRequireDefault(require("@atlaskit/ds-lib/once"));
9
- var _useAnchorPositioning = require("../internal/use-anchor-positioning");
9
+ var _useAnchorPosition = require("../internal/use-anchor-position");
10
10
  /**
11
11
  * CSS for popover arrows using the `clip-path: inset() margin-box` technique.
12
12
  *
@@ -51,7 +51,7 @@ function positionAreaToRuleName(_ref) {
51
51
  */
52
52
  function getTryFallbacks(_ref2) {
53
53
  var placement = _ref2.placement;
54
- var _getPlacement = (0, _useAnchorPositioning.getPlacement)({
54
+ var _getPlacement = (0, _useAnchorPosition.getPlacement)({
55
55
  placement: placement
56
56
  }),
57
57
  axis = _getPlacement.axis,
@@ -61,7 +61,7 @@ function getTryFallbacks(_ref2) {
61
61
  // Single-axis: only flip the edge
62
62
  var _flippedEdge = edge === 'start' ? 'end' : 'start';
63
63
  return positionAreaToRuleName({
64
- positionArea: (0, _useAnchorPositioning.placementToPositionArea)({
64
+ positionArea: (0, _useAnchorPosition.placementToPositionArea)({
65
65
  placement: {
66
66
  axis: axis,
67
67
  edge: _flippedEdge
@@ -74,7 +74,7 @@ function getTryFallbacks(_ref2) {
74
74
  var flippedEdge = edge === 'start' ? 'end' : 'start';
75
75
  var flippedAlign = align === 'start' ? 'end' : 'start';
76
76
  return [positionAreaToRuleName({
77
- positionArea: (0, _useAnchorPositioning.placementToPositionArea)({
77
+ positionArea: (0, _useAnchorPosition.placementToPositionArea)({
78
78
  placement: {
79
79
  axis: axis,
80
80
  edge: flippedEdge,
@@ -82,7 +82,7 @@ function getTryFallbacks(_ref2) {
82
82
  }
83
83
  })
84
84
  }), positionAreaToRuleName({
85
- positionArea: (0, _useAnchorPositioning.placementToPositionArea)({
85
+ positionArea: (0, _useAnchorPosition.placementToPositionArea)({
86
86
  placement: {
87
87
  axis: axis,
88
88
  edge: edge,
@@ -90,7 +90,7 @@ function getTryFallbacks(_ref2) {
90
90
  }
91
91
  })
92
92
  }), positionAreaToRuleName({
93
- positionArea: (0, _useAnchorPositioning.placementToPositionArea)({
93
+ positionArea: (0, _useAnchorPosition.placementToPositionArea)({
94
94
  placement: {
95
95
  axis: axis,
96
96
  edge: flippedEdge,
@@ -6,7 +6,7 @@ Object.defineProperty(exports, "__esModule", {
6
6
  Object.defineProperty(exports, "useAnchorPosition", {
7
7
  enumerable: true,
8
8
  get: function get() {
9
- return _useAnchorPositioning.useAnchorPosition;
9
+ return _useAnchorPosition.useAnchorPosition;
10
10
  }
11
11
  });
12
- var _useAnchorPositioning = require("../internal/use-anchor-positioning");
12
+ var _useAnchorPosition = require("../internal/use-anchor-position");
@@ -131,7 +131,7 @@ function applyAlignment(_ref2) {
131
131
  * Applies the cross-axis shift offset.
132
132
  *
133
133
  * The sign mirrors the CSS path (see `crossAxisShiftMargin` in
134
- * `use-anchor-positioning.tsx`):
134
+ * `use-anchor-position.tsx`):
135
135
  * - `align: 'start' | 'center'` + `forwards` → push toward end (positive)
136
136
  * - `align: 'end'` + `forwards` → push toward start (negative)
137
137
  * - `backwards` flips the sign in both cases.
@@ -210,6 +210,30 @@ function crossAxisShiftMargin(_ref4) {
210
210
  };
211
211
  }
212
212
 
213
+ /**
214
+ * Returns a referentially-stable, fully-resolved `TPlacement`: the same
215
+ * reference is preserved across renders as long as the resolved shape
216
+ * does not change.
217
+ */
218
+ function useStablePlacement(placement) {
219
+ var resolved = (0, _resolvePlacement.getPlacement)({
220
+ placement: placement
221
+ });
222
+ var stableRef = (0, _react.useRef)(resolved);
223
+ if (!isResolvedPlacementEqual(stableRef.current, resolved)) {
224
+ stableRef.current = resolved;
225
+ }
226
+ return stableRef.current;
227
+ }
228
+
229
+ /**
230
+ * Shallow-equals two fully-resolved placements. Avoids `JSON.stringify` so
231
+ * the comparison cost stays proportional to the number of fields.
232
+ */
233
+ function isResolvedPlacementEqual(a, b) {
234
+ return a.axis === b.axis && a.edge === b.edge && a.align === b.align && a.offset.gap === b.offset.gap && a.offset.crossAxisShift.value === b.offset.crossAxisShift.value && a.offset.crossAxisShift.direction === b.offset.crossAxisShift.direction;
235
+ }
236
+
213
237
  /**
214
238
  * Hook that positions an element relative to an anchor element using
215
239
  * CSS Anchor Positioning (with a JS fallback for older browsers).
@@ -225,37 +249,44 @@ function crossAxisShiftMargin(_ref4) {
225
249
  * When not supported, it falls back to JavaScript-based positioning using
226
250
  * `position: fixed` with measured coordinates, re-running on scroll (capture)
227
251
  * and resize events.
252
+ *
253
+ * `placement` may be a fresh inline object on every render: a structural
254
+ * comparison is used internally so re-runs are skipped when the resolved
255
+ * placement shape is unchanged.
228
256
  */
229
257
  function useAnchorPosition(_ref5) {
230
258
  var anchorRef = _ref5.anchorRef,
231
259
  popoverRef = _ref5.popoverRef,
232
- placement = _ref5.placement,
260
+ _ref5$placement = _ref5.placement,
261
+ placement = _ref5$placement === void 0 ? {} : _ref5$placement,
233
262
  _ref5$forceFallbackPo = _ref5.forceFallbackPositioning,
234
263
  forceFallbackPositioning = _ref5$forceFallbackPo === void 0 ? false : _ref5$forceFallbackPo,
235
264
  arrow = _ref5.arrow;
236
265
  var id = (0, _react.useId)();
237
- var anchorName = "--anchor-".concat(id.replace(/:/g, ''));
266
+ // Stabilize `placement` object so the same object literal
267
+ // value returns an object with the same reference
268
+ var stablePlacement = useStablePlacement(placement);
238
269
  (0, _react.useLayoutEffect)(function () {
239
270
  var trigger = anchorRef.current;
240
271
  var popover = popoverRef.current;
241
272
  if (!trigger || !popover) {
242
273
  return;
243
274
  }
244
- var resolvedPlacement = (0, _resolvePlacement.getPlacement)({
245
- placement: placement
246
- });
247
- var gapCssValue = resolvedPlacement.offset.gap;
248
- var crossAxisShiftCssValue = resolvedPlacement.offset.crossAxisShift.value;
275
+ var gapCssValue = stablePlacement.offset.gap;
276
+ var crossAxisShiftCssValue = stablePlacement.offset.crossAxisShift.value;
249
277
  var shouldForceFallback = Boolean(forceFallbackPositioning);
250
278
  if (supportsAnchorPositioning() && !shouldForceFallback) {
279
+ // Reuse an existing `anchor-name` if the element already has one.
280
+ // Another popover might already be anchored to the old name.
281
+ var anchorName = trigger.style.getPropertyValue('anchor-name') || "--anchor-".concat(id.replace(/:/g, ''));
251
282
  var gap = edgeMargin({
252
- placement: resolvedPlacement,
283
+ placement: stablePlacement,
253
284
  offset: gapCssValue
254
285
  });
255
286
  var crossAxisShift = crossAxisShiftMargin({
256
- placement: resolvedPlacement,
287
+ placement: stablePlacement,
257
288
  crossAxisShiftCssValue: crossAxisShiftCssValue,
258
- direction: resolvedPlacement.offset.crossAxisShift.direction
289
+ direction: stablePlacement.offset.crossAxisShift.direction
259
290
  });
260
291
  var popoverStyles = [{
261
292
  property: 'position-anchor',
@@ -263,14 +294,14 @@ function useAnchorPosition(_ref5) {
263
294
  }, {
264
295
  property: 'position-area',
265
296
  value: placementToPositionArea({
266
- placement: resolvedPlacement
297
+ placement: stablePlacement
267
298
  })
268
299
  }, {
269
300
  property: 'position-try-fallbacks',
270
301
  value: arrow ? arrow.getTryFallbacks({
271
- placement: resolvedPlacement
302
+ placement: stablePlacement
272
303
  }) : placementToTryFallbacks({
273
- placement: resolvedPlacement
304
+ placement: stablePlacement
274
305
  })
275
306
  },
276
307
  // Reset browser default popover positioning that conflicts
@@ -314,9 +345,9 @@ function useAnchorPosition(_ref5) {
314
345
  value: '0px'
315
346
  });
316
347
  var crossAxisShiftActive = crossAxisShiftMargin({
317
- placement: resolvedPlacement,
348
+ placement: stablePlacement,
318
349
  crossAxisShiftCssValue: crossAxisShiftCssValue,
319
- direction: resolvedPlacement.offset.crossAxisShift.direction
350
+ direction: stablePlacement.offset.crossAxisShift.direction
320
351
  });
321
352
  // Map margin property to its corresponding custom property name.
322
353
  var customPropertyByMarginProperty = {
@@ -345,13 +376,26 @@ function useAnchorPosition(_ref5) {
345
376
  // because that's where useAnchorPosition sets the margin gap.
346
377
  popover.setAttribute('data-ds-popover-arrow', '');
347
378
  }
379
+
380
+ /**
381
+ * **We are never cleaning up anchor names**
382
+ *
383
+ * _Rationale_
384
+ *
385
+ * - Multiple popovers can share the same anchor element.
386
+ * - Sometimes the `useAnchorPosition` hook and `Popover`
387
+ * are not in the same component.
388
+ * - There can be times when, even with reference counting, we
389
+ * could remove the anchor-name while it's still being used
390
+ * by something else - especially with async react updates
391
+ * which can cause tearing
392
+ *
393
+ * _More_
394
+ *
395
+ * See `notes/decisions/anchor-name-lifetime.md`
396
+ */
397
+ trigger.style.setProperty('anchor-name', anchorName);
348
398
  var _undoPositioning = (0, _combine.combine)((0, _setStyle.setStyle)({
349
- el: trigger,
350
- styles: [{
351
- property: 'anchor-name',
352
- value: anchorName
353
- }]
354
- }), (0, _setStyle.setStyle)({
355
399
  el: popover,
356
400
  styles: popoverStyles
357
401
  }), arrow ? function () {
@@ -403,12 +447,12 @@ function useAnchorPosition(_ref5) {
403
447
  var _computeFallbackPosit = (0, _anchorPositioningFallback.computeFallbackPosition)({
404
448
  triggerRect: triggerRect,
405
449
  popoverEl: popover,
406
- placement: resolvedPlacement,
450
+ placement: stablePlacement,
407
451
  viewport: viewport,
408
452
  gap: gapPx,
409
453
  crossAxisShift: {
410
454
  value: crossAxisShiftPx,
411
- direction: resolvedPlacement.offset.crossAxisShift.direction
455
+ direction: stablePlacement.offset.crossAxisShift.direction
412
456
  }
413
457
  }),
414
458
  top = _computeFallbackPosit.top,
@@ -483,5 +527,5 @@ function useAnchorPosition(_ref5) {
483
527
  popover.style.removeProperty('opacity');
484
528
  });
485
529
  return undoPositioning;
486
- }, [anchorRef, popoverRef, placement, anchorName, forceFallbackPositioning, arrow]);
530
+ }, [anchorRef, popoverRef, stablePlacement, forceFallbackPositioning, arrow, id]);
487
531
  }
@@ -12,7 +12,7 @@ var _extends2 = _interopRequireDefault(require("@babel/runtime/helpers/extends")
12
12
  var _react = _interopRequireWildcard(require("react"));
13
13
  var _mergeRefs = _interopRequireDefault(require("@atlaskit/ds-lib/merge-refs"));
14
14
  var _roleTypes = require("../internal/role-types");
15
- var _useAnchorPositioning = require("../internal/use-anchor-positioning");
15
+ var _useAnchorPosition = require("../internal/use-anchor-position");
16
16
  var _usePresetStyles = require("../internal/use-preset-styles");
17
17
  var _popover = require("../popover/popover");
18
18
  var _popupContext = require("./popup-context");
@@ -131,7 +131,7 @@ var PopupContent = exports.PopupContent = /*#__PURE__*/(0, _react.forwardRef)(fu
131
131
  var arrowPreset = (0, _usePresetStyles.usePresetStyles)({
132
132
  preset: arrow
133
133
  });
134
- (0, _useAnchorPositioning.useAnchorPosition)({
134
+ (0, _useAnchorPosition.useAnchorPosition)({
135
135
  anchorRef: triggerRef !== null && triggerRef !== void 0 ? triggerRef : {
136
136
  current: null
137
137
  },
@@ -17,7 +17,7 @@ var _bindEventListener = require("bind-event-listener");
17
17
  * - Auto popovers: maintain an open stack, perform light-dismiss on document mousedown
18
18
  * outside the open chain, and dismiss the topmost on Escape.
19
19
  * - On open, removes any inline `opacity: 0` from the popover element so callers that
20
- * rely on a `ResizeObserver` reveal callback (e.g. `use-anchor-positioning`) become
20
+ * rely on a `ResizeObserver` reveal callback (e.g. `use-anchor-position`) become
21
21
  * visible in jsdom, where layout-driven RO callbacks never fire.
22
22
  *
23
23
  * Companion `toBeVisible` patch lives at `./to-be-visible`.
@@ -251,7 +251,7 @@ function stackForMode(_ref9) {
251
251
 
252
252
  // Polyfill-only. Real browsers reveal the popover once `ResizeObserver`
253
253
  // callbacks fire after the first layout pass. jsdom has no layout, so we clear
254
- // any inline `opacity: 0` (set by callers like `use-anchor-positioning`) once
254
+ // any inline `opacity: 0` (set by callers like `use-anchor-position`) once
255
255
  // the open toggle has fired.
256
256
  function clearOpacityOnOpenToggle(_ref0) {
257
257
  var element = _ref0.element;
@@ -1,4 +1,4 @@
1
- import { getPlacement } from '../internal/use-anchor-positioning';
1
+ import { getPlacement } from '../internal/use-anchor-position';
2
2
 
3
3
  // ── slideAndFade ──
4
4
 
@@ -1,5 +1,5 @@
1
1
  import once from '@atlaskit/ds-lib/once';
2
- import { getPlacement, placementToPositionArea } from '../internal/use-anchor-positioning';
2
+ import { getPlacement, placementToPositionArea } from '../internal/use-anchor-position';
3
3
  /**
4
4
  * CSS for popover arrows using the `clip-path: inset() margin-box` technique.
5
5
  *
@@ -1 +1 @@
1
- export { useAnchorPosition } from '../internal/use-anchor-positioning';
1
+ export { useAnchorPosition } from '../internal/use-anchor-position';
@@ -127,7 +127,7 @@ function applyAlignment({
127
127
  * Applies the cross-axis shift offset.
128
128
  *
129
129
  * The sign mirrors the CSS path (see `crossAxisShiftMargin` in
130
- * `use-anchor-positioning.tsx`):
130
+ * `use-anchor-position.tsx`):
131
131
  * - `align: 'start' | 'center'` + `forwards` → push toward end (positive)
132
132
  * - `align: 'end'` + `forwards` → push toward start (negative)
133
133
  * - `backwards` flips the sign in both cases.
@@ -12,7 +12,7 @@
12
12
  * popover with `opacity: 0` until the first measurement completes, so it
13
13
  * is never painted at the wrong location.
14
14
  */
15
- import { useId, useLayoutEffect } from 'react';
15
+ import { useId, useLayoutEffect, useRef } from 'react';
16
16
  import { bind } from 'bind-event-listener';
17
17
  import rafSchedule from 'raf-schd';
18
18
  import once from '@atlaskit/ds-lib/once';
@@ -206,6 +206,30 @@ function crossAxisShiftMargin({
206
206
  };
207
207
  }
208
208
 
209
+ /**
210
+ * Returns a referentially-stable, fully-resolved `TPlacement`: the same
211
+ * reference is preserved across renders as long as the resolved shape
212
+ * does not change.
213
+ */
214
+ function useStablePlacement(placement) {
215
+ const resolved = getPlacement({
216
+ placement
217
+ });
218
+ const stableRef = useRef(resolved);
219
+ if (!isResolvedPlacementEqual(stableRef.current, resolved)) {
220
+ stableRef.current = resolved;
221
+ }
222
+ return stableRef.current;
223
+ }
224
+
225
+ /**
226
+ * Shallow-equals two fully-resolved placements. Avoids `JSON.stringify` so
227
+ * the comparison cost stays proportional to the number of fields.
228
+ */
229
+ function isResolvedPlacementEqual(a, b) {
230
+ return a.axis === b.axis && a.edge === b.edge && a.align === b.align && a.offset.gap === b.offset.gap && a.offset.crossAxisShift.value === b.offset.crossAxisShift.value && a.offset.crossAxisShift.direction === b.offset.crossAxisShift.direction;
231
+ }
232
+
209
233
  /**
210
234
  * Hook that positions an element relative to an anchor element using
211
235
  * CSS Anchor Positioning (with a JS fallback for older browsers).
@@ -221,37 +245,43 @@ function crossAxisShiftMargin({
221
245
  * When not supported, it falls back to JavaScript-based positioning using
222
246
  * `position: fixed` with measured coordinates, re-running on scroll (capture)
223
247
  * and resize events.
248
+ *
249
+ * `placement` may be a fresh inline object on every render: a structural
250
+ * comparison is used internally so re-runs are skipped when the resolved
251
+ * placement shape is unchanged.
224
252
  */
225
253
  export function useAnchorPosition({
226
254
  anchorRef,
227
255
  popoverRef,
228
- placement,
256
+ placement = {},
229
257
  forceFallbackPositioning = false,
230
258
  arrow
231
259
  }) {
232
260
  const id = useId();
233
- const anchorName = `--anchor-${id.replace(/:/g, '')}`;
261
+ // Stabilize `placement` object so the same object literal
262
+ // value returns an object with the same reference
263
+ const stablePlacement = useStablePlacement(placement);
234
264
  useLayoutEffect(() => {
235
265
  const trigger = anchorRef.current;
236
266
  const popover = popoverRef.current;
237
267
  if (!trigger || !popover) {
238
268
  return;
239
269
  }
240
- const resolvedPlacement = getPlacement({
241
- placement
242
- });
243
- const gapCssValue = resolvedPlacement.offset.gap;
244
- const crossAxisShiftCssValue = resolvedPlacement.offset.crossAxisShift.value;
270
+ const gapCssValue = stablePlacement.offset.gap;
271
+ const crossAxisShiftCssValue = stablePlacement.offset.crossAxisShift.value;
245
272
  const shouldForceFallback = Boolean(forceFallbackPositioning);
246
273
  if (supportsAnchorPositioning() && !shouldForceFallback) {
274
+ // Reuse an existing `anchor-name` if the element already has one.
275
+ // Another popover might already be anchored to the old name.
276
+ const anchorName = trigger.style.getPropertyValue('anchor-name') || `--anchor-${id.replace(/:/g, '')}`;
247
277
  const gap = edgeMargin({
248
- placement: resolvedPlacement,
278
+ placement: stablePlacement,
249
279
  offset: gapCssValue
250
280
  });
251
281
  const crossAxisShift = crossAxisShiftMargin({
252
- placement: resolvedPlacement,
282
+ placement: stablePlacement,
253
283
  crossAxisShiftCssValue,
254
- direction: resolvedPlacement.offset.crossAxisShift.direction
284
+ direction: stablePlacement.offset.crossAxisShift.direction
255
285
  });
256
286
  const popoverStyles = [{
257
287
  property: 'position-anchor',
@@ -259,14 +289,14 @@ export function useAnchorPosition({
259
289
  }, {
260
290
  property: 'position-area',
261
291
  value: placementToPositionArea({
262
- placement: resolvedPlacement
292
+ placement: stablePlacement
263
293
  })
264
294
  }, {
265
295
  property: 'position-try-fallbacks',
266
296
  value: arrow ? arrow.getTryFallbacks({
267
- placement: resolvedPlacement
297
+ placement: stablePlacement
268
298
  }) : placementToTryFallbacks({
269
- placement: resolvedPlacement
299
+ placement: stablePlacement
270
300
  })
271
301
  },
272
302
  // Reset browser default popover positioning that conflicts
@@ -310,9 +340,9 @@ export function useAnchorPosition({
310
340
  value: '0px'
311
341
  });
312
342
  const crossAxisShiftActive = crossAxisShiftMargin({
313
- placement: resolvedPlacement,
343
+ placement: stablePlacement,
314
344
  crossAxisShiftCssValue,
315
- direction: resolvedPlacement.offset.crossAxisShift.direction
345
+ direction: stablePlacement.offset.crossAxisShift.direction
316
346
  });
317
347
  // Map margin property to its corresponding custom property name.
318
348
  const customPropertyByMarginProperty = {
@@ -341,13 +371,26 @@ export function useAnchorPosition({
341
371
  // because that's where useAnchorPosition sets the margin gap.
342
372
  popover.setAttribute('data-ds-popover-arrow', '');
343
373
  }
374
+
375
+ /**
376
+ * **We are never cleaning up anchor names**
377
+ *
378
+ * _Rationale_
379
+ *
380
+ * - Multiple popovers can share the same anchor element.
381
+ * - Sometimes the `useAnchorPosition` hook and `Popover`
382
+ * are not in the same component.
383
+ * - There can be times when, even with reference counting, we
384
+ * could remove the anchor-name while it's still being used
385
+ * by something else - especially with async react updates
386
+ * which can cause tearing
387
+ *
388
+ * _More_
389
+ *
390
+ * See `notes/decisions/anchor-name-lifetime.md`
391
+ */
392
+ trigger.style.setProperty('anchor-name', anchorName);
344
393
  const undoPositioning = combine(setStyle({
345
- el: trigger,
346
- styles: [{
347
- property: 'anchor-name',
348
- value: anchorName
349
- }]
350
- }), setStyle({
351
394
  el: popover,
352
395
  styles: popoverStyles
353
396
  }), arrow ? () => popover.removeAttribute('data-ds-popover-arrow') : () => {});
@@ -400,12 +443,12 @@ export function useAnchorPosition({
400
443
  } = computeFallbackPosition({
401
444
  triggerRect,
402
445
  popoverEl: popover,
403
- placement: resolvedPlacement,
446
+ placement: stablePlacement,
404
447
  viewport,
405
448
  gap: gapPx,
406
449
  crossAxisShift: {
407
450
  value: crossAxisShiftPx,
408
- direction: resolvedPlacement.offset.crossAxisShift.direction
451
+ direction: stablePlacement.offset.crossAxisShift.direction
409
452
  }
410
453
  });
411
454
  popover.style.setProperty('top', `${top}px`);
@@ -476,5 +519,5 @@ export function useAnchorPosition({
476
519
  popover.style.removeProperty('opacity');
477
520
  });
478
521
  return undoPositioning;
479
- }, [anchorRef, popoverRef, placement, anchorName, forceFallbackPositioning, arrow]);
522
+ }, [anchorRef, popoverRef, stablePlacement, forceFallbackPositioning, arrow, id]);
480
523
  }
@@ -4,7 +4,7 @@ import { ax, ix } from "@compiled/react/runtime";
4
4
  import React, { forwardRef, useCallback, useLayoutEffect, useRef } from 'react';
5
5
  import mergeRefs from '@atlaskit/ds-lib/merge-refs';
6
6
  import { roleToAriaHasPopup } from '../internal/role-types';
7
- import { useAnchorPosition } from '../internal/use-anchor-positioning';
7
+ import { useAnchorPosition } from '../internal/use-anchor-position';
8
8
  import { usePresetStyles } from '../internal/use-preset-styles';
9
9
  import { Popover } from '../popover/popover';
10
10
  import { useMaybePopupContext } from './popup-context';
@@ -14,7 +14,7 @@
14
14
  * - Auto popovers: maintain an open stack, perform light-dismiss on document mousedown
15
15
  * outside the open chain, and dismiss the topmost on Escape.
16
16
  * - On open, removes any inline `opacity: 0` from the popover element so callers that
17
- * rely on a `ResizeObserver` reveal callback (e.g. `use-anchor-positioning`) become
17
+ * rely on a `ResizeObserver` reveal callback (e.g. `use-anchor-position`) become
18
18
  * visible in jsdom, where layout-driven RO callbacks never fire.
19
19
  *
20
20
  * Companion `toBeVisible` patch lives at `./to-be-visible`.
@@ -258,7 +258,7 @@ function stackForMode({
258
258
 
259
259
  // Polyfill-only. Real browsers reveal the popover once `ResizeObserver`
260
260
  // callbacks fire after the first layout pass. jsdom has no layout, so we clear
261
- // any inline `opacity: 0` (set by callers like `use-anchor-positioning`) once
261
+ // any inline `opacity: 0` (set by callers like `use-anchor-position`) once
262
262
  // the open toggle has fired.
263
263
  function clearOpacityOnOpenToggle({
264
264
  element
@@ -1,4 +1,4 @@
1
- import { getPlacement } from '../internal/use-anchor-positioning';
1
+ import { getPlacement } from '../internal/use-anchor-position';
2
2
 
3
3
  // ── slideAndFade ──
4
4
 
@@ -1,5 +1,5 @@
1
1
  import once from '@atlaskit/ds-lib/once';
2
- import { getPlacement, placementToPositionArea } from '../internal/use-anchor-positioning';
2
+ import { getPlacement, placementToPositionArea } from '../internal/use-anchor-position';
3
3
  /**
4
4
  * CSS for popover arrows using the `clip-path: inset() margin-box` technique.
5
5
  *
@@ -1 +1 @@
1
- export { useAnchorPosition } from '../internal/use-anchor-positioning';
1
+ export { useAnchorPosition } from '../internal/use-anchor-position';
@@ -124,7 +124,7 @@ function applyAlignment(_ref2) {
124
124
  * Applies the cross-axis shift offset.
125
125
  *
126
126
  * The sign mirrors the CSS path (see `crossAxisShiftMargin` in
127
- * `use-anchor-positioning.tsx`):
127
+ * `use-anchor-position.tsx`):
128
128
  * - `align: 'start' | 'center'` + `forwards` → push toward end (positive)
129
129
  * - `align: 'end'` + `forwards` → push toward start (negative)
130
130
  * - `backwards` flips the sign in both cases.
@@ -12,7 +12,7 @@
12
12
  * popover with `opacity: 0` until the first measurement completes, so it
13
13
  * is never painted at the wrong location.
14
14
  */
15
- import { useId, useLayoutEffect } from 'react';
15
+ import { useId, useLayoutEffect, useRef } from 'react';
16
16
  import { bind } from 'bind-event-listener';
17
17
  import rafSchedule from 'raf-schd';
18
18
  import once from '@atlaskit/ds-lib/once';
@@ -196,6 +196,30 @@ function crossAxisShiftMargin(_ref4) {
196
196
  };
197
197
  }
198
198
 
199
+ /**
200
+ * Returns a referentially-stable, fully-resolved `TPlacement`: the same
201
+ * reference is preserved across renders as long as the resolved shape
202
+ * does not change.
203
+ */
204
+ function useStablePlacement(placement) {
205
+ var resolved = getPlacement({
206
+ placement: placement
207
+ });
208
+ var stableRef = useRef(resolved);
209
+ if (!isResolvedPlacementEqual(stableRef.current, resolved)) {
210
+ stableRef.current = resolved;
211
+ }
212
+ return stableRef.current;
213
+ }
214
+
215
+ /**
216
+ * Shallow-equals two fully-resolved placements. Avoids `JSON.stringify` so
217
+ * the comparison cost stays proportional to the number of fields.
218
+ */
219
+ function isResolvedPlacementEqual(a, b) {
220
+ return a.axis === b.axis && a.edge === b.edge && a.align === b.align && a.offset.gap === b.offset.gap && a.offset.crossAxisShift.value === b.offset.crossAxisShift.value && a.offset.crossAxisShift.direction === b.offset.crossAxisShift.direction;
221
+ }
222
+
199
223
  /**
200
224
  * Hook that positions an element relative to an anchor element using
201
225
  * CSS Anchor Positioning (with a JS fallback for older browsers).
@@ -211,37 +235,44 @@ function crossAxisShiftMargin(_ref4) {
211
235
  * When not supported, it falls back to JavaScript-based positioning using
212
236
  * `position: fixed` with measured coordinates, re-running on scroll (capture)
213
237
  * and resize events.
238
+ *
239
+ * `placement` may be a fresh inline object on every render: a structural
240
+ * comparison is used internally so re-runs are skipped when the resolved
241
+ * placement shape is unchanged.
214
242
  */
215
243
  export function useAnchorPosition(_ref5) {
216
244
  var anchorRef = _ref5.anchorRef,
217
245
  popoverRef = _ref5.popoverRef,
218
- placement = _ref5.placement,
246
+ _ref5$placement = _ref5.placement,
247
+ placement = _ref5$placement === void 0 ? {} : _ref5$placement,
219
248
  _ref5$forceFallbackPo = _ref5.forceFallbackPositioning,
220
249
  forceFallbackPositioning = _ref5$forceFallbackPo === void 0 ? false : _ref5$forceFallbackPo,
221
250
  arrow = _ref5.arrow;
222
251
  var id = useId();
223
- var anchorName = "--anchor-".concat(id.replace(/:/g, ''));
252
+ // Stabilize `placement` object so the same object literal
253
+ // value returns an object with the same reference
254
+ var stablePlacement = useStablePlacement(placement);
224
255
  useLayoutEffect(function () {
225
256
  var trigger = anchorRef.current;
226
257
  var popover = popoverRef.current;
227
258
  if (!trigger || !popover) {
228
259
  return;
229
260
  }
230
- var resolvedPlacement = getPlacement({
231
- placement: placement
232
- });
233
- var gapCssValue = resolvedPlacement.offset.gap;
234
- var crossAxisShiftCssValue = resolvedPlacement.offset.crossAxisShift.value;
261
+ var gapCssValue = stablePlacement.offset.gap;
262
+ var crossAxisShiftCssValue = stablePlacement.offset.crossAxisShift.value;
235
263
  var shouldForceFallback = Boolean(forceFallbackPositioning);
236
264
  if (supportsAnchorPositioning() && !shouldForceFallback) {
265
+ // Reuse an existing `anchor-name` if the element already has one.
266
+ // Another popover might already be anchored to the old name.
267
+ var anchorName = trigger.style.getPropertyValue('anchor-name') || "--anchor-".concat(id.replace(/:/g, ''));
237
268
  var gap = edgeMargin({
238
- placement: resolvedPlacement,
269
+ placement: stablePlacement,
239
270
  offset: gapCssValue
240
271
  });
241
272
  var crossAxisShift = crossAxisShiftMargin({
242
- placement: resolvedPlacement,
273
+ placement: stablePlacement,
243
274
  crossAxisShiftCssValue: crossAxisShiftCssValue,
244
- direction: resolvedPlacement.offset.crossAxisShift.direction
275
+ direction: stablePlacement.offset.crossAxisShift.direction
245
276
  });
246
277
  var popoverStyles = [{
247
278
  property: 'position-anchor',
@@ -249,14 +280,14 @@ export function useAnchorPosition(_ref5) {
249
280
  }, {
250
281
  property: 'position-area',
251
282
  value: placementToPositionArea({
252
- placement: resolvedPlacement
283
+ placement: stablePlacement
253
284
  })
254
285
  }, {
255
286
  property: 'position-try-fallbacks',
256
287
  value: arrow ? arrow.getTryFallbacks({
257
- placement: resolvedPlacement
288
+ placement: stablePlacement
258
289
  }) : placementToTryFallbacks({
259
- placement: resolvedPlacement
290
+ placement: stablePlacement
260
291
  })
261
292
  },
262
293
  // Reset browser default popover positioning that conflicts
@@ -300,9 +331,9 @@ export function useAnchorPosition(_ref5) {
300
331
  value: '0px'
301
332
  });
302
333
  var crossAxisShiftActive = crossAxisShiftMargin({
303
- placement: resolvedPlacement,
334
+ placement: stablePlacement,
304
335
  crossAxisShiftCssValue: crossAxisShiftCssValue,
305
- direction: resolvedPlacement.offset.crossAxisShift.direction
336
+ direction: stablePlacement.offset.crossAxisShift.direction
306
337
  });
307
338
  // Map margin property to its corresponding custom property name.
308
339
  var customPropertyByMarginProperty = {
@@ -331,13 +362,26 @@ export function useAnchorPosition(_ref5) {
331
362
  // because that's where useAnchorPosition sets the margin gap.
332
363
  popover.setAttribute('data-ds-popover-arrow', '');
333
364
  }
365
+
366
+ /**
367
+ * **We are never cleaning up anchor names**
368
+ *
369
+ * _Rationale_
370
+ *
371
+ * - Multiple popovers can share the same anchor element.
372
+ * - Sometimes the `useAnchorPosition` hook and `Popover`
373
+ * are not in the same component.
374
+ * - There can be times when, even with reference counting, we
375
+ * could remove the anchor-name while it's still being used
376
+ * by something else - especially with async react updates
377
+ * which can cause tearing
378
+ *
379
+ * _More_
380
+ *
381
+ * See `notes/decisions/anchor-name-lifetime.md`
382
+ */
383
+ trigger.style.setProperty('anchor-name', anchorName);
334
384
  var _undoPositioning = combine(setStyle({
335
- el: trigger,
336
- styles: [{
337
- property: 'anchor-name',
338
- value: anchorName
339
- }]
340
- }), setStyle({
341
385
  el: popover,
342
386
  styles: popoverStyles
343
387
  }), arrow ? function () {
@@ -389,12 +433,12 @@ export function useAnchorPosition(_ref5) {
389
433
  var _computeFallbackPosit = computeFallbackPosition({
390
434
  triggerRect: triggerRect,
391
435
  popoverEl: popover,
392
- placement: resolvedPlacement,
436
+ placement: stablePlacement,
393
437
  viewport: viewport,
394
438
  gap: gapPx,
395
439
  crossAxisShift: {
396
440
  value: crossAxisShiftPx,
397
- direction: resolvedPlacement.offset.crossAxisShift.direction
441
+ direction: stablePlacement.offset.crossAxisShift.direction
398
442
  }
399
443
  }),
400
444
  top = _computeFallbackPosit.top,
@@ -469,5 +513,5 @@ export function useAnchorPosition(_ref5) {
469
513
  popover.style.removeProperty('opacity');
470
514
  });
471
515
  return undoPositioning;
472
- }, [anchorRef, popoverRef, placement, anchorName, forceFallbackPositioning, arrow]);
516
+ }, [anchorRef, popoverRef, stablePlacement, forceFallbackPositioning, arrow, id]);
473
517
  }
@@ -4,7 +4,7 @@ import { ax, ix } from "@compiled/react/runtime";
4
4
  import React, { forwardRef, useCallback, useLayoutEffect, useRef } from 'react';
5
5
  import mergeRefs from '@atlaskit/ds-lib/merge-refs';
6
6
  import { roleToAriaHasPopup } from '../internal/role-types';
7
- import { useAnchorPosition } from '../internal/use-anchor-positioning';
7
+ import { useAnchorPosition } from '../internal/use-anchor-position';
8
8
  import { usePresetStyles } from '../internal/use-preset-styles';
9
9
  import { Popover } from '../popover/popover';
10
10
  import { useMaybePopupContext } from './popup-context';
@@ -14,7 +14,7 @@
14
14
  * - Auto popovers: maintain an open stack, perform light-dismiss on document mousedown
15
15
  * outside the open chain, and dismiss the topmost on Escape.
16
16
  * - On open, removes any inline `opacity: 0` from the popover element so callers that
17
- * rely on a `ResizeObserver` reveal callback (e.g. `use-anchor-positioning`) become
17
+ * rely on a `ResizeObserver` reveal callback (e.g. `use-anchor-position`) become
18
18
  * visible in jsdom, where layout-driven RO callbacks never fire.
19
19
  *
20
20
  * Companion `toBeVisible` patch lives at `./to-be-visible`.
@@ -249,7 +249,7 @@ function stackForMode(_ref9) {
249
249
 
250
250
  // Polyfill-only. Real browsers reveal the popover once `ResizeObserver`
251
251
  // callbacks fire after the first layout pass. jsdom has no layout, so we clear
252
- // any inline `opacity: 0` (set by callers like `use-anchor-positioning`) once
252
+ // any inline `opacity: 0` (set by callers like `use-anchor-position`) once
253
253
  // the open toggle has fired.
254
254
  function clearOpacityOnOpenToggle(_ref0) {
255
255
  var element = _ref0.element;
@@ -1 +1 @@
1
- export { useAnchorPosition } from '../internal/use-anchor-positioning';
1
+ export { useAnchorPosition } from '../internal/use-anchor-position';
@@ -72,6 +72,10 @@ export declare function placementToTryFallbacks({ placement }: {
72
72
  * When not supported, it falls back to JavaScript-based positioning using
73
73
  * `position: fixed` with measured coordinates, re-running on scroll (capture)
74
74
  * and resize events.
75
+ *
76
+ * `placement` may be a fresh inline object on every render: a structural
77
+ * comparison is used internally so re-runs are skipped when the resolved
78
+ * placement shape is unchanged.
75
79
  */
76
80
  export declare function useAnchorPosition({ anchorRef, popoverRef, placement, forceFallbackPositioning, arrow, }: {
77
81
  /**
@@ -86,8 +90,23 @@ export declare function useAnchorPosition({ anchorRef, popoverRef, placement, fo
86
90
  * Where to place the element relative to the anchor.
87
91
  * `offset.gap` and `offset.crossAxisShift` are part of the
88
92
  * placement object.
93
+ *
94
+ * All fields are optional. Omitted fields fall back to:
95
+ * - `axis`: `'block'`
96
+ * - `edge`: `'end'`
97
+ * - `align`: `'center'`
98
+ * - `offset.gap`: `token('space.100', '8px')`
99
+ * - `offset.crossAxisShift.value`: `'0px'`
100
+ * - `offset.crossAxisShift.direction`: `'forwards'`
101
+ *
102
+ * The full default placement (`{}`) renders the popover centered
103
+ * below the trigger with one `space.100` of gap and no cross-axis
104
+ * shift.
105
+ *
106
+ * `offset.gap` and `offset.crossAxisShift.value` accept either a
107
+ * number (pixels) or a CSS length string (eg `token('space.200')`).
89
108
  */
90
- placement: TPlacementOptions;
109
+ placement?: TPlacementOptions;
91
110
  /**
92
111
  * Forces the JavaScript positioning fallback even when the browser
93
112
  * supports CSS Anchor Positioning. Useful for testing fallback
@@ -13,7 +13,7 @@
13
13
  * - Auto popovers: maintain an open stack, perform light-dismiss on document mousedown
14
14
  * outside the open chain, and dismiss the topmost on Escape.
15
15
  * - On open, removes any inline `opacity: 0` from the popover element so callers that
16
- * rely on a `ResizeObserver` reveal callback (e.g. `use-anchor-positioning`) become
16
+ * rely on a `ResizeObserver` reveal callback (e.g. `use-anchor-position`) become
17
17
  * visible in jsdom, where layout-driven RO callbacks never fire.
18
18
  *
19
19
  * Companion `toBeVisible` patch lives at `./to-be-visible`.
@@ -1 +1 @@
1
- export { useAnchorPosition } from '../internal/use-anchor-positioning';
1
+ export { useAnchorPosition } from '../internal/use-anchor-position';
@@ -72,6 +72,10 @@ export declare function placementToTryFallbacks({ placement }: {
72
72
  * When not supported, it falls back to JavaScript-based positioning using
73
73
  * `position: fixed` with measured coordinates, re-running on scroll (capture)
74
74
  * and resize events.
75
+ *
76
+ * `placement` may be a fresh inline object on every render: a structural
77
+ * comparison is used internally so re-runs are skipped when the resolved
78
+ * placement shape is unchanged.
75
79
  */
76
80
  export declare function useAnchorPosition({ anchorRef, popoverRef, placement, forceFallbackPositioning, arrow, }: {
77
81
  /**
@@ -86,8 +90,23 @@ export declare function useAnchorPosition({ anchorRef, popoverRef, placement, fo
86
90
  * Where to place the element relative to the anchor.
87
91
  * `offset.gap` and `offset.crossAxisShift` are part of the
88
92
  * placement object.
93
+ *
94
+ * All fields are optional. Omitted fields fall back to:
95
+ * - `axis`: `'block'`
96
+ * - `edge`: `'end'`
97
+ * - `align`: `'center'`
98
+ * - `offset.gap`: `token('space.100', '8px')`
99
+ * - `offset.crossAxisShift.value`: `'0px'`
100
+ * - `offset.crossAxisShift.direction`: `'forwards'`
101
+ *
102
+ * The full default placement (`{}`) renders the popover centered
103
+ * below the trigger with one `space.100` of gap and no cross-axis
104
+ * shift.
105
+ *
106
+ * `offset.gap` and `offset.crossAxisShift.value` accept either a
107
+ * number (pixels) or a CSS length string (eg `token('space.200')`).
89
108
  */
90
- placement: TPlacementOptions;
109
+ placement?: TPlacementOptions;
91
110
  /**
92
111
  * Forces the JavaScript positioning fallback even when the browser
93
112
  * supports CSS Anchor Positioning. Useful for testing fallback
@@ -13,7 +13,7 @@
13
13
  * - Auto popovers: maintain an open stack, perform light-dismiss on document mousedown
14
14
  * outside the open chain, and dismiss the topmost on Escape.
15
15
  * - On open, removes any inline `opacity: 0` from the popover element so callers that
16
- * rely on a `ResizeObserver` reveal callback (e.g. `use-anchor-positioning`) become
16
+ * rely on a `ResizeObserver` reveal callback (e.g. `use-anchor-position`) become
17
17
  * visible in jsdom, where layout-driven RO callbacks never fire.
18
18
  *
19
19
  * Companion `toBeVisible` patch lives at `./to-be-visible`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/top-layer",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Low-level layering primitives based on popover and dialog",
5
5
  "homepage": "https://atlassian.design/components/top-layer",
6
6
  "repository": "https://bitbucket.org/atlassian/atlassian-frontend-mirror",