@atlaskit/top-layer 0.15.0 → 0.16.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 (55) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/cjs/dialog/dialog-content.js +33 -4
  3. package/dist/cjs/dialog-scroll-lock/index.js +17 -7
  4. package/dist/cjs/entry-points/use-popover-id.js +3 -1
  5. package/dist/cjs/internal/get-aria-for-trigger.js +8 -3
  6. package/dist/cjs/internal/use-anchor-position-at-point.js +4 -2
  7. package/dist/cjs/internal/use-anchor-position.js +3 -2
  8. package/dist/cjs/internal/use-animated-visibility.js +233 -155
  9. package/dist/cjs/internal/use-focus-wrap.js +5 -3
  10. package/dist/cjs/internal/use-initial-focus.js +196 -50
  11. package/dist/cjs/internal/use-width-from-anchor.js +5 -4
  12. package/dist/cjs/popover/popover.js +58 -9
  13. package/dist/es2019/dialog/dialog-content.js +33 -4
  14. package/dist/es2019/dialog-scroll-lock/index.js +18 -8
  15. package/dist/es2019/entry-points/use-popover-id.js +3 -1
  16. package/dist/es2019/internal/get-aria-for-trigger.js +8 -3
  17. package/dist/es2019/internal/use-anchor-position-at-point.js +4 -2
  18. package/dist/es2019/internal/use-anchor-position.js +3 -2
  19. package/dist/es2019/internal/use-animated-visibility.js +222 -149
  20. package/dist/es2019/internal/use-focus-wrap.js +5 -4
  21. package/dist/es2019/internal/use-initial-focus.js +189 -45
  22. package/dist/es2019/internal/use-width-from-anchor.js +5 -4
  23. package/dist/es2019/popover/popover.js +59 -10
  24. package/dist/esm/dialog/dialog-content.js +33 -4
  25. package/dist/esm/dialog-scroll-lock/index.js +17 -8
  26. package/dist/esm/entry-points/use-popover-id.js +3 -1
  27. package/dist/esm/internal/get-aria-for-trigger.js +8 -3
  28. package/dist/esm/internal/use-anchor-position-at-point.js +4 -2
  29. package/dist/esm/internal/use-anchor-position.js +3 -2
  30. package/dist/esm/internal/use-animated-visibility.js +235 -157
  31. package/dist/esm/internal/use-focus-wrap.js +5 -4
  32. package/dist/esm/internal/use-initial-focus.js +196 -51
  33. package/dist/esm/internal/use-width-from-anchor.js +5 -4
  34. package/dist/esm/popover/popover.js +59 -10
  35. package/dist/types/dialog/types.d.ts +9 -1
  36. package/dist/types/dialog-scroll-lock/index.d.ts +22 -6
  37. package/dist/types/internal/get-aria-for-trigger.d.ts +8 -1
  38. package/dist/types/internal/use-anchor-position-at-point.d.ts +6 -1
  39. package/dist/types/internal/use-anchor-position.d.ts +14 -1
  40. package/dist/types/internal/use-animated-visibility.d.ts +48 -19
  41. package/dist/types/internal/use-focus-wrap.d.ts +6 -1
  42. package/dist/types/internal/use-initial-focus.d.ts +55 -4
  43. package/dist/types/internal/use-width-from-anchor.d.ts +3 -2
  44. package/dist/types/popover/types.d.ts +13 -2
  45. package/dist/types-ts4.5/dialog/types.d.ts +9 -1
  46. package/dist/types-ts4.5/dialog-scroll-lock/index.d.ts +22 -6
  47. package/dist/types-ts4.5/internal/get-aria-for-trigger.d.ts +8 -1
  48. package/dist/types-ts4.5/internal/use-anchor-position-at-point.d.ts +6 -1
  49. package/dist/types-ts4.5/internal/use-anchor-position.d.ts +14 -1
  50. package/dist/types-ts4.5/internal/use-animated-visibility.d.ts +48 -19
  51. package/dist/types-ts4.5/internal/use-focus-wrap.d.ts +6 -1
  52. package/dist/types-ts4.5/internal/use-initial-focus.d.ts +55 -4
  53. package/dist/types-ts4.5/internal/use-width-from-anchor.d.ts +3 -2
  54. package/dist/types-ts4.5/popover/types.d.ts +13 -2
  55. package/package.json +1 -1
@@ -22,10 +22,106 @@ var _getFirstFocusable = require("../focus/get-first-focusable");
22
22
  * has no effect on hidden popover content. Consumers must set the native
23
23
  * attribute via a ref callback (e.g. `node.setAttribute('autofocus', '')`).
24
24
  * React 19+ reflects the JSX prop as the HTML attribute automatically.
25
+ *
26
+ * Combobox pattern exception: if the popup is controlled by a focused
27
+ * combobox-like trigger (its `aria-controls` references this popup),
28
+ * focus stays on the textbox regardless of role. See
29
+ * `isComboboxControllingPopup`.
30
+ */
31
+
32
+ /**
33
+ * WAI-ARIA APG Combobox Pattern: when a combobox controls a popup (listbox,
34
+ * grid, tree, or menu), DOM focus must remain on the textbox. Subsequent
35
+ * navigation through popup items uses `aria-activedescendant` (or proxied
36
+ * keyboard events), never DOM focus movement.
37
+ *
38
+ * Detects both variants in a single pass:
39
+ * - In-popup combobox: the textbox lives inside the popup `container`
40
+ * (the popup is rendered as a child of the combobox wrapper).
41
+ * - External combobox: the textbox is rendered OUTSIDE the popup
42
+ * (`react-select` pattern, where the listbox is portalled separately
43
+ * from the textbox).
44
+ *
45
+ * Acceptance criteria for the active element:
46
+ * - Role / element-type filter: `role="combobox"`, `role="textbox"`,
47
+ * `TEXTAREA`, `contentEditable`, or `INPUT` of text-like type
48
+ * (`text`, `search`, `email`, `url`, `tel`, `password`, or
49
+ * unspecified). Non-text input types (`button`, `checkbox`, etc.)
50
+ * are rejected.
51
+ * - Reference filter: `aria-controls` (or legacy `aria-owns`) IDREFS
52
+ * must resolve to an element that IS or CONTAINS the popup container.
53
+ *
54
+ * See: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
25
55
  */
26
- function getInitialFocusTarget(_ref) {
27
- var container = _ref.container,
28
- role = _ref.role;
56
+ // `<input>` types treated as text-like for combobox detection.
57
+ // Non-text input types (`button`, `checkbox`, `submit`, etc.) are rejected.
58
+ // A missing `type` attribute defaults to `text` per the HTML spec, so we
59
+ // normalize that case before lookup rather than including an empty string here.
60
+ var TEXT_LIKE_INPUT_TYPES = new Set(['text', 'search', 'email', 'url', 'tel', 'password']);
61
+ function isTextboxLikeElement(element) {
62
+ var role = element.getAttribute('role');
63
+ if (role === 'combobox' || role === 'textbox') {
64
+ return true;
65
+ }
66
+ // `tagName` is uppercase for HTML elements but can be mixed-case for
67
+ // XML/SVG. Normalize once so downstream comparisons stay lowercase.
68
+ var tag = element.tagName.toLowerCase();
69
+ if (tag === 'textarea') {
70
+ return true;
71
+ }
72
+ if (element.isContentEditable) {
73
+ return true;
74
+ }
75
+ if (tag === 'input') {
76
+ var _element$getAttribute;
77
+ var type = ((_element$getAttribute = element.getAttribute('type')) !== null && _element$getAttribute !== void 0 ? _element$getAttribute : 'text').toLowerCase();
78
+ return TEXT_LIKE_INPUT_TYPES.has(type);
79
+ }
80
+ return false;
81
+ }
82
+ function isComboboxControllingPopup(_ref) {
83
+ var _active$getAttribute;
84
+ var container = _ref.container;
85
+ var ownerDocument = container.ownerDocument;
86
+ var active = ownerDocument.activeElement;
87
+ if (!(active instanceof HTMLElement)) {
88
+ return false;
89
+ }
90
+ if (!isTextboxLikeElement(active)) {
91
+ return false;
92
+ }
93
+
94
+ // Accept legacy `aria-owns` in addition to `aria-controls`.
95
+ // `aria-controls` / `aria-owns` are IDREFS (space-separated list of IDs)
96
+ // per the WAI-ARIA spec, so we must split and check membership rather
97
+ // than comparing with strict equality. A combobox that controls both
98
+ // this popup and another element (e.g. a results status region) must
99
+ // still be recognised as controlling this popup.
100
+ var idRefs = (_active$getAttribute = active.getAttribute('aria-controls')) !== null && _active$getAttribute !== void 0 ? _active$getAttribute : active.getAttribute('aria-owns');
101
+ if (!idRefs) {
102
+ return false;
103
+ }
104
+
105
+ // Match if any referenced element IS, CONTAINS, or IS CONTAINED BY the
106
+ // popup container. The "contained by" case covers wrapper-style adopters
107
+ // like react-select, where the combobox input's `aria-controls` points at
108
+ // a listbox element that is a descendant of the Popover host (the
109
+ // listbox role lives on a child the wrapped library owns, not on the
110
+ // host we render).
111
+ var referencedIds = idRefs.split(/\s+/).filter(function (id) {
112
+ return id !== '';
113
+ });
114
+ return referencedIds.some(function (id) {
115
+ var referenced = ownerDocument.getElementById(id);
116
+ if (!referenced) {
117
+ return false;
118
+ }
119
+ return referenced === container || referenced.contains(container) || container.contains(referenced);
120
+ });
121
+ }
122
+ function getInitialFocusTarget(_ref2) {
123
+ var container = _ref2.container,
124
+ role = _ref2.role;
29
125
  if (role === 'dialog' || role === 'alertdialog') {
30
126
  // Prefer element with the native HTML autofocus attribute.
31
127
  var autofocusEl = container.querySelector('[autofocus]');
@@ -38,6 +134,13 @@ function getInitialFocusTarget(_ref) {
38
134
  });
39
135
  }
40
136
  if (role === 'menu') {
137
+ // ARIA Combobox Pattern: if the popup is owned by a focused combobox,
138
+ // leave focus on the textbox. Navigation is proxied via the combobox.
139
+ if (isComboboxControllingPopup({
140
+ container: container
141
+ })) {
142
+ return null;
143
+ }
41
144
  // Initial focus placement is the extent of what top-layer provides
42
145
  // for menus. All subsequent keyboard navigation (arrow keys, Home/End,
43
146
  // type-ahead, Enter/Space, submenu coordination) is the responsibility
@@ -49,11 +152,31 @@ function getInitialFocusTarget(_ref) {
49
152
  });
50
153
  }
51
154
  if (role === 'listbox') {
52
- // Prefer selected option, fall back to first focusable
155
+ // ARIA Combobox Pattern: if the popup is owned by a focused
156
+ // combobox-like textbox (in-popup or external e.g. react-select),
157
+ // leave focus on the textbox. Navigation is proxied via the combobox.
158
+ if (isComboboxControllingPopup({
159
+ container: container
160
+ })) {
161
+ return null;
162
+ }
163
+ // Prefer the selected option, then fall back to the first option.
164
+ // We intentionally match by `[role="option"]` rather than by
165
+ // `getFirstFocusable`. The standard WAI-ARIA APG listbox pattern
166
+ // renders options with `tabindex="-1"` (managed via roving
167
+ // tabindex); `getFirstFocusable` excludes `tabindex="-1"` elements
168
+ // and would silently lose initial focus for spec-conformant
169
+ // listboxes. Options remain programmatically focusable.
53
170
  var selectedOption = container.querySelector('[role="option"][aria-selected="true"]');
54
171
  if (selectedOption) {
55
172
  return selectedOption;
56
173
  }
174
+ var firstOption = container.querySelector('[role="option"]');
175
+ if (firstOption) {
176
+ return firstOption;
177
+ }
178
+ // Last resort for non-standard listboxes that render no options
179
+ // but do contain a focusable element (e.g. an empty-state action).
57
180
  return (0, _getFirstFocusable.getFirstFocusable)({
58
181
  container: container
59
182
  });
@@ -76,58 +199,81 @@ function getInitialFocusTarget(_ref) {
76
199
  * Role is taken from the typed `role` prop - this is the consumer's contract
77
200
  * and is not subject to drift from a directly-mutated DOM attribute.
78
201
  *
79
- * Always call this hook unconditionally. Focus is only moved when `isOpen`
80
- * transitions to `true` and the role requires it.
202
+ * Always call this hook unconditionally. Focus is moved on each transition
203
+ * into a visible phase, the moment the host element first becomes visible
204
+ * for that open intent:
205
+ *
206
+ * - Animated path: on `closed → entering`. The host has just been mounted
207
+ * and the layout effect that calls `showPopover()` / `showModal()` has
208
+ * already run, so the element is in the top layer. We focus before the
209
+ * entry transition settles to match the WAI-ARIA APG expectation that
210
+ * focus lands on the new menu / dialog the instant it opens, not after
211
+ * the CSS finishes animating.
212
+ * - Non-animated path: on `closed → open` (the entering phase is skipped).
213
+ * - Reopen mid-exit: on `exiting → entering`. When a user dismisses the
214
+ * popup (Escape, close button, light dismiss) the browser natively
215
+ * restores focus to the trigger as part of `hidePopover()` /
216
+ * `<dialog>.close()`. If the consumer reopens before the exit transition
217
+ * has settled, the phase machine jumps `exiting → entering` without
218
+ * passing through `closed`, so without this branch the popup would be
219
+ * visible again with focus stranded on the trigger — a keyboard usability
220
+ * gap for menu/dialog/listbox roles.
221
+ *
222
+ * Note: there is a rare programmatic path where a consumer flips `isOpen`
223
+ * false → true without any user-initiated close gesture and had moved focus
224
+ * to a non-default target inside the popup. In that scenario this hook
225
+ * re-focuses the role-appropriate default (e.g. first menu item) rather
226
+ * than preserving the consumer-chosen target. This matches the documented
227
+ * `closed → entering` behavior and is considered acceptable given how
228
+ * abstract the scenario is. See `notes/architecture/focus.md`.
229
+ *
230
+ * Pure `entering → exiting → entering` stutters where the popup never
231
+ * reaches `closed` and the consumer toggles in the same frame are still
232
+ * collapsed: the `prevPhase` ref guard rejects any transition whose
233
+ * previous phase was not `closed` or `exiting`.
234
+ *
235
+ * Tradeoff vs the previous `requestAnimationFrame` based approach: we no
236
+ * longer wait one paint before focusing. The element is already attached
237
+ * and in the top layer when this effect runs (the show / hide layout
238
+ * effect runs first), so screen-reader announcement and visible focus
239
+ * land together. If a regression appears that needs the extra paint
240
+ * (e.g. browsers that require a layout pass before `focus()` works on a
241
+ * freshly promoted top-layer element), wrap the `focus()` call in a
242
+ * single `requestAnimationFrame` here and re-add a same-element ref
243
+ * check inside the RAF callback.
81
244
  */
82
- function useInitialFocus(_ref2) {
83
- var elementRef = _ref2.elementRef,
84
- isOpen = _ref2.isOpen,
85
- role = _ref2.role;
86
- // Track the most recently scheduled RAF so a rapid `isOpen → false → true`
87
- // transition can cancel the previous frame before its closure runs against
88
- // a stale popover element. Without this, the RAF queued during the first
89
- // open could fire after the second open had begun and focus the wrong
90
- // instance.
91
- var pendingRafRef = (0, _react.useRef)(null);
245
+ function useInitialFocus(_ref3) {
246
+ var elementRef = _ref3.elementRef,
247
+ phase = _ref3.phase,
248
+ role = _ref3.role;
249
+ // Track the previous phase so we only focus on transitions into a
250
+ // visible phase from either `'closed'` (fresh mount) or `'exiting'`
251
+ // (mid-exit reopen where the browser has already restored focus to
252
+ // the trigger). Pure `entering → exiting → entering` stutters where
253
+ // the consumer toggles in the same frame are still collapsed.
254
+ var prevPhaseRef = (0, _react.useRef)('closed');
92
255
  (0, _react.useEffect)(function () {
93
- if (!isOpen) {
256
+ var _getInitialFocusTarge;
257
+ var prevPhase = prevPhaseRef.current;
258
+ prevPhaseRef.current = phase;
259
+
260
+ // Skip the `phase === 'closed'` transitions (host is going away,
261
+ // nothing to focus) and any transition whose previous phase was
262
+ // neither `'closed'` (fresh open) nor `'exiting'` (mid-exit
263
+ // reopen, focus already on the trigger).
264
+ if (phase === 'closed') {
265
+ return;
266
+ }
267
+ if (prevPhase !== 'closed' && prevPhase !== 'exiting') {
94
268
  return;
95
269
  }
96
270
  var el = elementRef.current;
97
271
  if (!el) {
98
272
  return;
99
273
  }
100
-
101
- // Cancel any RAF queued by the previous open cycle.
102
- if (pendingRafRef.current !== null) {
103
- cancelAnimationFrame(pendingRafRef.current);
104
- }
105
-
106
- // Use requestAnimationFrame to ensure the popover is fully rendered
107
- // and visible in the top layer before attempting to move focus.
108
- // showPopover() is called in useLayoutEffect (which runs before this
109
- // useEffect), so by the time the RAF fires after the next paint the
110
- // element is guaranteed to be in the top layer.
111
- var rafId = requestAnimationFrame(function () {
112
- var _getInitialFocusTarge;
113
- pendingRafRef.current = null;
114
- // Verify the element we captured is still the live popover. In
115
- // rapid-toggle scenarios the ref may already point to a different
116
- // instance - skip rather than focus the wrong popover.
117
- if (elementRef.current !== el) {
118
- return;
119
- }
120
- (_getInitialFocusTarge = getInitialFocusTarget({
121
- container: el,
122
- role: role
123
- })) === null || _getInitialFocusTarge === void 0 || _getInitialFocusTarge.focus();
124
- });
125
- pendingRafRef.current = rafId;
126
- return function () {
127
- cancelAnimationFrame(rafId);
128
- if (pendingRafRef.current === rafId) {
129
- pendingRafRef.current = null;
130
- }
131
- };
132
- }, [elementRef, isOpen, role]);
274
+ (_getInitialFocusTarge = getInitialFocusTarget({
275
+ container: el,
276
+ role: role
277
+ })) === null || _getInitialFocusTarge === void 0 || _getInitialFocusTarge.focus();
278
+ }, [elementRef, phase, role]);
133
279
  }
@@ -22,7 +22,8 @@ var _setStyle = require("./set-style");
22
22
  function useWidthFromAnchor(_ref) {
23
23
  var mode = _ref.mode,
24
24
  popoverRef = _ref.popoverRef,
25
- anchorRef = _ref.anchorRef;
25
+ anchorRef = _ref.anchorRef,
26
+ isOpen = _ref.isOpen;
26
27
  (0, _react.useLayoutEffect)(function () {
27
28
  var node = popoverRef.current;
28
29
  if (!node) {
@@ -48,7 +49,7 @@ function useWidthFromAnchor(_ref) {
48
49
  }
49
50
 
50
51
  // JS fallback: one-off read of the anchor's width.
51
- var anchor = anchorRef === null || anchorRef === void 0 ? void 0 : anchorRef.current;
52
+ var anchor = anchorRef.current;
52
53
  if (anchor) {
53
54
  return (0, _setStyle.setStyle)({
54
55
  el: node,
@@ -80,7 +81,7 @@ function useWidthFromAnchor(_ref) {
80
81
  }]
81
82
  });
82
83
  }
83
- var _anchor = anchorRef === null || anchorRef === void 0 ? void 0 : anchorRef.current;
84
+ var _anchor = anchorRef.current;
84
85
  if (_anchor) {
85
86
  return (0, _setStyle.setStyle)({
86
87
  el: node,
@@ -103,5 +104,5 @@ function useWidthFromAnchor(_ref) {
103
104
  el: node,
104
105
  styles: [minInlineSizeStyle]
105
106
  });
106
- }, [mode, popoverRef, anchorRef]);
107
+ }, [mode, popoverRef, anchorRef, isOpen]);
107
108
  }
@@ -86,20 +86,32 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
86
86
  onEnterFinish: onEnterFinish,
87
87
  onExitFinish: onExitFinish
88
88
  }),
89
- showChildren = _useAnimatedVisibilit.showChildren,
89
+ phase = _useAnimatedVisibilit.phase,
90
90
  preset = _useAnimatedVisibilit.preset;
91
91
 
92
- // Focus management: initial focus on open (role-dependent), Tab cycling
92
+ // Pre-computed phase predicate. `isVisible` mirrors "host element is
93
+ // mounted and on screen" (any phase except `closed`); used as a dep
94
+ // signal for listener-re-bind effects so they re-attach to the fresh
95
+ // host element after a close → reopen unmount/remount cycle.
96
+ var isVisible = phase !== 'closed';
97
+
98
+ // Focus management: initial focus on entry (role-dependent), Tab cycling
93
99
  // for dialog roles. Restoration is native for outermost popovers; nested
94
100
  // focus-capturing roles are handled below via `beforetoggle` snapshot.
95
101
  // See: notes/architecture/focus-restoration.md
102
+ //
103
+ // Both hooks take `phase` directly: `useFocusWrap` keeps its listener
104
+ // attached while `phase !== 'closed'` (so focus stays trapped through
105
+ // the animated-exit window — WCAG 2.4.3 regression guard);
106
+ // `useInitialFocus` moves focus on the transition into `'open'`.
96
107
  (0, _useFocusWrap.useFocusWrap)({
97
108
  elementRef: ownRef,
98
- role: role
109
+ role: role,
110
+ phase: phase
99
111
  });
100
112
  (0, _useInitialFocus.useInitialFocus)({
101
113
  elementRef: ownRef,
102
- isOpen: isOpen,
114
+ phase: phase,
103
115
  role: role
104
116
  });
105
117
 
@@ -196,7 +208,23 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
196
208
  });
197
209
  }
198
210
  }, [onClose, onOpenChange, role]);
199
- (0, _react.useEffect)(function () {
211
+
212
+ // Bind toggle/beforetoggle/keydown listeners via `useLayoutEffect` so
213
+ // they are attached BEFORE the show/hide layout effect calls
214
+ // `el.showPopover()`. `showPopover()` dispatches `beforetoggle`
215
+ // synchronously; binding the listener in a regular `useEffect`
216
+ // (which runs after layout effects) misses that first dispatch and
217
+ // the `previouslyFocusedElementRef` snapshot never gets taken — which
218
+ // breaks nested-popover focus restoration on close.
219
+ //
220
+ // `isVisible` is in deps so the listeners re-bind to the new host
221
+ // element after a host unmount/remount cycle. The cleanup detaches
222
+ // the old listeners.
223
+ //
224
+ // This layout effect MUST appear in the file before the show/hide
225
+ // layout effect — React runs layout effects in source order on the
226
+ // same commit, and we need listeners attached first.
227
+ (0, _react.useLayoutEffect)(function () {
200
228
  var element = ownRef.current;
201
229
  if (!element) {
202
230
  return;
@@ -228,10 +256,13 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
228
256
  unbindToggle();
229
257
  unbindBeforeToggle();
230
258
  };
231
- }, [handleToggle, handleBeforeToggle]);
259
+ }, [handleToggle, handleBeforeToggle, isVisible]);
232
260
 
233
261
  // Placement-dependent animation CSS vars. Kept separate from show/hide
234
- // so preset reference changes do not re-trigger visibility.
262
+ // so preset reference changes do not re-trigger visibility. `isVisible`
263
+ // is in deps so the CSS vars are re-applied to the new host element after
264
+ // a remount cycle (otherwise the freshly mounted element would have no
265
+ // transform / placement vars set).
235
266
  (0, _react.useLayoutEffect)(function () {
236
267
  var el = ownRef.current;
237
268
  if (!el || !(preset !== null && preset !== void 0 && preset.getProperties) || !placement) {
@@ -246,7 +277,7 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
246
277
  value = _ref3[1];
247
278
  el.style.setProperty(key, String(value));
248
279
  });
249
- }, [preset, placement]);
280
+ }, [preset, placement, isVisible]);
250
281
 
251
282
  // Show/hide based on isOpen. `showPopover`/`hidePopover` are no-ops when
252
283
  // already in the target state. Try/catch guards `InvalidStateError` on
@@ -258,6 +289,13 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
258
289
  }
259
290
  if (isOpen) {
260
291
  programmaticCloseRef.current = false;
292
+ // Reset the close-reason snapshot at the start of every open
293
+ // cycle. The ref outlives the host element (lives on the
294
+ // component, not the DOM), so a stale 'escape' from a prior
295
+ // cycle would otherwise leak into the next close — especially
296
+ // in the no-animation path where the host unmounts before
297
+ // `handleToggle` has a chance to reset the ref itself.
298
+ closeReasonRef.current = 'light-dismiss';
261
299
  try {
262
300
  el.showPopover();
263
301
  } catch (_unused) {}
@@ -273,6 +311,17 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
273
311
  el.hidePopover();
274
312
  } catch (_unused3) {}
275
313
  }, [isOpen]);
314
+
315
+ // The host element is only rendered while the popover is open or its exit
316
+ // animation is playing. Once the phase returns to `closed`, we unmount
317
+ // the host entirely so it does not leave an empty `role="..."` /
318
+ // `popover` element in the accessibility tree. The element re-mounts on
319
+ // the next `isOpen=true` commit; refs and event listeners re-bind via
320
+ // the existing layout effect and `useEffect`. The popover `id` is stable
321
+ // across opens (derived from `useId()` or the consumer-supplied `idProp`).
322
+ if (!isVisible) {
323
+ return null;
324
+ }
276
325
  return /*#__PURE__*/_react.default.createElement("div", (0, _extends2.default)({
277
326
  ref: combinedRef,
278
327
  id: popoverId
@@ -287,5 +336,5 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
287
336
  }, preset ? (0, _defineProperty2.default)({}, "data-ds-popover-".concat(preset.name), '') : undefined, {
288
337
  // eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop -- xcss prop passes compiled atomic class names
289
338
  className: (0, _runtime.ax)([styles.root, xcssFromProps])
290
- }), showChildren ? children : null);
339
+ }), children);
291
340
  });
@@ -51,7 +51,7 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
51
51
 
52
52
  // Animation lifecycle
53
53
  const {
54
- showChildren,
54
+ phase,
55
55
  preset
56
56
  } = useAnimatedVisibility({
57
57
  isOpen,
@@ -60,13 +60,27 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
60
60
  onExitFinish
61
61
  });
62
62
 
63
+ // Pre-computed phase predicate. `isVisible` means "host element is
64
+ // mounted and on screen" (any phase except `closed`), used as a dep
65
+ // signal for the backdrop-click rebind effect so it re-attaches to
66
+ // the fresh <dialog> element after a close → reopen unmount/remount
67
+ // cycle.
68
+ const isVisible = phase !== 'closed';
69
+
63
70
  // Focus wrap
64
71
  // Native <dialog>.showModal() traps focus but wraps through <body> at
65
72
  // the boundary (A → B → C → body → A). This hook intercepts Tab to
66
73
  // wrap directly (A → B → C → A), matching the WAI-ARIA APG pattern.
74
+ //
75
+ // Takes `phase` directly: the listener stays attached while
76
+ // `phase !== 'closed'`, i.e. through the animated-exit window.
77
+ // Gating on `isOpen` would unbind the listener at the start of an
78
+ // exit animation while the dialog is still visible, letting focus
79
+ // escape (WCAG 2.4.3 Focus Order regression).
67
80
  useFocusWrap({
68
81
  elementRef: ownRef,
69
- role: 'dialog'
82
+ role: 'dialog',
83
+ phase
70
84
  });
71
85
 
72
86
  // Open layer observer registration
@@ -112,6 +126,8 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
112
126
  // Attached via bind-event-listener rather than a React prop so we avoid
113
127
  // a11y lint suppressions on the <dialog> element.
114
128
  // Keyboard dismiss is already handled natively (Escape → onCancel above).
129
+ // `isVisible` is in deps so the listener re-binds to the new <dialog>
130
+ // element after a host unmount / remount cycle.
115
131
  useEffect(() => {
116
132
  const dialog = ownRef.current;
117
133
  if (!dialog) {
@@ -127,13 +143,26 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
127
143
  }
128
144
  }
129
145
  });
130
- }, [onClose]);
146
+ }, [onClose, isVisible]);
131
147
 
132
148
  // Atomic CSS (Compiled) deduplicates ::backdrop { background-color }
133
149
  // into a single class, making it impossible to toggle between two values
134
150
  // via cssMap entries. An ID-scoped <style> has higher specificity than
135
151
  // any atomic class, so the override always wins.
136
152
  const escapedId = CSS.escape(dialogId);
153
+
154
+ // The `<dialog>` host element is only rendered while open or its exit
155
+ // animation is playing. After exit completes the phase returns to
156
+ // `closed` and we unmount the dialog entirely so it does not leave an
157
+ // empty `role="dialog"` element in the accessibility tree. The element
158
+ // re-mounts on the next `isOpen=true` commit; `showModal()` is called
159
+ // from the existing `useLayoutEffect([isOpen])` on that same commit,
160
+ // and listeners (cancel, click) re-bind via the `[onClose, isVisible]`
161
+ // effect re-running after remount (React clears the ref on unmount, so
162
+ // we need the listener effect to re-run with the fresh element).
163
+ if (!isVisible) {
164
+ return null;
165
+ }
137
166
  return /*#__PURE__*/React.createElement("dialog", _extends({
138
167
  ref: combinedRef,
139
168
  id: dialogId
@@ -156,5 +185,5 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
156
185
  }), shouldHideBackdrop &&
157
186
  /*#__PURE__*/
158
187
  // eslint-disable-next-line @atlaskit/ui-styling-standard/no-global-styles
159
- React.createElement("style", null, `#${escapedId}::backdrop{background-color:transparent}`), showChildren ? children : null);
188
+ React.createElement("style", null, `#${escapedId}::backdrop{background-color:transparent}`), children);
160
189
  });
@@ -1,17 +1,22 @@
1
1
  import { useLayoutEffect } from 'react';
2
2
  import { getDocument } from '@atlaskit/browser-apis';
3
-
4
3
  /**
5
4
  * __DialogScrollLock__
6
5
  *
7
- * Prevents background page scrolling while mounted. Designed to be rendered
8
- * inside a `Dialog` so that the scroll lock is automatically removed when the
9
- * dialog closes (unmounts its children).
6
+ * Prevents background page scrolling while a dialog is open. Designed to be
7
+ * rendered inside a `Dialog` and given the same `isOpen` value.
10
8
  *
11
9
  * Native `<dialog>.showModal()` makes background content _inert_ (not
12
10
  * focusable/clickable), but does **not** prevent scroll events from reaching
13
11
  * the document body. This component fills that gap by setting
14
- * `overflow: hidden` on `document.body`.
12
+ * `overflow: hidden` on `document.body` while `isOpen` is `true`.
13
+ *
14
+ * The lock is driven by `isOpen` rather than by mount/unmount so that release
15
+ * happens synchronously in the same commit that flips `isOpen` to `false`.
16
+ * Relying on unmount alone was racy: `Dialog` defers the unmount of its
17
+ * children until the host element's close/toggle event fires, which can be
18
+ * after a consumer or test observes the dialog as hidden, leading to a
19
+ * stale `overflow: hidden` window.
15
20
  *
16
21
  * @example
17
22
  * ```tsx
@@ -19,14 +24,19 @@ import { getDocument } from '@atlaskit/browser-apis';
19
24
  * import { DialogScrollLock } from '@atlaskit/top-layer/dialog-scroll-lock';
20
25
  *
21
26
  * <Dialog isOpen={isOpen} onClose={onClose}>
22
- * <DialogScrollLock />
27
+ * <DialogScrollLock isOpen={isOpen} />
23
28
  * {children}
24
29
  * </Dialog>
25
30
  * ```
26
31
  */
27
- export function DialogScrollLock() {
32
+ export function DialogScrollLock({
33
+ isOpen
34
+ }) {
28
35
  useLayoutEffect(() => {
29
36
  var _getDocument;
37
+ if (!isOpen) {
38
+ return;
39
+ }
30
40
  const body = (_getDocument = getDocument()) === null || _getDocument === void 0 ? void 0 : _getDocument.body;
31
41
  if (!body) {
32
42
  return;
@@ -36,6 +46,6 @@ export function DialogScrollLock() {
36
46
  return () => {
37
47
  body.style.overflow = previousOverflow;
38
48
  };
39
- }, []);
49
+ }, [isOpen]);
40
50
  return null;
41
51
  }
@@ -10,6 +10,8 @@ import { useId } from '@atlaskit/ds-lib/use-id';
10
10
  * own `anchor-name` on the trigger element.
11
11
  */
12
12
  export function usePopoverId() {
13
- // prefixing for debug clarity in the DOM
13
+ // `@atlaskit/ds-lib/use-id` already strips characters that React's
14
+ // `useId` produces (`:`, `«`, `»`) which are invalid in HTML ID
15
+ // tokens. The `popover-` prefix is purely for DOM debug clarity.
14
16
  return `popover-${useId()}`;
15
17
  }
@@ -74,11 +74,16 @@ export function getAriaForTrigger({
74
74
  const ariaHasPopup = roleToAriaHasPopup({
75
75
  role
76
76
  });
77
+ // `aria-controls` is set to `undefined` while closed. The `Popover` /
78
+ // `Dialog` primitives unmount their host element after the exit
79
+ // animation finishes, so a closed-state `aria-controls` would point
80
+ // at a node that is not in the DOM. Returning `undefined` here means
81
+ // JSX spread renders no `aria-controls` attribute on the trigger
82
+ // until the target exists, avoiding the dangling reference while
83
+ // keeping the relationship live whenever it is meaningful.
77
84
  return {
78
85
  'aria-haspopup': ariaHasPopup,
79
86
  'aria-expanded': isOpen,
80
- // Keep the trigger-to-popover relationship stable. `aria-expanded`
81
- // tells assistive technology whether the controlled popover is visible.
82
- 'aria-controls': popoverId
87
+ 'aria-controls': isOpen ? popoverId : undefined
83
88
  };
84
89
  }
@@ -69,7 +69,8 @@ export function useAnchorPositionAtPoint({
69
69
  popoverRef,
70
70
  placement,
71
71
  getPoint,
72
- isEnabled = true
72
+ isEnabled = true,
73
+ isOpen
73
74
  }) {
74
75
  const syntheticAnchorRef = useRef(null);
75
76
 
@@ -117,6 +118,7 @@ export function useAnchorPositionAtPoint({
117
118
  anchorRef: syntheticAnchorRef,
118
119
  popoverRef,
119
120
  placement,
120
- isEnabled
121
+ isEnabled,
122
+ isOpen
121
123
  });
122
124
  }
@@ -203,7 +203,8 @@ export function useAnchorPosition({
203
203
  popoverRef,
204
204
  placement = {},
205
205
  forceFallbackPositioning = false,
206
- isEnabled = true
206
+ isEnabled = true,
207
+ isOpen
207
208
  }) {
208
209
  const id = useId();
209
210
  // Stabilize `placement` object so the same object literal
@@ -463,5 +464,5 @@ export function useAnchorPosition({
463
464
  popover.style.removeProperty('opacity');
464
465
  });
465
466
  return undoPositioning;
466
- }, [anchorRef, popoverRef, stablePlacement, forceFallbackPositioning, isEnabled, id]);
467
+ }, [anchorRef, popoverRef, stablePlacement, forceFallbackPositioning, isEnabled, id, isOpen]);
467
468
  }