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