@atlaskit/top-layer 4.0.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
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
+
3
12
  ## 4.0.0
4
13
 
5
14
  ### Major Changes
@@ -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
  }
@@ -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
  }
@@ -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
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
@@ -1,9 +1,9 @@
1
1
  /* dialog-content.tsx generated by @compiled/babel-plugin v2.0.0 */
2
2
  import "./dialog-content.compiled.css";
3
3
  import { ax, ix } from "@compiled/react/runtime";
4
- import React, { forwardRef, useCallback, useEffect, useId, useLayoutEffect, useRef } from 'react';
4
+ import React, { forwardRef, useCallback, useId, useLayoutEffect, useRef } from 'react';
5
5
  import { cx } from '@compiled/react';
6
- import { bind } from 'bind-event-listener';
6
+ import { bindAll } from 'bind-event-listener';
7
7
  import mergeRefs from '@atlaskit/ds-lib/merge-refs';
8
8
  import noop from '@atlaskit/ds-lib/noop';
9
9
  import { useNotifyOpenLayerObserver } from '@atlaskit/layering/use-notify-open-layer-observer';
@@ -45,8 +45,8 @@ const backdropStyles = {
45
45
  *
46
46
  * Handles native `cancel` event (Escape) and backdrop click detection.
47
47
  *
48
- * Close flow: we never call `dialog.close()` from event handlers. We always call
49
- * `onClose`; the consumer decides whether to set `isOpen={false}`.
48
+ * Close flow: allowed user dismissal closes the native dialog before `onClose`
49
+ * notifies the consumer. The consumer must synchronize `isOpen` in response.
50
50
  *
51
51
  * Accessibility: render at least one focusable element (typically a close
52
52
  * button) when `isOpen` becomes `true`. Tab is always trapped inside the
@@ -56,6 +56,7 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
56
56
  children,
57
57
  isOpen,
58
58
  onClose,
59
+ dismissedBy = 'escape-and-outside-click',
59
60
  onEnterFinish,
60
61
  onExitFinish,
61
62
  shouldAnimate = false,
@@ -73,8 +74,10 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
73
74
  const dialogId = providedId !== null && providedId !== void 0 ? providedId : generatedId;
74
75
  const ownRef = useRef(null);
75
76
  const combinedRef = mergeRefs([ownRef, ref]);
77
+ const closeReasonRef = useRef(null);
76
78
  const {
77
- phase
79
+ phase,
80
+ onToggle
78
81
  } = useAnimatedVisibility({
79
82
  isOpen,
80
83
  animationKind: 'dialog',
@@ -113,6 +116,7 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
113
116
  return;
114
117
  }
115
118
  if (isOpen) {
119
+ closeReasonRef.current = null;
116
120
  if (!dialog.open) {
117
121
  dialog.showModal();
118
122
  }
@@ -146,45 +150,67 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
146
150
  if (event.target !== event.currentTarget) {
147
151
  return;
148
152
  }
149
- event.preventDefault();
153
+
150
154
  // Spurious Safari `cancel`: the keydown snapshot saw an open child
151
155
  // popover, so this Escape belongs to that popover (light-dismissed
152
156
  // natively). Keep the dialog open instead of forwarding to `onClose`.
153
157
  if (shouldIgnoreEscape()) {
158
+ event.preventDefault();
159
+ return;
160
+ }
161
+
162
+ // Without native `closedby`, cancel Escape only when dismissal is disabled.
163
+ // Allowed dismissal follows Popover behavior: the browser closes the element
164
+ // and `onClose` notifies the controlled owner of the reason.
165
+ if (dismissedBy === 'none') {
166
+ event.preventDefault();
154
167
  return;
155
168
  }
156
- onClose({
157
- reason: 'escape'
158
- });
159
- }, [onClose, shouldIgnoreEscape]);
169
+ closeReasonRef.current = 'escape';
170
+ }, [dismissedBy, shouldIgnoreEscape]);
160
171
 
161
- // Handle backdrop click
172
+ // Handle backdrop interaction
162
173
  // Attached via bind-event-listener rather than a React prop so we avoid
163
174
  // a11y lint suppressions on the <dialog> element.
164
- // Keyboard dismiss is already handled natively (Escape → onCancel above).
175
+ // The click listener provides light dismiss consistently across browsers.
165
176
  // `isVisible` is in deps so the listener re-binds to the new <dialog>
166
177
  // element after a host unmount / remount cycle.
167
- useEffect(() => {
178
+ useLayoutEffect(() => {
168
179
  const dialog = ownRef.current;
169
180
  if (!dialog) {
170
181
  return;
171
182
  }
172
- return bind(dialog, {
183
+ return bindAll(dialog, [{
173
184
  type: 'click',
174
185
  listener(event) {
175
- if (event.target === event.currentTarget) {
176
- onClose({
177
- reason: 'overlay-click'
178
- });
186
+ if (event.target === event.currentTarget && dismissedBy === 'escape-and-outside-click') {
187
+ closeReasonRef.current = 'overlay-click';
188
+ // Calls native close because this is emulating the native light-dismiss behavior
189
+ dialog.close();
179
190
  }
180
191
  }
181
- });
182
- }, [onClose, isVisible]);
192
+ }, {
193
+ type: 'toggle',
194
+ listener(event) {
195
+ if (event.newState === 'closed') {
196
+ const reason = closeReasonRef.current;
197
+ closeReasonRef.current = null;
198
+ if (reason) {
199
+ onClose({
200
+ reason
201
+ });
202
+ }
203
+ }
204
+
205
+ // Visibility cleanup runs after close-reason handling.
206
+ onToggle(event);
207
+ }
208
+ }]);
209
+ }, [dismissedBy, onClose, onToggle, isVisible]);
183
210
 
184
211
  // Unmount the `<dialog>` once exit completes so it does not leave an
185
212
  // empty `role="dialog"` element in the accessibility tree. On the next
186
- // open the element remounts and the `[isOpen]` and `[onClose, isVisible]`
187
- // effects re-run against the fresh element.
213
+ // open the element remounts and its effects run against the fresh element.
188
214
  if (!isVisible) {
189
215
  return null;
190
216
  }
@@ -1,5 +1,5 @@
1
- import { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
- import { bind, bindAll } from 'bind-event-listener';
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { bind } from 'bind-event-listener';
3
3
  import { prefersReducedMotion } from './reduced-motion';
4
4
  /**
5
5
  * Using the recommended motion timings for safety net.
@@ -110,7 +110,7 @@ function runOnTransitionEndOrTimeout({
110
110
  * `entering`, `open`, `exiting`). The host element is rendered while
111
111
  * `phase !== 'closed'`, so the `exiting` phase keeps the host mounted
112
112
  * while the CSS exit transition plays (or, with animation disabled,
113
- * while we wait for the browser `toggle` / `close` event to fire on the
113
+ * while we wait for the browser's closing `toggle` event to fire on the
114
114
  * still-attached element):
115
115
  *
116
116
  * ```
@@ -130,6 +130,7 @@ export function useAnimatedVisibility({
130
130
  onExitFinish
131
131
  }) {
132
132
  const willAnimate = shouldAnimate && !prefersReducedMotion();
133
+ const isNativeClosedRef = useRef(false);
133
134
 
134
135
  // Promote `closed → entering`/`closed → open` synchronously during
135
136
  // render so the host element mounts in the same commit as the open
@@ -140,16 +141,23 @@ export function useAnimatedVisibility({
140
141
  }
141
142
  return willAnimate ? 'entering' : 'open';
142
143
  });
143
- if (isOpen && phase === 'closed') {
144
+
145
+ // Rearm only after controlled state and the native lifecycle have both
146
+ // reached closed. Resetting as soon as `isOpen` becomes false can erase a
147
+ // closing toggle that was handled in the same React batch.
148
+ if (!isOpen && phase === 'closed') {
149
+ isNativeClosedRef.current = false;
150
+ }
151
+ if (isOpen && phase === 'closed' && !isNativeClosedRef.current) {
144
152
  setPhase(willAnimate ? 'entering' : 'open');
145
153
  }
146
154
  if (!isOpen && (phase === 'open' || phase === 'entering')) {
147
155
  // Close intent. Move to `exiting` to gate the unmount on either
148
- // the exit transition (animated) or the toggle/close event
156
+ // the exit transition (animated) or the closing toggle event
149
157
  // (non-animated). Includes interrupted entry (`entering → exiting`).
150
158
  setPhase('exiting');
151
159
  }
152
- if (isOpen && phase === 'exiting') {
160
+ if (isOpen && phase === 'exiting' && !isNativeClosedRef.current) {
153
161
  // Reopen mid-exit. With animation, jump back to `entering` and let
154
162
  // the entry transition restart from current visual state. Without
155
163
  // animation, jump straight to `open` (the handshake we were waiting
@@ -234,47 +242,37 @@ export function useAnimatedVisibility({
234
242
  });
235
243
  }, [phase, willAnimate, animationKind, elementRef]);
236
244
 
237
- // Non-animated exit: wait for the browser's `toggle` (closed) or
238
- // `close` event before unmounting, so the consumer's listener (close
239
- // reason, nested focus restoration, `onClose`) and the browser's
240
- // native focus restoration both run against the still-attached
241
- // element. A naive `setTimeout(0)` occasionally beats the `toggle`
242
- // task in Chromium and drops focus restoration.
243
- //
244
- // `useLayoutEffect` so the listener binds in the same commit as the
245
- // show/hide layout effect that calls `hidePopover()` / `dialog.close()`.
246
- // The toggle/close event is queued as a task and cannot run until the
247
- // frame completes. SSR-safe: `phase` starts at `'closed'`.
248
- //
249
- // `onExitFinish` is fired separately (see effect below) so consumers
250
- // observe it the moment `isOpen` flips to `false`.
245
+ // Native dismissal is authoritative. Its toggle can arrive while controlled
246
+ // state still says open, so remember that native state and do not
247
+ // reopen until `isOpen` has first moved to false.
251
248
  useLayoutEffect(() => {
252
- if (phase !== 'exiting' || willAnimate) {
253
- return;
249
+ if (phase === 'exiting' && !willAnimate && isNativeClosedRef.current) {
250
+ setPhase('closed');
254
251
  }
255
- const element = elementRef.current;
256
- if (!element) {
252
+ }, [phase, willAnimate]);
253
+ const onToggle = useCallback(event => {
254
+ if (event.newState === 'open') {
255
+ isNativeClosedRef.current = false;
257
256
  return;
258
257
  }
259
- function unmount() {
260
- setPhase(current => current === 'exiting' ? 'closed' : current);
258
+ if (event.newState !== 'closed') {
259
+ return;
261
260
  }
262
- return bindAll(element, [{
263
- type: 'toggle',
264
- listener: event => {
265
- if (event.newState === 'closed') {
266
- unmount();
267
- }
261
+ isNativeClosedRef.current = true;
262
+ setPhase(current => {
263
+ if (current === 'open' || current === 'entering') {
264
+ return 'exiting';
268
265
  }
269
- }, {
270
- type: 'close',
271
- listener: unmount
272
- }]);
273
- }, [phase, willAnimate, elementRef]);
266
+ if (current === 'exiting' && !willAnimate) {
267
+ return 'closed';
268
+ }
269
+ return current;
270
+ });
271
+ }, [willAnimate]);
274
272
 
275
273
  // Non-animated exit callback. No `transitionend` to wait for, so fire
276
274
  // `onExitFinish` on the transition into `exiting` (the unmount itself
277
- // is gated by the toggle/close handshake above).
275
+ // is gated by the closing toggle handshake above).
278
276
  const prevPhaseForExitRef = useRef('closed');
279
277
  useEffect(() => {
280
278
  const prevPhase = prevPhaseForExitRef.current;
@@ -289,6 +287,7 @@ export function useAnimatedVisibility({
289
287
  }
290
288
  }, [phase, willAnimate]);
291
289
  return {
292
- phase
290
+ phase,
291
+ onToggle
293
292
  };
294
293
  }
@@ -88,11 +88,7 @@ const POPUP_ROLES = new Set(['menu', 'listbox', 'dialog', 'alertdialog', 'tree',
88
88
  * ### 📜 Browser dismiss is non-cancellable
89
89
  *
90
90
  * Per the popover spec, `beforetoggle` is cancellable on open transitions
91
- * only, not on close. Consumers that need to ignore a specific dismiss
92
- * (e.g. a click on a logically-associated external trigger) must reconcile
93
- * inside their own `onClose` handler by calling `showPopover()` again.
94
- * `Popover` does not self-heal so consumers do not pay a reconcile cost
95
- * by default.
91
+ * only, not on close.
96
92
  *
97
93
  * - Spec: <https://html.spec.whatwg.org/multipage/popover.html#the-popover-attribute>
98
94
  * - <https://github.com/whatwg/html/issues/8973>
@@ -132,7 +128,8 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
132
128
  // `useId()` colons are invalid in CSS selectors and popover target attributes.
133
129
  const popoverId = idProp !== null && idProp !== void 0 ? idProp : `popover-${autoId.replace(/:/g, '')}`;
134
130
  const {
135
- phase
131
+ phase,
132
+ onToggle
136
133
  } = useAnimatedVisibility({
137
134
  isOpen,
138
135
  animationKind: 'popover',
@@ -214,6 +211,7 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
214
211
  const handleToggle = useCallback(event => {
215
212
  const element = ownRef.current;
216
213
  if (event.newState === 'open') {
214
+ onToggle(event);
217
215
  return;
218
216
  }
219
217
  if (event.newState === 'closed') {
@@ -243,18 +241,18 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
243
241
  // Reset reason so a stale 'escape' from a race with Escape keydown
244
242
  // does not corrupt the next browser-dismiss cycle.
245
243
  closeReasonRef.current = 'light-dismiss';
246
- return;
244
+ } else {
245
+ // Browser dismiss (Escape/click-outside).
246
+ const reason = closeReasonRef.current;
247
+ closeReasonRef.current = 'light-dismiss';
248
+ // Optional: `manual` mode has no `onClose` in the forwarded type.
249
+ onClose === null || onClose === void 0 ? void 0 : onClose({
250
+ reason
251
+ });
247
252
  }
248
-
249
- // Browser dismiss (Escape/click-outside).
250
- const reason = closeReasonRef.current;
251
- closeReasonRef.current = 'light-dismiss';
252
- // Optional: `manual` mode has no `onClose` in the forwarded type.
253
- onClose === null || onClose === void 0 ? void 0 : onClose({
254
- reason
255
- });
256
253
  }
257
- }, [onClose, role]);
254
+ onToggle(event);
255
+ }, [onClose, onToggle, role]);
258
256
 
259
257
  // Bind via `useLayoutEffect`, and BEFORE the show/hide layout effect
260
258
  // below, so listeners are attached when `showPopover()` synchronously
@@ -1,9 +1,9 @@
1
1
  /* dialog-content.tsx generated by @compiled/babel-plugin v2.0.0 */
2
2
  import "./dialog-content.compiled.css";
3
3
  import { ax, ix } from "@compiled/react/runtime";
4
- import React, { forwardRef, useCallback, useEffect, useId, useLayoutEffect, useRef } from 'react';
4
+ import React, { forwardRef, useCallback, useId, useLayoutEffect, useRef } from 'react';
5
5
  import { cx } from '@compiled/react';
6
- import { bind } from 'bind-event-listener';
6
+ import { bindAll } from 'bind-event-listener';
7
7
  import mergeRefs from '@atlaskit/ds-lib/merge-refs';
8
8
  import noop from '@atlaskit/ds-lib/noop';
9
9
  import { useNotifyOpenLayerObserver } from '@atlaskit/layering/use-notify-open-layer-observer';
@@ -45,8 +45,8 @@ var backdropStyles = {
45
45
  *
46
46
  * Handles native `cancel` event (Escape) and backdrop click detection.
47
47
  *
48
- * Close flow: we never call `dialog.close()` from event handlers. We always call
49
- * `onClose`; the consumer decides whether to set `isOpen={false}`.
48
+ * Close flow: allowed user dismissal closes the native dialog before `onClose`
49
+ * notifies the consumer. The consumer must synchronize `isOpen` in response.
50
50
  *
51
51
  * Accessibility: render at least one focusable element (typically a close
52
52
  * button) when `isOpen` becomes `true`. Tab is always trapped inside the
@@ -56,6 +56,8 @@ export var Dialog = /*#__PURE__*/forwardRef(function Dialog(_ref, ref) {
56
56
  var children = _ref.children,
57
57
  isOpen = _ref.isOpen,
58
58
  onClose = _ref.onClose,
59
+ _ref$dismissedBy = _ref.dismissedBy,
60
+ dismissedBy = _ref$dismissedBy === void 0 ? 'escape-and-outside-click' : _ref$dismissedBy,
59
61
  onEnterFinish = _ref.onEnterFinish,
60
62
  onExitFinish = _ref.onExitFinish,
61
63
  _ref$shouldAnimate = _ref.shouldAnimate,
@@ -73,6 +75,7 @@ export var Dialog = /*#__PURE__*/forwardRef(function Dialog(_ref, ref) {
73
75
  var dialogId = providedId !== null && providedId !== void 0 ? providedId : generatedId;
74
76
  var ownRef = useRef(null);
75
77
  var combinedRef = mergeRefs([ownRef, ref]);
78
+ var closeReasonRef = useRef(null);
76
79
  var _useAnimatedVisibilit = useAnimatedVisibility({
77
80
  isOpen: isOpen,
78
81
  animationKind: 'dialog',
@@ -81,7 +84,8 @@ export var Dialog = /*#__PURE__*/forwardRef(function Dialog(_ref, ref) {
81
84
  onEnterFinish: onEnterFinish,
82
85
  onExitFinish: onExitFinish
83
86
  }),
84
- phase = _useAnimatedVisibilit.phase;
87
+ phase = _useAnimatedVisibilit.phase,
88
+ onToggle = _useAnimatedVisibilit.onToggle;
85
89
 
86
90
  // True while the host element is mounted (any phase except `closed`).
87
91
  // Used as a dep so listener-rebind effects re-attach after a remount.
@@ -112,6 +116,7 @@ export var Dialog = /*#__PURE__*/forwardRef(function Dialog(_ref, ref) {
112
116
  return;
113
117
  }
114
118
  if (isOpen) {
119
+ closeReasonRef.current = null;
115
120
  if (!dialog.open) {
116
121
  dialog.showModal();
117
122
  }
@@ -144,45 +149,67 @@ export var Dialog = /*#__PURE__*/forwardRef(function Dialog(_ref, ref) {
144
149
  if (event.target !== event.currentTarget) {
145
150
  return;
146
151
  }
147
- event.preventDefault();
152
+
148
153
  // Spurious Safari `cancel`: the keydown snapshot saw an open child
149
154
  // popover, so this Escape belongs to that popover (light-dismissed
150
155
  // natively). Keep the dialog open instead of forwarding to `onClose`.
151
156
  if (shouldIgnoreEscape()) {
157
+ event.preventDefault();
152
158
  return;
153
159
  }
154
- onClose({
155
- reason: 'escape'
156
- });
157
- }, [onClose, shouldIgnoreEscape]);
158
160
 
159
- // Handle backdrop click
161
+ // Without native `closedby`, cancel Escape only when dismissal is disabled.
162
+ // Allowed dismissal follows Popover behavior: the browser closes the element
163
+ // and `onClose` notifies the controlled owner of the reason.
164
+ if (dismissedBy === 'none') {
165
+ event.preventDefault();
166
+ return;
167
+ }
168
+ closeReasonRef.current = 'escape';
169
+ }, [dismissedBy, shouldIgnoreEscape]);
170
+
171
+ // Handle backdrop interaction
160
172
  // Attached via bind-event-listener rather than a React prop so we avoid
161
173
  // a11y lint suppressions on the <dialog> element.
162
- // Keyboard dismiss is already handled natively (Escape → onCancel above).
174
+ // The click listener provides light dismiss consistently across browsers.
163
175
  // `isVisible` is in deps so the listener re-binds to the new <dialog>
164
176
  // element after a host unmount / remount cycle.
165
- useEffect(function () {
177
+ useLayoutEffect(function () {
166
178
  var dialog = ownRef.current;
167
179
  if (!dialog) {
168
180
  return;
169
181
  }
170
- return bind(dialog, {
182
+ return bindAll(dialog, [{
171
183
  type: 'click',
172
184
  listener: function listener(event) {
173
- if (event.target === event.currentTarget) {
174
- onClose({
175
- reason: 'overlay-click'
176
- });
185
+ if (event.target === event.currentTarget && dismissedBy === 'escape-and-outside-click') {
186
+ closeReasonRef.current = 'overlay-click';
187
+ // Calls native close because this is emulating the native light-dismiss behavior
188
+ dialog.close();
177
189
  }
178
190
  }
179
- });
180
- }, [onClose, isVisible]);
191
+ }, {
192
+ type: 'toggle',
193
+ listener: function listener(event) {
194
+ if (event.newState === 'closed') {
195
+ var reason = closeReasonRef.current;
196
+ closeReasonRef.current = null;
197
+ if (reason) {
198
+ onClose({
199
+ reason: reason
200
+ });
201
+ }
202
+ }
203
+
204
+ // Visibility cleanup runs after close-reason handling.
205
+ onToggle(event);
206
+ }
207
+ }]);
208
+ }, [dismissedBy, onClose, onToggle, isVisible]);
181
209
 
182
210
  // Unmount the `<dialog>` once exit completes so it does not leave an
183
211
  // empty `role="dialog"` element in the accessibility tree. On the next
184
- // open the element remounts and the `[isOpen]` and `[onClose, isVisible]`
185
- // effects re-run against the fresh element.
212
+ // open the element remounts and its effects run against the fresh element.
186
213
  if (!isVisible) {
187
214
  return null;
188
215
  }
@@ -1,6 +1,6 @@
1
1
  import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
2
- import { useEffect, useLayoutEffect, useRef, useState } from 'react';
3
- import { bind, bindAll } from 'bind-event-listener';
2
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
3
+ import { bind } from 'bind-event-listener';
4
4
  import { prefersReducedMotion } from './reduced-motion';
5
5
  /**
6
6
  * Using the recommended motion timings for safety net.
@@ -110,7 +110,7 @@ function runOnTransitionEndOrTimeout(_ref) {
110
110
  * `entering`, `open`, `exiting`). The host element is rendered while
111
111
  * `phase !== 'closed'`, so the `exiting` phase keeps the host mounted
112
112
  * while the CSS exit transition plays (or, with animation disabled,
113
- * while we wait for the browser `toggle` / `close` event to fire on the
113
+ * while we wait for the browser's closing `toggle` event to fire on the
114
114
  * still-attached element):
115
115
  *
116
116
  * ```
@@ -129,6 +129,7 @@ export function useAnimatedVisibility(_ref2) {
129
129
  onEnterFinish = _ref2.onEnterFinish,
130
130
  onExitFinish = _ref2.onExitFinish;
131
131
  var willAnimate = shouldAnimate && !prefersReducedMotion();
132
+ var isNativeClosedRef = useRef(false);
132
133
 
133
134
  // Promote `closed → entering`/`closed → open` synchronously during
134
135
  // render so the host element mounts in the same commit as the open
@@ -142,16 +143,23 @@ export function useAnimatedVisibility(_ref2) {
142
143
  _useState2 = _slicedToArray(_useState, 2),
143
144
  phase = _useState2[0],
144
145
  setPhase = _useState2[1];
145
- if (isOpen && phase === 'closed') {
146
+
147
+ // Rearm only after controlled state and the native lifecycle have both
148
+ // reached closed. Resetting as soon as `isOpen` becomes false can erase a
149
+ // closing toggle that was handled in the same React batch.
150
+ if (!isOpen && phase === 'closed') {
151
+ isNativeClosedRef.current = false;
152
+ }
153
+ if (isOpen && phase === 'closed' && !isNativeClosedRef.current) {
146
154
  setPhase(willAnimate ? 'entering' : 'open');
147
155
  }
148
156
  if (!isOpen && (phase === 'open' || phase === 'entering')) {
149
157
  // Close intent. Move to `exiting` to gate the unmount on either
150
- // the exit transition (animated) or the toggle/close event
158
+ // the exit transition (animated) or the closing toggle event
151
159
  // (non-animated). Includes interrupted entry (`entering → exiting`).
152
160
  setPhase('exiting');
153
161
  }
154
- if (isOpen && phase === 'exiting') {
162
+ if (isOpen && phase === 'exiting' && !isNativeClosedRef.current) {
155
163
  // Reopen mid-exit. With animation, jump back to `entering` and let
156
164
  // the entry transition restart from current visual state. Without
157
165
  // animation, jump straight to `open` (the handshake we were waiting
@@ -240,49 +248,37 @@ export function useAnimatedVisibility(_ref2) {
240
248
  });
241
249
  }, [phase, willAnimate, animationKind, elementRef]);
242
250
 
243
- // Non-animated exit: wait for the browser's `toggle` (closed) or
244
- // `close` event before unmounting, so the consumer's listener (close
245
- // reason, nested focus restoration, `onClose`) and the browser's
246
- // native focus restoration both run against the still-attached
247
- // element. A naive `setTimeout(0)` occasionally beats the `toggle`
248
- // task in Chromium and drops focus restoration.
249
- //
250
- // `useLayoutEffect` so the listener binds in the same commit as the
251
- // show/hide layout effect that calls `hidePopover()` / `dialog.close()`.
252
- // The toggle/close event is queued as a task and cannot run until the
253
- // frame completes. SSR-safe: `phase` starts at `'closed'`.
254
- //
255
- // `onExitFinish` is fired separately (see effect below) so consumers
256
- // observe it the moment `isOpen` flips to `false`.
251
+ // Native dismissal is authoritative. Its toggle can arrive while controlled
252
+ // state still says open, so remember that native state and do not
253
+ // reopen until `isOpen` has first moved to false.
257
254
  useLayoutEffect(function () {
258
- if (phase !== 'exiting' || willAnimate) {
259
- return;
255
+ if (phase === 'exiting' && !willAnimate && isNativeClosedRef.current) {
256
+ setPhase('closed');
260
257
  }
261
- var element = elementRef.current;
262
- if (!element) {
258
+ }, [phase, willAnimate]);
259
+ var onToggle = useCallback(function (event) {
260
+ if (event.newState === 'open') {
261
+ isNativeClosedRef.current = false;
263
262
  return;
264
263
  }
265
- function unmount() {
266
- setPhase(function (current) {
267
- return current === 'exiting' ? 'closed' : current;
268
- });
264
+ if (event.newState !== 'closed') {
265
+ return;
269
266
  }
270
- return bindAll(element, [{
271
- type: 'toggle',
272
- listener: function listener(event) {
273
- if (event.newState === 'closed') {
274
- unmount();
275
- }
267
+ isNativeClosedRef.current = true;
268
+ setPhase(function (current) {
269
+ if (current === 'open' || current === 'entering') {
270
+ return 'exiting';
276
271
  }
277
- }, {
278
- type: 'close',
279
- listener: unmount
280
- }]);
281
- }, [phase, willAnimate, elementRef]);
272
+ if (current === 'exiting' && !willAnimate) {
273
+ return 'closed';
274
+ }
275
+ return current;
276
+ });
277
+ }, [willAnimate]);
282
278
 
283
279
  // Non-animated exit callback. No `transitionend` to wait for, so fire
284
280
  // `onExitFinish` on the transition into `exiting` (the unmount itself
285
- // is gated by the toggle/close handshake above).
281
+ // is gated by the closing toggle handshake above).
286
282
  var prevPhaseForExitRef = useRef('closed');
287
283
  useEffect(function () {
288
284
  var prevPhase = prevPhaseForExitRef.current;
@@ -297,6 +293,7 @@ export function useAnimatedVisibility(_ref2) {
297
293
  }
298
294
  }, [phase, willAnimate]);
299
295
  return {
300
- phase: phase
296
+ phase: phase,
297
+ onToggle: onToggle
301
298
  };
302
299
  }
@@ -88,11 +88,7 @@ var POPUP_ROLES = new Set(['menu', 'listbox', 'dialog', 'alertdialog', 'tree', '
88
88
  * ### 📜 Browser dismiss is non-cancellable
89
89
  *
90
90
  * Per the popover spec, `beforetoggle` is cancellable on open transitions
91
- * only, not on close. Consumers that need to ignore a specific dismiss
92
- * (e.g. a click on a logically-associated external trigger) must reconcile
93
- * inside their own `onClose` handler by calling `showPopover()` again.
94
- * `Popover` does not self-heal so consumers do not pay a reconcile cost
95
- * by default.
91
+ * only, not on close.
96
92
  *
97
93
  * - Spec: <https://html.spec.whatwg.org/multipage/popover.html#the-popover-attribute>
98
94
  * - <https://github.com/whatwg/html/issues/8973>
@@ -139,7 +135,8 @@ export var Popover = /*#__PURE__*/forwardRef(function Popover(_ref, ref) {
139
135
  onEnterFinish: onEnterFinish,
140
136
  onExitFinish: onExitFinish
141
137
  }),
142
- phase = _useAnimatedVisibilit.phase;
138
+ phase = _useAnimatedVisibilit.phase,
139
+ onToggle = _useAnimatedVisibilit.onToggle;
143
140
 
144
141
  // True while the host element is mounted (any phase except `closed`).
145
142
  // Used as a dep so listener-rebind effects re-attach after a remount.
@@ -212,6 +209,7 @@ export var Popover = /*#__PURE__*/forwardRef(function Popover(_ref, ref) {
212
209
  var handleToggle = useCallback(function (event) {
213
210
  var element = ownRef.current;
214
211
  if (event.newState === 'open') {
212
+ onToggle(event);
215
213
  return;
216
214
  }
217
215
  if (event.newState === 'closed') {
@@ -223,11 +221,11 @@ export var Popover = /*#__PURE__*/forwardRef(function Popover(_ref, ref) {
223
221
  // click target keeps focus per HTML spec
224
222
  // (`focusPreviousElement=false`).
225
223
  var previouslyFocused = previouslyFocusedElementRef.current;
226
- var _reason = closeReasonRef.current;
224
+ var reason = closeReasonRef.current;
227
225
  var isProgrammatic = programmaticCloseRef.current;
228
226
  var shouldRestore = previouslyFocused !== null && previouslyFocused.isConnected && shouldFocusIntoPopover({
229
227
  role: role
230
- }) && (_reason === 'escape' || isProgrammatic);
228
+ }) && (reason === 'escape' || isProgrammatic);
231
229
  if (shouldRestore && previouslyFocused) {
232
230
  previouslyFocused.focus({
233
231
  preventScroll: true
@@ -241,18 +239,18 @@ export var Popover = /*#__PURE__*/forwardRef(function Popover(_ref, ref) {
241
239
  // Reset reason so a stale 'escape' from a race with Escape keydown
242
240
  // does not corrupt the next browser-dismiss cycle.
243
241
  closeReasonRef.current = 'light-dismiss';
244
- return;
242
+ } else {
243
+ // Browser dismiss (Escape/click-outside).
244
+ var _reason = closeReasonRef.current;
245
+ closeReasonRef.current = 'light-dismiss';
246
+ // Optional: `manual` mode has no `onClose` in the forwarded type.
247
+ onClose === null || onClose === void 0 || onClose({
248
+ reason: _reason
249
+ });
245
250
  }
246
-
247
- // Browser dismiss (Escape/click-outside).
248
- var reason = closeReasonRef.current;
249
- closeReasonRef.current = 'light-dismiss';
250
- // Optional: `manual` mode has no `onClose` in the forwarded type.
251
- onClose === null || onClose === void 0 || onClose({
252
- reason: reason
253
- });
254
251
  }
255
- }, [onClose, role]);
252
+ onToggle(event);
253
+ }, [onClose, onToggle, role]);
256
254
 
257
255
  // Bind via `useLayoutEffect`, and BEFORE the show/hide layout effect
258
256
  // below, so listeners are attached when `showPopover()` synchronously
@@ -14,8 +14,8 @@ import { type TDialogProps } from './types';
14
14
  *
15
15
  * Handles native `cancel` event (Escape) and backdrop click detection.
16
16
  *
17
- * Close flow: we never call `dialog.close()` from event handlers. We always call
18
- * `onClose`; the consumer decides whether to set `isOpen={false}`.
17
+ * Close flow: allowed user dismissal closes the native dialog before `onClose`
18
+ * notifies the consumer. The consumer must synchronize `isOpen` in response.
19
19
  *
20
20
  * Accessibility: render at least one focusable element (typically a close
21
21
  * button) when `isOpen` becomes `true`. Tab is always trapped inside the
@@ -1,3 +1,3 @@
1
1
  export { createCloseEvent } from './create-close-event';
2
2
  export { Dialog } from './dialog-content';
3
- export type { TDialogCloseReason, TDialogProps } from './types';
3
+ export type { TDialogCloseReason, TDialogDismissedBy, TDialogProps } from './types';
@@ -11,6 +11,10 @@ import type { StrictXCSSProp } from '@atlaskit/css';
11
11
  * produces. The consumer simply sets `isOpen={false}`.
12
12
  */
13
13
  export type TDialogCloseReason = 'escape' | 'overlay-click';
14
+ /**
15
+ * User interactions that are allowed to dismiss the dialog.
16
+ */
17
+ export type TDialogDismissedBy = 'none' | 'escape' | 'escape-and-outside-click';
14
18
  /**
15
19
  * Props shared across `TDialogProps` variants.
16
20
  *
@@ -40,14 +44,24 @@ type TDialogBaseProps = {
40
44
  */
41
45
  isOpen: boolean;
42
46
  /**
43
- * Called when the user triggers close via Escape or backdrop click.
47
+ * Called after the dialog closes through an allowed Escape or backdrop interaction.
44
48
  *
45
- * The dialog does **not** close itself. The consumer decides whether to
46
- * set `isOpen={false}` (e.g. gate by reason).
49
+ * User dismissal cannot be rejected from this callback. The consumer must set
50
+ * `isOpen={false}` in response to synchronize controlled state with the native dialog.
47
51
  */
48
52
  onClose: (args: {
49
53
  reason: TDialogCloseReason;
50
54
  }) => void;
55
+ /**
56
+ * Controls which user actions can dismiss the dialog.
57
+ *
58
+ * - `'escape-and-outside-click'`: Escape and outside click.
59
+ * - `'escape'`: Escape only.
60
+ * - `'none'`: no user action.
61
+ *
62
+ * Defaults to `'escape-and-outside-click'`.
63
+ */
64
+ dismissedBy?: TDialogDismissedBy;
51
65
  /**
52
66
  * Whether the dialog should animate in and out.
53
67
  *
@@ -1,2 +1,2 @@
1
1
  export { createCloseEvent, Dialog } from '../dialog';
2
- export type { TDialogCloseReason, TDialogProps } from '../dialog';
2
+ export type { TDialogCloseReason, TDialogDismissedBy, TDialogProps } from '../dialog';
@@ -42,6 +42,12 @@ type TUseAnimatedVisibilityResult = {
42
42
  * trapping, listeners that must outlive the exit).
43
43
  */
44
44
  phase: TPhase;
45
+ /**
46
+ * Notifies the visibility lifecycle after the primitive has finished its
47
+ * own native toggle handling. Closing toggles make native dismissal
48
+ * authoritative while preserving primitive-specific handler ordering.
49
+ */
50
+ onToggle: (event: ToggleEvent) => void;
45
51
  };
46
52
  /**
47
53
  * Visibility lifecycle phase shared between `useAnimatedVisibility`,
@@ -93,7 +99,7 @@ export type TPhase = 'closed' | 'entering' | 'open' | 'exiting';
93
99
  * `entering`, `open`, `exiting`). The host element is rendered while
94
100
  * `phase !== 'closed'`, so the `exiting` phase keeps the host mounted
95
101
  * while the CSS exit transition plays (or, with animation disabled,
96
- * while we wait for the browser `toggle` / `close` event to fire on the
102
+ * while we wait for the browser's closing `toggle` event to fire on the
97
103
  * still-attached element):
98
104
  *
99
105
  * ```
@@ -16,11 +16,7 @@ import { type TPopoverForwardedProps } from './types';
16
16
  * ### 📜 Browser dismiss is non-cancellable
17
17
  *
18
18
  * Per the popover spec, `beforetoggle` is cancellable on open transitions
19
- * only, not on close. Consumers that need to ignore a specific dismiss
20
- * (e.g. a click on a logically-associated external trigger) must reconcile
21
- * inside their own `onClose` handler by calling `showPopover()` again.
22
- * `Popover` does not self-heal so consumers do not pay a reconcile cost
23
- * by default.
19
+ * only, not on close.
24
20
  *
25
21
  * - Spec: <https://html.spec.whatwg.org/multipage/popover.html#the-popover-attribute>
26
22
  * - <https://github.com/whatwg/html/issues/8973>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/top-layer",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Low-level layering primitives based on popover and dialog",
5
5
  "homepage": "https://atlassian.design/components/top-layer",
6
6
  "repository": "https://bitbucket.org/atlassian/atlassian-frontend-mirror",