@atlaskit/top-layer 0.8.0 → 0.10.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 (130) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +5 -1
  3. package/dialog/create-close-event/package.json +17 -0
  4. package/dist/cjs/animations/presets.js +11 -4
  5. package/dist/cjs/arrow/index.js +131 -0
  6. package/dist/cjs/arrow/types.js +5 -0
  7. package/dist/cjs/dialog/dialog-content.js +23 -6
  8. package/dist/cjs/entry-points/dialog/create-close-event.js +12 -0
  9. package/dist/cjs/entry-points/placement-map.js +6 -5
  10. package/dist/cjs/entry-points/popover/create-close-event.js +12 -0
  11. package/dist/cjs/focus/focus.js +73 -19
  12. package/dist/cjs/index.js +3 -4
  13. package/dist/cjs/internal/resolve-css-length-to-pixels.js +61 -10
  14. package/dist/cjs/internal/role-types.js +40 -9
  15. package/dist/cjs/internal/set-style.js +23 -25
  16. package/dist/cjs/internal/use-anchor-position.js +109 -68
  17. package/dist/cjs/internal/use-focus-wrap.js +13 -2
  18. package/dist/cjs/internal/use-initial-focus.js +31 -10
  19. package/dist/cjs/placement-map/index.js +9 -110
  20. package/dist/cjs/placement-map/legacy-placements.js +113 -0
  21. package/dist/cjs/popover/popover.js +44 -11
  22. package/dist/cjs/popup/popup-content.js +19 -13
  23. package/dist/cjs/popup/popup-context.js +2 -0
  24. package/dist/cjs/popup/popup-surface.js +6 -0
  25. package/dist/cjs/popup/popup-trigger-function.js +1 -1
  26. package/dist/cjs/popup/popup-trigger.js +17 -37
  27. package/dist/cjs/popup/popup.js +21 -10
  28. package/dist/cjs/use-arrow-navigation/use-arrow-navigation.js +27 -10
  29. package/dist/es2019/animations/presets.js +10 -3
  30. package/dist/es2019/arrow/index.js +269 -0
  31. package/dist/es2019/arrow/types.js +1 -0
  32. package/dist/es2019/dialog/dialog-content.js +23 -6
  33. package/dist/es2019/entry-points/create-close-event.js +7 -0
  34. package/dist/es2019/entry-points/dialog/create-close-event.js +1 -0
  35. package/dist/es2019/entry-points/placement-map.js +2 -1
  36. package/dist/es2019/entry-points/popover/create-close-event.js +1 -0
  37. package/dist/es2019/focus/focus.js +58 -7
  38. package/dist/es2019/index.js +3 -4
  39. package/dist/es2019/internal/resolve-css-length-to-pixels.js +60 -7
  40. package/dist/es2019/internal/role-types.js +40 -9
  41. package/dist/es2019/internal/set-style.js +19 -22
  42. package/dist/es2019/internal/use-anchor-position.js +97 -54
  43. package/dist/es2019/internal/use-focus-wrap.js +14 -3
  44. package/dist/es2019/internal/use-initial-focus.js +32 -11
  45. package/dist/es2019/placement-map/index.js +8 -108
  46. package/dist/es2019/placement-map/legacy-placements.js +107 -0
  47. package/dist/es2019/popover/popover.js +44 -10
  48. package/dist/es2019/popup/popup-content.js +20 -14
  49. package/dist/es2019/popup/popup-context.js +2 -0
  50. package/dist/es2019/popup/popup-surface.js +6 -0
  51. package/dist/es2019/popup/popup-trigger-function.js +1 -1
  52. package/dist/es2019/popup/popup-trigger.js +17 -37
  53. package/dist/es2019/popup/popup.js +20 -9
  54. package/dist/es2019/use-arrow-navigation/use-arrow-navigation.js +27 -10
  55. package/dist/esm/animations/presets.js +10 -3
  56. package/dist/esm/arrow/index.js +124 -0
  57. package/dist/esm/arrow/types.js +1 -0
  58. package/dist/esm/dialog/dialog-content.js +23 -6
  59. package/dist/esm/entry-points/create-close-event.js +7 -0
  60. package/dist/esm/entry-points/dialog/create-close-event.js +1 -0
  61. package/dist/esm/entry-points/placement-map.js +2 -1
  62. package/dist/esm/entry-points/popover/create-close-event.js +1 -0
  63. package/dist/esm/focus/focus.js +72 -19
  64. package/dist/esm/index.js +3 -4
  65. package/dist/esm/internal/resolve-css-length-to-pixels.js +61 -10
  66. package/dist/esm/internal/role-types.js +39 -9
  67. package/dist/esm/internal/set-style.js +23 -25
  68. package/dist/esm/internal/use-anchor-position.js +110 -64
  69. package/dist/esm/internal/use-focus-wrap.js +14 -3
  70. package/dist/esm/internal/use-initial-focus.js +32 -11
  71. package/dist/esm/placement-map/index.js +8 -108
  72. package/dist/esm/placement-map/legacy-placements.js +107 -0
  73. package/dist/esm/popover/popover.js +44 -11
  74. package/dist/esm/popup/popup-content.js +20 -14
  75. package/dist/esm/popup/popup-context.js +2 -0
  76. package/dist/esm/popup/popup-surface.js +6 -0
  77. package/dist/esm/popup/popup-trigger-function.js +1 -1
  78. package/dist/esm/popup/popup-trigger.js +17 -37
  79. package/dist/esm/popup/popup.js +21 -10
  80. package/dist/esm/use-arrow-navigation/use-arrow-navigation.js +27 -10
  81. package/dist/types/arrow/index.d.ts +24 -0
  82. package/dist/types/arrow/types.d.ts +29 -0
  83. package/dist/types/dialog/dialog-content.d.ts +5 -1
  84. package/dist/types/dialog/types.d.ts +50 -24
  85. package/dist/types/entry-points/create-close-event.d.ts +7 -0
  86. package/dist/types/entry-points/dialog/create-close-event.d.ts +2 -0
  87. package/dist/types/entry-points/placement-map.d.ts +2 -1
  88. package/dist/types/entry-points/popover/create-close-event.d.ts +2 -0
  89. package/dist/types/focus/focus.d.ts +8 -0
  90. package/dist/types/internal/resolve-css-length-to-pixels.d.ts +4 -6
  91. package/dist/types/internal/role-types.d.ts +25 -4
  92. package/dist/types/internal/set-style.d.ts +2 -19
  93. package/dist/types/internal/use-anchor-position.d.ts +2 -3
  94. package/dist/types/internal/use-initial-focus.d.ts +5 -4
  95. package/dist/types/placement-map/index.d.ts +8 -21
  96. package/dist/types/placement-map/legacy-placements.d.ts +21 -0
  97. package/dist/types/popover/popover.d.ts +1 -1
  98. package/dist/types/popover/types.d.ts +20 -19
  99. package/dist/types/popup/popup-surface.d.ts +6 -0
  100. package/dist/types/popup/popup-trigger-function.d.ts +10 -6
  101. package/dist/types/popup/popup-trigger.d.ts +8 -17
  102. package/dist/types/popup/popup.d.ts +2 -2
  103. package/dist/types/popup/types.d.ts +68 -22
  104. package/dist/types/use-arrow-navigation/use-arrow-navigation.d.ts +19 -5
  105. package/dist/types-ts4.5/arrow/index.d.ts +24 -0
  106. package/dist/types-ts4.5/arrow/types.d.ts +29 -0
  107. package/dist/types-ts4.5/dialog/dialog-content.d.ts +5 -1
  108. package/dist/types-ts4.5/dialog/types.d.ts +50 -24
  109. package/dist/types-ts4.5/entry-points/create-close-event.d.ts +7 -0
  110. package/dist/types-ts4.5/entry-points/dialog/create-close-event.d.ts +2 -0
  111. package/dist/types-ts4.5/entry-points/placement-map.d.ts +2 -1
  112. package/dist/types-ts4.5/entry-points/popover/create-close-event.d.ts +2 -0
  113. package/dist/types-ts4.5/focus/focus.d.ts +8 -0
  114. package/dist/types-ts4.5/internal/resolve-css-length-to-pixels.d.ts +4 -6
  115. package/dist/types-ts4.5/internal/role-types.d.ts +25 -4
  116. package/dist/types-ts4.5/internal/set-style.d.ts +2 -19
  117. package/dist/types-ts4.5/internal/use-anchor-position.d.ts +2 -3
  118. package/dist/types-ts4.5/internal/use-initial-focus.d.ts +5 -4
  119. package/dist/types-ts4.5/placement-map/index.d.ts +8 -21
  120. package/dist/types-ts4.5/placement-map/legacy-placements.d.ts +39 -0
  121. package/dist/types-ts4.5/popover/popover.d.ts +1 -1
  122. package/dist/types-ts4.5/popover/types.d.ts +20 -19
  123. package/dist/types-ts4.5/popup/popup-surface.d.ts +6 -0
  124. package/dist/types-ts4.5/popup/popup-trigger-function.d.ts +10 -6
  125. package/dist/types-ts4.5/popup/popup-trigger.d.ts +8 -17
  126. package/dist/types-ts4.5/popup/popup.d.ts +2 -2
  127. package/dist/types-ts4.5/popup/types.d.ts +68 -22
  128. package/dist/types-ts4.5/use-arrow-navigation/use-arrow-navigation.d.ts +19 -5
  129. package/package.json +3 -1
  130. package/popover/create-close-event/package.json +17 -0
@@ -10,6 +10,7 @@
10
10
  * restrict which focusable elements are included.
11
11
  */
12
12
 
13
+ import { getDocument } from '@atlaskit/browser-apis';
13
14
  const selectors = (() => {
14
15
  // Common exclusion filter applied to every selector.
15
16
  const not = ':not([tabindex="-1"]):not([aria-disabled="true"]):not([aria-hidden="true"])';
@@ -17,9 +18,54 @@ const selectors = (() => {
17
18
  const focusable = [`a[href]${not}`, `area[href]${not}`, `input${notDisabled}:not([type="hidden"])`, `select${notDisabled}`, `textarea${notDisabled}`, `button${notDisabled}`, `iframe${not}`, `audio[controls]${not}`, `video[controls]${not}`, `[contenteditable]:not([contenteditable="false"])${not}`, `[tabindex]${notDisabled}${not}`];
18
19
  return {
19
20
  focusable: focusable.join(','),
20
- focused: focusable.join(':focus,') + ':focus'
21
+ // Build the `:focus` selector
22
+ focused: focusable.map(selector => `${selector}:focus`).join(',')
21
23
  };
22
24
  })();
25
+ const topLayerSelector = '[popover], dialog, [role="dialog"], [role="alertdialog"]';
26
+
27
+ /**
28
+ * Returns true when `element` and `container` resolve to the same nearest
29
+ * top-layer host (or both resolve to `null`, meaning the document layer).
30
+ */
31
+ function isInSameLayer({
32
+ element,
33
+ container
34
+ }) {
35
+ const elementLayer = element.closest(topLayerSelector);
36
+ const containerLayer = container.closest(topLayerSelector);
37
+ // Both elements are in the "document" layer
38
+ if (!elementLayer && !containerLayer) {
39
+ return true;
40
+ }
41
+ return elementLayer === containerLayer;
42
+ }
43
+
44
+ /**
45
+ * Returns `true` when `document.activeElement` is inside a nested top-layer
46
+ * descendant of `container` (a `[popover]`, `<dialog>`, `[role="dialog"]` or
47
+ * `[role="alertdialog"]` that is itself a descendant of `container`).
48
+ */
49
+ export function isNestedLayerFocused({
50
+ container
51
+ }) {
52
+ var _getDocument;
53
+ const focused = (_getDocument = getDocument()) === null || _getDocument === void 0 ? void 0 : _getDocument.activeElement;
54
+ if (!(focused instanceof HTMLElement)) {
55
+ return false;
56
+ }
57
+
58
+ // Cannot be in a nested layer if it is not in the container
59
+ if (!container.contains(focused)) {
60
+ return false;
61
+ }
62
+
63
+ // If not in the same layer, must be in a nested layer
64
+ return !isInSameLayer({
65
+ element: focused,
66
+ container
67
+ });
68
+ }
23
69
 
24
70
  /**
25
71
  * An optional callback to restrict which focusable elements are included.
@@ -29,22 +75,25 @@ const selectors = (() => {
29
75
  /**
30
76
  * Returns all focusable HTMLElements within the container, optionally filtered.
31
77
  *
32
- * Note: this searches the full subtree and does not stop at nested
33
- * popover/dialog boundaries. In practice this is fine because nested
34
- * popovers are not rendered until they are opened, so their content
35
- * is not in the DOM when the parent's initial focus runs. If that
36
- * assumption changes, this would need to be scoped to the current layer.
78
+ * Focusables that belong to a nested top-layer scope (a `[popover]` or
79
+ * `<dialog>` descendant of the container) are excluded - those elements
80
+ * are owned by the inner layer's focus management. The container itself
81
+ * is allowed to be a popover/dialog; only nested ones are filtered.
37
82
  */
38
83
  function getFocusables({
39
84
  container,
40
85
  filter
41
86
  }) {
42
- return Array.from(container.querySelectorAll(selectors.focusable)).filter(element => element instanceof HTMLElement).filter(element => !filter || filter(element, container));
87
+ return Array.from(container.querySelectorAll(selectors.focusable)).filter(element => element instanceof HTMLElement).filter(element => isInSameLayer({
88
+ element,
89
+ container
90
+ })).filter(element => !filter || filter(element, container));
43
91
  }
44
92
 
45
93
  /**
46
94
  * Returns the first focusable element within the container.
47
95
  */
96
+ // eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
48
97
  export function getFirstFocusable({
49
98
  container,
50
99
  filter
@@ -59,6 +108,7 @@ export function getFirstFocusable({
59
108
  /**
60
109
  * Returns the last focusable element within the container.
61
110
  */
111
+ // eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
62
112
  export function getLastFocusable({
63
113
  container,
64
114
  filter
@@ -77,6 +127,7 @@ export function getLastFocusable({
77
127
  * Returns `null` if there is no focused element within the container or if
78
128
  * the focused element is not in the list of focusable elements.
79
129
  */
130
+ // eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
80
131
  export function getNextFocusable({
81
132
  container,
82
133
  direction,
@@ -1,7 +1,6 @@
1
1
  // This file exists for module resolution purposes.
2
2
  // Not exporting anything from the root entry point.
3
- // Please use individual entry points for minimum bundle size:
4
3
  //
5
- // import { Popup } from '@atlaskit/top-layer/popup';
6
- // import { Dialog } from '@atlaskit/top-layer/dialog';
7
- // import { Popover } from '@atlaskit/top-layer/popover';
4
+ // Use individual entry points for minimum bundle size - the full list of
5
+ // available subpaths is the `"exports"` field in this package's
6
+ // `package.json`. The README documents the most common ones.
@@ -1,3 +1,34 @@
1
+ const elementCaches = new WeakMap();
2
+ function canCacheResolvedLength(value) {
3
+ // Custom-property values can be retargeted by inline overrides on the
4
+ // consumer's wrapper, so we cannot prove a cached pixel value is still valid.
5
+ return !value.includes('var(');
6
+ }
7
+ function getCachedResolvedLength({
8
+ element,
9
+ value
10
+ }) {
11
+ var _cacheForElement$get;
12
+ if (!canCacheResolvedLength(value)) {
13
+ return null;
14
+ }
15
+ const cacheForElement = elementCaches.get(element);
16
+ return (_cacheForElement$get = cacheForElement === null || cacheForElement === void 0 ? void 0 : cacheForElement.get(value)) !== null && _cacheForElement$get !== void 0 ? _cacheForElement$get : null;
17
+ }
18
+ function cacheResolvedLength({
19
+ element,
20
+ value,
21
+ result
22
+ }) {
23
+ var _elementCaches$get;
24
+ if (!canCacheResolvedLength(value)) {
25
+ return;
26
+ }
27
+ const cacheForElement = (_elementCaches$get = elementCaches.get(element)) !== null && _elementCaches$get !== void 0 ? _elementCaches$get : new Map();
28
+ cacheForElement.set(value, result);
29
+ elementCaches.set(element, cacheForElement);
30
+ }
31
+
1
32
  /**
2
33
  * Resolves a CSS length value to a pixel number using a hidden DOM probe.
3
34
  *
@@ -8,13 +39,11 @@
8
39
  * the browser do the math.
9
40
  *
10
41
  * 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).
42
+ * block, font size, and custom-property scope as the popover.
14
43
  *
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.
44
+ * Cost: one synchronous reflow on first cacheable resolution per
45
+ * (container, value) pair, then a `WeakMap` lookup. Resolutions involving
46
+ * `var(` are intentionally not cached.
18
47
  */
19
48
  export function resolveCssLengthToPixels({
20
49
  value,
@@ -24,12 +53,26 @@ export function resolveCssLengthToPixels({
24
53
  return value;
25
54
  }
26
55
 
56
+ // Fast path: unitless zero is universally zero pixels - accept `'0'` and
57
+ // `'-0'` without a probe. (Consumers occasionally pass `'0'` instead of
58
+ // the number `0`.)
59
+ if (value === '0' || value === '-0') {
60
+ return 0;
61
+ }
62
+
27
63
  // Fast path: bare `${n}px` strings come from the consumer normalisation
28
64
  // in `getPlacement`. Avoid a reflow when we can.
29
65
  const pxMatch = value.match(/^(-?\d+(?:\.\d+)?)px$/);
30
66
  if (pxMatch) {
31
67
  return Number(pxMatch[1]);
32
68
  }
69
+ const cached = getCachedResolvedLength({
70
+ element: container,
71
+ value
72
+ });
73
+ if (cached !== null) {
74
+ return cached;
75
+ }
33
76
  const probe = container.ownerDocument.createElement('div');
34
77
  probe.style.position = 'absolute';
35
78
  probe.style.visibility = 'hidden';
@@ -41,8 +84,18 @@ export function resolveCssLengthToPixels({
41
84
  // preserved. `getComputedStyle` returns the resolved pixel value for
42
85
  // margin-* properties even when the source was a token / calc / var.
43
86
  probe.style.marginLeft = value;
87
+ // Hide the probe from assistive tech and event targeting; defensive
88
+ // against `getElementsByTagName('div')` walks in consumer code.
89
+ probe.setAttribute('aria-hidden', 'true');
90
+ probe.setAttribute('inert', '');
44
91
  container.appendChild(probe);
45
92
  const resolved = parseFloat(getComputedStyle(probe).marginLeft);
46
93
  container.removeChild(probe);
47
- return Number.isFinite(resolved) ? resolved : 0;
94
+ const result = Number.isFinite(resolved) ? resolved : 0;
95
+ cacheResolvedLength({
96
+ element: container,
97
+ value,
98
+ result
99
+ });
100
+ return result;
48
101
  }
@@ -1,6 +1,15 @@
1
1
  /**
2
2
  * Valid values for the `aria-haspopup` attribute.
3
- * Derived from the HTML spec — maps popover roles to what the trigger announces.
3
+ *
4
+ * Derived from the HTML spec - maps popover roles to what the trigger
5
+ * announces. `undefined` means the attribute is omitted entirely (used for
6
+ * non-popup roles like `tooltip`/`status`/`alert`/`note`/`log` where
7
+ * `aria-haspopup` would be misleading).
8
+ *
9
+ * `true` is intentionally NOT in the union - the runtime only ever produces
10
+ * the explicit string forms, so widening the type would invite consumers to
11
+ * pass `true` and get an `aria-haspopup="true"` serialisation that the
12
+ * runtime no longer emits.
4
13
  */
5
14
 
6
15
  /**
@@ -22,21 +31,27 @@
22
31
  /**
23
32
  * ARIA role props where a role is always required.
24
33
  *
25
- * Used by `TPopupContentProps` — popup content must always declare
34
+ * Used by `TPopupContentProps`. Popup content must always declare
26
35
  * its role so assistive technology can identify the layer.
27
36
  */
28
37
 
29
38
  /**
30
39
  * ARIA role props where a role is optional.
31
40
  *
32
- * Used by `TPopoverProps` — the low-level primitive allows omitting
41
+ * Used by `TPopoverProps`. The low-level primitive allows omitting
33
42
  * the role for cases where it is set externally or not needed.
34
43
  */
35
44
 
36
45
  /**
37
46
  * Maps a popover content role to the correct `aria-haspopup` value
38
47
  * for its trigger element.
48
+ *
49
+ * Returns `undefined` for roles that are not popups in the ARIA sense
50
+ * (`tooltip`, `note`, `status`, `alert`, `log`). Triggers for those roles
51
+ * should not announce a popup at all - emitting `aria-haspopup="dialog"`
52
+ * on a tooltip trigger would mislead assistive technology.
39
53
  */
54
+ // eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
40
55
  export function roleToAriaHasPopup({
41
56
  role
42
57
  }) {
@@ -52,10 +67,26 @@ export function roleToAriaHasPopup({
52
67
  if (role === 'grid') {
53
68
  return 'grid';
54
69
  }
55
- // Default: return 'dialog' for all unmapped roles (tooltip, alertdialog,
56
- // note, status, alert, log, etc). This is a kinder API — rather than
57
- // throwing on unknown roles, we fall back to the most common popup type.
58
- // New roles that need specific aria-haspopup values should be added
59
- // as explicit cases above.
60
- return 'dialog';
70
+ if (role === 'dialog' || role === 'alertdialog') {
71
+ return 'dialog';
72
+ }
73
+ // Non-popup roles (tooltip, note, status, alert, log) and the
74
+ // no-role case: omit `aria-haspopup` entirely. React drops the
75
+ // attribute when the value is `undefined`.
76
+ return undefined;
77
+ }
78
+ const rolesThatMoveFocus = new Set(['dialog', 'alertdialog', 'menu', 'listbox', 'tree', 'grid']);
79
+
80
+ /**
81
+ * Returns `true` when the given popover role moves focus into the popover
82
+ * on open, and `false` otherwise (including when `role` is `undefined`).
83
+ */
84
+ // eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
85
+ export function shouldFocusIntoPopover({
86
+ role
87
+ }) {
88
+ if (!role) {
89
+ return false;
90
+ }
91
+ return rolesThatMoveFocus.has(role);
61
92
  }
@@ -1,28 +1,20 @@
1
1
  /**
2
- * Sets inline styles on an element and returns a cleanup function
3
- * that removes only the styles that were applied.
4
- *
5
- * Uses `style.setProperty()` / `style.removeProperty()` so that
6
- * hyphenated CSS property names (e.g. `position-area`, `anchor-name`)
7
- * work correctly. These newer properties do not have camelCase
8
- * mappings in `CSSStyleDeclaration` yet.
9
- *
10
- * @example
11
- * ```ts
12
- * const cleanup = setStyle({ el: popover, styles: [
13
- * { property: 'position-anchor', value: '--my-anchor' },
14
- * { property: 'position-area', value: 'block-end' },
15
- * { property: 'margin', value: '0' },
16
- * ] });
17
- *
18
- * // Later: removes all three properties
19
- * cleanup();
20
- * ```
2
+ * Sets inline styles on an element and returns a cleanup function that
3
+ * restores the prior inline values (so we do not stomp consumer styles).
21
4
  */
22
5
  export function setStyle({
23
6
  el,
24
7
  styles
25
8
  }) {
9
+ // Snapshot the prior inline value (NOT the computed style - we only want
10
+ // to restore values that the consumer/our previous run inlined). An empty
11
+ // string means "no inline value", in which case cleanup uses removeProperty.
12
+ const priorValues = styles.map(({
13
+ property
14
+ }) => ({
15
+ property,
16
+ value: el.style.getPropertyValue(property)
17
+ }));
26
18
  styles.forEach(({
27
19
  property,
28
20
  value
@@ -30,10 +22,15 @@ export function setStyle({
30
22
  el.style.setProperty(property, value);
31
23
  });
32
24
  return function cleanup() {
33
- styles.forEach(({
34
- property
25
+ priorValues.forEach(({
26
+ property,
27
+ value
35
28
  }) => {
36
- el.style.removeProperty(property);
29
+ if (value === '') {
30
+ el.style.removeProperty(property);
31
+ return;
32
+ }
33
+ el.style.setProperty(property, value);
37
34
  });
38
35
  };
39
36
  }
@@ -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, useRef } from 'react';
15
+ import { useId, useLayoutEffect, useMemo } 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';
@@ -21,7 +21,20 @@ import { combine } from './combine';
21
21
  import { resolveCssLengthToPixels } from './resolve-css-length-to-pixels';
22
22
  import { getPlacement } from './resolve-placement';
23
23
  import { setStyle } from './set-style';
24
- export { getPlacement } from './resolve-placement';
24
+
25
+ // `getPlacement` is intentionally NOT re-exported here - import it directly
26
+ // from `./resolve-placement` so search-and-jump lands at the source of truth.
27
+
28
+ /**
29
+ * Module-scope no-op `ResizeObserver` used in non-DOM jest environments where
30
+ * `ResizeObserver` is `undefined`. Hoisted out of the effect to avoid
31
+ * allocating a new class declaration on every run.
32
+ */
33
+ const NoopResizeObserver = class {
34
+ observe() {}
35
+ unobserve() {}
36
+ disconnect() {}
37
+ };
25
38
 
26
39
  /**
27
40
  * Resolves placement options to CSS `position-area` syntax.
@@ -86,7 +99,7 @@ export function placementToPositionArea({
86
99
  * top-right), flipping only the primary axis (`flip-inline`) moves the popup
87
100
  * to the left but keeps the same cross-axis span direction (e.g. upward),
88
101
  * which is still off-screen. The combined keyword `flip-inline flip-block`
89
- * mirrors the popup diagonally — it flips BOTH the position-area AND both
102
+ * mirrors the popup diagonally. It flips BOTH the position-area AND both
90
103
  * inline and block margins simultaneously, placing the popup at the
91
104
  * diagonally opposite corner where there is always space.
92
105
  *
@@ -94,6 +107,7 @@ export function placementToPositionArea({
94
107
  * `position-try-fallbacks` do NOT update margins, so only the `flip-*`
95
108
  * tactic keywords correctly flip the gap margin along with the position.
96
109
  */
110
+ // eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
97
111
  export function placementToTryFallbacks({
98
112
  placement
99
113
  }) {
@@ -106,19 +120,36 @@ export function placementToTryFallbacks({
106
120
  });
107
121
  const crossAxis = axis === 'block' ? 'inline' : 'block';
108
122
  const flippedEdge = edge === 'start' ? 'end' : 'start';
109
- if (align !== 'center') {
110
- const sameEdge = `${axis}-${edge}`;
111
- const oppositeEdge = `${axis}-${flippedEdge}`;
112
- const flipKeyword = axis === 'block' ? 'flip-block' : 'flip-inline';
113
- const flipCrossKeyword = axis === 'block' ? 'flip-inline' : 'flip-block';
114
- // Try same-edge shifts first; two flip entries cover single-edge then corner overflow.
115
- // Legacy Popper fallbacks for e.g. bottom-start: [bottom, bottom-end, top-start, top, top-end, auto].
116
- return [`${sameEdge} span-${crossAxis}-${align === 'start' ? 'end' : 'start'}`, sameEdge, `${sameEdge} span-${crossAxis}-${align === 'start' ? 'start' : 'end'}`, flipKeyword, `${flipKeyword} ${flipCrossKeyword}`, `${oppositeEdge} span-${crossAxis}-${align === 'start' ? 'end' : 'start'}`, oppositeEdge, `${oppositeEdge} span-${crossAxis}-${align === 'start' ? 'start' : 'end'}`].join(', ');
117
- }
118
123
  const sameEdge = `${axis}-${edge}`;
119
124
  const oppositeEdge = `${axis}-${flippedEdge}`;
120
125
  const flipKeyword = axis === 'block' ? 'flip-block' : 'flip-inline';
121
- return [`${sameEdge} span-${crossAxis}-end`, `${sameEdge} span-${crossAxis}-start`, flipKeyword, `${oppositeEdge} span-${crossAxis}-end`, `${oppositeEdge} span-${crossAxis}-start`].join(', ');
126
+ const flipCrossKeyword = axis === 'block' ? 'flip-inline' : 'flip-block';
127
+
128
+ // Single algorithm: produce the shift list against any base alignment.
129
+ // For `align: 'start'` the "near" shift expands toward `end`; for
130
+ // `align: 'end'` it expands toward `start`; for `align: 'center'`
131
+ // either order is symmetric (we use end-then-start to match the
132
+ // historical fallback list).
133
+ function shifts(baseAlign, edgeKey) {
134
+ if (baseAlign === 'center') {
135
+ return [`${edgeKey} span-${crossAxis}-end`, `${edgeKey} span-${crossAxis}-start`];
136
+ }
137
+ const near = baseAlign === 'start' ? 'end' : 'start';
138
+ const far = baseAlign === 'start' ? 'start' : 'end';
139
+ return [`${edgeKey} span-${crossAxis}-${near}`, edgeKey, `${edgeKey} span-${crossAxis}-${far}`];
140
+ }
141
+ if (align !== 'center') {
142
+ // Aligned (start/end): try same-edge shifts, then single-axis flip,
143
+ // then diagonal flip for corner overflow, then opposite-edge shifts.
144
+ // Legacy Popper fallback ordering for e.g. bottom-start:
145
+ // [bottom, bottom-end, top-start, top, top-end, auto].
146
+ return [...shifts(align, sameEdge), flipKeyword, `${flipKeyword} ${flipCrossKeyword}`, ...shifts(align, oppositeEdge)].join(', ');
147
+ }
148
+
149
+ // Centered: same-edge shifts, single-axis flip, opposite-edge shifts.
150
+ // Diagonal flip is unnecessary because centered placements expand
151
+ // equally in both cross-axis directions.
152
+ return [...shifts('center', sameEdge), flipKeyword, ...shifts('center', oppositeEdge)].join(', ');
122
153
  }
123
154
 
124
155
  /**
@@ -136,7 +167,7 @@ function isPopoverOpen(popover) {
136
167
 
137
168
  /**
138
169
  * Detects whether the browser supports CSS Anchor Positioning.
139
- * Uses `once()` for lazy evaluation — safe for SSR and avoids hydration mismatches.
170
+ * Uses `once()` for lazy evaluation: safe for SSR and avoids hydration mismatches.
140
171
  */
141
172
  const supportsAnchorPositioning = once(() => {
142
173
  if (typeof window === 'undefined' || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') {
@@ -222,34 +253,49 @@ function crossAxisShiftMargin({
222
253
  }
223
254
 
224
255
  /**
225
- * Returns a referentially-stable, fully-resolved `TPlacement`: the same
226
- * reference is preserved across renders as long as the resolved shape
227
- * does not change.
256
+ * Returns a referentially-stable, fully-resolved `TPlacement`. `useMemo` is
257
+ * keyed on the primitive fields of the input so a fresh inline `placement`
258
+ * object on every render does not produce a fresh resolved object. Both
259
+ * `getPlacement` and the equality check are skipped when the inputs are
260
+ * unchanged.
228
261
  */
229
262
  function useStablePlacement(placement) {
263
+ // Resolve to the fully-defaulted primitives BEFORE the memo so the deps
264
+ // array sees the same key for `{}`, `undefined` fields, and explicit
265
+ // defaults. Otherwise `axis: undefined` and `axis: 'block'` would be
266
+ // treated as different inputs and re-run the downstream effect.
230
267
  const resolved = getPlacement({
231
268
  placement
232
269
  });
233
- const stableRef = useRef(resolved);
234
- if (!isResolvedPlacementEqual(stableRef.current, resolved)) {
235
- stableRef.current = resolved;
236
- }
237
- return stableRef.current;
238
- }
239
-
240
- /**
241
- * Shallow-equals two fully-resolved placements. Avoids `JSON.stringify` so
242
- * the comparison cost stays proportional to the number of fields.
243
- */
244
- function isResolvedPlacementEqual(a, b) {
245
- 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;
270
+ const axis = resolved.axis;
271
+ const edge = resolved.edge;
272
+ const align = resolved.align;
273
+ const gap = resolved.offset.gap;
274
+ const shiftValue = resolved.offset.crossAxisShift.value;
275
+ const shiftDirection = resolved.offset.crossAxisShift.direction;
276
+ // Rebuild the placement object inside the memo from the resolved primitive
277
+ // deps so the factory does not close over the (potentially fresh) outer
278
+ // `resolved` reference, and `react-hooks/exhaustive-deps` can verify the
279
+ // deps array.
280
+ return useMemo(() => ({
281
+ axis,
282
+ edge,
283
+ align,
284
+ offset: {
285
+ gap,
286
+ crossAxisShift: {
287
+ value: shiftValue,
288
+ direction: shiftDirection
289
+ }
290
+ }
291
+ }), [axis, edge, align, gap, shiftValue, shiftDirection]);
246
292
  }
247
293
 
248
294
  /**
249
295
  * Hook that positions an element relative to an anchor element using
250
296
  * CSS Anchor Positioning (with a JS fallback for older browsers).
251
297
  *
252
- * This hook is the positioning primitive — it has no knowledge of popovers,
298
+ * This hook is the positioning primitive. It has no knowledge of popovers,
253
299
  * visibility, or animation. Compose it with `Popover` for anchor-positioned
254
300
  * top-layer content.
255
301
  *
@@ -265,6 +311,7 @@ function isResolvedPlacementEqual(a, b) {
265
311
  * comparison is used internally so re-runs are skipped when the resolved
266
312
  * placement shape is unchanged.
267
313
  */
314
+ // eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
268
315
  export function useAnchorPosition({
269
316
  anchorRef,
270
317
  popoverRef,
@@ -292,6 +339,9 @@ export function useAnchorPosition({
292
339
  placement: stablePlacement,
293
340
  offset: gapCssValue
294
341
  });
342
+ // Hoist crossAxisShift to a single computation; reused below to
343
+ // populate the active `--ds-cross-axis-shift-margin-*` custom
344
+ // property for the named arrow @position-try rules.
295
345
  const crossAxisShift = crossAxisShiftMargin({
296
346
  placement: stablePlacement,
297
347
  crossAxisShiftCssValue,
@@ -351,11 +401,8 @@ export function useAnchorPosition({
351
401
  property: '--ds-cross-axis-shift-margin-block-end',
352
402
  value: '0px'
353
403
  });
354
- const crossAxisShiftActive = crossAxisShiftMargin({
355
- placement: stablePlacement,
356
- crossAxisShiftCssValue,
357
- direction: stablePlacement.offset.crossAxisShift.direction
358
- });
404
+ // `crossAxisShift` IS the active margin - reuse instead of recomputing.
405
+ const crossAxisShiftActive = crossAxisShift;
359
406
  // Map margin property to its corresponding custom property name.
360
407
  const customPropertyByMarginProperty = {
361
408
  'margin-inline-start': '--ds-cross-axis-shift-margin-start',
@@ -380,7 +427,7 @@ export function useAnchorPosition({
380
427
  * - Sometimes the `useAnchorPosition` hook and `Popover`
381
428
  * are not in the same component.
382
429
  * - There can be times when, even with reference counting, we
383
- * could remove the anchor-name while it's still being used
430
+ * could remove the anchor-name while it is still being used
384
431
  * by something else - especially with async react updates
385
432
  * which can cause tearing
386
433
  *
@@ -411,7 +458,6 @@ export function useAnchorPosition({
411
458
  }]
412
459
  });
413
460
  function update() {
414
- var _popover$parentElemen;
415
461
  if (!trigger || !popover) {
416
462
  return;
417
463
  }
@@ -422,19 +468,19 @@ export function useAnchorPosition({
422
468
  };
423
469
 
424
470
  // Resolve consumer-supplied CSS length strings (tokens, calc, var,
425
- // px, rem, etc) to pixels. The probe is mounted next to the popover
426
- // so it inherits the same custom-property scope and font size.
427
- // `popover.parentElement` is guaranteed to exist for any rendered
428
- // popover (the popover itself is appended somewhere in the tree),
429
- // but fall back to the popover element if not for safety.
430
- const probeContainer = (_popover$parentElemen = popover.parentElement) !== null && _popover$parentElemen !== void 0 ? _popover$parentElemen : popover;
471
+ // px, rem, etc) to pixels. The probe is mounted INSIDE the popover
472
+ // itself so the resolved length matches the scope a token would
473
+ // resolve against from inside the popover content. (The popover
474
+ // lives in the top layer, not the trigger's DOM tree, so probing
475
+ // at `popover.parentElement` would resolve in a different scope
476
+ // than the consumer's token authoring expects.)
431
477
  const gapPx = resolveCssLengthToPixels({
432
478
  value: gapCssValue,
433
- container: probeContainer
479
+ container: popover
434
480
  });
435
481
  const crossAxisShiftPx = resolveCssLengthToPixels({
436
482
  value: crossAxisShiftCssValue,
437
- container: probeContainer
483
+ container: popover
438
484
  });
439
485
  const {
440
486
  top,
@@ -468,15 +514,12 @@ export function useAnchorPosition({
468
514
  // scroll/resize is handled by the window listeners below.
469
515
  // `ResizeObserver` is missing in some non-DOM jest environments
470
516
  // (e.g. post-office's `node` environment). Fall back to a no-op
471
- // observer there — the scroll/resize listeners below still keep
517
+ // observer there. The scroll/resize listeners below still keep
472
518
  // the popover positioned in the rare case the consumer also
473
519
  // polyfilled `showPopover` but not `ResizeObserver`. Real
474
- // browsers always have it.
475
- const NoopResizeObserver = class {
476
- observe() {}
477
- unobserve() {}
478
- disconnect() {}
479
- };
520
+ // browsers always have it. The `NoopResizeObserver` class is
521
+ // hoisted to module scope (see top of file) so we do not allocate
522
+ // a new constructor on every effect run.
480
523
  const ResizeObserverImpl = typeof ResizeObserver !== 'undefined' ? ResizeObserver : NoopResizeObserver;
481
524
  const resizeObserver = new ResizeObserverImpl(() => {
482
525
  if (popover.offsetWidth > 0 && popover.offsetHeight > 0) {
@@ -499,7 +542,7 @@ export function useAnchorPosition({
499
542
  // the UA-default position, then re-observe for a fresh
500
543
  // measurement. We use `opacity: 0` rather than
501
544
  // `visibility: hidden` because Firefox skips visibility-hidden
502
- // elements during `<dialog>` initial-focus traversal — see
545
+ // elements during `<dialog>` initial-focus traversal. See
503
546
  // `form-in-popup.spec.tsx` on `desktop-firefox`.
504
547
  bind(popover, {
505
548
  type: 'toggle',