@atlaskit/top-layer 0.15.0 → 0.17.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 (66) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +4 -4
  3. package/dist/cjs/animations/presets.js +96 -9
  4. package/dist/cjs/dialog/dialog-content.js +33 -4
  5. package/dist/cjs/dialog-scroll-lock/index.js +17 -7
  6. package/dist/cjs/entry-points/animations.js +12 -0
  7. package/dist/cjs/entry-points/use-popover-id.js +3 -1
  8. package/dist/cjs/internal/get-aria-for-trigger.js +8 -3
  9. package/dist/cjs/internal/use-anchor-position-at-point.js +4 -2
  10. package/dist/cjs/internal/use-anchor-position.js +3 -2
  11. package/dist/cjs/internal/use-animated-visibility.js +233 -155
  12. package/dist/cjs/internal/use-focus-wrap.js +5 -3
  13. package/dist/cjs/internal/use-initial-focus.js +196 -50
  14. package/dist/cjs/internal/use-width-from-anchor.js +5 -4
  15. package/dist/cjs/popover/popover.js +58 -9
  16. package/dist/es2019/animations/presets.js +145 -2
  17. package/dist/es2019/dialog/dialog-content.js +33 -4
  18. package/dist/es2019/dialog-scroll-lock/index.js +18 -8
  19. package/dist/es2019/entry-points/animations.js +1 -1
  20. package/dist/es2019/entry-points/use-popover-id.js +3 -1
  21. package/dist/es2019/internal/get-aria-for-trigger.js +8 -3
  22. package/dist/es2019/internal/use-anchor-position-at-point.js +4 -2
  23. package/dist/es2019/internal/use-anchor-position.js +3 -2
  24. package/dist/es2019/internal/use-animated-visibility.js +222 -149
  25. package/dist/es2019/internal/use-focus-wrap.js +5 -4
  26. package/dist/es2019/internal/use-initial-focus.js +189 -45
  27. package/dist/es2019/internal/use-width-from-anchor.js +5 -4
  28. package/dist/es2019/popover/popover.js +59 -10
  29. package/dist/esm/animations/presets.js +93 -9
  30. package/dist/esm/dialog/dialog-content.js +33 -4
  31. package/dist/esm/dialog-scroll-lock/index.js +17 -8
  32. package/dist/esm/entry-points/animations.js +1 -1
  33. package/dist/esm/entry-points/use-popover-id.js +3 -1
  34. package/dist/esm/internal/get-aria-for-trigger.js +8 -3
  35. package/dist/esm/internal/use-anchor-position-at-point.js +4 -2
  36. package/dist/esm/internal/use-anchor-position.js +3 -2
  37. package/dist/esm/internal/use-animated-visibility.js +235 -157
  38. package/dist/esm/internal/use-focus-wrap.js +5 -4
  39. package/dist/esm/internal/use-initial-focus.js +196 -51
  40. package/dist/esm/internal/use-width-from-anchor.js +5 -4
  41. package/dist/esm/popover/popover.js +59 -10
  42. package/dist/types/animations/presets.d.ts +16 -0
  43. package/dist/types/dialog/types.d.ts +9 -1
  44. package/dist/types/dialog-scroll-lock/index.d.ts +22 -6
  45. package/dist/types/entry-points/animations.d.ts +1 -1
  46. package/dist/types/internal/get-aria-for-trigger.d.ts +8 -1
  47. package/dist/types/internal/use-anchor-position-at-point.d.ts +6 -1
  48. package/dist/types/internal/use-anchor-position.d.ts +14 -1
  49. package/dist/types/internal/use-animated-visibility.d.ts +48 -19
  50. package/dist/types/internal/use-focus-wrap.d.ts +6 -1
  51. package/dist/types/internal/use-initial-focus.d.ts +55 -4
  52. package/dist/types/internal/use-width-from-anchor.d.ts +3 -2
  53. package/dist/types/popover/types.d.ts +13 -2
  54. package/dist/types-ts4.5/animations/presets.d.ts +16 -0
  55. package/dist/types-ts4.5/dialog/types.d.ts +9 -1
  56. package/dist/types-ts4.5/dialog-scroll-lock/index.d.ts +22 -6
  57. package/dist/types-ts4.5/entry-points/animations.d.ts +1 -1
  58. package/dist/types-ts4.5/internal/get-aria-for-trigger.d.ts +8 -1
  59. package/dist/types-ts4.5/internal/use-anchor-position-at-point.d.ts +6 -1
  60. package/dist/types-ts4.5/internal/use-anchor-position.d.ts +14 -1
  61. package/dist/types-ts4.5/internal/use-animated-visibility.d.ts +48 -19
  62. package/dist/types-ts4.5/internal/use-focus-wrap.d.ts +6 -1
  63. package/dist/types-ts4.5/internal/use-initial-focus.d.ts +55 -4
  64. package/dist/types-ts4.5/internal/use-width-from-anchor.d.ts +3 -2
  65. package/dist/types-ts4.5/popover/types.d.ts +13 -2
  66. package/package.json +3 -3
@@ -4,7 +4,6 @@ import { getFirstFocusable } from '../focus/get-first-focusable';
4
4
  import { getLastFocusable } from '../focus/get-last-focusable';
5
5
  import { getNextFocusable } from '../focus/get-next-focusable';
6
6
  import { isNestedLayerFocused } from '../focus/is-nested-layer-focused';
7
-
8
7
  /**
9
8
  * Roles that require focus wrapping per WAI-ARIA APG.
10
9
  *
@@ -45,11 +44,13 @@ function roleRequiresFocusWrap(role) {
45
44
  */
46
45
  export function useFocusWrap({
47
46
  elementRef,
48
- role
47
+ role,
48
+ phase
49
49
  }) {
50
+ const isVisible = phase !== 'closed';
50
51
  useEffect(() => {
51
52
  const element = elementRef.current;
52
- if (!element || !roleRequiresFocusWrap(role)) {
53
+ if (!element || !roleRequiresFocusWrap(role) || !isVisible) {
53
54
  return;
54
55
  }
55
56
  const unbind = bind(element, {
@@ -100,5 +101,5 @@ export function useFocusWrap({
100
101
  }
101
102
  });
102
103
  return unbind;
103
- }, [elementRef, role]);
104
+ }, [elementRef, role, isVisible]);
104
105
  }
@@ -1,6 +1,5 @@
1
1
  import { useEffect, useRef } from 'react';
2
2
  import { getFirstFocusable } from '../focus/get-first-focusable';
3
-
4
3
  /**
5
4
  * Determines the correct element to receive initial focus within a popover,
6
5
  * based on its ARIA role.
@@ -17,7 +16,102 @@ import { getFirstFocusable } from '../focus/get-first-focusable';
17
16
  * has no effect on hidden popover content. Consumers must set the native
18
17
  * attribute via a ref callback (e.g. `node.setAttribute('autofocus', '')`).
19
18
  * React 19+ reflects the JSX prop as the HTML attribute automatically.
19
+ *
20
+ * Combobox pattern exception: if the popup is controlled by a focused
21
+ * combobox-like trigger (its `aria-controls` references this popup),
22
+ * focus stays on the textbox regardless of role. See
23
+ * `isComboboxControllingPopup`.
24
+ */
25
+
26
+ /**
27
+ * WAI-ARIA APG Combobox Pattern: when a combobox controls a popup (listbox,
28
+ * grid, tree, or menu), DOM focus must remain on the textbox. Subsequent
29
+ * navigation through popup items uses `aria-activedescendant` (or proxied
30
+ * keyboard events), never DOM focus movement.
31
+ *
32
+ * Detects both variants in a single pass:
33
+ * - In-popup combobox: the textbox lives inside the popup `container`
34
+ * (the popup is rendered as a child of the combobox wrapper).
35
+ * - External combobox: the textbox is rendered OUTSIDE the popup
36
+ * (`react-select` pattern, where the listbox is portalled separately
37
+ * from the textbox).
38
+ *
39
+ * Acceptance criteria for the active element:
40
+ * - Role / element-type filter: `role="combobox"`, `role="textbox"`,
41
+ * `TEXTAREA`, `contentEditable`, or `INPUT` of text-like type
42
+ * (`text`, `search`, `email`, `url`, `tel`, `password`, or
43
+ * unspecified). Non-text input types (`button`, `checkbox`, etc.)
44
+ * are rejected.
45
+ * - Reference filter: `aria-controls` (or legacy `aria-owns`) IDREFS
46
+ * must resolve to an element that IS or CONTAINS the popup container.
47
+ *
48
+ * See: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
20
49
  */
50
+ // `<input>` types treated as text-like for combobox detection.
51
+ // Non-text input types (`button`, `checkbox`, `submit`, etc.) are rejected.
52
+ // A missing `type` attribute defaults to `text` per the HTML spec, so we
53
+ // normalize that case before lookup rather than including an empty string here.
54
+ const TEXT_LIKE_INPUT_TYPES = new Set(['text', 'search', 'email', 'url', 'tel', 'password']);
55
+ function isTextboxLikeElement(element) {
56
+ const role = element.getAttribute('role');
57
+ if (role === 'combobox' || role === 'textbox') {
58
+ return true;
59
+ }
60
+ // `tagName` is uppercase for HTML elements but can be mixed-case for
61
+ // XML/SVG. Normalize once so downstream comparisons stay lowercase.
62
+ const tag = element.tagName.toLowerCase();
63
+ if (tag === 'textarea') {
64
+ return true;
65
+ }
66
+ if (element.isContentEditable) {
67
+ return true;
68
+ }
69
+ if (tag === 'input') {
70
+ var _element$getAttribute;
71
+ const type = ((_element$getAttribute = element.getAttribute('type')) !== null && _element$getAttribute !== void 0 ? _element$getAttribute : 'text').toLowerCase();
72
+ return TEXT_LIKE_INPUT_TYPES.has(type);
73
+ }
74
+ return false;
75
+ }
76
+ function isComboboxControllingPopup({
77
+ container
78
+ }) {
79
+ var _active$getAttribute;
80
+ const ownerDocument = container.ownerDocument;
81
+ const active = ownerDocument.activeElement;
82
+ if (!(active instanceof HTMLElement)) {
83
+ return false;
84
+ }
85
+ if (!isTextboxLikeElement(active)) {
86
+ return false;
87
+ }
88
+
89
+ // Accept legacy `aria-owns` in addition to `aria-controls`.
90
+ // `aria-controls` / `aria-owns` are IDREFS (space-separated list of IDs)
91
+ // per the WAI-ARIA spec, so we must split and check membership rather
92
+ // than comparing with strict equality. A combobox that controls both
93
+ // this popup and another element (e.g. a results status region) must
94
+ // still be recognised as controlling this popup.
95
+ const idRefs = (_active$getAttribute = active.getAttribute('aria-controls')) !== null && _active$getAttribute !== void 0 ? _active$getAttribute : active.getAttribute('aria-owns');
96
+ if (!idRefs) {
97
+ return false;
98
+ }
99
+
100
+ // Match if any referenced element IS, CONTAINS, or IS CONTAINED BY the
101
+ // popup container. The "contained by" case covers wrapper-style adopters
102
+ // like react-select, where the combobox input's `aria-controls` points at
103
+ // a listbox element that is a descendant of the Popover host (the
104
+ // listbox role lives on a child the wrapped library owns, not on the
105
+ // host we render).
106
+ const referencedIds = idRefs.split(/\s+/).filter(id => id !== '');
107
+ return referencedIds.some(id => {
108
+ const referenced = ownerDocument.getElementById(id);
109
+ if (!referenced) {
110
+ return false;
111
+ }
112
+ return referenced === container || referenced.contains(container) || container.contains(referenced);
113
+ });
114
+ }
21
115
  function getInitialFocusTarget({
22
116
  container,
23
117
  role
@@ -34,6 +128,13 @@ function getInitialFocusTarget({
34
128
  });
35
129
  }
36
130
  if (role === 'menu') {
131
+ // ARIA Combobox Pattern: if the popup is owned by a focused combobox,
132
+ // leave focus on the textbox. Navigation is proxied via the combobox.
133
+ if (isComboboxControllingPopup({
134
+ container
135
+ })) {
136
+ return null;
137
+ }
37
138
  // Initial focus placement is the extent of what top-layer provides
38
139
  // for menus. All subsequent keyboard navigation (arrow keys, Home/End,
39
140
  // type-ahead, Enter/Space, submenu coordination) is the responsibility
@@ -45,11 +146,31 @@ function getInitialFocusTarget({
45
146
  });
46
147
  }
47
148
  if (role === 'listbox') {
48
- // Prefer selected option, fall back to first focusable
149
+ // ARIA Combobox Pattern: if the popup is owned by a focused
150
+ // combobox-like textbox (in-popup or external e.g. react-select),
151
+ // leave focus on the textbox. Navigation is proxied via the combobox.
152
+ if (isComboboxControllingPopup({
153
+ container
154
+ })) {
155
+ return null;
156
+ }
157
+ // Prefer the selected option, then fall back to the first option.
158
+ // We intentionally match by `[role="option"]` rather than by
159
+ // `getFirstFocusable`. The standard WAI-ARIA APG listbox pattern
160
+ // renders options with `tabindex="-1"` (managed via roving
161
+ // tabindex); `getFirstFocusable` excludes `tabindex="-1"` elements
162
+ // and would silently lose initial focus for spec-conformant
163
+ // listboxes. Options remain programmatically focusable.
49
164
  const selectedOption = container.querySelector('[role="option"][aria-selected="true"]');
50
165
  if (selectedOption) {
51
166
  return selectedOption;
52
167
  }
168
+ const firstOption = container.querySelector('[role="option"]');
169
+ if (firstOption) {
170
+ return firstOption;
171
+ }
172
+ // Last resort for non-standard listboxes that render no options
173
+ // but do contain a focusable element (e.g. an empty-state action).
53
174
  return getFirstFocusable({
54
175
  container
55
176
  });
@@ -72,59 +193,82 @@ function getInitialFocusTarget({
72
193
  * Role is taken from the typed `role` prop - this is the consumer's contract
73
194
  * and is not subject to drift from a directly-mutated DOM attribute.
74
195
  *
75
- * Always call this hook unconditionally. Focus is only moved when `isOpen`
76
- * transitions to `true` and the role requires it.
196
+ * Always call this hook unconditionally. Focus is moved on each transition
197
+ * into a visible phase, the moment the host element first becomes visible
198
+ * for that open intent:
199
+ *
200
+ * - Animated path: on `closed → entering`. The host has just been mounted
201
+ * and the layout effect that calls `showPopover()` / `showModal()` has
202
+ * already run, so the element is in the top layer. We focus before the
203
+ * entry transition settles to match the WAI-ARIA APG expectation that
204
+ * focus lands on the new menu / dialog the instant it opens, not after
205
+ * the CSS finishes animating.
206
+ * - Non-animated path: on `closed → open` (the entering phase is skipped).
207
+ * - Reopen mid-exit: on `exiting → entering`. When a user dismisses the
208
+ * popup (Escape, close button, light dismiss) the browser natively
209
+ * restores focus to the trigger as part of `hidePopover()` /
210
+ * `<dialog>.close()`. If the consumer reopens before the exit transition
211
+ * has settled, the phase machine jumps `exiting → entering` without
212
+ * passing through `closed`, so without this branch the popup would be
213
+ * visible again with focus stranded on the trigger — a keyboard usability
214
+ * gap for menu/dialog/listbox roles.
215
+ *
216
+ * Note: there is a rare programmatic path where a consumer flips `isOpen`
217
+ * false → true without any user-initiated close gesture and had moved focus
218
+ * to a non-default target inside the popup. In that scenario this hook
219
+ * re-focuses the role-appropriate default (e.g. first menu item) rather
220
+ * than preserving the consumer-chosen target. This matches the documented
221
+ * `closed → entering` behavior and is considered acceptable given how
222
+ * abstract the scenario is. See `notes/architecture/focus.md`.
223
+ *
224
+ * Pure `entering → exiting → entering` stutters where the popup never
225
+ * reaches `closed` and the consumer toggles in the same frame are still
226
+ * collapsed: the `prevPhase` ref guard rejects any transition whose
227
+ * previous phase was not `closed` or `exiting`.
228
+ *
229
+ * Tradeoff vs the previous `requestAnimationFrame` based approach: we no
230
+ * longer wait one paint before focusing. The element is already attached
231
+ * and in the top layer when this effect runs (the show / hide layout
232
+ * effect runs first), so screen-reader announcement and visible focus
233
+ * land together. If a regression appears that needs the extra paint
234
+ * (e.g. browsers that require a layout pass before `focus()` works on a
235
+ * freshly promoted top-layer element), wrap the `focus()` call in a
236
+ * single `requestAnimationFrame` here and re-add a same-element ref
237
+ * check inside the RAF callback.
77
238
  */
78
239
  export function useInitialFocus({
79
240
  elementRef,
80
- isOpen,
241
+ phase,
81
242
  role
82
243
  }) {
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);
244
+ // Track the previous phase so we only focus on transitions into a
245
+ // visible phase from either `'closed'` (fresh mount) or `'exiting'`
246
+ // (mid-exit reopen where the browser has already restored focus to
247
+ // the trigger). Pure `entering → exiting → entering` stutters where
248
+ // the consumer toggles in the same frame are still collapsed.
249
+ const prevPhaseRef = useRef('closed');
89
250
  useEffect(() => {
90
- if (!isOpen) {
251
+ var _getInitialFocusTarge;
252
+ const prevPhase = prevPhaseRef.current;
253
+ prevPhaseRef.current = phase;
254
+
255
+ // Skip the `phase === 'closed'` transitions (host is going away,
256
+ // nothing to focus) and any transition whose previous phase was
257
+ // neither `'closed'` (fresh open) nor `'exiting'` (mid-exit
258
+ // reopen, focus already on the trigger).
259
+ if (phase === 'closed') {
260
+ return;
261
+ }
262
+ if (prevPhase !== 'closed' && prevPhase !== 'exiting') {
91
263
  return;
92
264
  }
93
265
  const el = elementRef.current;
94
266
  if (!el) {
95
267
  return;
96
268
  }
97
-
98
- // Cancel any RAF queued by the previous open cycle.
99
- if (pendingRafRef.current !== null) {
100
- cancelAnimationFrame(pendingRafRef.current);
101
- }
102
-
103
- // Use requestAnimationFrame to ensure the popover is fully rendered
104
- // and visible in the top layer before attempting to move focus.
105
- // showPopover() is called in useLayoutEffect (which runs before this
106
- // useEffect), so by the time the RAF fires after the next paint the
107
- // element is guaranteed to be in the top layer.
108
- const rafId = requestAnimationFrame(() => {
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
- }
117
- (_getInitialFocusTarge = getInitialFocusTarget({
118
- container: el,
119
- role
120
- })) === null || _getInitialFocusTarge === void 0 ? void 0 : _getInitialFocusTarge.focus();
121
- });
122
- pendingRafRef.current = rafId;
123
- return () => {
124
- cancelAnimationFrame(rafId);
125
- if (pendingRafRef.current === rafId) {
126
- pendingRafRef.current = null;
127
- }
128
- };
129
- }, [elementRef, isOpen, role]);
269
+ (_getInitialFocusTarge = getInitialFocusTarget({
270
+ container: el,
271
+ role
272
+ })) === null || _getInitialFocusTarge === void 0 ? void 0 : _getInitialFocusTarge.focus();
273
+ }, [elementRef, phase, role]);
130
274
  }
@@ -17,7 +17,8 @@ import { setStyle } from './set-style';
17
17
  export function useWidthFromAnchor({
18
18
  mode,
19
19
  popoverRef,
20
- anchorRef
20
+ anchorRef,
21
+ isOpen
21
22
  }) {
22
23
  useLayoutEffect(() => {
23
24
  const node = popoverRef.current;
@@ -44,7 +45,7 @@ export function useWidthFromAnchor({
44
45
  }
45
46
 
46
47
  // JS fallback: one-off read of the anchor's width.
47
- const anchor = anchorRef === null || anchorRef === void 0 ? void 0 : anchorRef.current;
48
+ const anchor = anchorRef.current;
48
49
  if (anchor) {
49
50
  return setStyle({
50
51
  el: node,
@@ -76,7 +77,7 @@ export function useWidthFromAnchor({
76
77
  }]
77
78
  });
78
79
  }
79
- const anchor = anchorRef === null || anchorRef === void 0 ? void 0 : anchorRef.current;
80
+ const anchor = anchorRef.current;
80
81
  if (anchor) {
81
82
  return setStyle({
82
83
  el: node,
@@ -99,5 +100,5 @@ export function useWidthFromAnchor({
99
100
  el: node,
100
101
  styles: [minInlineSizeStyle]
101
102
  });
102
- }, [mode, popoverRef, anchorRef]);
103
+ }, [mode, popoverRef, anchorRef, isOpen]);
103
104
  }
@@ -2,7 +2,7 @@
2
2
  import _extends from "@babel/runtime/helpers/extends";
3
3
  import "./popover.compiled.css";
4
4
  import { ax, ix } from "@compiled/react/runtime";
5
- import React, { forwardRef, useCallback, useEffect, useId, useLayoutEffect, useRef } from 'react';
5
+ import React, { forwardRef, useCallback, useId, useLayoutEffect, useRef } from 'react';
6
6
  import { bind } from 'bind-event-listener';
7
7
  import mergeRefs from '@atlaskit/ds-lib/merge-refs';
8
8
  import once from '@atlaskit/ds-lib/once';
@@ -70,7 +70,7 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
70
70
  // dropped (React 19.2 uses underscores).
71
71
  const popoverId = idProp !== null && idProp !== void 0 ? idProp : `popover-${autoId.replace(/:/g, '')}`;
72
72
  const {
73
- showChildren,
73
+ phase,
74
74
  preset
75
75
  } = useAnimatedVisibility({
76
76
  isOpen,
@@ -80,17 +80,29 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
80
80
  onExitFinish
81
81
  });
82
82
 
83
- // Focus management: initial focus on open (role-dependent), Tab cycling
83
+ // Pre-computed phase predicate. `isVisible` mirrors "host element is
84
+ // mounted and on screen" (any phase except `closed`); used as a dep
85
+ // signal for listener-re-bind effects so they re-attach to the fresh
86
+ // host element after a close → reopen unmount/remount cycle.
87
+ const isVisible = phase !== 'closed';
88
+
89
+ // Focus management: initial focus on entry (role-dependent), Tab cycling
84
90
  // for dialog roles. Restoration is native for outermost popovers; nested
85
91
  // focus-capturing roles are handled below via `beforetoggle` snapshot.
86
92
  // See: notes/architecture/focus-restoration.md
93
+ //
94
+ // Both hooks take `phase` directly: `useFocusWrap` keeps its listener
95
+ // attached while `phase !== 'closed'` (so focus stays trapped through
96
+ // the animated-exit window — WCAG 2.4.3 regression guard);
97
+ // `useInitialFocus` moves focus on the transition into `'open'`.
87
98
  useFocusWrap({
88
99
  elementRef: ownRef,
89
- role
100
+ role,
101
+ phase
90
102
  });
91
103
  useInitialFocus({
92
104
  elementRef: ownRef,
93
- isOpen,
105
+ phase,
94
106
  role
95
107
  });
96
108
 
@@ -187,7 +199,23 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
187
199
  });
188
200
  }
189
201
  }, [onClose, onOpenChange, role]);
190
- useEffect(() => {
202
+
203
+ // Bind toggle/beforetoggle/keydown listeners via `useLayoutEffect` so
204
+ // they are attached BEFORE the show/hide layout effect calls
205
+ // `el.showPopover()`. `showPopover()` dispatches `beforetoggle`
206
+ // synchronously; binding the listener in a regular `useEffect`
207
+ // (which runs after layout effects) misses that first dispatch and
208
+ // the `previouslyFocusedElementRef` snapshot never gets taken — which
209
+ // breaks nested-popover focus restoration on close.
210
+ //
211
+ // `isVisible` is in deps so the listeners re-bind to the new host
212
+ // element after a host unmount/remount cycle. The cleanup detaches
213
+ // the old listeners.
214
+ //
215
+ // This layout effect MUST appear in the file before the show/hide
216
+ // layout effect — React runs layout effects in source order on the
217
+ // same commit, and we need listeners attached first.
218
+ useLayoutEffect(() => {
191
219
  const element = ownRef.current;
192
220
  if (!element) {
193
221
  return;
@@ -219,10 +247,13 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
219
247
  unbindToggle();
220
248
  unbindBeforeToggle();
221
249
  };
222
- }, [handleToggle, handleBeforeToggle]);
250
+ }, [handleToggle, handleBeforeToggle, isVisible]);
223
251
 
224
252
  // Placement-dependent animation CSS vars. Kept separate from show/hide
225
- // so preset reference changes do not re-trigger visibility.
253
+ // so preset reference changes do not re-trigger visibility. `isVisible`
254
+ // is in deps so the CSS vars are re-applied to the new host element after
255
+ // a remount cycle (otherwise the freshly mounted element would have no
256
+ // transform / placement vars set).
226
257
  useLayoutEffect(() => {
227
258
  const el = ownRef.current;
228
259
  if (!el || !(preset !== null && preset !== void 0 && preset.getProperties) || !placement) {
@@ -234,7 +265,7 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
234
265
  Object.entries(props).forEach(([key, value]) => {
235
266
  el.style.setProperty(key, String(value));
236
267
  });
237
- }, [preset, placement]);
268
+ }, [preset, placement, isVisible]);
238
269
 
239
270
  // Show/hide based on isOpen. `showPopover`/`hidePopover` are no-ops when
240
271
  // already in the target state. Try/catch guards `InvalidStateError` on
@@ -246,6 +277,13 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
246
277
  }
247
278
  if (isOpen) {
248
279
  programmaticCloseRef.current = false;
280
+ // Reset the close-reason snapshot at the start of every open
281
+ // cycle. The ref outlives the host element (lives on the
282
+ // component, not the DOM), so a stale 'escape' from a prior
283
+ // cycle would otherwise leak into the next close — especially
284
+ // in the no-animation path where the host unmounts before
285
+ // `handleToggle` has a chance to reset the ref itself.
286
+ closeReasonRef.current = 'light-dismiss';
249
287
  try {
250
288
  el.showPopover();
251
289
  } catch {}
@@ -261,6 +299,17 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
261
299
  el.hidePopover();
262
300
  } catch {}
263
301
  }, [isOpen]);
302
+
303
+ // The host element is only rendered while the popover is open or its exit
304
+ // animation is playing. Once the phase returns to `closed`, we unmount
305
+ // the host entirely so it does not leave an empty `role="..."` /
306
+ // `popover` element in the accessibility tree. The element re-mounts on
307
+ // the next `isOpen=true` commit; refs and event listeners re-bind via
308
+ // the existing layout effect and `useEffect`. The popover `id` is stable
309
+ // across opens (derived from `useId()` or the consumer-supplied `idProp`).
310
+ if (!isVisible) {
311
+ return null;
312
+ }
264
313
  return /*#__PURE__*/React.createElement("div", _extends({
265
314
  ref: combinedRef,
266
315
  id: popoverId
@@ -277,5 +326,5 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
277
326
  } : undefined, {
278
327
  // eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop -- xcss prop passes compiled atomic class names
279
328
  className: ax([styles.root, xcssFromProps])
280
- }), showChildren ? children : null);
329
+ }), children);
281
330
  });
@@ -1,17 +1,71 @@
1
1
  import { getPlacement } from '../internal/resolve-placement';
2
+ // ══════════════════════════════════════════════════════════════════════════════
3
+ // Popover presets
4
+ // ══════════════════════════════════════════════════════════════════════════════
2
5
 
3
- // slideAndFade
6
+ // ── popupMotion ──
4
7
 
5
- function getSlideAndFadeProperties(_ref) {
6
- var placement = _ref.placement,
7
- distance = _ref.distance;
8
- var pos = "".concat(distance, "px");
9
- var neg = "-".concat(distance, "px");
8
+ var POPUP_MOTION_CSS = "\n [data-ds-popover-popup-motion] {\n animation: var(--ds-popover-motion-exit);\n animation-fill-mode: forwards;\n transition:\n overlay 150ms allow-discrete,\n display 150ms allow-discrete;\n }\n\n [data-ds-popover-popup-motion]:popover-open {\n animation: var(--ds-popover-motion-enter);\n animation-fill-mode: backwards;\n }\n\n @media (prefers-reduced-motion: reduce) {\n [data-ds-popover-popup-motion],\n [data-ds-popover-popup-motion]:popover-open {\n animation: none;\n transition-duration: 0s;\n }\n }\n";
9
+ function getPopupMotionProperties(_ref) {
10
+ var placement = _ref.placement;
10
11
  var _getPlacement = getPlacement({
11
12
  placement: placement
12
13
  }),
13
14
  axis = _getPlacement.axis,
14
15
  edge = _getPlacement.edge;
16
+ if (axis === 'block' && edge === 'start') {
17
+ return {
18
+ '--ds-popover-motion-enter': "var(--ds-popup-enter-top, 150ms cubic-bezier(0.4, 1, 0.6, 1) SlideInTop8px, 150ms cubic-bezier(0.4, 1, 0.6, 1) FadeIn0to100)",
19
+ '--ds-popover-motion-exit': "var(--ds-popup-exit-top, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) SlideOutTop8px, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) FadeOut100to0)"
20
+ };
21
+ }
22
+ if (axis === 'inline' && edge === 'start') {
23
+ return {
24
+ '--ds-popover-motion-enter': "var(--ds-popup-enter-left, 150ms cubic-bezier(0.4, 1, 0.6, 1) SlideInLeft8px, 150ms cubic-bezier(0.4, 1, 0.6, 1) FadeIn0to100)",
25
+ '--ds-popover-motion-exit': "var(--ds-popup-exit-left, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) SlideOutLeft8px, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) FadeOut100to0)"
26
+ };
27
+ }
28
+ if (axis === 'inline' && edge === 'end') {
29
+ return {
30
+ '--ds-popover-motion-enter': "var(--ds-popup-enter-right, 150ms cubic-bezier(0.4, 1, 0.6, 1) SlideInRight8px, 150ms cubic-bezier(0.4, 1, 0.6, 1) FadeIn0to100)",
31
+ '--ds-popover-motion-exit': "var(--ds-popup-exit-right, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) SlideOutRight8px, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) FadeOut100to0)"
32
+ };
33
+ }
34
+ // Default: block/end (popover below trigger)
35
+ return {
36
+ '--ds-popover-motion-enter': "var(--ds-popup-enter-bottom, 150ms cubic-bezier(0.4, 1, 0.6, 1) SlideInBottom8px, 150ms cubic-bezier(0.4, 1, 0.6, 1) FadeIn0to100)",
37
+ '--ds-popover-motion-exit': "var(--ds-popup-exit-bottom, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) SlideOutBottom8px, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) FadeOut100to0)"
38
+ };
39
+ }
40
+
41
+ // eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
42
+ export function popupMotion() {
43
+ return {
44
+ name: 'popup-motion',
45
+ css: POPUP_MOTION_CSS,
46
+ enterDurationMs: 150,
47
+ exitDurationMs: 100,
48
+ getProperties: function getProperties(_ref2) {
49
+ var placement = _ref2.placement;
50
+ return getPopupMotionProperties({
51
+ placement: placement
52
+ });
53
+ }
54
+ };
55
+ }
56
+
57
+ // ── slideAndFade ──
58
+
59
+ function getSlideAndFadeProperties(_ref3) {
60
+ var placement = _ref3.placement,
61
+ distance = _ref3.distance;
62
+ var pos = "".concat(distance, "px");
63
+ var neg = "-".concat(distance, "px");
64
+ var _getPlacement2 = getPlacement({
65
+ placement: placement
66
+ }),
67
+ axis = _getPlacement2.axis,
68
+ edge = _getPlacement2.edge;
15
69
  if (axis === 'block' && edge === 'start') {
16
70
  return {
17
71
  '--ds-popover-tx': '0',
@@ -64,14 +118,15 @@ var SLIDE_AND_FADE_CSS = "\n[data-ds-popover-slide-and-fade] {\n opacity: 0;\n
64
118
  * <Popover animate={slideAndFade({ distance: 8 })} />
65
119
  * ```
66
120
  */
121
+ // eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
67
122
  export function slideAndFade(options) {
68
123
  var _options$distance;
69
124
  var distance = (_options$distance = options === null || options === void 0 ? void 0 : options.distance) !== null && _options$distance !== void 0 ? _options$distance : 4;
70
125
  return {
71
126
  name: 'slide-and-fade',
72
127
  css: SLIDE_AND_FADE_CSS,
73
- getProperties: function getProperties(_ref2) {
74
- var placement = _ref2.placement;
128
+ getProperties: function getProperties(_ref4) {
129
+ var placement = _ref4.placement;
75
130
  return getSlideAndFadeProperties({
76
131
  placement: placement,
77
132
  distance: distance
@@ -138,7 +193,36 @@ export function scaleAndFade() {
138
193
  // Dialog presets
139
194
  // ══════════════════════════════════════════════════════════════════════════════
140
195
 
141
- // dialogSlideUpAndFade
196
+ // ── dialogMotion ──
197
+
198
+ var DIALOG_MOTION_CSS = "\n [data-ds-dialog-motion] {\n animation: ".concat("var(--ds-modal-exit, 200ms cubic-bezier(0.6, 0, 0.8, 0.6) ScaleOut100to95)", ";\n animation-fill-mode: forwards;\n transition:\n overlay 200ms allow-discrete,\n display 200ms allow-discrete;\n }\n\n [data-ds-dialog-motion][open] {\n animation: ", "var(--ds-modal-enter, 250ms cubic-bezier(0.4, 0, 0, 1) ScaleIn95to100)", ";\n animation-fill-mode: backwards;\n }\n\n [data-ds-dialog-motion]::backdrop {\n animation: ", "var(--ds-blanket-exit, 200ms cubic-bezier(0.6, 0, 0.8, 0.6) FadeOut100to0)", ";\n animation-fill-mode: forwards;\n background-color: var(--ds-blanket, #050C1F75);\n transition:\n overlay 200ms allow-discrete,\n display 200ms allow-discrete;\n }\n\n [data-ds-dialog-motion][open]::backdrop {\n animation: ", "var(--ds-blanket-enter, 250ms cubic-bezier(0.4, 0, 0, 1) FadeIn0to100)", ";\n animation-fill-mode: backwards;\n }\n\n @media (prefers-reduced-motion: reduce) {\n [data-ds-dialog-motion],\n [data-ds-dialog-motion][open],\n [data-ds-dialog-motion]::backdrop,\n [data-ds-dialog-motion][open]::backdrop {\n animation: none;\n transition-duration: 0s;\n }\n }\n");
199
+
200
+ /**
201
+ * Dialog motion animation.
202
+ *
203
+ * Includes backdrop fade animation.
204
+ *
205
+ * @example
206
+ * ```tsx
207
+ * import { dialogMotion } from '@atlaskit/top-layer/animations';
208
+ *
209
+ * <Dialog animate={dialogMotion()} isOpen={isOpen} onClose={handleClose} label="...">
210
+ * ...
211
+ * </Dialog>
212
+ * ```
213
+ */
214
+ // eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
215
+ export function dialogMotion() {
216
+ // Bare preset name; `Dialog` prefixes it via `data-ds-dialog-{name}`.
217
+ return {
218
+ name: 'motion',
219
+ css: DIALOG_MOTION_CSS,
220
+ enterDurationMs: 250,
221
+ exitDurationMs: 200
222
+ };
223
+ }
224
+
225
+ // ── dialogSlideUpAndFade ──
142
226
 
143
227
  var DIALOG_SLIDE_UP_AND_FADE_CSS = "\n[data-ds-dialog-slide-up-and-fade] {\n opacity: 0;\n transform: translateY(calc(-1 * var(--ds-dialog-ty, 12px)));\n transition:\n opacity 175ms cubic-bezier(0.15, 1, 0.3, 1),\n transform 175ms cubic-bezier(0.15, 1, 0.3, 1),\n overlay 175ms allow-discrete,\n display 175ms allow-discrete;\n}\n\n[data-ds-dialog-slide-up-and-fade][open] {\n opacity: 1;\n transform: none;\n transition-duration: 350ms;\n}\n\n@starting-style {\n [data-ds-dialog-slide-up-and-fade][open] {\n opacity: 0;\n transform: translateY(var(--ds-dialog-ty, 12px));\n }\n}\n\n[data-ds-dialog-slide-up-and-fade]::backdrop {\n background-color: transparent;\n transition:\n background-color 175ms cubic-bezier(0.15, 1, 0.3, 1),\n overlay 175ms allow-discrete,\n display 175ms allow-discrete;\n}\n\n[data-ds-dialog-slide-up-and-fade][open]::backdrop {\n background-color: var(--ds-blanket, #050C1F75);\n transition-duration: 350ms;\n}\n\n@starting-style {\n [data-ds-dialog-slide-up-and-fade][open]::backdrop {\n background-color: transparent;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n [data-ds-dialog-slide-up-and-fade],\n [data-ds-dialog-slide-up-and-fade][open],\n [data-ds-dialog-slide-up-and-fade]::backdrop,\n [data-ds-dialog-slide-up-and-fade][open]::backdrop {\n transition-duration: 0s;\n }\n}\n";
144
228