@atlaskit/top-layer 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/cjs/dialog/create-close-event.js +1 -2
  3. package/dist/cjs/dialog/dialog-content.js +33 -4
  4. package/dist/cjs/dialog-scroll-lock/index.js +17 -7
  5. package/dist/cjs/entry-points/dialog.js +6 -0
  6. package/dist/cjs/entry-points/popover.js +6 -0
  7. package/dist/cjs/entry-points/use-popover-id.js +3 -1
  8. package/dist/cjs/internal/get-aria-for-trigger.js +8 -3
  9. package/dist/cjs/internal/use-anchor-position-at-point.js +4 -2
  10. package/dist/cjs/internal/use-anchor-position.js +3 -2
  11. package/dist/cjs/internal/use-animated-visibility.js +233 -155
  12. package/dist/cjs/internal/use-focus-wrap.js +5 -3
  13. package/dist/cjs/internal/use-initial-focus.js +196 -50
  14. package/dist/cjs/internal/use-width-from-anchor.js +5 -4
  15. package/dist/cjs/popover/create-close-event.js +1 -2
  16. package/dist/cjs/popover/popover.js +58 -9
  17. package/dist/es2019/dialog/create-close-event.js +1 -2
  18. package/dist/es2019/dialog/dialog-content.js +33 -4
  19. package/dist/es2019/dialog-scroll-lock/index.js +18 -8
  20. package/dist/es2019/entry-points/dialog.js +1 -1
  21. package/dist/es2019/entry-points/popover.js +1 -1
  22. package/dist/es2019/entry-points/use-popover-id.js +3 -1
  23. package/dist/es2019/internal/get-aria-for-trigger.js +8 -3
  24. package/dist/es2019/internal/use-anchor-position-at-point.js +4 -2
  25. package/dist/es2019/internal/use-anchor-position.js +3 -2
  26. package/dist/es2019/internal/use-animated-visibility.js +222 -149
  27. package/dist/es2019/internal/use-focus-wrap.js +5 -4
  28. package/dist/es2019/internal/use-initial-focus.js +189 -45
  29. package/dist/es2019/internal/use-width-from-anchor.js +5 -4
  30. package/dist/es2019/popover/create-close-event.js +1 -2
  31. package/dist/es2019/popover/popover.js +59 -10
  32. package/dist/esm/dialog/create-close-event.js +1 -2
  33. package/dist/esm/dialog/dialog-content.js +33 -4
  34. package/dist/esm/dialog-scroll-lock/index.js +17 -8
  35. package/dist/esm/entry-points/dialog.js +1 -1
  36. package/dist/esm/entry-points/popover.js +1 -1
  37. package/dist/esm/entry-points/use-popover-id.js +3 -1
  38. package/dist/esm/internal/get-aria-for-trigger.js +8 -3
  39. package/dist/esm/internal/use-anchor-position-at-point.js +4 -2
  40. package/dist/esm/internal/use-anchor-position.js +3 -2
  41. package/dist/esm/internal/use-animated-visibility.js +235 -157
  42. package/dist/esm/internal/use-focus-wrap.js +5 -4
  43. package/dist/esm/internal/use-initial-focus.js +196 -51
  44. package/dist/esm/internal/use-width-from-anchor.js +5 -4
  45. package/dist/esm/popover/create-close-event.js +1 -2
  46. package/dist/esm/popover/popover.js +59 -10
  47. package/dist/types/animations/types.d.ts +0 -8
  48. package/dist/types/dialog/create-close-event.d.ts +1 -2
  49. package/dist/types/dialog/types.d.ts +9 -1
  50. package/dist/types/dialog-scroll-lock/index.d.ts +22 -6
  51. package/dist/types/entry-points/animations.d.ts +1 -1
  52. package/dist/types/entry-points/dialog.d.ts +1 -1
  53. package/dist/types/entry-points/popover.d.ts +1 -1
  54. package/dist/types/internal/get-aria-for-trigger.d.ts +8 -1
  55. package/dist/types/internal/use-anchor-position-at-point.d.ts +6 -1
  56. package/dist/types/internal/use-anchor-position.d.ts +14 -1
  57. package/dist/types/internal/use-animated-visibility.d.ts +48 -19
  58. package/dist/types/internal/use-focus-wrap.d.ts +6 -1
  59. package/dist/types/internal/use-initial-focus.d.ts +55 -4
  60. package/dist/types/internal/use-width-from-anchor.d.ts +3 -2
  61. package/dist/types/popover/create-close-event.d.ts +1 -2
  62. package/dist/types/popover/types.d.ts +13 -2
  63. package/dist/types-ts4.5/animations/types.d.ts +0 -8
  64. package/dist/types-ts4.5/dialog/create-close-event.d.ts +1 -2
  65. package/dist/types-ts4.5/dialog/types.d.ts +9 -1
  66. package/dist/types-ts4.5/dialog-scroll-lock/index.d.ts +22 -6
  67. package/dist/types-ts4.5/entry-points/animations.d.ts +1 -1
  68. package/dist/types-ts4.5/entry-points/dialog.d.ts +1 -1
  69. package/dist/types-ts4.5/entry-points/popover.d.ts +1 -1
  70. package/dist/types-ts4.5/internal/get-aria-for-trigger.d.ts +8 -1
  71. package/dist/types-ts4.5/internal/use-anchor-position-at-point.d.ts +6 -1
  72. package/dist/types-ts4.5/internal/use-anchor-position.d.ts +14 -1
  73. package/dist/types-ts4.5/internal/use-animated-visibility.d.ts +48 -19
  74. package/dist/types-ts4.5/internal/use-focus-wrap.d.ts +6 -1
  75. package/dist/types-ts4.5/internal/use-initial-focus.d.ts +55 -4
  76. package/dist/types-ts4.5/internal/use-width-from-anchor.d.ts +3 -2
  77. package/dist/types-ts4.5/popover/create-close-event.d.ts +1 -2
  78. package/dist/types-ts4.5/popover/types.d.ts +13 -2
  79. package/package.json +1 -1
  80. package/create-close-event/package.json +0 -17
  81. package/dialog/create-close-event/package.json +0 -17
  82. package/dist/cjs/entry-points/create-close-event.js +0 -19
  83. package/dist/cjs/entry-points/dialog/create-close-event.js +0 -12
  84. package/dist/cjs/entry-points/popover/create-close-event.js +0 -12
  85. package/dist/es2019/entry-points/create-close-event.js +0 -9
  86. package/dist/es2019/entry-points/dialog/create-close-event.js +0 -1
  87. package/dist/es2019/entry-points/popover/create-close-event.js +0 -1
  88. package/dist/esm/entry-points/create-close-event.js +0 -9
  89. package/dist/esm/entry-points/dialog/create-close-event.js +0 -1
  90. package/dist/esm/entry-points/popover/create-close-event.js +0 -1
  91. package/dist/types/entry-points/create-close-event.d.ts +0 -11
  92. package/dist/types/entry-points/dialog/create-close-event.d.ts +0 -2
  93. package/dist/types/entry-points/popover/create-close-event.d.ts +0 -2
  94. package/dist/types-ts4.5/entry-points/create-close-event.d.ts +0 -11
  95. package/dist/types-ts4.5/entry-points/dialog/create-close-event.d.ts +0 -2
  96. package/dist/types-ts4.5/entry-points/popover/create-close-event.d.ts +0 -2
  97. package/popover/create-close-event/package.json +0 -17
@@ -10,6 +10,69 @@ var _react = require("react");
10
10
  var _bindEventListener = require("bind-event-listener");
11
11
  var _reducedMotion = require("./reduced-motion");
12
12
  var _usePresetStyles = require("./use-preset-styles");
13
+ /**
14
+ * Fires `onSettled` once when `transitionend` fires on `element`, or after
15
+ * `safetyNetMs` if the event is swallowed (mid-transition unmount, CSS
16
+ * mismatch, browser quirks). Returns a cleanup that cancels both paths.
17
+ */
18
+ function runOnTransitionEndOrTimeout(_ref) {
19
+ var element = _ref.element,
20
+ safetyNetMs = _ref.safetyNetMs,
21
+ onSettled = _ref.onSettled;
22
+ var isSettled = false;
23
+ function teardown() {
24
+ isSettled = true;
25
+ clearTimeout(safetyNetId);
26
+ unbind();
27
+ }
28
+ function settle() {
29
+ if (isSettled) {
30
+ return;
31
+ }
32
+ teardown();
33
+ onSettled();
34
+ }
35
+ var unbind = (0, _bindEventListener.bind)(element, {
36
+ type: 'transitionend',
37
+ listener: settle,
38
+ options: {
39
+ once: true
40
+ }
41
+ });
42
+ var safetyNetId = setTimeout(settle, safetyNetMs);
43
+ return teardown;
44
+ }
45
+
46
+ /**
47
+ * Visibility lifecycle phase shared between `useAnimatedVisibility`,
48
+ * `useFocusWrap`, `useInitialFocus`, and the `Popover` / `Dialog`
49
+ * components.
50
+ *
51
+ * - `closed`: host element is unmounted. No DOM presence.
52
+ * - `entering`: host element is mounted and the CSS entry transition
53
+ * is playing. **Only emitted when animation is enabled.** With
54
+ * animation disabled the lifecycle goes directly `closed → open`.
55
+ * - `open`: host element is mounted and settled. No transition is
56
+ * playing.
57
+ * - `exiting`: `isOpen` has flipped to `false` but the host element
58
+ * is still mounted. Emitted while the CSS exit transition is playing
59
+ * (animation enabled) OR while we wait for the browser's
60
+ * `toggle`/`close` event to fire on the still-attached element so
61
+ * focus restoration can run (animation disabled).
62
+ *
63
+ * State machine:
64
+ * ```
65
+ * animation ON: closed → entering → open → exiting → closed
66
+ * animation OFF: closed → open → exiting → closed
67
+ * interrupts: entering → exiting (close mid-entry)
68
+ * exiting → entering (reopen mid-exit, animated)
69
+ * exiting → open (reopen mid-handshake, non-animated)
70
+ * ```
71
+ *
72
+ * `phase !== 'closed'` is the canonical "host is currently in the DOM
73
+ * and on screen" predicate.
74
+ */
75
+
13
76
  /**
14
77
  * Manages the children mount/unmount lifecycle around CSS exit transitions.
15
78
  *
@@ -26,33 +89,27 @@ var _usePresetStyles = require("./use-preset-styles");
26
89
  *
27
90
  * ## How it works
28
91
  *
29
- * `showChildren` tracks whether the consumer's children should be in the DOM.
30
- * It does not mirror `isOpen` exactly - it intentionally lags behind during
31
- * exit animations:
92
+ * The lifecycle is modeled as a `phase` discriminated union (`closed`,
93
+ * `entering`, `open`, `exiting`). The host element is rendered while
94
+ * `phase !== 'closed'`, so the `exiting` phase keeps the host mounted
95
+ * while the CSS exit transition plays (or, with animation disabled,
96
+ * while we wait for the browser `toggle` / `close` event to fire on the
97
+ * still-attached element):
32
98
  *
33
99
  * ```
34
- * isOpen: true ──────────────── false
35
- * showChildren: true ──────────────── true ─── (exit animation) ─── false
100
+ * isOpen: true ──────────────── false
101
+ * phase: open ──────────────── exiting ─── (exit settle) ─── closed
36
102
  * ```
37
103
  *
38
- * Two close paths exist:
39
- *
40
- * 1. **Animated close** (`willAnimate === true`):
41
- * `isOpen` goes `false` → `showChildren` stays `true` → the CSS exit
42
- * transition plays → `transitionend` fires → `showChildren` → `false`
43
- * → `onExitFinish` fires. A timeout fallback ensures we still unmount
44
- * if `transitionend` never fires (e.g. browser quirks, zero-duration).
45
- *
46
- * 2. **Non-animated close** (`willAnimate === false`):
47
- * `isOpen` goes `false` → `showChildren` → `false` synchronously during
48
- * the same render → `onExitFinish` fires in a follow-up effect.
104
+ * Every effect dispatches on the single `phase` value instead of a
105
+ * cross-product of booleans.
49
106
  */
50
- function useAnimatedVisibility(_ref) {
51
- var isOpen = _ref.isOpen,
52
- animate = _ref.animate,
53
- elementRef = _ref.elementRef,
54
- onEnterFinish = _ref.onEnterFinish,
55
- onExitFinish = _ref.onExitFinish;
107
+ function useAnimatedVisibility(_ref2) {
108
+ var isOpen = _ref2.isOpen,
109
+ animate = _ref2.animate,
110
+ elementRef = _ref2.elementRef,
111
+ onEnterFinish = _ref2.onEnterFinish,
112
+ onExitFinish = _ref2.onExitFinish;
56
113
  // Resolve the animation preset and inject its CSS (idempotent).
57
114
  // Returns `null` when `animate` is falsy.
58
115
  var preset = (0, _usePresetStyles.usePresetStyles)({
@@ -63,29 +120,37 @@ function useAnimatedVisibility(_ref) {
63
120
  // or the user has enabled the "prefers reduced motion" OS setting.
64
121
  var willAnimate = Boolean(animate) && !(0, _reducedMotion.prefersReducedMotion)();
65
122
 
66
- // showChildren state
67
- //
68
- // This is the core of the hook. `showChildren` controls whether the
69
- // consumer's children are in the DOM. The two `if` blocks below are
70
- // intentionally written as synchronous state updates during render
71
- // (React supports this pattern - setState during render is treated as
72
- // a synchronous re-render before the browser paints).
73
- //
74
- // Mount path: isOpen went true but showChildren is still false → mount.
75
- // Unmount path: isOpen went false, showChildren is still true, and we
76
- // are NOT animating → unmount immediately (no transition
77
- // to wait for). If we ARE animating, showChildren stays
78
- // true here and the effect below will set it to false
79
- // after `transitionend`.
80
- var _useState = (0, _react.useState)(isOpen),
123
+ // `phase` is the single source of truth for the visibility lifecycle.
124
+ // We promote `closed → entering` (animated) or `closed → open`
125
+ // (non-animated) synchronously during render so the host element mounts
126
+ // in the same commit as the open intent (React supports a single
127
+ // setState during render as a synchronous re-render before paint).
128
+ // All other transitions are driven by phase-specific effects below.
129
+ var _useState = (0, _react.useState)(function () {
130
+ if (!isOpen) {
131
+ return 'closed';
132
+ }
133
+ return willAnimate ? 'entering' : 'open';
134
+ }),
81
135
  _useState2 = (0, _slicedToArray2.default)(_useState, 2),
82
- showChildren = _useState2[0],
83
- setShowChildren = _useState2[1];
84
- if (isOpen && !showChildren) {
85
- setShowChildren(true);
136
+ phase = _useState2[0],
137
+ setPhase = _useState2[1];
138
+ if (isOpen && phase === 'closed') {
139
+ setPhase(willAnimate ? 'entering' : 'open');
86
140
  }
87
- if (!isOpen && showChildren && !willAnimate) {
88
- setShowChildren(false);
141
+ if (!isOpen && (phase === 'open' || phase === 'entering')) {
142
+ // Close intent. Move to `exiting` to gate the unmount on either
143
+ // the exit transition (animated) or the toggle/close event
144
+ // (non-animated). Includes interrupted entry (`entering → exiting`).
145
+ setPhase('exiting');
146
+ }
147
+ if (isOpen && phase === 'exiting') {
148
+ // Reopen mid-exit. With animation, jump back to `entering` and let
149
+ // the entry transition restart from current visual state. Without
150
+ // animation, jump straight to `open` (the handshake we were waiting
151
+ // for is no longer relevant; the consumer wants the host visible
152
+ // again synchronously).
153
+ setPhase(willAnimate ? 'entering' : 'open');
89
154
  }
90
155
 
91
156
  // Keep stable refs to the latest callbacks so effects and timeout closures
@@ -95,152 +160,165 @@ function useAnimatedVisibility(_ref) {
95
160
  var onExitFinishRef = (0, _react.useRef)(onExitFinish);
96
161
  onExitFinishRef.current = onExitFinish;
97
162
 
98
- // Animated entry effect
99
- //
100
- // Fires `onEnterFinish` after the entry transition completes.
101
- // When there is no animation, fires it immediately via a separate effect.
102
- //
103
- // A fallback timeout (enterDurationMs + 50ms buffer) ensures it always
104
- // fires even if `transitionend` is never emitted (e.g. zero-duration,
105
- // browser quirk, or reduced motion).
163
+ // `entering` (animation enabled):
164
+ // Wait for the entry transition to settle, then move to `open` and fire
165
+ // `onEnterFinish`. A fallback timeout (enterDurationMs + 50ms buffer)
166
+ // guarantees the settle path runs even if `transitionend` is swallowed
167
+ // (zero-duration, browser quirk, mid-transition unmount).
106
168
  (0, _react.useEffect)(function () {
107
169
  var _preset$enterDuration;
108
- if (!isOpen || !showChildren || !willAnimate) {
170
+ if (phase !== 'entering') {
109
171
  return;
110
172
  }
111
173
  var element = elementRef.current;
112
174
  if (!element) {
113
175
  return;
114
176
  }
115
-
116
- // Both the transitionend listener and the fallback timeout are guarded by
117
- // a shared `hasFired` flag to prevent double-firing. This handles the race
118
- // where the 350ms transition finishes after the 400ms fallback fires
119
- // (or vice versa): whichever fires first sets `hasFired = true`, and the
120
- // second path becomes a no-op.
121
- var hasFired = false;
122
- function handleTransitionEnd() {
123
- var _onEnterFinishRef$cur;
124
- if (hasFired) {
125
- return;
126
- }
127
- hasFired = true;
128
- clearTimeout(fallbackId);
129
- unbind();
130
- (_onEnterFinishRef$cur = onEnterFinishRef.current) === null || _onEnterFinishRef$cur === void 0 || _onEnterFinishRef$cur.call(onEnterFinishRef);
131
- }
132
- var fallbackId = setTimeout(handleTransitionEnd, ((_preset$enterDuration = preset === null || preset === void 0 ? void 0 : preset.enterDurationMs) !== null && _preset$enterDuration !== void 0 ? _preset$enterDuration : 0) + 50);
133
- var unbind = (0, _bindEventListener.bind)(element, {
134
- type: 'transitionend',
135
- listener: handleTransitionEnd,
136
- options: {
137
- once: true
177
+ return runOnTransitionEndOrTimeout({
178
+ element: element,
179
+ safetyNetMs: ((_preset$enterDuration = preset === null || preset === void 0 ? void 0 : preset.enterDurationMs) !== null && _preset$enterDuration !== void 0 ? _preset$enterDuration : 0) + 50,
180
+ onSettled: function onSettled() {
181
+ var _onEnterFinishRef$cur;
182
+ // Fire the consumer callback before transitioning phase so any
183
+ // state the consumer reads (refs, DOM nodes) is still attached
184
+ // in the same synchronous tick, regardless of React batching
185
+ // semantics.
186
+ (_onEnterFinishRef$cur = onEnterFinishRef.current) === null || _onEnterFinishRef$cur === void 0 || _onEnterFinishRef$cur.call(onEnterFinishRef);
187
+ setPhase(function (current) {
188
+ return current === 'entering' ? 'open' : current;
189
+ });
138
190
  }
139
191
  });
140
- return function () {
141
- hasFired = true;
142
- clearTimeout(fallbackId);
143
- unbind();
144
- };
145
- }, [isOpen, showChildren, willAnimate, preset === null || preset === void 0 ? void 0 : preset.enterDurationMs, elementRef]);
192
+ }, [phase, preset === null || preset === void 0 ? void 0 : preset.enterDurationMs, elementRef]);
146
193
 
147
- // Non-animated entry callback
148
- //
149
- // When there is no animation, fire `onEnterFinish` immediately on mount and after `isOpen` goes false->true.
150
- // The initial value is false to make sure it fires on mount if the current `isOpen` is true.
151
- var prevIsOpenForEntryRef = (0, _react.useRef)(false);
194
+ // Non-animated entry callback:
195
+ // When animation is disabled, the lifecycle goes `closed → open`
196
+ // directly with no `entering` phase. Fire `onEnterFinish` on the
197
+ // transition into `open` from the non-animated path. We detect this
198
+ // via a previous-phase ref to avoid firing on the animated path's
199
+ // `entering → open` transition (which has its own callback above).
200
+ var prevPhaseForEntryRef = (0, _react.useRef)('closed');
152
201
  (0, _react.useEffect)(function () {
153
- var prevIsOpen = prevIsOpenForEntryRef.current;
154
- prevIsOpenForEntryRef.current = isOpen;
155
- if (!prevIsOpen && isOpen && !willAnimate) {
202
+ var prevPhase = prevPhaseForEntryRef.current;
203
+ prevPhaseForEntryRef.current = phase;
204
+
205
+ // Animated entry settle goes through `entering` and already fired
206
+ // onEnterFinish above; only handle the non-animated direct jumps.
207
+ if (prevPhase !== 'open' && phase === 'open' && prevPhase !== 'entering') {
156
208
  var _onEnterFinishRef$cur2;
157
209
  (_onEnterFinishRef$cur2 = onEnterFinishRef.current) === null || _onEnterFinishRef$cur2 === void 0 || _onEnterFinishRef$cur2.call(onEnterFinishRef);
158
210
  }
159
- }, [isOpen, willAnimate]);
211
+ }, [phase]);
160
212
 
161
- // Animated exit effect
162
- //
163
- // Runs when all three conditions are true:
164
- // • `isOpen` is false (consumer wants to close)
165
- // • `showChildren` is true (children are still mounted for exit anim)
166
- // • `willAnimate` is true (we have a preset and reduced motion is off)
167
- //
168
- // Listens for `transitionend` on the element to know when the CSS exit
169
- // transition has finished, then unmounts children and fires onExitFinish.
170
- //
171
- // Fallback timeout: `transitionend` can fail to fire in edge cases
172
- // (e.g. element removed from top layer by browser, zero-duration
173
- // transition, interrupted animation). The timeout is set to the
174
- // preset's exit duration + 50ms buffer to guarantee we always unmount.
213
+ // `exiting` (animation enabled):
214
+ // Wait for the exit transition to settle, then unmount the host element
215
+ // (set phase back to `closed`) and fire `onExitFinish`. The fallback
216
+ // timeout (exitDurationMs + 50ms buffer) guarantees we always unmount
217
+ // even if `transitionend` is never emitted (element removed from the
218
+ // top layer, zero-duration transition, interrupted animation).
175
219
  (0, _react.useEffect)(function () {
176
220
  var _preset$exitDurationM;
177
- // Guard: only run during the "exiting" window
178
- if (isOpen || !showChildren || !willAnimate) {
221
+ if (phase !== 'exiting' || !willAnimate) {
179
222
  return;
180
223
  }
181
224
  var element = elementRef.current;
182
225
  if (!element) {
183
226
  return;
184
227
  }
185
-
186
- // Both the transitionend listener and the fallback timeout are guarded by
187
- // a shared `hasFired` flag to prevent double-firing. This handles the race
188
- // where the 350ms transition finishes after the 400ms fallback fires
189
- // (or vice versa): whichever fires first sets `hasFired = true`, and the
190
- // second path becomes a no-op.
191
- var hasFired = false;
192
- function handleTransitionEnd() {
193
- var _onExitFinishRef$curr;
194
- if (hasFired) {
195
- return;
196
- }
197
- hasFired = true;
198
- clearTimeout(fallbackId);
199
- unbind();
200
- setShowChildren(false);
201
- (_onExitFinishRef$curr = onExitFinishRef.current) === null || _onExitFinishRef$curr === void 0 || _onExitFinishRef$curr.call(onExitFinishRef);
202
- }
203
-
204
- // Fallback: unmount even if transitionend never fires
205
- var fallbackId = setTimeout(handleTransitionEnd, ((_preset$exitDurationM = preset === null || preset === void 0 ? void 0 : preset.exitDurationMs) !== null && _preset$exitDurationM !== void 0 ? _preset$exitDurationM : 0) + 50);
206
-
207
- // Primary: listen for the CSS transition to complete
208
- var unbind = (0, _bindEventListener.bind)(element, {
209
- type: 'transitionend',
210
- listener: handleTransitionEnd,
211
- options: {
212
- once: true
228
+ return runOnTransitionEndOrTimeout({
229
+ element: element,
230
+ safetyNetMs: ((_preset$exitDurationM = preset === null || preset === void 0 ? void 0 : preset.exitDurationMs) !== null && _preset$exitDurationM !== void 0 ? _preset$exitDurationM : 0) + 50,
231
+ onSettled: function onSettled() {
232
+ var _onExitFinishRef$curr;
233
+ // Fire the consumer callback before transitioning phase to
234
+ // `closed`. The phase change drives the host element unmount,
235
+ // and consumers expect to be able to read the element (or a
236
+ // forwarded ref to it) inside `onExitFinish`. Doing this in
237
+ // the safe order avoids relying on React's automatic batching
238
+ // to keep the element attached across the callback.
239
+ (_onExitFinishRef$curr = onExitFinishRef.current) === null || _onExitFinishRef$curr === void 0 || _onExitFinishRef$curr.call(onExitFinishRef);
240
+ setPhase(function (current) {
241
+ return current === 'exiting' ? 'closed' : current;
242
+ });
213
243
  }
214
244
  });
245
+ }, [phase, willAnimate, preset === null || preset === void 0 ? void 0 : preset.exitDurationMs, elementRef]);
215
246
 
216
- // Cleanup on re-render or unmount: cancel pending timeout and listener
217
- return function () {
218
- hasFired = true;
219
- clearTimeout(fallbackId);
220
- unbind();
221
- };
222
- }, [isOpen, showChildren, willAnimate, preset === null || preset === void 0 ? void 0 : preset.exitDurationMs, elementRef]);
223
-
224
- // Non-animated exit callback
247
+ // `exiting` (animation disabled):
248
+ // Wait for the browser's `toggle` (closed) event (for `<div popover>`)
249
+ // or `close` event (for `<dialog>`) to fire before unmounting. This
250
+ // guarantees:
251
+ // - the consumer's toggle/close listener (close-reason capture,
252
+ // nested focus restoration, `onClose` notification) runs against
253
+ // the still-attached element,
254
+ // - the browser's native focus restoration triggered by
255
+ // `hidePopover()` / `<dialog>.close()` has fired,
256
+ // before React unmounts the node. A naive `setTimeout(0)` race
257
+ // occasionally beats the `toggle` task in Chromium, causing focus
258
+ // restoration to drop (the toggle event arrives at a detached element).
225
259
  //
226
- // When there is no animation, children unmount synchronously (handled
227
- // by the `if` block above). But `onExitFinish` still needs to fire -
228
- // we do that in a follow-up effect so the consumer can react to it.
260
+ // `useLayoutEffect` (not `useEffect`) so the listener is bound in the
261
+ // same commit as the consumer's show/hide layout effect that calls
262
+ // `hidePopover()` / `dialog.close()`. The browser queues the resulting
263
+ // `toggle`/`close` event as a task, which cannot run until the JS
264
+ // frame (including all effect flushes) completes, so the listener is
265
+ // always attached before the event fires. Promotion to layout timing
266
+ // removes any theoretical gap and future-proofs the ordering against
267
+ // effect reordering. Safe under SSR: `phase` starts at `'closed'`, so
268
+ // the body short-circuits and no DOM access happens on the server.
229
269
  //
230
- // Uses a previous-value ref (`wasOpenRef`) to detect the exact
231
- // true→false transition of `isOpen`, avoiding false positives on
232
- // mount or re-renders where `isOpen` was already false.
233
- var wasOpenRef = (0, _react.useRef)(isOpen);
270
+ // `onExitFinish` is NOT called here. It is decoupled from the unmount
271
+ // handshake and fired by the dedicated non-animated exit-callback
272
+ // effect below, so consumers observe a synchronous "exit finished"
273
+ // signal that matches `isOpen` flipping to `false`.
274
+ (0, _react.useLayoutEffect)(function () {
275
+ if (phase !== 'exiting' || willAnimate) {
276
+ return;
277
+ }
278
+ var element = elementRef.current;
279
+ if (!element) {
280
+ return;
281
+ }
282
+ function unmount() {
283
+ setPhase(function (current) {
284
+ return current === 'exiting' ? 'closed' : current;
285
+ });
286
+ }
287
+ return (0, _bindEventListener.bindAll)(element, [{
288
+ type: 'toggle',
289
+ listener: function listener(event) {
290
+ if (event.newState === 'closed') {
291
+ unmount();
292
+ }
293
+ }
294
+ }, {
295
+ type: 'close',
296
+ listener: unmount
297
+ }]);
298
+ }, [phase, willAnimate, elementRef]);
299
+
300
+ // Non-animated exit callback:
301
+ // When animation is disabled there is no `transitionend` to wait for,
302
+ // so fire `onExitFinish` on the transition into `exiting`. The actual
303
+ // host-element unmount is gated on the browser `toggle`/`close` event
304
+ // by the effect above; `onExitFinish` is observably decoupled from
305
+ // that handshake so consumers see the callback fire at the same time
306
+ // `isOpen` flipped to `false`.
307
+ var prevPhaseForExitRef = (0, _react.useRef)('closed');
234
308
  (0, _react.useEffect)(function () {
235
- var wasOpen = wasOpenRef.current;
236
- wasOpenRef.current = isOpen;
237
- if (wasOpen && !isOpen && !willAnimate) {
309
+ var prevPhase = prevPhaseForExitRef.current;
310
+ prevPhaseForExitRef.current = phase;
311
+
312
+ // Only fire on the non-animated transition into `exiting`. The
313
+ // animated path's `exiting → closed` settle already fires
314
+ // `onExitFinish` from `runOnTransitionEndOrTimeout`.
315
+ if (prevPhase !== 'exiting' && phase === 'exiting' && !willAnimate) {
238
316
  var _onExitFinishRef$curr2;
239
317
  (_onExitFinishRef$curr2 = onExitFinishRef.current) === null || _onExitFinishRef$curr2 === void 0 || _onExitFinishRef$curr2.call(onExitFinishRef);
240
318
  }
241
- }, [isOpen, willAnimate]);
319
+ }, [phase, willAnimate]);
242
320
  return {
243
- showChildren: showChildren,
321
+ phase: phase,
244
322
  preset: preset
245
323
  };
246
324
  }
@@ -50,10 +50,12 @@ function roleRequiresFocusWrap(role) {
50
50
  */
51
51
  function useFocusWrap(_ref) {
52
52
  var elementRef = _ref.elementRef,
53
- role = _ref.role;
53
+ role = _ref.role,
54
+ phase = _ref.phase;
55
+ var isVisible = phase !== 'closed';
54
56
  (0, _react.useEffect)(function () {
55
57
  var element = elementRef.current;
56
- if (!element || !roleRequiresFocusWrap(role)) {
58
+ if (!element || !roleRequiresFocusWrap(role) || !isVisible) {
57
59
  return;
58
60
  }
59
61
  var unbind = (0, _bindEventListener.bind)(element, {
@@ -104,5 +106,5 @@ function useFocusWrap(_ref) {
104
106
  }
105
107
  });
106
108
  return unbind;
107
- }, [elementRef, role]);
109
+ }, [elementRef, role, isVisible]);
108
110
  }