@atlaskit/top-layer 4.4.3 → 4.4.5

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,22 @@
1
1
  # @atlaskit/top-layer
2
2
 
3
+ ## 4.4.5
4
+
5
+ ### Patch Changes
6
+
7
+ - [`4aa51e925cecb`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/4aa51e925cecb) -
8
+ Handle environments that do not support matching the `:popover-open` selector during close
9
+ settlement.
10
+
11
+ ## 4.4.4
12
+
13
+ ### Patch Changes
14
+
15
+ - [`610d564dbc5dd`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/610d564dbc5dd) -
16
+ Behind `platform-dst-top-layer`, harden the Popover and Dialog animation lifecycle state machine
17
+ and completion callbacks, restore focus before controlled Popover closure removes native
18
+ listeners, and preserve Modal Dialog open-complete callbacks across reopen cycles.
19
+
3
20
  ## 4.4.3
4
21
 
5
22
  ### Patch Changes
@@ -1,4 +1,4 @@
1
- /* dialog-content.tsx generated by @compiled/babel-plugin v2.0.0 */
1
+ /* dialog-content.tsx generated by @compiled/babel-plugin v3.0.1 */
2
2
  "use strict";
3
3
 
4
4
  var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
@@ -85,6 +85,13 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
85
85
  var ownRef = (0, _react.useRef)(null);
86
86
  var combinedRef = (0, _mergeRefs.default)([ownRef, ref]);
87
87
  var closeReasonRef = (0, _react.useRef)(null);
88
+ var dismissedByRef = (0, _react.useRef)(dismissedBy);
89
+ var onCloseRef = (0, _react.useRef)(onClose);
90
+ // Keep native event handlers stable while ensuring they read the latest committed props.
91
+ (0, _react.useLayoutEffect)(function () {
92
+ dismissedByRef.current = dismissedBy;
93
+ onCloseRef.current = onClose;
94
+ }, [dismissedBy, onClose]);
88
95
  var _useAnimatedVisibilit = (0, _useAnimatedVisibility.useAnimatedVisibility)({
89
96
  isOpen: isOpen,
90
97
  shouldAnimate: shouldAnimate,
@@ -93,11 +100,52 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
93
100
  onExitFinish: onExitFinish
94
101
  }),
95
102
  phase = _useAnimatedVisibilit.phase,
103
+ isMounted = _useAnimatedVisibilit.isMounted,
104
+ onBeforeToggle = _useAnimatedVisibilit.onBeforeToggle,
96
105
  onToggle = _useAnimatedVisibilit.onToggle;
97
106
 
98
- // True while the host element is mounted (any phase except `closed`).
99
- // Used as a dep so listener-rebind effects re-attach after a remount.
100
- var isVisible = phase !== 'closed';
107
+ // Ordering is important: bind native lifecycle listeners before the later
108
+ // layout effect calls showModal() or close(), because `beforetoggle` fires
109
+ // synchronously during those calls.
110
+ (0, _react.useLayoutEffect)(function () {
111
+ if (!isMounted) {
112
+ return;
113
+ }
114
+ var dialog = ownRef.current;
115
+ if (!dialog) {
116
+ return;
117
+ }
118
+ return (0, _bindEventListener.bindAll)(dialog, [{
119
+ type: 'beforetoggle',
120
+ listener: onBeforeToggle
121
+ }, {
122
+ // Browsers retarget clicks on ::backdrop to the <dialog> element.
123
+ // Close on backdrop click when outside-click dismissal is enabled.
124
+ type: 'click',
125
+ listener: function listener(event) {
126
+ if (event.target === event.currentTarget && dismissedByRef.current === 'escape-and-outside-click') {
127
+ closeReasonRef.current = 'overlay-click';
128
+ dialog.close();
129
+ }
130
+ }
131
+ }, {
132
+ type: 'toggle',
133
+ listener: function listener(event) {
134
+ if (event.newState === 'closed') {
135
+ var reason = closeReasonRef.current;
136
+ closeReasonRef.current = null;
137
+ // A reason is only set for user-initiated closes. When React closes the
138
+ // dialog because isOpen changed, the consumer already knows.
139
+ if (reason) {
140
+ onCloseRef.current({
141
+ reason: reason
142
+ });
143
+ }
144
+ }
145
+ onToggle(event);
146
+ }
147
+ }]);
148
+ }, [isMounted, onBeforeToggle, onToggle]);
101
149
 
102
150
  // Native `<dialog>.showModal()` traps focus but wraps through `<body>` at
103
151
  // the boundary (A → B → C → body → A). This hook intercepts Tab to wrap
@@ -143,7 +191,7 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
143
191
  // See notes/decisions/safari-escape-nested-popover-in-dialog.md
144
192
  var _useSafariEscapeFix = (0, _useSafariEscapeFix2.useSafariEscapeFix)({
145
193
  dialogRef: ownRef,
146
- isVisible: isVisible
194
+ isVisible: isMounted
147
195
  }),
148
196
  shouldIgnoreEscape = _useSafariEscapeFix.shouldIgnoreEscape;
149
197
 
@@ -176,49 +224,11 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
176
224
  closeReasonRef.current = 'escape';
177
225
  }, [dismissedBy, shouldIgnoreEscape]);
178
226
 
179
- // Handle backdrop interaction
180
- // Attached via bind-event-listener rather than a React prop so we avoid
181
- // a11y lint suppressions on the <dialog> element.
182
- // The click listener provides light dismiss consistently across browsers.
183
- // `isVisible` is in deps so the listener re-binds to the new <dialog>
184
- // element after a host unmount / remount cycle.
185
- (0, _react.useLayoutEffect)(function () {
186
- var dialog = ownRef.current;
187
- if (!dialog) {
188
- return;
189
- }
190
- return (0, _bindEventListener.bindAll)(dialog, [{
191
- type: 'click',
192
- listener: function listener(event) {
193
- if (event.target === event.currentTarget && dismissedBy === 'escape-and-outside-click') {
194
- closeReasonRef.current = 'overlay-click';
195
- // Calls native close because this is emulating the native light-dismiss behavior
196
- dialog.close();
197
- }
198
- }
199
- }, {
200
- type: 'toggle',
201
- listener: function listener(event) {
202
- if (event.newState === 'closed') {
203
- var reason = closeReasonRef.current;
204
- closeReasonRef.current = null;
205
- if (reason) {
206
- onClose({
207
- reason: reason
208
- });
209
- }
210
- }
211
-
212
- // Visibility cleanup runs after close-reason handling.
213
- onToggle(event);
214
- }
215
- }]);
216
- }, [dismissedBy, onClose, onToggle, isVisible]);
217
-
218
227
  // Unmount the `<dialog>` once exit completes so it does not leave an
219
- // empty `role="dialog"` element in the accessibility tree. On the next
220
- // open the element remounts and its effects run against the fresh element.
221
- if (!isVisible) {
228
+ // empty `role="dialog"` element in the accessibility tree. A later open
229
+ // remounts a fresh element, so the listener-binding and native-visibility
230
+ // effects run against that element.
231
+ if (!isMounted) {
222
232
  return null;
223
233
  }
224
234
  var shouldAnimateBackdrop = !shouldHideBackdrop && shouldAnimate;
@@ -6,8 +6,11 @@ Object.defineProperty(exports, "__esModule", {
6
6
  });
7
7
  exports.useAnimatedVisibility = useAnimatedVisibility;
8
8
  var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray"));
9
+ var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
9
10
  var _react = require("react");
10
11
  var _reducedMotion = require("./reduced-motion");
12
+ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
13
+ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
11
14
  function runCancellableSettlement(_ref) {
12
15
  var schedule = _ref.schedule,
13
16
  onSettled = _ref.onSettled;
@@ -19,6 +22,7 @@ function runCancellableSettlement(_ref) {
19
22
  onSettled();
20
23
  });
21
24
  return function () {
25
+ // Prevent an interrupted or unmounted lifecycle from firing a stale callback.
22
26
  isCancelled = true;
23
27
  };
24
28
  }
@@ -41,6 +45,19 @@ function runOnAnimationsSettled(_ref2) {
41
45
  }
42
46
  });
43
47
  }
48
+ function isNativeElementOpen(_ref3) {
49
+ var element = _ref3.element;
50
+ if (element instanceof HTMLDialogElement) {
51
+ return element.open;
52
+ }
53
+
54
+ // Some DOM implementations throw for unsupported pseudo-classes.
55
+ try {
56
+ return element.matches(':popover-open');
57
+ } catch (_unused) {
58
+ return false;
59
+ }
60
+ }
44
61
 
45
62
  /**
46
63
  * Visibility lifecycle phase shared between `useAnimatedVisibility`,
@@ -53,11 +70,11 @@ function runOnAnimationsSettled(_ref2) {
53
70
  * animation disabled the lifecycle goes directly `closed → open`.
54
71
  * - `open`: host element is mounted and settled. No transition is
55
72
  * playing.
56
- * - `exiting`: `isOpen` has flipped to `false` but the host element
57
- * is still mounted. Emitted while the CSS exit transition is playing
58
- * (animation enabled) OR while we wait for the browser's
59
- * `toggle`/`close` event to fire on the still-attached element so
60
- * focus restoration can run (animation disabled).
73
+ * - `exiting`: a native close has begun but the host element remains
74
+ * mounted. Usually this follows `isOpen` changing to `false`, but it can
75
+ * also begin with a browser-initiated dismissal while controlled intent
76
+ * is still open. With animation disabled, this phase lasts until the
77
+ * browser's closed `toggle` fires so native focus restoration can finish.
61
78
  *
62
79
  * State machine:
63
80
  * ```
@@ -67,210 +84,273 @@ function runOnAnimationsSettled(_ref2) {
67
84
  * exiting → entering (reopen mid-exit, animated)
68
85
  * exiting → open (reopen mid-handshake, non-animated)
69
86
  * ```
70
- *
71
- * `phase !== 'closed'` is the canonical "host is currently in the DOM
72
- * and on screen" predicate.
73
87
  */
74
88
 
89
+ function getInitialVisibilityState(_ref4) {
90
+ var isOpen = _ref4.isOpen,
91
+ willAnimate = _ref4.willAnimate;
92
+ if (!isOpen) {
93
+ return {
94
+ phase: 'closed',
95
+ controlledIntent: 'closed'
96
+ };
97
+ }
98
+ if (willAnimate) {
99
+ return {
100
+ phase: 'entering',
101
+ controlledIntent: 'open'
102
+ };
103
+ }
104
+ return {
105
+ phase: 'open',
106
+ controlledIntent: 'open'
107
+ };
108
+ }
109
+
75
110
  /**
76
- * Manages the children mount/unmount lifecycle around CSS exit transitions.
77
- *
78
- * Used by both `Popover` and `Dialog` to share the same animation lifecycle
79
- * logic. Those components own their own show/hide mechanisms (showPopover /
80
- * hidePopover vs showModal / close) and event handling - this hook only
81
- * manages the relationship between `isOpen` and when children are rendered.
111
+ * Applies one lifecycle event to the current visibility phase.
82
112
  *
83
- * ## Problem
84
- *
85
- * We want to delay the unmount of children until a CSS exit transition has
86
- * finished playing. If we unmount children the moment `isOpen` becomes
87
- * `false`, the exit animation is never visible - the content just disappears.
113
+ * Events that are stale for the current phase are ignored. This allows
114
+ * animation callbacks and native close events to settle safely after an
115
+ * interrupted lifecycle.
116
+ */
117
+ function getNextVisibilityState(state, action) {
118
+ if (action.type === 'open-requested') {
119
+ var shouldEnter = state.phase === 'closed' || state.phase === 'exiting' && state.controlledIntent === 'closed';
120
+ if (shouldEnter && action.willAnimate) {
121
+ return {
122
+ phase: 'entering',
123
+ controlledIntent: 'open'
124
+ };
125
+ }
126
+ if (shouldEnter) {
127
+ return {
128
+ phase: 'open',
129
+ controlledIntent: 'open'
130
+ };
131
+ }
132
+ return _objectSpread(_objectSpread({}, state), {}, {
133
+ controlledIntent: 'open'
134
+ });
135
+ }
136
+ if (action.type === 'close-requested') {
137
+ return _objectSpread(_objectSpread({}, state), {}, {
138
+ controlledIntent: 'closed'
139
+ });
140
+ }
141
+ if (action.type === 'exit-started') {
142
+ if (state.phase !== 'open' && state.phase !== 'entering') {
143
+ return state;
144
+ }
145
+ return _objectSpread(_objectSpread({}, state), {}, {
146
+ phase: 'exiting'
147
+ });
148
+ }
149
+ if (action.type === 'entry-settled') {
150
+ if (state.phase !== 'entering') {
151
+ return state;
152
+ }
153
+ return _objectSpread(_objectSpread({}, state), {}, {
154
+ phase: 'open'
155
+ });
156
+ }
157
+ if (action.type === 'exit-settled') {
158
+ if (state.phase !== 'exiting') {
159
+ return state;
160
+ }
161
+ return _objectSpread(_objectSpread({}, state), {}, {
162
+ phase: 'closed'
163
+ });
164
+ }
165
+ return state;
166
+ }
167
+
168
+ /**
169
+ * Coordinates controlled intent, native visibility, lifecycle phase, and host
170
+ * mounting around CSS exit transitions. The canonical contract is documented in
171
+ * `notes/architecture/animations.md#canonical-visibility-lifecycle-contract`.
88
172
  *
89
- * ## How it works
173
+ * Used by both `Popover` and `Dialog` to share the same lifecycle logic. Those
174
+ * components own native event binding, show and hide commands, and
175
+ * component-specific event handling. This hook returns stable `onBeforeToggle`
176
+ * and `onToggle` callbacks that advance lifecycle phase and host mounting.
90
177
  *
91
- * The lifecycle is modeled as a `phase` discriminated union (`closed`,
92
- * `entering`, `open`, `exiting`). The host element is rendered while
93
- * `phase !== 'closed'`, so the `exiting` phase keeps the host mounted
94
- * while the CSS exit transition plays (or, with animation disabled,
95
- * while we wait for the browser's closing `toggle` event to fire on the
96
- * still-attached element):
178
+ * The host element is rendered while `phase !== 'closed'`. The `exiting`
179
+ * phase keeps it mounted until its CSS animation settles or, with animation
180
+ * disabled, until the browser reports that the native element has closed.
97
181
  *
98
182
  * ```
99
183
  * isOpen: true ──────────────── false
100
184
  * phase: open ──────────────── exiting ─── (exit settle) ─── closed
101
185
  * ```
102
- *
103
- * Every effect dispatches on the single `phase` value instead of a
104
- * cross-product of booleans.
105
186
  */
106
- function useAnimatedVisibility(_ref3) {
107
- var isOpen = _ref3.isOpen,
108
- shouldAnimate = _ref3.shouldAnimate,
109
- elementRef = _ref3.elementRef,
110
- onEnterFinish = _ref3.onEnterFinish,
111
- onExitFinish = _ref3.onExitFinish;
187
+ function useAnimatedVisibility(_ref5) {
188
+ var isOpen = _ref5.isOpen,
189
+ shouldAnimate = _ref5.shouldAnimate,
190
+ elementRef = _ref5.elementRef,
191
+ onEnterFinish = _ref5.onEnterFinish,
192
+ onExitFinish = _ref5.onExitFinish;
112
193
  var willAnimate = shouldAnimate && !(0, _reducedMotion.prefersReducedMotion)();
113
- var isNativeClosedRef = (0, _react.useRef)(false);
114
-
115
- // Promote `closed → entering`/`closed → open` synchronously during
116
- // render so the host element mounts in the same commit as the open
117
- // intent. All other transitions are driven by phase-specific effects.
118
- var _useState = (0, _react.useState)(function () {
119
- if (!isOpen) {
120
- return 'closed';
121
- }
122
- return willAnimate ? 'entering' : 'open';
123
- }),
124
- _useState2 = (0, _slicedToArray2.default)(_useState, 2),
125
- phase = _useState2[0],
126
- setPhase = _useState2[1];
194
+ var _useReducer = (0, _react.useReducer)(getNextVisibilityState, {
195
+ isOpen: isOpen,
196
+ willAnimate: willAnimate
197
+ }, getInitialVisibilityState),
198
+ _useReducer2 = (0, _slicedToArray2.default)(_useReducer, 2),
199
+ visibilityState = _useReducer2[0],
200
+ dispatch = _useReducer2[1];
201
+ var phase = visibilityState.phase,
202
+ controlledIntent = visibilityState.controlledIntent;
127
203
 
128
- // Rearm only after controlled state and the native lifecycle have both
129
- // reached closed. Resetting as soon as `isOpen` becomes false can erase a
130
- // closing toggle that was handled in the same React batch.
131
- if (!isOpen && phase === 'closed') {
132
- isNativeClosedRef.current = false;
133
- }
134
- if (isOpen && phase === 'closed' && !isNativeClosedRef.current) {
135
- setPhase(willAnimate ? 'entering' : 'open');
136
- }
137
- if (!isOpen && (phase === 'open' || phase === 'entering')) {
138
- // Close intent. Move to `exiting` to gate the unmount on either
139
- // the exit transition (animated) or the closing toggle event
140
- // (non-animated). Includes interrupted entry (`entering → exiting`).
141
- setPhase('exiting');
142
- }
143
- if (isOpen && phase === 'exiting' && !isNativeClosedRef.current) {
144
- // Reopen mid-exit. With animation, jump back to `entering` and let
145
- // the entry transition restart from current visual state. Without
146
- // animation, jump straight to `open` (the handshake we were waiting
147
- // for is no longer relevant; the consumer wants the host visible
148
- // again synchronously).
149
- setPhase(willAnimate ? 'entering' : 'open');
150
- }
151
-
152
- // Keep stable refs to the latest callbacks so effects and promise callbacks
204
+ // Keep stable refs to the latest callbacks so effects and animation settlement closures
153
205
  // always call the current callback without needing them in dependency arrays.
154
206
  var onEnterFinishRef = (0, _react.useRef)(onEnterFinish);
155
- onEnterFinishRef.current = onEnterFinish;
156
207
  var onExitFinishRef = (0, _react.useRef)(onExitFinish);
157
- onExitFinishRef.current = onExitFinish;
208
+ var willAnimateRef = (0, _react.useRef)(willAnimate);
209
+ (0, _react.useLayoutEffect)(function () {
210
+ onEnterFinishRef.current = onEnterFinish;
211
+ onExitFinishRef.current = onExitFinish;
212
+ willAnimateRef.current = willAnimate;
213
+ }, [onEnterFinish, onExitFinish, willAnimate]);
214
+
215
+ // Synchronize controlled intent during render so opening mounts the host in
216
+ // the same commit. Keeping the processed intent in reducer state prevents a
217
+ // browser-driven exit from being mistaken for a new controlled open request.
218
+ if (isOpen && controlledIntent === 'closed') {
219
+ dispatch({
220
+ type: 'open-requested',
221
+ willAnimate: willAnimate
222
+ });
223
+ }
224
+ if (!isOpen && controlledIntent === 'open') {
225
+ dispatch({
226
+ type: 'close-requested'
227
+ });
228
+ }
158
229
 
159
- // Animated entry: wait for all active animations and move to `open`.
230
+ // Entry can commit `open` before its callback because the host stays mounted.
231
+ // This ensures consumers observe the settled DOM rather than entering styles.
232
+ // Treat the phase before the initial commit as closed so an initially open host
233
+ // notifies consumers.
234
+ var prevPhaseRef = (0, _react.useRef)('closed');
160
235
  (0, _react.useEffect)(function () {
161
- if (phase !== 'entering') {
236
+ var prevPhase = prevPhaseRef.current;
237
+ prevPhaseRef.current = phase;
238
+ if (prevPhase === phase) {
162
239
  return;
163
240
  }
164
- var element = elementRef.current;
165
- if (!element) {
166
- return;
241
+ if (phase === 'open') {
242
+ var _onEnterFinishRef$cur;
243
+ (_onEnterFinishRef$cur = onEnterFinishRef.current) === null || _onEnterFinishRef$cur === void 0 || _onEnterFinishRef$cur.call(onEnterFinishRef);
167
244
  }
168
- return runOnAnimationsSettled({
169
- element: element,
170
- onSettled: function onSettled() {
171
- var _onEnterFinishRef$cur;
172
- // Fire the consumer callback before transitioning phase so any
173
- // state the consumer reads (refs, DOM nodes) is still attached
174
- // in the same synchronous tick, regardless of React batching
175
- // semantics.
176
- (_onEnterFinishRef$cur = onEnterFinishRef.current) === null || _onEnterFinishRef$cur === void 0 || _onEnterFinishRef$cur.call(onEnterFinishRef);
177
- setPhase(function (current) {
178
- return current === 'entering' ? 'open' : current;
179
- });
180
- }
181
- });
182
- }, [phase, elementRef]);
245
+ }, [phase]);
246
+ var isMounted = phase !== 'closed';
247
+ var cancelExitSettlementRef = (0, _react.useRef)(function () {});
183
248
 
184
- // Non-animated entry callback. The lifecycle goes `closed → open`
185
- // directly with no `entering` phase; fire `onEnterFinish` on that jump.
186
- // A previous-phase ref avoids double-firing on the animated path.
187
- var prevPhaseForEntryRef = (0, _react.useRef)('closed');
188
- (0, _react.useEffect)(function () {
189
- var prevPhase = prevPhaseForEntryRef.current;
190
- prevPhaseForEntryRef.current = phase;
249
+ // Exit must notify consumers before `closed` unmounts the host.
250
+ var settleExit = (0, _react.useCallback)(function () {
251
+ var _onExitFinishRef$curr;
252
+ (_onExitFinishRef$curr = onExitFinishRef.current) === null || _onExitFinishRef$curr === void 0 || _onExitFinishRef$curr.call(onExitFinishRef);
253
+ dispatch({
254
+ type: 'exit-settled'
255
+ });
256
+ }, []);
191
257
 
192
- // Animated entry settle goes through `entering` and already fired
193
- // onEnterFinish above; only handle the non-animated direct jumps.
194
- if (prevPhase !== 'open' && phase === 'open' && prevPhase !== 'entering') {
195
- var _onEnterFinishRef$cur2;
196
- (_onEnterFinishRef$cur2 = onEnterFinishRef.current) === null || _onEnterFinishRef$cur2 === void 0 || _onEnterFinishRef$cur2.call(onEnterFinishRef);
258
+ // Primitives bind these stable callbacks to their native lifecycle events.
259
+ // This keeps event ownership at the primitive while the hook owns state transitions.
260
+ var onBeforeToggle = (0, _react.useCallback)(function (event) {
261
+ // Any native transition supersedes a previous exit snapshot. This prevents
262
+ // a stale settlement from closing a host that is reopening or closing again.
263
+ cancelExitSettlementRef.current();
264
+ // Snapshot currentTarget because the DOM clears it after event dispatch.
265
+ var element = event.currentTarget;
266
+ if (event.newState === 'open' && element instanceof HTMLElement) {
267
+ element.removeAttribute('inert');
268
+ element.removeAttribute('aria-hidden');
197
269
  }
198
- }, [phase]);
270
+ if (event.newState === 'closed') {
271
+ if (element instanceof HTMLElement) {
272
+ // We use a microtask to let native focus restoration happen before making the element inert.
273
+ // The browser restores focus after beforetoggle returns, during its close steps.
274
+ queueMicrotask(function () {
275
+ // A controlled close can be interrupted by isOpen changing back to true,
276
+ // which reopens the element before this microtask runs.
277
+ if (isNativeElementOpen({
278
+ element: element
279
+ })) {
280
+ return;
281
+ }
199
282
 
200
- // Animated exit: wait for all active animations, fire `onExitFinish`,
201
- // then move to `closed` to unmount the host.
202
- (0, _react.useEffect)(function () {
203
- if (phase !== 'exiting' || !willAnimate) {
283
+ // Exit animations keep closed content rendered, so make it non-interactive and hide it from assistive technology.
284
+ element.setAttribute('inert', '');
285
+ // Tooling does not consistently treat inert content as hidden, even though the browser does.
286
+ element.setAttribute('aria-hidden', 'true');
287
+ });
288
+ }
289
+ dispatch({
290
+ type: 'exit-started'
291
+ });
292
+ }
293
+ }, []);
294
+ var onToggle = (0, _react.useCallback)(function (event) {
295
+ if (event.newState !== 'closed') {
296
+ return;
297
+ }
298
+ if (!willAnimateRef.current) {
299
+ cancelExitSettlementRef.current = runCancellableSettlement({
300
+ schedule: queueMicrotask,
301
+ onSettled: settleExit
302
+ });
204
303
  return;
205
304
  }
206
305
  var element = elementRef.current;
207
306
  if (!element) {
208
307
  return;
209
308
  }
210
- return runOnAnimationsSettled({
309
+
310
+ // The closed `toggle` is task-queued after synchronous `beforetoggle`.
311
+ // Snapshot here so the exiting phase styles have committed and the list
312
+ // represents the animations that actually govern exit settlement.
313
+ cancelExitSettlementRef.current = runOnAnimationsSettled({
211
314
  element: element,
212
- onSettled: function onSettled() {
213
- var _onExitFinishRef$curr;
214
- // Fire the consumer callback before transitioning phase to
215
- // `closed`. The phase change drives the host element unmount,
216
- // and consumers expect to be able to read the element (or a
217
- // forwarded ref to it) inside `onExitFinish`. Doing this in
218
- // the safe order avoids relying on React's automatic batching
219
- // to keep the element attached across the callback.
220
- (_onExitFinishRef$curr = onExitFinishRef.current) === null || _onExitFinishRef$curr === void 0 || _onExitFinishRef$curr.call(onExitFinishRef);
221
- setPhase(function (current) {
222
- return current === 'exiting' ? 'closed' : current;
223
- });
224
- }
315
+ onSettled: settleExit
225
316
  });
226
- }, [phase, willAnimate, elementRef]);
227
-
228
- // Native dismissal is authoritative. Its toggle can arrive while controlled
229
- // state still says open, so remember that native state and do not
230
- // reopen until `isOpen` has first moved to false.
317
+ }, [elementRef, settleExit]);
231
318
  (0, _react.useLayoutEffect)(function () {
232
- if (phase === 'exiting' && !willAnimate && isNativeClosedRef.current) {
233
- setPhase('closed');
319
+ return function () {
320
+ // Prevent a pending animation or microtask from notifying after unmount.
321
+ cancelExitSettlementRef.current();
322
+ };
323
+ }, []);
324
+
325
+ // Entry settlement only removes entering styles. Its callback runs after the
326
+ // resulting `open` commit in the effect above.
327
+ (0, _react.useEffect)(function settleEntry() {
328
+ if (phase !== 'entering') {
329
+ return;
234
330
  }
235
- }, [phase, willAnimate]);
236
- var onToggle = (0, _react.useCallback)(function (event) {
237
- if (event.newState === 'open') {
238
- isNativeClosedRef.current = false;
331
+ if (!willAnimate) {
332
+ dispatch({
333
+ type: 'entry-settled'
334
+ });
239
335
  return;
240
336
  }
241
- if (event.newState !== 'closed') {
337
+ var element = elementRef.current;
338
+ if (!element) {
242
339
  return;
243
340
  }
244
- isNativeClosedRef.current = true;
245
- setPhase(function (current) {
246
- if (current === 'open' || current === 'entering') {
247
- return 'exiting';
248
- }
249
- if (current === 'exiting' && !willAnimate) {
250
- return 'closed';
341
+ return runOnAnimationsSettled({
342
+ element: element,
343
+ onSettled: function onSettled() {
344
+ dispatch({
345
+ type: 'entry-settled'
346
+ });
251
347
  }
252
- return current;
253
348
  });
254
- }, [willAnimate]);
255
-
256
- // Non-animated exit callback. There are no animations to wait for, so fire
257
- // `onExitFinish` on the transition into `exiting` (the unmount itself
258
- // is gated by the closing toggle handshake above).
259
- var prevPhaseForExitRef = (0, _react.useRef)('closed');
260
- (0, _react.useEffect)(function () {
261
- var prevPhase = prevPhaseForExitRef.current;
262
- prevPhaseForExitRef.current = phase;
263
-
264
- // Only fire on the non-animated transition into `exiting`. The
265
- // animated path's `exiting → closed` settle already fires
266
- // `onExitFinish` from `runOnAnimationsSettled`.
267
- if (prevPhase !== 'exiting' && phase === 'exiting' && !willAnimate) {
268
- var _onExitFinishRef$curr2;
269
- (_onExitFinishRef$curr2 = onExitFinishRef.current) === null || _onExitFinishRef$curr2 === void 0 || _onExitFinishRef$curr2.call(onExitFinishRef);
270
- }
271
- }, [phase, willAnimate]);
349
+ }, [phase, willAnimate, elementRef]);
272
350
  return {
273
351
  phase: phase,
352
+ isMounted: isMounted,
353
+ onBeforeToggle: onBeforeToggle,
274
354
  onToggle: onToggle
275
355
  };
276
356
  }