@atlaskit/top-layer 3.2.0 → 4.1.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 (50) hide show
  1. package/AGENTS.md +3 -1
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +0 -14
  4. package/dist/cjs/dialog/dialog-content.js +47 -20
  5. package/dist/cjs/focus/is-in-same-layer.js +1 -7
  6. package/dist/cjs/focus/selectors.js +1 -1
  7. package/dist/cjs/internal/get-aria-for-trigger.js +23 -9
  8. package/dist/cjs/internal/role-types.js +1 -64
  9. package/dist/cjs/internal/use-anchor-position.js +2 -3
  10. package/dist/cjs/internal/use-animated-visibility.js +36 -39
  11. package/dist/cjs/popover/popover.js +18 -20
  12. package/dist/es2019/dialog/dialog-content.js +48 -22
  13. package/dist/es2019/focus/is-in-same-layer.js +1 -7
  14. package/dist/es2019/focus/selectors.js +1 -1
  15. package/dist/es2019/internal/get-aria-for-trigger.js +23 -10
  16. package/dist/es2019/internal/role-types.js +0 -59
  17. package/dist/es2019/internal/use-anchor-position.js +2 -3
  18. package/dist/es2019/internal/use-animated-visibility.js +38 -39
  19. package/dist/es2019/popover/popover.js +15 -17
  20. package/dist/esm/dialog/dialog-content.js +49 -22
  21. package/dist/esm/focus/is-in-same-layer.js +1 -7
  22. package/dist/esm/focus/selectors.js +1 -1
  23. package/dist/esm/internal/get-aria-for-trigger.js +23 -10
  24. package/dist/esm/internal/role-types.js +0 -58
  25. package/dist/esm/internal/use-anchor-position.js +2 -3
  26. package/dist/esm/internal/use-animated-visibility.js +38 -41
  27. package/dist/esm/popover/popover.js +17 -19
  28. package/dist/types/dialog/dialog-content.d.ts +2 -2
  29. package/dist/types/dialog/index.d.ts +1 -1
  30. package/dist/types/dialog/types.d.ts +17 -3
  31. package/dist/types/entry-points/dialog.d.ts +1 -1
  32. package/dist/types/internal/get-aria-for-trigger.d.ts +21 -7
  33. package/dist/types/internal/role-types.d.ts +0 -32
  34. package/dist/types/internal/use-animated-visibility.d.ts +7 -1
  35. package/dist/types/popover/popover.d.ts +1 -5
  36. package/dist/types/popover/types.d.ts +1 -1
  37. package/package.json +3 -30
  38. package/animations/package.json +0 -10
  39. package/dist/cjs/entry-points/animations.js +0 -1
  40. package/dist/cjs/internal/role-to-aria-has-popup.js +0 -37
  41. package/dist/cjs/internal/slot.js +0 -65
  42. package/dist/es2019/entry-points/animations.js +0 -0
  43. package/dist/es2019/internal/role-to-aria-has-popup.js +0 -32
  44. package/dist/es2019/internal/slot.js +0 -57
  45. package/dist/esm/entry-points/animations.js +0 -0
  46. package/dist/esm/internal/role-to-aria-has-popup.js +0 -31
  47. package/dist/esm/internal/slot.js +0 -59
  48. package/dist/types/entry-points/animations.d.ts +0 -0
  49. package/dist/types/internal/role-to-aria-has-popup.d.ts +0 -13
  50. package/dist/types/internal/slot.d.ts +0 -29
package/AGENTS.md CHANGED
@@ -110,4 +110,6 @@ const [state, setState] = useState<TState>({ type: 'idle' });
110
110
 
111
111
  ## Versioning
112
112
 
113
- This package is still on `0.x` releases. All changes should be a `minor` (`0.x`) release.
113
+ This package is on stable releases. Follow semantic versioning: use `major` for public API removals
114
+ or incompatible behavior changes, `minor` for public API additions, and `patch` for compatible fixes
115
+ and internal implementation changes.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @atlaskit/top-layer
2
2
 
3
+ ## 4.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`ccb9237bc10d9`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/ccb9237bc10d9) -
8
+ Add the `dismissedBy` API to control Dialog dismissal consistently across browsers. Align Dialog
9
+ with Popover by notifying `onClose` after native dismissal, ensure both primitives unmount after
10
+ native dismissal, and simplify Modal close handling behind `platform-dst-top-layer`.
11
+
12
+ ## 4.0.0
13
+
14
+ ### Major Changes
15
+
16
+ - [`eff4aad608ff2`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/eff4aad608ff2) -
17
+ Remove the obsolete `@atlaskit/top-layer/animations` entry point and simplify internal helpers.
18
+ Use the built-in `shouldAnimate` behavior on `Popover` and `Dialog`, with phase-specific xcss
19
+ overrides when customization is required.
20
+
3
21
  ## 3.2.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -92,20 +92,6 @@ for entry and
92
92
  [`allow-discrete`](https://developer.mozilla.org/en-US/docs/Web/CSS/transition-behavior) on
93
93
  `display`/`overlay` for exit — no JavaScript animation coordination required.
94
94
 
95
- Presets are available from `@atlaskit/top-layer/animations`:
96
-
97
- ```tsx
98
- import {
99
- // Popover presets
100
- slideAndFade,
101
- fade,
102
- scaleAndFade,
103
- // Dialog presets
104
- dialogSlideUpAndFade,
105
- dialogFade,
106
- } from '@atlaskit/top-layer/animations';
107
- ```
108
-
109
95
  ### Popover animation
110
96
 
111
97
  Pass `shouldAnimate` to enable the default popover entry and exit animation:
@@ -54,8 +54,8 @@ var backdropStyles = {
54
54
  *
55
55
  * Handles native `cancel` event (Escape) and backdrop click detection.
56
56
  *
57
- * Close flow: we never call `dialog.close()` from event handlers. We always call
58
- * `onClose`; the consumer decides whether to set `isOpen={false}`.
57
+ * Close flow: allowed user dismissal closes the native dialog before `onClose`
58
+ * notifies the consumer. The consumer must synchronize `isOpen` in response.
59
59
  *
60
60
  * Accessibility: render at least one focusable element (typically a close
61
61
  * button) when `isOpen` becomes `true`. Tab is always trapped inside the
@@ -65,6 +65,8 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
65
65
  var children = _ref.children,
66
66
  isOpen = _ref.isOpen,
67
67
  onClose = _ref.onClose,
68
+ _ref$dismissedBy = _ref.dismissedBy,
69
+ dismissedBy = _ref$dismissedBy === void 0 ? 'escape-and-outside-click' : _ref$dismissedBy,
68
70
  onEnterFinish = _ref.onEnterFinish,
69
71
  onExitFinish = _ref.onExitFinish,
70
72
  _ref$shouldAnimate = _ref.shouldAnimate,
@@ -82,6 +84,7 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
82
84
  var dialogId = providedId !== null && providedId !== void 0 ? providedId : generatedId;
83
85
  var ownRef = (0, _react.useRef)(null);
84
86
  var combinedRef = (0, _mergeRefs.default)([ownRef, ref]);
87
+ var closeReasonRef = (0, _react.useRef)(null);
85
88
  var _useAnimatedVisibilit = (0, _useAnimatedVisibility.useAnimatedVisibility)({
86
89
  isOpen: isOpen,
87
90
  animationKind: 'dialog',
@@ -90,7 +93,8 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
90
93
  onEnterFinish: onEnterFinish,
91
94
  onExitFinish: onExitFinish
92
95
  }),
93
- phase = _useAnimatedVisibilit.phase;
96
+ phase = _useAnimatedVisibilit.phase,
97
+ onToggle = _useAnimatedVisibilit.onToggle;
94
98
 
95
99
  // True while the host element is mounted (any phase except `closed`).
96
100
  // Used as a dep so listener-rebind effects re-attach after a remount.
@@ -121,6 +125,7 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
121
125
  return;
122
126
  }
123
127
  if (isOpen) {
128
+ closeReasonRef.current = null;
124
129
  if (!dialog.open) {
125
130
  dialog.showModal();
126
131
  }
@@ -153,45 +158,67 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
153
158
  if (event.target !== event.currentTarget) {
154
159
  return;
155
160
  }
156
- event.preventDefault();
161
+
157
162
  // Spurious Safari `cancel`: the keydown snapshot saw an open child
158
163
  // popover, so this Escape belongs to that popover (light-dismissed
159
164
  // natively). Keep the dialog open instead of forwarding to `onClose`.
160
165
  if (shouldIgnoreEscape()) {
166
+ event.preventDefault();
161
167
  return;
162
168
  }
163
- onClose({
164
- reason: 'escape'
165
- });
166
- }, [onClose, shouldIgnoreEscape]);
167
169
 
168
- // Handle backdrop click
170
+ // Without native `closedby`, cancel Escape only when dismissal is disabled.
171
+ // Allowed dismissal follows Popover behavior: the browser closes the element
172
+ // and `onClose` notifies the controlled owner of the reason.
173
+ if (dismissedBy === 'none') {
174
+ event.preventDefault();
175
+ return;
176
+ }
177
+ closeReasonRef.current = 'escape';
178
+ }, [dismissedBy, shouldIgnoreEscape]);
179
+
180
+ // Handle backdrop interaction
169
181
  // Attached via bind-event-listener rather than a React prop so we avoid
170
182
  // a11y lint suppressions on the <dialog> element.
171
- // Keyboard dismiss is already handled natively (Escape → onCancel above).
183
+ // The click listener provides light dismiss consistently across browsers.
172
184
  // `isVisible` is in deps so the listener re-binds to the new <dialog>
173
185
  // element after a host unmount / remount cycle.
174
- (0, _react.useEffect)(function () {
186
+ (0, _react.useLayoutEffect)(function () {
175
187
  var dialog = ownRef.current;
176
188
  if (!dialog) {
177
189
  return;
178
190
  }
179
- return (0, _bindEventListener.bind)(dialog, {
191
+ return (0, _bindEventListener.bindAll)(dialog, [{
180
192
  type: 'click',
181
193
  listener: function listener(event) {
182
- if (event.target === event.currentTarget) {
183
- onClose({
184
- reason: 'overlay-click'
185
- });
194
+ if (event.target === event.currentTarget && dismissedBy === 'escape-and-outside-click') {
195
+ closeReasonRef.current = 'overlay-click';
196
+ // Calls native close because this is emulating the native light-dismiss behavior
197
+ dialog.close();
186
198
  }
187
199
  }
188
- });
189
- }, [onClose, isVisible]);
200
+ }, {
201
+ type: 'toggle',
202
+ listener: function listener(event) {
203
+ if (event.newState === 'closed') {
204
+ var reason = closeReasonRef.current;
205
+ closeReasonRef.current = null;
206
+ if (reason) {
207
+ onClose({
208
+ reason: reason
209
+ });
210
+ }
211
+ }
212
+
213
+ // Visibility cleanup runs after close-reason handling.
214
+ onToggle(event);
215
+ }
216
+ }]);
217
+ }, [dismissedBy, onClose, onToggle, isVisible]);
190
218
 
191
219
  // Unmount the `<dialog>` once exit completes so it does not leave an
192
220
  // empty `role="dialog"` element in the accessibility tree. On the next
193
- // open the element remounts and the `[isOpen]` and `[onClose, isVisible]`
194
- // effects re-run against the fresh element.
221
+ // open the element remounts and its effects run against the fresh element.
195
222
  if (!isVisible) {
196
223
  return null;
197
224
  }
@@ -13,11 +13,5 @@ var topLayerSelector = '[popover], dialog, [role="dialog"], [role="alertdialog"]
13
13
  function isInSameLayer(_ref) {
14
14
  var element = _ref.element,
15
15
  container = _ref.container;
16
- var elementLayer = element.closest(topLayerSelector);
17
- var containerLayer = container.closest(topLayerSelector);
18
- // Both elements are in the "document" layer
19
- if (!elementLayer && !containerLayer) {
20
- return true;
21
- }
22
- return elementLayer === containerLayer;
16
+ return element.closest(topLayerSelector) === container.closest(topLayerSelector);
23
17
  }
@@ -8,7 +8,7 @@ var selectors = exports.selectors = function () {
8
8
  // Common exclusion filter applied to every selector.
9
9
  var not = ':not([tabindex="-1"]):not([aria-disabled="true"]):not([aria-hidden="true"])';
10
10
  var notDisabled = ":not([disabled])".concat(not);
11
- var focusable = ["a[href]".concat(not), "area[href]".concat(not), "input".concat(notDisabled, ":not([type=\"hidden\"])"), "select".concat(notDisabled), "textarea".concat(notDisabled), "button".concat(notDisabled), "iframe".concat(not), "audio[controls]".concat(not), "video[controls]".concat(not), "[contenteditable]:not([contenteditable=\"false\"])".concat(not), "[tabindex]".concat(notDisabled).concat(not)];
11
+ var focusable = ["a[href]".concat(not), "area[href]".concat(not), "input".concat(notDisabled, ":not([type=\"hidden\"])"), "select".concat(notDisabled), "textarea".concat(notDisabled), "button".concat(notDisabled), "iframe".concat(not), "audio[controls]".concat(not), "video[controls]".concat(not), "[contenteditable]:not([contenteditable=\"false\"])".concat(not), "[tabindex]".concat(notDisabled)];
12
12
  return {
13
13
  focusable: focusable.join(','),
14
14
  // Build the `:focus` selector
@@ -4,7 +4,6 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.getAriaForTrigger = getAriaForTrigger;
7
- var _roleToAriaHasPopup = require("./role-to-aria-has-popup");
8
7
  /**
9
8
  * Roles accepted by `getAriaForTrigger`.
10
9
  *
@@ -14,12 +13,34 @@ var _roleToAriaHasPopup = require("./role-to-aria-has-popup");
14
13
  * manually for tooltip triggers.
15
14
  */
16
15
 
16
+ /**
17
+ * Valid values for the `aria-haspopup` attribute.
18
+ *
19
+ * Derived from the HTML spec - maps popover roles to what the trigger
20
+ * announces. `undefined` means the attribute is omitted entirely (used for
21
+ * non-popup roles like `tooltip`/`status`/`alert`/`note`/`log` where
22
+ * `aria-haspopup` would be misleading).
23
+ *
24
+ * `true` is intentionally NOT in the union - the runtime only ever produces
25
+ * the explicit string forms, so widening the type would invite consumers to
26
+ * pass `true` and get an `aria-haspopup="true"` serialisation that the
27
+ * runtime no longer emits.
28
+ */
29
+
17
30
  /**
18
31
  * Non-undefined subset of `TAriaHasPopupValue`. Every role accepted by
19
32
  * `getAriaForTrigger` maps to a concrete `aria-haspopup` string, so the
20
33
  * trigger always receives a defined attribute value.
21
34
  */
22
35
 
36
+ var roleToAriaHasPopup = {
37
+ dialog: 'dialog',
38
+ alertdialog: 'dialog',
39
+ menu: 'menu',
40
+ listbox: 'listbox',
41
+ tree: 'tree',
42
+ grid: 'grid'
43
+ };
23
44
  /**
24
45
  * Returns the ARIA attributes to spread onto a click-activated popover trigger.
25
46
  *
@@ -71,13 +92,6 @@ function getAriaForTrigger(_ref) {
71
92
  var role = _ref.role,
72
93
  isOpen = _ref.isOpen,
73
94
  popoverId = _ref.popoverId;
74
- // `roleToAriaHasPopup` accepts a wider role union (including tooltip-family
75
- // roles that map to `undefined`). `TAriaForTriggerRole` excludes those, so
76
- // the returned value is guaranteed to be a defined string. The cast narrows
77
- // the wider helper signature to match.
78
- var ariaHasPopup = (0, _roleToAriaHasPopup.roleToAriaHasPopup)({
79
- role: role
80
- });
81
95
  // `aria-controls` is set to `undefined` while closed. The `Popover` /
82
96
  // `Dialog` primitives unmount their host element after the exit
83
97
  // animation finishes, so a closed-state `aria-controls` would point
@@ -86,7 +100,7 @@ function getAriaForTrigger(_ref) {
86
100
  // until the target exists, avoiding the dangling reference while
87
101
  // keeping the relationship live whenever it is meaningful.
88
102
  return {
89
- 'aria-haspopup': ariaHasPopup,
103
+ 'aria-haspopup': roleToAriaHasPopup[role],
90
104
  'aria-expanded': isOpen,
91
105
  'aria-controls': isOpen ? popoverId : undefined
92
106
  };
@@ -1,64 +1 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- exports.shouldFocusIntoPopover = shouldFocusIntoPopover;
7
- /**
8
- * Valid values for the `aria-haspopup` attribute.
9
- *
10
- * Derived from the HTML spec - maps popover roles to what the trigger
11
- * announces. `undefined` means the attribute is omitted entirely (used for
12
- * non-popup roles like `tooltip`/`status`/`alert`/`note`/`log` where
13
- * `aria-haspopup` would be misleading).
14
- *
15
- * `true` is intentionally NOT in the union - the runtime only ever produces
16
- * the explicit string forms, so widening the type would invite consumers to
17
- * pass `true` and get an `aria-haspopup="true"` serialisation that the
18
- * runtime no longer emits.
19
- */
20
-
21
- /**
22
- * Roles that require an accessible name (`label` or `labelledBy`) per WCAG 4.1.2.
23
- * Without a name, assistive technology cannot identify these landmarks.
24
- */
25
-
26
- /**
27
- * Roles where the accessible name is derived from content or provided externally
28
- * (e.g. a combobox labels its listbox, tooltip content IS the accessible name).
29
- */
30
-
31
- // Shared ARIA role discrimination types
32
- //
33
- // These encode the WCAG 4.1.2 constraint: roles like `dialog`, `alertdialog`,
34
- // and `menu` MUST have an accessible name (either `label` or `labelledBy`).
35
- // TypeScript enforces this at compile time via discriminated unions.
36
-
37
- /**
38
- * ARIA role props where a role is always required.
39
- *
40
- * Used by `TPopupContentProps`. Popup content must always declare
41
- * its role so assistive technology can identify the layer.
42
- */
43
-
44
- /**
45
- * ARIA role props where a role is optional.
46
- *
47
- * Used by `TPopoverProps`. The low-level primitive allows omitting
48
- * the role for cases where it is set externally or not needed.
49
- */
50
-
51
- var rolesThatMoveFocus = new Set(['dialog', 'alertdialog', 'menu', 'listbox', 'tree', 'grid']);
52
-
53
- /**
54
- * Returns `true` when the given popover role moves focus into the popover
55
- * on open, and `false` otherwise (including when `role` is `undefined`).
56
- */
57
- // eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
58
- function shouldFocusIntoPopover(_ref) {
59
- var role = _ref.role;
60
- if (!role) {
61
- return false;
62
- }
63
- return rolesThatMoveFocus.has(role);
64
- }
1
+ "use strict";
@@ -324,11 +324,10 @@ function useAnchorPosition(_ref3) {
324
324
  * See `notes/decisions/anchor-name-lifetime.md`
325
325
  */
326
326
  trigger.style.setProperty('anchor-name', anchorName);
327
- var _undoPositioning = (0, _combine.combine)((0, _setStyle.setStyle)({
327
+ return (0, _setStyle.setStyle)({
328
328
  element: popover,
329
329
  styles: popoverStyles
330
- }));
331
- return _undoPositioning;
330
+ });
332
331
  }
333
332
 
334
333
  // JS fallback. The popover is already in the top layer via
@@ -117,7 +117,7 @@ function runOnTransitionEndOrTimeout(_ref) {
117
117
  * `entering`, `open`, `exiting`). The host element is rendered while
118
118
  * `phase !== 'closed'`, so the `exiting` phase keeps the host mounted
119
119
  * while the CSS exit transition plays (or, with animation disabled,
120
- * while we wait for the browser `toggle` / `close` event to fire on the
120
+ * while we wait for the browser's closing `toggle` event to fire on the
121
121
  * still-attached element):
122
122
  *
123
123
  * ```
@@ -136,6 +136,7 @@ function useAnimatedVisibility(_ref2) {
136
136
  onEnterFinish = _ref2.onEnterFinish,
137
137
  onExitFinish = _ref2.onExitFinish;
138
138
  var willAnimate = shouldAnimate && !(0, _reducedMotion.prefersReducedMotion)();
139
+ var isNativeClosedRef = (0, _react.useRef)(false);
139
140
 
140
141
  // Promote `closed → entering`/`closed → open` synchronously during
141
142
  // render so the host element mounts in the same commit as the open
@@ -149,16 +150,23 @@ function useAnimatedVisibility(_ref2) {
149
150
  _useState2 = (0, _slicedToArray2.default)(_useState, 2),
150
151
  phase = _useState2[0],
151
152
  setPhase = _useState2[1];
152
- if (isOpen && phase === 'closed') {
153
+
154
+ // Rearm only after controlled state and the native lifecycle have both
155
+ // reached closed. Resetting as soon as `isOpen` becomes false can erase a
156
+ // closing toggle that was handled in the same React batch.
157
+ if (!isOpen && phase === 'closed') {
158
+ isNativeClosedRef.current = false;
159
+ }
160
+ if (isOpen && phase === 'closed' && !isNativeClosedRef.current) {
153
161
  setPhase(willAnimate ? 'entering' : 'open');
154
162
  }
155
163
  if (!isOpen && (phase === 'open' || phase === 'entering')) {
156
164
  // Close intent. Move to `exiting` to gate the unmount on either
157
- // the exit transition (animated) or the toggle/close event
165
+ // the exit transition (animated) or the closing toggle event
158
166
  // (non-animated). Includes interrupted entry (`entering → exiting`).
159
167
  setPhase('exiting');
160
168
  }
161
- if (isOpen && phase === 'exiting') {
169
+ if (isOpen && phase === 'exiting' && !isNativeClosedRef.current) {
162
170
  // Reopen mid-exit. With animation, jump back to `entering` and let
163
171
  // the entry transition restart from current visual state. Without
164
172
  // animation, jump straight to `open` (the handshake we were waiting
@@ -247,49 +255,37 @@ function useAnimatedVisibility(_ref2) {
247
255
  });
248
256
  }, [phase, willAnimate, animationKind, elementRef]);
249
257
 
250
- // Non-animated exit: wait for the browser's `toggle` (closed) or
251
- // `close` event before unmounting, so the consumer's listener (close
252
- // reason, nested focus restoration, `onClose`) and the browser's
253
- // native focus restoration both run against the still-attached
254
- // element. A naive `setTimeout(0)` occasionally beats the `toggle`
255
- // task in Chromium and drops focus restoration.
256
- //
257
- // `useLayoutEffect` so the listener binds in the same commit as the
258
- // show/hide layout effect that calls `hidePopover()` / `dialog.close()`.
259
- // The toggle/close event is queued as a task and cannot run until the
260
- // frame completes. SSR-safe: `phase` starts at `'closed'`.
261
- //
262
- // `onExitFinish` is fired separately (see effect below) so consumers
263
- // observe it the moment `isOpen` flips to `false`.
258
+ // Native dismissal is authoritative. Its toggle can arrive while controlled
259
+ // state still says open, so remember that native state and do not
260
+ // reopen until `isOpen` has first moved to false.
264
261
  (0, _react.useLayoutEffect)(function () {
265
- if (phase !== 'exiting' || willAnimate) {
266
- return;
262
+ if (phase === 'exiting' && !willAnimate && isNativeClosedRef.current) {
263
+ setPhase('closed');
267
264
  }
268
- var element = elementRef.current;
269
- if (!element) {
265
+ }, [phase, willAnimate]);
266
+ var onToggle = (0, _react.useCallback)(function (event) {
267
+ if (event.newState === 'open') {
268
+ isNativeClosedRef.current = false;
270
269
  return;
271
270
  }
272
- function unmount() {
273
- setPhase(function (current) {
274
- return current === 'exiting' ? 'closed' : current;
275
- });
271
+ if (event.newState !== 'closed') {
272
+ return;
276
273
  }
277
- return (0, _bindEventListener.bindAll)(element, [{
278
- type: 'toggle',
279
- listener: function listener(event) {
280
- if (event.newState === 'closed') {
281
- unmount();
282
- }
274
+ isNativeClosedRef.current = true;
275
+ setPhase(function (current) {
276
+ if (current === 'open' || current === 'entering') {
277
+ return 'exiting';
283
278
  }
284
- }, {
285
- type: 'close',
286
- listener: unmount
287
- }]);
288
- }, [phase, willAnimate, elementRef]);
279
+ if (current === 'exiting' && !willAnimate) {
280
+ return 'closed';
281
+ }
282
+ return current;
283
+ });
284
+ }, [willAnimate]);
289
285
 
290
286
  // Non-animated exit callback. No `transitionend` to wait for, so fire
291
287
  // `onExitFinish` on the transition into `exiting` (the unmount itself
292
- // is gated by the toggle/close handshake above).
288
+ // is gated by the closing toggle handshake above).
293
289
  var prevPhaseForExitRef = (0, _react.useRef)('closed');
294
290
  (0, _react.useEffect)(function () {
295
291
  var prevPhase = prevPhaseForExitRef.current;
@@ -304,6 +300,7 @@ function useAnimatedVisibility(_ref2) {
304
300
  }
305
301
  }, [phase, willAnimate]);
306
302
  return {
307
- phase: phase
303
+ phase: phase,
304
+ onToggle: onToggle
308
305
  };
309
306
  }
@@ -16,7 +16,7 @@ var _mergeRefs = _interopRequireDefault(require("@atlaskit/ds-lib/merge-refs"));
16
16
  var _once = _interopRequireDefault(require("@atlaskit/ds-lib/once"));
17
17
  var _useNotifyOpenLayerObserver = require("@atlaskit/layering/use-notify-open-layer-observer");
18
18
  var _resolvePlacement2 = require("../internal/resolve-placement");
19
- var _roleTypes = require("../internal/role-types");
19
+ var _shouldFocusIntoPopover = require("../internal/should-focus-into-popover");
20
20
  var _useAnimatedVisibility = require("../internal/use-animated-visibility");
21
21
  var _useFocusWrap = require("../internal/use-focus-wrap");
22
22
  var _useInitialFocus = require("../internal/use-initial-focus");
@@ -97,11 +97,7 @@ var POPUP_ROLES = new Set(['menu', 'listbox', 'dialog', 'alertdialog', 'tree', '
97
97
  * ### 📜 Browser dismiss is non-cancellable
98
98
  *
99
99
  * Per the popover spec, `beforetoggle` is cancellable on open transitions
100
- * only, not on close. Consumers that need to ignore a specific dismiss
101
- * (e.g. a click on a logically-associated external trigger) must reconcile
102
- * inside their own `onClose` handler by calling `showPopover()` again.
103
- * `Popover` does not self-heal so consumers do not pay a reconcile cost
104
- * by default.
100
+ * only, not on close.
105
101
  *
106
102
  * - Spec: <https://html.spec.whatwg.org/multipage/popover.html#the-popover-attribute>
107
103
  * - <https://github.com/whatwg/html/issues/8973>
@@ -148,7 +144,8 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
148
144
  onEnterFinish: onEnterFinish,
149
145
  onExitFinish: onExitFinish
150
146
  }),
151
- phase = _useAnimatedVisibilit.phase;
147
+ phase = _useAnimatedVisibilit.phase,
148
+ onToggle = _useAnimatedVisibilit.onToggle;
152
149
 
153
150
  // True while the host element is mounted (any phase except `closed`).
154
151
  // Used as a dep so listener-rebind effects re-attach after a remount.
@@ -221,6 +218,7 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
221
218
  var handleToggle = (0, _react.useCallback)(function (event) {
222
219
  var element = ownRef.current;
223
220
  if (event.newState === 'open') {
221
+ onToggle(event);
224
222
  return;
225
223
  }
226
224
  if (event.newState === 'closed') {
@@ -232,11 +230,11 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
232
230
  // click target keeps focus per HTML spec
233
231
  // (`focusPreviousElement=false`).
234
232
  var previouslyFocused = previouslyFocusedElementRef.current;
235
- var _reason = closeReasonRef.current;
233
+ var reason = closeReasonRef.current;
236
234
  var isProgrammatic = programmaticCloseRef.current;
237
- var shouldRestore = previouslyFocused !== null && previouslyFocused.isConnected && (0, _roleTypes.shouldFocusIntoPopover)({
235
+ var shouldRestore = previouslyFocused !== null && previouslyFocused.isConnected && (0, _shouldFocusIntoPopover.shouldFocusIntoPopover)({
238
236
  role: role
239
- }) && (_reason === 'escape' || isProgrammatic);
237
+ }) && (reason === 'escape' || isProgrammatic);
240
238
  if (shouldRestore && previouslyFocused) {
241
239
  previouslyFocused.focus({
242
240
  preventScroll: true
@@ -250,18 +248,18 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
250
248
  // Reset reason so a stale 'escape' from a race with Escape keydown
251
249
  // does not corrupt the next browser-dismiss cycle.
252
250
  closeReasonRef.current = 'light-dismiss';
253
- return;
251
+ } else {
252
+ // Browser dismiss (Escape/click-outside).
253
+ var _reason = closeReasonRef.current;
254
+ closeReasonRef.current = 'light-dismiss';
255
+ // Optional: `manual` mode has no `onClose` in the forwarded type.
256
+ onClose === null || onClose === void 0 || onClose({
257
+ reason: _reason
258
+ });
254
259
  }
255
-
256
- // Browser dismiss (Escape/click-outside).
257
- var reason = closeReasonRef.current;
258
- closeReasonRef.current = 'light-dismiss';
259
- // Optional: `manual` mode has no `onClose` in the forwarded type.
260
- onClose === null || onClose === void 0 || onClose({
261
- reason: reason
262
- });
263
260
  }
264
- }, [onClose, role]);
261
+ onToggle(event);
262
+ }, [onClose, onToggle, role]);
265
263
 
266
264
  // Bind via `useLayoutEffect`, and BEFORE the show/hide layout effect
267
265
  // below, so listeners are attached when `showPopover()` synchronously