@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.
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useLayoutEffect, useReducer, useRef } from 'react';
2
2
  import { prefersReducedMotion } from './reduced-motion';
3
3
  function runCancellableSettlement({
4
4
  schedule,
@@ -12,6 +12,7 @@ function runCancellableSettlement({
12
12
  onSettled();
13
13
  });
14
14
  return () => {
15
+ // Prevent an interrupted or unmounted lifecycle from firing a stale callback.
15
16
  isCancelled = true;
16
17
  };
17
18
  }
@@ -33,6 +34,20 @@ function runOnAnimationsSettled({
33
34
  }
34
35
  });
35
36
  }
37
+ function isNativeElementOpen({
38
+ element
39
+ }) {
40
+ if (element instanceof HTMLDialogElement) {
41
+ return element.open;
42
+ }
43
+
44
+ // Some DOM implementations throw for unsupported pseudo-classes.
45
+ try {
46
+ return element.matches(':popover-open');
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
36
51
 
37
52
  /**
38
53
  * Visibility lifecycle phase shared between `useAnimatedVisibility`,
@@ -45,11 +60,11 @@ function runOnAnimationsSettled({
45
60
  * animation disabled the lifecycle goes directly `closed → open`.
46
61
  * - `open`: host element is mounted and settled. No transition is
47
62
  * playing.
48
- * - `exiting`: `isOpen` has flipped to `false` but the host element
49
- * is still mounted. Emitted while the CSS exit transition is playing
50
- * (animation enabled) OR while we wait for the browser's
51
- * `toggle`/`close` event to fire on the still-attached element so
52
- * focus restoration can run (animation disabled).
63
+ * - `exiting`: a native close has begun but the host element remains
64
+ * mounted. Usually this follows `isOpen` changing to `false`, but it can
65
+ * also begin with a browser-initiated dismissal while controlled intent
66
+ * is still open. With animation disabled, this phase lasts until the
67
+ * browser's closed `toggle` fires so native focus restoration can finish.
53
68
  *
54
69
  * State machine:
55
70
  * ```
@@ -59,41 +74,111 @@ function runOnAnimationsSettled({
59
74
  * exiting → entering (reopen mid-exit, animated)
60
75
  * exiting → open (reopen mid-handshake, non-animated)
61
76
  * ```
62
- *
63
- * `phase !== 'closed'` is the canonical "host is currently in the DOM
64
- * and on screen" predicate.
65
77
  */
66
78
 
79
+ function getInitialVisibilityState({
80
+ isOpen,
81
+ willAnimate
82
+ }) {
83
+ if (!isOpen) {
84
+ return {
85
+ phase: 'closed',
86
+ controlledIntent: 'closed'
87
+ };
88
+ }
89
+ if (willAnimate) {
90
+ return {
91
+ phase: 'entering',
92
+ controlledIntent: 'open'
93
+ };
94
+ }
95
+ return {
96
+ phase: 'open',
97
+ controlledIntent: 'open'
98
+ };
99
+ }
100
+
67
101
  /**
68
- * Manages the children mount/unmount lifecycle around CSS exit transitions.
69
- *
70
- * Used by both `Popover` and `Dialog` to share the same animation lifecycle
71
- * logic. Those components own their own show/hide mechanisms (showPopover /
72
- * hidePopover vs showModal / close) and event handling - this hook only
73
- * manages the relationship between `isOpen` and when children are rendered.
74
- *
75
- * ## Problem
102
+ * Applies one lifecycle event to the current visibility phase.
76
103
  *
77
- * We want to delay the unmount of children until a CSS exit transition has
78
- * finished playing. If we unmount children the moment `isOpen` becomes
79
- * `false`, the exit animation is never visible - the content just disappears.
104
+ * Events that are stale for the current phase are ignored. This allows
105
+ * animation callbacks and native close events to settle safely after an
106
+ * interrupted lifecycle.
107
+ */
108
+ function getNextVisibilityState(state, action) {
109
+ if (action.type === 'open-requested') {
110
+ const shouldEnter = state.phase === 'closed' || state.phase === 'exiting' && state.controlledIntent === 'closed';
111
+ if (shouldEnter && action.willAnimate) {
112
+ return {
113
+ phase: 'entering',
114
+ controlledIntent: 'open'
115
+ };
116
+ }
117
+ if (shouldEnter) {
118
+ return {
119
+ phase: 'open',
120
+ controlledIntent: 'open'
121
+ };
122
+ }
123
+ return {
124
+ ...state,
125
+ controlledIntent: 'open'
126
+ };
127
+ }
128
+ if (action.type === 'close-requested') {
129
+ return {
130
+ ...state,
131
+ controlledIntent: 'closed'
132
+ };
133
+ }
134
+ if (action.type === 'exit-started') {
135
+ if (state.phase !== 'open' && state.phase !== 'entering') {
136
+ return state;
137
+ }
138
+ return {
139
+ ...state,
140
+ phase: 'exiting'
141
+ };
142
+ }
143
+ if (action.type === 'entry-settled') {
144
+ if (state.phase !== 'entering') {
145
+ return state;
146
+ }
147
+ return {
148
+ ...state,
149
+ phase: 'open'
150
+ };
151
+ }
152
+ if (action.type === 'exit-settled') {
153
+ if (state.phase !== 'exiting') {
154
+ return state;
155
+ }
156
+ return {
157
+ ...state,
158
+ phase: 'closed'
159
+ };
160
+ }
161
+ return state;
162
+ }
163
+
164
+ /**
165
+ * Coordinates controlled intent, native visibility, lifecycle phase, and host
166
+ * mounting around CSS exit transitions. The canonical contract is documented in
167
+ * `notes/architecture/animations.md#canonical-visibility-lifecycle-contract`.
80
168
  *
81
- * ## How it works
169
+ * Used by both `Popover` and `Dialog` to share the same lifecycle logic. Those
170
+ * components own native event binding, show and hide commands, and
171
+ * component-specific event handling. This hook returns stable `onBeforeToggle`
172
+ * and `onToggle` callbacks that advance lifecycle phase and host mounting.
82
173
  *
83
- * The lifecycle is modeled as a `phase` discriminated union (`closed`,
84
- * `entering`, `open`, `exiting`). The host element is rendered while
85
- * `phase !== 'closed'`, so the `exiting` phase keeps the host mounted
86
- * while the CSS exit transition plays (or, with animation disabled,
87
- * while we wait for the browser's closing `toggle` event to fire on the
88
- * still-attached element):
174
+ * The host element is rendered while `phase !== 'closed'`. The `exiting`
175
+ * phase keeps it mounted until its CSS animation settles or, with animation
176
+ * disabled, until the browser reports that the native element has closed.
89
177
  *
90
178
  * ```
91
179
  * isOpen: true ──────────────── false
92
180
  * phase: open ──────────────── exiting ─── (exit settle) ─── closed
93
181
  * ```
94
- *
95
- * Every effect dispatches on the single `phase` value instead of a
96
- * cross-product of booleans.
97
182
  */
98
183
  export function useAnimatedVisibility({
99
184
  isOpen,
@@ -103,160 +188,163 @@ export function useAnimatedVisibility({
103
188
  onExitFinish
104
189
  }) {
105
190
  const willAnimate = shouldAnimate && !prefersReducedMotion();
106
- const isNativeClosedRef = useRef(false);
107
-
108
- // Promote `closed → entering`/`closed → open` synchronously during
109
- // render so the host element mounts in the same commit as the open
110
- // intent. All other transitions are driven by phase-specific effects.
111
- const [phase, setPhase] = useState(() => {
112
- if (!isOpen) {
113
- return 'closed';
114
- }
115
- return willAnimate ? 'entering' : 'open';
116
- });
117
-
118
- // Rearm only after controlled state and the native lifecycle have both
119
- // reached closed. Resetting as soon as `isOpen` becomes false can erase a
120
- // closing toggle that was handled in the same React batch.
121
- if (!isOpen && phase === 'closed') {
122
- isNativeClosedRef.current = false;
123
- }
124
- if (isOpen && phase === 'closed' && !isNativeClosedRef.current) {
125
- setPhase(willAnimate ? 'entering' : 'open');
126
- }
127
- if (!isOpen && (phase === 'open' || phase === 'entering')) {
128
- // Close intent. Move to `exiting` to gate the unmount on either
129
- // the exit transition (animated) or the closing toggle event
130
- // (non-animated). Includes interrupted entry (`entering → exiting`).
131
- setPhase('exiting');
132
- }
133
- if (isOpen && phase === 'exiting' && !isNativeClosedRef.current) {
134
- // Reopen mid-exit. With animation, jump back to `entering` and let
135
- // the entry transition restart from current visual state. Without
136
- // animation, jump straight to `open` (the handshake we were waiting
137
- // for is no longer relevant; the consumer wants the host visible
138
- // again synchronously).
139
- setPhase(willAnimate ? 'entering' : 'open');
140
- }
191
+ const [visibilityState, dispatch] = useReducer(getNextVisibilityState, {
192
+ isOpen,
193
+ willAnimate
194
+ }, getInitialVisibilityState);
195
+ const {
196
+ phase,
197
+ controlledIntent
198
+ } = visibilityState;
141
199
 
142
- // Keep stable refs to the latest callbacks so effects and promise callbacks
200
+ // Keep stable refs to the latest callbacks so effects and animation settlement closures
143
201
  // always call the current callback without needing them in dependency arrays.
144
202
  const onEnterFinishRef = useRef(onEnterFinish);
145
- onEnterFinishRef.current = onEnterFinish;
146
203
  const onExitFinishRef = useRef(onExitFinish);
147
- onExitFinishRef.current = onExitFinish;
204
+ const willAnimateRef = useRef(willAnimate);
205
+ useLayoutEffect(() => {
206
+ onEnterFinishRef.current = onEnterFinish;
207
+ onExitFinishRef.current = onExitFinish;
208
+ willAnimateRef.current = willAnimate;
209
+ }, [onEnterFinish, onExitFinish, willAnimate]);
210
+
211
+ // Synchronize controlled intent during render so opening mounts the host in
212
+ // the same commit. Keeping the processed intent in reducer state prevents a
213
+ // browser-driven exit from being mistaken for a new controlled open request.
214
+ if (isOpen && controlledIntent === 'closed') {
215
+ dispatch({
216
+ type: 'open-requested',
217
+ willAnimate
218
+ });
219
+ }
220
+ if (!isOpen && controlledIntent === 'open') {
221
+ dispatch({
222
+ type: 'close-requested'
223
+ });
224
+ }
148
225
 
149
- // Animated entry: wait for all active animations and move to `open`.
226
+ // Entry can commit `open` before its callback because the host stays mounted.
227
+ // This ensures consumers observe the settled DOM rather than entering styles.
228
+ // Treat the phase before the initial commit as closed so an initially open host
229
+ // notifies consumers.
230
+ const prevPhaseRef = useRef('closed');
150
231
  useEffect(() => {
151
- if (phase !== 'entering') {
232
+ const prevPhase = prevPhaseRef.current;
233
+ prevPhaseRef.current = phase;
234
+ if (prevPhase === phase) {
152
235
  return;
153
236
  }
154
- const element = elementRef.current;
155
- if (!element) {
156
- return;
237
+ if (phase === 'open') {
238
+ var _onEnterFinishRef$cur;
239
+ (_onEnterFinishRef$cur = onEnterFinishRef.current) === null || _onEnterFinishRef$cur === void 0 ? void 0 : _onEnterFinishRef$cur.call(onEnterFinishRef);
157
240
  }
158
- return runOnAnimationsSettled({
159
- element,
160
- onSettled: () => {
161
- var _onEnterFinishRef$cur;
162
- // Fire the consumer callback before transitioning phase so any
163
- // state the consumer reads (refs, DOM nodes) is still attached
164
- // in the same synchronous tick, regardless of React batching
165
- // semantics.
166
- (_onEnterFinishRef$cur = onEnterFinishRef.current) === null || _onEnterFinishRef$cur === void 0 ? void 0 : _onEnterFinishRef$cur.call(onEnterFinishRef);
167
- setPhase(current => current === 'entering' ? 'open' : current);
168
- }
169
- });
170
- }, [phase, elementRef]);
241
+ }, [phase]);
242
+ const isMounted = phase !== 'closed';
243
+ const cancelExitSettlementRef = useRef(() => {});
171
244
 
172
- // Non-animated entry callback. The lifecycle goes `closed → open`
173
- // directly with no `entering` phase; fire `onEnterFinish` on that jump.
174
- // A previous-phase ref avoids double-firing on the animated path.
175
- const prevPhaseForEntryRef = useRef('closed');
176
- useEffect(() => {
177
- const prevPhase = prevPhaseForEntryRef.current;
178
- prevPhaseForEntryRef.current = phase;
245
+ // Exit must notify consumers before `closed` unmounts the host.
246
+ const settleExit = useCallback(() => {
247
+ var _onExitFinishRef$curr;
248
+ (_onExitFinishRef$curr = onExitFinishRef.current) === null || _onExitFinishRef$curr === void 0 ? void 0 : _onExitFinishRef$curr.call(onExitFinishRef);
249
+ dispatch({
250
+ type: 'exit-settled'
251
+ });
252
+ }, []);
179
253
 
180
- // Animated entry settle goes through `entering` and already fired
181
- // onEnterFinish above; only handle the non-animated direct jumps.
182
- if (prevPhase !== 'open' && phase === 'open' && prevPhase !== 'entering') {
183
- var _onEnterFinishRef$cur2;
184
- (_onEnterFinishRef$cur2 = onEnterFinishRef.current) === null || _onEnterFinishRef$cur2 === void 0 ? void 0 : _onEnterFinishRef$cur2.call(onEnterFinishRef);
254
+ // Primitives bind these stable callbacks to their native lifecycle events.
255
+ // This keeps event ownership at the primitive while the hook owns state transitions.
256
+ const onBeforeToggle = useCallback(event => {
257
+ // Any native transition supersedes a previous exit snapshot. This prevents
258
+ // a stale settlement from closing a host that is reopening or closing again.
259
+ cancelExitSettlementRef.current();
260
+ // Snapshot currentTarget because the DOM clears it after event dispatch.
261
+ const element = event.currentTarget;
262
+ if (event.newState === 'open' && element instanceof HTMLElement) {
263
+ element.removeAttribute('inert');
264
+ element.removeAttribute('aria-hidden');
185
265
  }
186
- }, [phase]);
266
+ if (event.newState === 'closed') {
267
+ if (element instanceof HTMLElement) {
268
+ // We use a microtask to let native focus restoration happen before making the element inert.
269
+ // The browser restores focus after beforetoggle returns, during its close steps.
270
+ queueMicrotask(() => {
271
+ // A controlled close can be interrupted by isOpen changing back to true,
272
+ // which reopens the element before this microtask runs.
273
+ if (isNativeElementOpen({
274
+ element
275
+ })) {
276
+ return;
277
+ }
187
278
 
188
- // Animated exit: wait for all active animations, fire `onExitFinish`,
189
- // then move to `closed` to unmount the host.
190
- useEffect(() => {
191
- if (phase !== 'exiting' || !willAnimate) {
279
+ // Exit animations keep closed content rendered, so make it non-interactive and hide it from assistive technology.
280
+ element.setAttribute('inert', '');
281
+ // Tooling does not consistently treat inert content as hidden, even though the browser does.
282
+ element.setAttribute('aria-hidden', 'true');
283
+ });
284
+ }
285
+ dispatch({
286
+ type: 'exit-started'
287
+ });
288
+ }
289
+ }, []);
290
+ const onToggle = useCallback(event => {
291
+ if (event.newState !== 'closed') {
292
+ return;
293
+ }
294
+ if (!willAnimateRef.current) {
295
+ cancelExitSettlementRef.current = runCancellableSettlement({
296
+ schedule: queueMicrotask,
297
+ onSettled: settleExit
298
+ });
192
299
  return;
193
300
  }
194
301
  const element = elementRef.current;
195
302
  if (!element) {
196
303
  return;
197
304
  }
198
- return runOnAnimationsSettled({
305
+
306
+ // The closed `toggle` is task-queued after synchronous `beforetoggle`.
307
+ // Snapshot here so the exiting phase styles have committed and the list
308
+ // represents the animations that actually govern exit settlement.
309
+ cancelExitSettlementRef.current = runOnAnimationsSettled({
199
310
  element,
200
- onSettled: () => {
201
- var _onExitFinishRef$curr;
202
- // Fire the consumer callback before transitioning phase to
203
- // `closed`. The phase change drives the host element unmount,
204
- // and consumers expect to be able to read the element (or a
205
- // forwarded ref to it) inside `onExitFinish`. Doing this in
206
- // the safe order avoids relying on React's automatic batching
207
- // to keep the element attached across the callback.
208
- (_onExitFinishRef$curr = onExitFinishRef.current) === null || _onExitFinishRef$curr === void 0 ? void 0 : _onExitFinishRef$curr.call(onExitFinishRef);
209
- setPhase(current => current === 'exiting' ? 'closed' : current);
210
- }
311
+ onSettled: settleExit
211
312
  });
212
- }, [phase, willAnimate, elementRef]);
313
+ }, [elementRef, settleExit]);
314
+ useLayoutEffect(() => () => {
315
+ // Prevent a pending animation or microtask from notifying after unmount.
316
+ cancelExitSettlementRef.current();
317
+ }, []);
213
318
 
214
- // Native dismissal is authoritative. Its toggle can arrive while controlled
215
- // state still says open, so remember that native state and do not
216
- // reopen until `isOpen` has first moved to false.
217
- useLayoutEffect(() => {
218
- if (phase === 'exiting' && !willAnimate && isNativeClosedRef.current) {
219
- setPhase('closed');
319
+ // Entry settlement only removes entering styles. Its callback runs after the
320
+ // resulting `open` commit in the effect above.
321
+ useEffect(function settleEntry() {
322
+ if (phase !== 'entering') {
323
+ return;
220
324
  }
221
- }, [phase, willAnimate]);
222
- const onToggle = useCallback(event => {
223
- if (event.newState === 'open') {
224
- isNativeClosedRef.current = false;
325
+ if (!willAnimate) {
326
+ dispatch({
327
+ type: 'entry-settled'
328
+ });
225
329
  return;
226
330
  }
227
- if (event.newState !== 'closed') {
331
+ const element = elementRef.current;
332
+ if (!element) {
228
333
  return;
229
334
  }
230
- isNativeClosedRef.current = true;
231
- setPhase(current => {
232
- if (current === 'open' || current === 'entering') {
233
- return 'exiting';
234
- }
235
- if (current === 'exiting' && !willAnimate) {
236
- return 'closed';
335
+ return runOnAnimationsSettled({
336
+ element,
337
+ onSettled() {
338
+ dispatch({
339
+ type: 'entry-settled'
340
+ });
237
341
  }
238
- return current;
239
342
  });
240
- }, [willAnimate]);
241
-
242
- // Non-animated exit callback. There are no animations to wait for, so fire
243
- // `onExitFinish` on the transition into `exiting` (the unmount itself
244
- // is gated by the closing toggle handshake above).
245
- const prevPhaseForExitRef = useRef('closed');
246
- useEffect(() => {
247
- const prevPhase = prevPhaseForExitRef.current;
248
- prevPhaseForExitRef.current = phase;
249
-
250
- // Only fire on the non-animated transition into `exiting`. The
251
- // animated path's `exiting → closed` settle already fires
252
- // `onExitFinish` from `runOnAnimationsSettled`.
253
- if (prevPhase !== 'exiting' && phase === 'exiting' && !willAnimate) {
254
- var _onExitFinishRef$curr2;
255
- (_onExitFinishRef$curr2 = onExitFinishRef.current) === null || _onExitFinishRef$curr2 === void 0 ? void 0 : _onExitFinishRef$curr2.call(onExitFinishRef);
256
- }
257
- }, [phase, willAnimate]);
343
+ }, [phase, willAnimate, elementRef]);
258
344
  return {
259
345
  phase,
346
+ isMounted,
347
+ onBeforeToggle,
260
348
  onToggle
261
349
  };
262
350
  }