@atlaskit/top-layer 0.9.0 → 0.11.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 (127) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +5 -1
  3. package/dialog/create-close-event/package.json +17 -0
  4. package/dist/cjs/animations/presets.js +10 -5
  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 +11 -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 +71 -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 +107 -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 -111
  20. package/dist/cjs/placement-map/legacy-placements.js +113 -0
  21. package/dist/cjs/popover/popover.js +14 -12
  22. package/dist/cjs/popup/popup-content.js +19 -13
  23. package/dist/cjs/popup/popup-surface.js +6 -0
  24. package/dist/cjs/popup/popup-trigger-function.js +1 -1
  25. package/dist/cjs/popup/popup-trigger.js +17 -37
  26. package/dist/cjs/popup/popup.js +21 -10
  27. package/dist/cjs/use-arrow-navigation/use-arrow-navigation.js +26 -10
  28. package/dist/es2019/animations/presets.js +20 -5
  29. package/dist/es2019/arrow/index.js +269 -0
  30. package/dist/es2019/arrow/types.js +1 -0
  31. package/dist/es2019/dialog/dialog-content.js +11 -6
  32. package/dist/es2019/entry-points/create-close-event.js +7 -0
  33. package/dist/es2019/entry-points/dialog/create-close-event.js +1 -0
  34. package/dist/es2019/entry-points/placement-map.js +2 -1
  35. package/dist/es2019/entry-points/popover/create-close-event.js +1 -0
  36. package/dist/es2019/focus/focus.js +56 -7
  37. package/dist/es2019/index.js +3 -4
  38. package/dist/es2019/internal/resolve-css-length-to-pixels.js +60 -7
  39. package/dist/es2019/internal/role-types.js +40 -9
  40. package/dist/es2019/internal/set-style.js +19 -22
  41. package/dist/es2019/internal/use-anchor-position.js +95 -54
  42. package/dist/es2019/internal/use-focus-wrap.js +14 -3
  43. package/dist/es2019/internal/use-initial-focus.js +32 -11
  44. package/dist/es2019/placement-map/index.js +8 -109
  45. package/dist/es2019/placement-map/legacy-placements.js +107 -0
  46. package/dist/es2019/popover/popover.js +14 -11
  47. package/dist/es2019/popup/popup-content.js +20 -14
  48. package/dist/es2019/popup/popup-surface.js +6 -0
  49. package/dist/es2019/popup/popup-trigger-function.js +1 -1
  50. package/dist/es2019/popup/popup-trigger.js +17 -37
  51. package/dist/es2019/popup/popup.js +20 -9
  52. package/dist/es2019/use-arrow-navigation/use-arrow-navigation.js +26 -10
  53. package/dist/esm/animations/presets.js +9 -4
  54. package/dist/esm/arrow/index.js +124 -0
  55. package/dist/esm/arrow/types.js +1 -0
  56. package/dist/esm/dialog/dialog-content.js +11 -6
  57. package/dist/esm/entry-points/create-close-event.js +7 -0
  58. package/dist/esm/entry-points/dialog/create-close-event.js +1 -0
  59. package/dist/esm/entry-points/placement-map.js +2 -1
  60. package/dist/esm/entry-points/popover/create-close-event.js +1 -0
  61. package/dist/esm/focus/focus.js +70 -19
  62. package/dist/esm/index.js +3 -4
  63. package/dist/esm/internal/resolve-css-length-to-pixels.js +61 -10
  64. package/dist/esm/internal/role-types.js +39 -9
  65. package/dist/esm/internal/set-style.js +23 -25
  66. package/dist/esm/internal/use-anchor-position.js +108 -64
  67. package/dist/esm/internal/use-focus-wrap.js +14 -3
  68. package/dist/esm/internal/use-initial-focus.js +32 -11
  69. package/dist/esm/placement-map/index.js +8 -109
  70. package/dist/esm/placement-map/legacy-placements.js +107 -0
  71. package/dist/esm/popover/popover.js +14 -12
  72. package/dist/esm/popup/popup-content.js +20 -14
  73. package/dist/esm/popup/popup-surface.js +6 -0
  74. package/dist/esm/popup/popup-trigger-function.js +1 -1
  75. package/dist/esm/popup/popup-trigger.js +17 -37
  76. package/dist/esm/popup/popup.js +21 -10
  77. package/dist/esm/use-arrow-navigation/use-arrow-navigation.js +26 -10
  78. package/dist/types/arrow/index.d.ts +24 -0
  79. package/dist/types/arrow/types.d.ts +29 -0
  80. package/dist/types/dialog/dialog-content.d.ts +5 -1
  81. package/dist/types/dialog/types.d.ts +50 -24
  82. package/dist/types/entry-points/create-close-event.d.ts +7 -0
  83. package/dist/types/entry-points/dialog/create-close-event.d.ts +2 -0
  84. package/dist/types/entry-points/placement-map.d.ts +2 -1
  85. package/dist/types/entry-points/popover/create-close-event.d.ts +2 -0
  86. package/dist/types/focus/focus.d.ts +8 -0
  87. package/dist/types/internal/resolve-css-length-to-pixels.d.ts +4 -6
  88. package/dist/types/internal/role-types.d.ts +25 -4
  89. package/dist/types/internal/set-style.d.ts +2 -19
  90. package/dist/types/internal/use-anchor-position.d.ts +2 -3
  91. package/dist/types/internal/use-initial-focus.d.ts +5 -4
  92. package/dist/types/placement-map/index.d.ts +8 -21
  93. package/dist/types/placement-map/legacy-placements.d.ts +21 -0
  94. package/dist/types/popover/popover.d.ts +1 -1
  95. package/dist/types/popover/types.d.ts +17 -17
  96. package/dist/types/popup/popup-surface.d.ts +6 -0
  97. package/dist/types/popup/popup-trigger-function.d.ts +10 -6
  98. package/dist/types/popup/popup-trigger.d.ts +8 -17
  99. package/dist/types/popup/popup.d.ts +2 -2
  100. package/dist/types/popup/types.d.ts +68 -22
  101. package/dist/types/use-arrow-navigation/use-arrow-navigation.d.ts +19 -5
  102. package/dist/types-ts4.5/arrow/index.d.ts +24 -0
  103. package/dist/types-ts4.5/arrow/types.d.ts +29 -0
  104. package/dist/types-ts4.5/dialog/dialog-content.d.ts +5 -1
  105. package/dist/types-ts4.5/dialog/types.d.ts +50 -24
  106. package/dist/types-ts4.5/entry-points/create-close-event.d.ts +7 -0
  107. package/dist/types-ts4.5/entry-points/dialog/create-close-event.d.ts +2 -0
  108. package/dist/types-ts4.5/entry-points/placement-map.d.ts +2 -1
  109. package/dist/types-ts4.5/entry-points/popover/create-close-event.d.ts +2 -0
  110. package/dist/types-ts4.5/focus/focus.d.ts +8 -0
  111. package/dist/types-ts4.5/internal/resolve-css-length-to-pixels.d.ts +4 -6
  112. package/dist/types-ts4.5/internal/role-types.d.ts +25 -4
  113. package/dist/types-ts4.5/internal/set-style.d.ts +2 -19
  114. package/dist/types-ts4.5/internal/use-anchor-position.d.ts +2 -3
  115. package/dist/types-ts4.5/internal/use-initial-focus.d.ts +5 -4
  116. package/dist/types-ts4.5/placement-map/index.d.ts +8 -21
  117. package/dist/types-ts4.5/placement-map/legacy-placements.d.ts +39 -0
  118. package/dist/types-ts4.5/popover/popover.d.ts +1 -1
  119. package/dist/types-ts4.5/popover/types.d.ts +17 -17
  120. package/dist/types-ts4.5/popup/popup-surface.d.ts +6 -0
  121. package/dist/types-ts4.5/popup/popup-trigger-function.d.ts +10 -6
  122. package/dist/types-ts4.5/popup/popup-trigger.d.ts +8 -17
  123. package/dist/types-ts4.5/popup/popup.d.ts +2 -2
  124. package/dist/types-ts4.5/popup/types.d.ts +68 -22
  125. package/dist/types-ts4.5/use-arrow-navigation/use-arrow-navigation.d.ts +19 -5
  126. package/package.json +1 -1
  127. package/popover/create-close-event/package.json +17 -0
@@ -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
  *
@@ -107,19 +120,36 @@ export function placementToTryFallbacks({
107
120
  });
108
121
  const crossAxis = axis === 'block' ? 'inline' : 'block';
109
122
  const flippedEdge = edge === 'start' ? 'end' : 'start';
110
- if (align !== 'center') {
111
- const sameEdge = `${axis}-${edge}`;
112
- const oppositeEdge = `${axis}-${flippedEdge}`;
113
- const flipKeyword = axis === 'block' ? 'flip-block' : 'flip-inline';
114
- const flipCrossKeyword = axis === 'block' ? 'flip-inline' : 'flip-block';
115
- // Try same-edge shifts first; two flip entries cover single-edge then corner overflow.
116
- // Legacy Popper fallbacks for e.g. bottom-start: [bottom, bottom-end, top-start, top, top-end, auto].
117
- 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(', ');
118
- }
119
123
  const sameEdge = `${axis}-${edge}`;
120
124
  const oppositeEdge = `${axis}-${flippedEdge}`;
121
125
  const flipKeyword = axis === 'block' ? 'flip-block' : 'flip-inline';
122
- 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(', ');
123
153
  }
124
154
 
125
155
  /**
@@ -137,7 +167,7 @@ function isPopoverOpen(popover) {
137
167
 
138
168
  /**
139
169
  * Detects whether the browser supports CSS Anchor Positioning.
140
- * 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.
141
171
  */
142
172
  const supportsAnchorPositioning = once(() => {
143
173
  if (typeof window === 'undefined' || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') {
@@ -223,34 +253,49 @@ function crossAxisShiftMargin({
223
253
  }
224
254
 
225
255
  /**
226
- * Returns a referentially-stable, fully-resolved `TPlacement`: the same
227
- * reference is preserved across renders as long as the resolved shape
228
- * 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.
229
261
  */
230
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.
231
267
  const resolved = getPlacement({
232
268
  placement
233
269
  });
234
- const stableRef = useRef(resolved);
235
- if (!isResolvedPlacementEqual(stableRef.current, resolved)) {
236
- stableRef.current = resolved;
237
- }
238
- return stableRef.current;
239
- }
240
-
241
- /**
242
- * Shallow-equals two fully-resolved placements. Avoids `JSON.stringify` so
243
- * the comparison cost stays proportional to the number of fields.
244
- */
245
- function isResolvedPlacementEqual(a, b) {
246
- 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]);
247
292
  }
248
293
 
249
294
  /**
250
295
  * Hook that positions an element relative to an anchor element using
251
296
  * CSS Anchor Positioning (with a JS fallback for older browsers).
252
297
  *
253
- * 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,
254
299
  * visibility, or animation. Compose it with `Popover` for anchor-positioned
255
300
  * top-layer content.
256
301
  *
@@ -294,6 +339,9 @@ export function useAnchorPosition({
294
339
  placement: stablePlacement,
295
340
  offset: gapCssValue
296
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.
297
345
  const crossAxisShift = crossAxisShiftMargin({
298
346
  placement: stablePlacement,
299
347
  crossAxisShiftCssValue,
@@ -353,11 +401,8 @@ export function useAnchorPosition({
353
401
  property: '--ds-cross-axis-shift-margin-block-end',
354
402
  value: '0px'
355
403
  });
356
- const crossAxisShiftActive = crossAxisShiftMargin({
357
- placement: stablePlacement,
358
- crossAxisShiftCssValue,
359
- direction: stablePlacement.offset.crossAxisShift.direction
360
- });
404
+ // `crossAxisShift` IS the active margin - reuse instead of recomputing.
405
+ const crossAxisShiftActive = crossAxisShift;
361
406
  // Map margin property to its corresponding custom property name.
362
407
  const customPropertyByMarginProperty = {
363
408
  'margin-inline-start': '--ds-cross-axis-shift-margin-start',
@@ -382,7 +427,7 @@ export function useAnchorPosition({
382
427
  * - Sometimes the `useAnchorPosition` hook and `Popover`
383
428
  * are not in the same component.
384
429
  * - There can be times when, even with reference counting, we
385
- * could remove the anchor-name while it's still being used
430
+ * could remove the anchor-name while it is still being used
386
431
  * by something else - especially with async react updates
387
432
  * which can cause tearing
388
433
  *
@@ -413,7 +458,6 @@ export function useAnchorPosition({
413
458
  }]
414
459
  });
415
460
  function update() {
416
- var _popover$parentElemen;
417
461
  if (!trigger || !popover) {
418
462
  return;
419
463
  }
@@ -424,19 +468,19 @@ export function useAnchorPosition({
424
468
  };
425
469
 
426
470
  // Resolve consumer-supplied CSS length strings (tokens, calc, var,
427
- // px, rem, etc) to pixels. The probe is mounted next to the popover
428
- // so it inherits the same custom-property scope and font size.
429
- // `popover.parentElement` is guaranteed to exist for any rendered
430
- // popover (the popover itself is appended somewhere in the tree),
431
- // but fall back to the popover element if not for safety.
432
- 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.)
433
477
  const gapPx = resolveCssLengthToPixels({
434
478
  value: gapCssValue,
435
- container: probeContainer
479
+ container: popover
436
480
  });
437
481
  const crossAxisShiftPx = resolveCssLengthToPixels({
438
482
  value: crossAxisShiftCssValue,
439
- container: probeContainer
483
+ container: popover
440
484
  });
441
485
  const {
442
486
  top,
@@ -470,15 +514,12 @@ export function useAnchorPosition({
470
514
  // scroll/resize is handled by the window listeners below.
471
515
  // `ResizeObserver` is missing in some non-DOM jest environments
472
516
  // (e.g. post-office's `node` environment). Fall back to a no-op
473
- // observer there — the scroll/resize listeners below still keep
517
+ // observer there. The scroll/resize listeners below still keep
474
518
  // the popover positioned in the rare case the consumer also
475
519
  // polyfilled `showPopover` but not `ResizeObserver`. Real
476
- // browsers always have it.
477
- const NoopResizeObserver = class {
478
- observe() {}
479
- unobserve() {}
480
- disconnect() {}
481
- };
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.
482
523
  const ResizeObserverImpl = typeof ResizeObserver !== 'undefined' ? ResizeObserver : NoopResizeObserver;
483
524
  const resizeObserver = new ResizeObserverImpl(() => {
484
525
  if (popover.offsetWidth > 0 && popover.offsetHeight > 0) {
@@ -501,7 +542,7 @@ export function useAnchorPosition({
501
542
  // the UA-default position, then re-observe for a fresh
502
543
  // measurement. We use `opacity: 0` rather than
503
544
  // `visibility: hidden` because Firefox skips visibility-hidden
504
- // elements during `<dialog>` initial-focus traversal — see
545
+ // elements during `<dialog>` initial-focus traversal. See
505
546
  // `form-in-popup.spec.tsx` on `desktop-firefox`.
506
547
  bind(popover, {
507
548
  type: 'toggle',
@@ -1,6 +1,6 @@
1
1
  import { useEffect } from 'react';
2
2
  import { bind } from 'bind-event-listener';
3
- import { getFirstFocusable, getLastFocusable, getNextFocusable } from '../focus/focus';
3
+ import { getFirstFocusable, getLastFocusable, getNextFocusable, isNestedLayerFocused } from '../focus/focus';
4
4
 
5
5
  /**
6
6
  * Roles that require focus wrapping per WAI-ARIA APG.
@@ -14,7 +14,7 @@ import { getFirstFocusable, getLastFocusable, getNextFocusable } from '../focus/
14
14
  * not Tab. Tab/Shift+Tab should move focus OUT of a menu entirely.
15
15
  *
16
16
  * Menu keyboard behavior (arrow keys, Home/End, type-ahead, Enter/Space)
17
- * is context-dependent — it varies by orientation, nesting depth, item
17
+ * is context-dependent. It varies by orientation, nesting depth, item
18
18
  * type, and control pattern (menu button vs menubar). This makes it
19
19
  * impossible to implement generically here. The consumer component
20
20
  * (e.g. `dropdown-menu`) owns all menu keyboard navigation.
@@ -55,6 +55,17 @@ export function useFocusWrap({
55
55
  if (event.key !== 'Tab') {
56
56
  return;
57
57
  }
58
+
59
+ // If focus is already in a nested layer, then
60
+ // we continue to let that nested layer own focus
61
+ if (isNestedLayerFocused({
62
+ container: element
63
+ })) {
64
+ return;
65
+ }
66
+
67
+ // Prevent the browser giving focus to where it wants to
68
+ // (we will be handling it, including with wrapping)
58
69
  event.preventDefault();
59
70
  const direction = event.shiftKey ? 'backwards' : 'forwards';
60
71
 
@@ -70,7 +81,7 @@ export function useFocusWrap({
70
81
  }
71
82
 
72
83
  // Nothing is currently focused inside the container (or the
73
- // focused element isn't in the focusable list). Fall back to
84
+ // focused element is not in the focusable list). Fall back to
74
85
  // the first or last focusable element depending on direction.
75
86
  const fallback = direction === 'forwards' ? getFirstFocusable({
76
87
  container: element
@@ -1,4 +1,4 @@
1
- import { useEffect } from 'react';
1
+ import { useEffect, useRef } from 'react';
2
2
  import { getFirstFocusable } from '../focus/focus';
3
3
 
4
4
  /**
@@ -19,9 +19,9 @@ import { getFirstFocusable } from '../focus/focus';
19
19
  * React 19+ reflects the JSX prop as the HTML attribute automatically.
20
20
  */
21
21
  function getInitialFocusTarget({
22
- container
22
+ container,
23
+ role
23
24
  }) {
24
- const role = container.getAttribute('role');
25
25
  if (role === 'dialog' || role === 'alertdialog') {
26
26
  // Prefer element with the native HTML autofocus attribute.
27
27
  const autofocusEl = container.querySelector('[autofocus]');
@@ -62,14 +62,15 @@ function getInitialFocusTarget({
62
62
  /**
63
63
  * Moves focus into a popover element when it opens, based on its ARIA role.
64
64
  *
65
- * This implements WCAG 2.4.3 (Focus Order) initial focus placement:
65
+ * Follows the WAI-ARIA APG initial-focus patterns for each role:
66
66
  * - `dialog` / `alertdialog` → focus first focusable (or `[autofocus]`)
67
67
  * - `menu` → focus first menu item
68
68
  * - `listbox` → focus selected or first option
69
- * - `tooltip` / no role → no focus movement
69
+ * - `tooltip` / no role → no focus movement (focus must remain on trigger;
70
+ * moving focus into the tooltip would dismiss it)
70
71
  *
71
- * The role is read from the element's `role` attribute (not passed as a prop),
72
- * following the same pattern as `useFocusWrap`.
72
+ * Role is taken from the typed `role` prop - this is the consumer's contract
73
+ * and is not subject to drift from a directly-mutated DOM attribute.
73
74
  *
74
75
  * Always call this hook unconditionally. Focus is only moved when `isOpen`
75
76
  * transitions to `true` and the role requires it.
@@ -79,6 +80,12 @@ export function useInitialFocus({
79
80
  isOpen,
80
81
  role
81
82
  }) {
83
+ // Track the most recently scheduled RAF so a rapid `isOpen → false → true`
84
+ // transition can cancel the previous frame before its closure runs against
85
+ // a stale popover element. Without this, the RAF queued during the first
86
+ // open could fire after the second open had begun and focus the wrong
87
+ // instance.
88
+ const pendingRafRef = useRef(null);
82
89
  useEffect(() => {
83
90
  if (!isOpen) {
84
91
  return;
@@ -88,6 +95,11 @@ export function useInitialFocus({
88
95
  return;
89
96
  }
90
97
 
98
+ // Cancel any RAF queued by the previous open cycle.
99
+ if (pendingRafRef.current !== null) {
100
+ cancelAnimationFrame(pendingRafRef.current);
101
+ }
102
+
91
103
  // Use requestAnimationFrame to ensure the popover is fully rendered
92
104
  // and visible in the top layer before attempting to move focus.
93
105
  // showPopover() is called in useLayoutEffect (which runs before this
@@ -95,15 +107,24 @@ export function useInitialFocus({
95
107
  // element is guaranteed to be in the top layer.
96
108
  const rafId = requestAnimationFrame(() => {
97
109
  var _getInitialFocusTarge;
110
+ pendingRafRef.current = null;
111
+ // Verify the element we captured is still the live popover. In
112
+ // rapid-toggle scenarios the ref may already point to a different
113
+ // instance - skip rather than focus the wrong popover.
114
+ if (elementRef.current !== el) {
115
+ return;
116
+ }
98
117
  (_getInitialFocusTarge = getInitialFocusTarget({
99
- container: el
118
+ container: el,
119
+ role
100
120
  })) === null || _getInitialFocusTarge === void 0 ? void 0 : _getInitialFocusTarge.focus();
101
121
  });
122
+ pendingRafRef.current = rafId;
102
123
  return () => {
103
124
  cancelAnimationFrame(rafId);
125
+ if (pendingRafRef.current === rafId) {
126
+ pendingRafRef.current = null;
127
+ }
104
128
  };
105
- // We intentionally depend on `role` even though `getInitialFocusTarget`
106
- // reads from the DOM attribute. This ensures the effect re-runs if
107
- // the role prop changes while the popover is open.
108
129
  }, [elementRef, isOpen, role]);
109
130
  }