@atlaskit/top-layer 0.15.0 → 0.17.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 (66) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +4 -4
  3. package/dist/cjs/animations/presets.js +96 -9
  4. package/dist/cjs/dialog/dialog-content.js +33 -4
  5. package/dist/cjs/dialog-scroll-lock/index.js +17 -7
  6. package/dist/cjs/entry-points/animations.js +12 -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/popover.js +58 -9
  16. package/dist/es2019/animations/presets.js +145 -2
  17. package/dist/es2019/dialog/dialog-content.js +33 -4
  18. package/dist/es2019/dialog-scroll-lock/index.js +18 -8
  19. package/dist/es2019/entry-points/animations.js +1 -1
  20. package/dist/es2019/entry-points/use-popover-id.js +3 -1
  21. package/dist/es2019/internal/get-aria-for-trigger.js +8 -3
  22. package/dist/es2019/internal/use-anchor-position-at-point.js +4 -2
  23. package/dist/es2019/internal/use-anchor-position.js +3 -2
  24. package/dist/es2019/internal/use-animated-visibility.js +222 -149
  25. package/dist/es2019/internal/use-focus-wrap.js +5 -4
  26. package/dist/es2019/internal/use-initial-focus.js +189 -45
  27. package/dist/es2019/internal/use-width-from-anchor.js +5 -4
  28. package/dist/es2019/popover/popover.js +59 -10
  29. package/dist/esm/animations/presets.js +93 -9
  30. package/dist/esm/dialog/dialog-content.js +33 -4
  31. package/dist/esm/dialog-scroll-lock/index.js +17 -8
  32. package/dist/esm/entry-points/animations.js +1 -1
  33. package/dist/esm/entry-points/use-popover-id.js +3 -1
  34. package/dist/esm/internal/get-aria-for-trigger.js +8 -3
  35. package/dist/esm/internal/use-anchor-position-at-point.js +4 -2
  36. package/dist/esm/internal/use-anchor-position.js +3 -2
  37. package/dist/esm/internal/use-animated-visibility.js +235 -157
  38. package/dist/esm/internal/use-focus-wrap.js +5 -4
  39. package/dist/esm/internal/use-initial-focus.js +196 -51
  40. package/dist/esm/internal/use-width-from-anchor.js +5 -4
  41. package/dist/esm/popover/popover.js +59 -10
  42. package/dist/types/animations/presets.d.ts +16 -0
  43. package/dist/types/dialog/types.d.ts +9 -1
  44. package/dist/types/dialog-scroll-lock/index.d.ts +22 -6
  45. package/dist/types/entry-points/animations.d.ts +1 -1
  46. package/dist/types/internal/get-aria-for-trigger.d.ts +8 -1
  47. package/dist/types/internal/use-anchor-position-at-point.d.ts +6 -1
  48. package/dist/types/internal/use-anchor-position.d.ts +14 -1
  49. package/dist/types/internal/use-animated-visibility.d.ts +48 -19
  50. package/dist/types/internal/use-focus-wrap.d.ts +6 -1
  51. package/dist/types/internal/use-initial-focus.d.ts +55 -4
  52. package/dist/types/internal/use-width-from-anchor.d.ts +3 -2
  53. package/dist/types/popover/types.d.ts +13 -2
  54. package/dist/types-ts4.5/animations/presets.d.ts +16 -0
  55. package/dist/types-ts4.5/dialog/types.d.ts +9 -1
  56. package/dist/types-ts4.5/dialog-scroll-lock/index.d.ts +22 -6
  57. package/dist/types-ts4.5/entry-points/animations.d.ts +1 -1
  58. package/dist/types-ts4.5/internal/get-aria-for-trigger.d.ts +8 -1
  59. package/dist/types-ts4.5/internal/use-anchor-position-at-point.d.ts +6 -1
  60. package/dist/types-ts4.5/internal/use-anchor-position.d.ts +14 -1
  61. package/dist/types-ts4.5/internal/use-animated-visibility.d.ts +48 -19
  62. package/dist/types-ts4.5/internal/use-focus-wrap.d.ts +6 -1
  63. package/dist/types-ts4.5/internal/use-initial-focus.d.ts +55 -4
  64. package/dist/types-ts4.5/internal/use-width-from-anchor.d.ts +3 -2
  65. package/dist/types-ts4.5/popover/types.d.ts +13 -2
  66. package/package.json +3 -3
@@ -51,7 +51,7 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
51
51
 
52
52
  // Animation lifecycle
53
53
  const {
54
- showChildren,
54
+ phase,
55
55
  preset
56
56
  } = useAnimatedVisibility({
57
57
  isOpen,
@@ -60,13 +60,27 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
60
60
  onExitFinish
61
61
  });
62
62
 
63
+ // Pre-computed phase predicate. `isVisible` means "host element is
64
+ // mounted and on screen" (any phase except `closed`), used as a dep
65
+ // signal for the backdrop-click rebind effect so it re-attaches to
66
+ // the fresh <dialog> element after a close → reopen unmount/remount
67
+ // cycle.
68
+ const isVisible = phase !== 'closed';
69
+
63
70
  // Focus wrap
64
71
  // Native <dialog>.showModal() traps focus but wraps through <body> at
65
72
  // the boundary (A → B → C → body → A). This hook intercepts Tab to
66
73
  // wrap directly (A → B → C → A), matching the WAI-ARIA APG pattern.
74
+ //
75
+ // Takes `phase` directly: the listener stays attached while
76
+ // `phase !== 'closed'`, i.e. through the animated-exit window.
77
+ // Gating on `isOpen` would unbind the listener at the start of an
78
+ // exit animation while the dialog is still visible, letting focus
79
+ // escape (WCAG 2.4.3 Focus Order regression).
67
80
  useFocusWrap({
68
81
  elementRef: ownRef,
69
- role: 'dialog'
82
+ role: 'dialog',
83
+ phase
70
84
  });
71
85
 
72
86
  // Open layer observer registration
@@ -112,6 +126,8 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
112
126
  // Attached via bind-event-listener rather than a React prop so we avoid
113
127
  // a11y lint suppressions on the <dialog> element.
114
128
  // Keyboard dismiss is already handled natively (Escape → onCancel above).
129
+ // `isVisible` is in deps so the listener re-binds to the new <dialog>
130
+ // element after a host unmount / remount cycle.
115
131
  useEffect(() => {
116
132
  const dialog = ownRef.current;
117
133
  if (!dialog) {
@@ -127,13 +143,26 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
127
143
  }
128
144
  }
129
145
  });
130
- }, [onClose]);
146
+ }, [onClose, isVisible]);
131
147
 
132
148
  // Atomic CSS (Compiled) deduplicates ::backdrop { background-color }
133
149
  // into a single class, making it impossible to toggle between two values
134
150
  // via cssMap entries. An ID-scoped <style> has higher specificity than
135
151
  // any atomic class, so the override always wins.
136
152
  const escapedId = CSS.escape(dialogId);
153
+
154
+ // The `<dialog>` host element is only rendered while open or its exit
155
+ // animation is playing. After exit completes the phase returns to
156
+ // `closed` and we unmount the dialog entirely so it does not leave an
157
+ // empty `role="dialog"` element in the accessibility tree. The element
158
+ // re-mounts on the next `isOpen=true` commit; `showModal()` is called
159
+ // from the existing `useLayoutEffect([isOpen])` on that same commit,
160
+ // and listeners (cancel, click) re-bind via the `[onClose, isVisible]`
161
+ // effect re-running after remount (React clears the ref on unmount, so
162
+ // we need the listener effect to re-run with the fresh element).
163
+ if (!isVisible) {
164
+ return null;
165
+ }
137
166
  return /*#__PURE__*/React.createElement("dialog", _extends({
138
167
  ref: combinedRef,
139
168
  id: dialogId
@@ -156,5 +185,5 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
156
185
  }), shouldHideBackdrop &&
157
186
  /*#__PURE__*/
158
187
  // eslint-disable-next-line @atlaskit/ui-styling-standard/no-global-styles
159
- React.createElement("style", null, `#${escapedId}::backdrop{background-color:transparent}`), showChildren ? children : null);
188
+ React.createElement("style", null, `#${escapedId}::backdrop{background-color:transparent}`), children);
160
189
  });
@@ -1,17 +1,22 @@
1
1
  import { useLayoutEffect } from 'react';
2
2
  import { getDocument } from '@atlaskit/browser-apis';
3
-
4
3
  /**
5
4
  * __DialogScrollLock__
6
5
  *
7
- * Prevents background page scrolling while mounted. Designed to be rendered
8
- * inside a `Dialog` so that the scroll lock is automatically removed when the
9
- * dialog closes (unmounts its children).
6
+ * Prevents background page scrolling while a dialog is open. Designed to be
7
+ * rendered inside a `Dialog` and given the same `isOpen` value.
10
8
  *
11
9
  * Native `<dialog>.showModal()` makes background content _inert_ (not
12
10
  * focusable/clickable), but does **not** prevent scroll events from reaching
13
11
  * the document body. This component fills that gap by setting
14
- * `overflow: hidden` on `document.body`.
12
+ * `overflow: hidden` on `document.body` while `isOpen` is `true`.
13
+ *
14
+ * The lock is driven by `isOpen` rather than by mount/unmount so that release
15
+ * happens synchronously in the same commit that flips `isOpen` to `false`.
16
+ * Relying on unmount alone was racy: `Dialog` defers the unmount of its
17
+ * children until the host element's close/toggle event fires, which can be
18
+ * after a consumer or test observes the dialog as hidden, leading to a
19
+ * stale `overflow: hidden` window.
15
20
  *
16
21
  * @example
17
22
  * ```tsx
@@ -19,14 +24,19 @@ import { getDocument } from '@atlaskit/browser-apis';
19
24
  * import { DialogScrollLock } from '@atlaskit/top-layer/dialog-scroll-lock';
20
25
  *
21
26
  * <Dialog isOpen={isOpen} onClose={onClose}>
22
- * <DialogScrollLock />
27
+ * <DialogScrollLock isOpen={isOpen} />
23
28
  * {children}
24
29
  * </Dialog>
25
30
  * ```
26
31
  */
27
- export function DialogScrollLock() {
32
+ export function DialogScrollLock({
33
+ isOpen
34
+ }) {
28
35
  useLayoutEffect(() => {
29
36
  var _getDocument;
37
+ if (!isOpen) {
38
+ return;
39
+ }
30
40
  const body = (_getDocument = getDocument()) === null || _getDocument === void 0 ? void 0 : _getDocument.body;
31
41
  if (!body) {
32
42
  return;
@@ -36,6 +46,6 @@ export function DialogScrollLock() {
36
46
  return () => {
37
47
  body.style.overflow = previousOverflow;
38
48
  };
39
- }, []);
49
+ }, [isOpen]);
40
50
  return null;
41
51
  }
@@ -1 +1 @@
1
- export { slideAndFade, fade, scaleAndFade, dialogSlideUpAndFade, dialogFade } from '../animations/presets';
1
+ export { dialogMotion, popupMotion, slideAndFade, fade, scaleAndFade, dialogSlideUpAndFade, dialogFade } from '../animations/presets';
@@ -10,6 +10,8 @@ import { useId } from '@atlaskit/ds-lib/use-id';
10
10
  * own `anchor-name` on the trigger element.
11
11
  */
12
12
  export function usePopoverId() {
13
- // prefixing for debug clarity in the DOM
13
+ // `@atlaskit/ds-lib/use-id` already strips characters that React's
14
+ // `useId` produces (`:`, `«`, `»`) which are invalid in HTML ID
15
+ // tokens. The `popover-` prefix is purely for DOM debug clarity.
14
16
  return `popover-${useId()}`;
15
17
  }
@@ -74,11 +74,16 @@ export function getAriaForTrigger({
74
74
  const ariaHasPopup = roleToAriaHasPopup({
75
75
  role
76
76
  });
77
+ // `aria-controls` is set to `undefined` while closed. The `Popover` /
78
+ // `Dialog` primitives unmount their host element after the exit
79
+ // animation finishes, so a closed-state `aria-controls` would point
80
+ // at a node that is not in the DOM. Returning `undefined` here means
81
+ // JSX spread renders no `aria-controls` attribute on the trigger
82
+ // until the target exists, avoiding the dangling reference while
83
+ // keeping the relationship live whenever it is meaningful.
77
84
  return {
78
85
  'aria-haspopup': ariaHasPopup,
79
86
  'aria-expanded': isOpen,
80
- // Keep the trigger-to-popover relationship stable. `aria-expanded`
81
- // tells assistive technology whether the controlled popover is visible.
82
- 'aria-controls': popoverId
87
+ 'aria-controls': isOpen ? popoverId : undefined
83
88
  };
84
89
  }
@@ -69,7 +69,8 @@ export function useAnchorPositionAtPoint({
69
69
  popoverRef,
70
70
  placement,
71
71
  getPoint,
72
- isEnabled = true
72
+ isEnabled = true,
73
+ isOpen
73
74
  }) {
74
75
  const syntheticAnchorRef = useRef(null);
75
76
 
@@ -117,6 +118,7 @@ export function useAnchorPositionAtPoint({
117
118
  anchorRef: syntheticAnchorRef,
118
119
  popoverRef,
119
120
  placement,
120
- isEnabled
121
+ isEnabled,
122
+ isOpen
121
123
  });
122
124
  }
@@ -203,7 +203,8 @@ export function useAnchorPosition({
203
203
  popoverRef,
204
204
  placement = {},
205
205
  forceFallbackPositioning = false,
206
- isEnabled = true
206
+ isEnabled = true,
207
+ isOpen
207
208
  }) {
208
209
  const id = useId();
209
210
  // Stabilize `placement` object so the same object literal
@@ -463,5 +464,5 @@ export function useAnchorPosition({
463
464
  popover.style.removeProperty('opacity');
464
465
  });
465
466
  return undoPositioning;
466
- }, [anchorRef, popoverRef, stablePlacement, forceFallbackPositioning, isEnabled, id]);
467
+ }, [anchorRef, popoverRef, stablePlacement, forceFallbackPositioning, isEnabled, id, isOpen]);
467
468
  }
@@ -1,7 +1,71 @@
1
- import { useEffect, useRef, useState } from 'react';
2
- import { bind } from 'bind-event-listener';
1
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { bind, bindAll } from 'bind-event-listener';
3
3
  import { prefersReducedMotion } from './reduced-motion';
4
4
  import { usePresetStyles } from './use-preset-styles';
5
+ /**
6
+ * Fires `onSettled` once when `transitionend` fires on `element`, or after
7
+ * `safetyNetMs` if the event is swallowed (mid-transition unmount, CSS
8
+ * mismatch, browser quirks). Returns a cleanup that cancels both paths.
9
+ */
10
+ function runOnTransitionEndOrTimeout({
11
+ element,
12
+ safetyNetMs,
13
+ onSettled
14
+ }) {
15
+ let 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
+ const unbind = bind(element, {
29
+ type: 'transitionend',
30
+ listener: settle,
31
+ options: {
32
+ once: true
33
+ }
34
+ });
35
+ const 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
+
5
69
  /**
6
70
  * Manages the children mount/unmount lifecycle around CSS exit transitions.
7
71
  *
@@ -18,26 +82,20 @@ import { usePresetStyles } from './use-preset-styles';
18
82
  *
19
83
  * ## How it works
20
84
  *
21
- * `showChildren` tracks whether the consumer's children should be in the DOM.
22
- * It does not mirror `isOpen` exactly - it intentionally lags behind during
23
- * 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):
24
91
  *
25
92
  * ```
26
- * isOpen: true ──────────────── false
27
- * showChildren: true ──────────────── true ─── (exit animation) ─── false
93
+ * isOpen: true ──────────────── false
94
+ * phase: open ──────────────── exiting ─── (exit settle) ─── closed
28
95
  * ```
29
96
  *
30
- * Two close paths exist:
31
- *
32
- * 1. **Animated close** (`willAnimate === true`):
33
- * `isOpen` goes `false` → `showChildren` stays `true` → the CSS exit
34
- * transition plays → `transitionend` fires → `showChildren` → `false`
35
- * → `onExitFinish` fires. A timeout fallback ensures we still unmount
36
- * if `transitionend` never fires (e.g. browser quirks, zero-duration).
37
- *
38
- * 2. **Non-animated close** (`willAnimate === false`):
39
- * `isOpen` goes `false` → `showChildren` → `false` synchronously during
40
- * 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.
41
99
  */
42
100
  export function useAnimatedVisibility({
43
101
  isOpen,
@@ -56,26 +114,34 @@ export function useAnimatedVisibility({
56
114
  // or the user has enabled the "prefers reduced motion" OS setting.
57
115
  const willAnimate = Boolean(animate) && !prefersReducedMotion();
58
116
 
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
- const [showChildren, setShowChildren] = useState(isOpen);
74
- if (isOpen && !showChildren) {
75
- setShowChildren(true);
117
+ // `phase` is the single source of truth for the visibility lifecycle.
118
+ // We promote `closed → entering` (animated) or `closed → open`
119
+ // (non-animated) synchronously during render so the host element mounts
120
+ // in the same commit as the open intent (React supports a single
121
+ // setState during render as a synchronous re-render before paint).
122
+ // All other transitions are driven by phase-specific effects below.
123
+ const [phase, setPhase] = useState(() => {
124
+ if (!isOpen) {
125
+ return 'closed';
126
+ }
127
+ return willAnimate ? 'entering' : 'open';
128
+ });
129
+ if (isOpen && phase === 'closed') {
130
+ setPhase(willAnimate ? 'entering' : 'open');
131
+ }
132
+ if (!isOpen && (phase === 'open' || phase === 'entering')) {
133
+ // Close intent. Move to `exiting` to gate the unmount on either
134
+ // the exit transition (animated) or the toggle/close event
135
+ // (non-animated). Includes interrupted entry (`entering → exiting`).
136
+ setPhase('exiting');
76
137
  }
77
- if (!isOpen && showChildren && !willAnimate) {
78
- setShowChildren(false);
138
+ if (isOpen && phase === 'exiting') {
139
+ // Reopen mid-exit. With animation, jump back to `entering` and let
140
+ // the entry transition restart from current visual state. Without
141
+ // animation, jump straight to `open` (the handshake we were waiting
142
+ // for is no longer relevant; the consumer wants the host visible
143
+ // again synchronously).
144
+ setPhase(willAnimate ? 'entering' : 'open');
79
145
  }
80
146
 
81
147
  // Keep stable refs to the latest callbacks so effects and timeout closures
@@ -85,152 +151,159 @@ export function useAnimatedVisibility({
85
151
  const onExitFinishRef = useRef(onExitFinish);
86
152
  onExitFinishRef.current = onExitFinish;
87
153
 
88
- // Animated entry effect
89
- //
90
- // Fires `onEnterFinish` after the entry transition completes.
91
- // When there is no animation, fires it immediately via a separate effect.
92
- //
93
- // A fallback timeout (enterDurationMs + 50ms buffer) ensures it always
94
- // fires even if `transitionend` is never emitted (e.g. zero-duration,
95
- // browser quirk, or reduced motion).
154
+ // `entering` (animation enabled):
155
+ // Wait for the entry transition to settle, then move to `open` and fire
156
+ // `onEnterFinish`. A fallback timeout (enterDurationMs + 50ms buffer)
157
+ // guarantees the settle path runs even if `transitionend` is swallowed
158
+ // (zero-duration, browser quirk, mid-transition unmount).
96
159
  useEffect(() => {
97
160
  var _preset$enterDuration;
98
- if (!isOpen || !showChildren || !willAnimate) {
161
+ if (phase !== 'entering') {
99
162
  return;
100
163
  }
101
164
  const element = elementRef.current;
102
165
  if (!element) {
103
166
  return;
104
167
  }
105
-
106
- // Both the transitionend listener and the fallback timeout are guarded by
107
- // a shared `hasFired` flag to prevent double-firing. This handles the race
108
- // where the 350ms transition finishes after the 400ms fallback fires
109
- // (or vice versa): whichever fires first sets `hasFired = true`, and the
110
- // second path becomes a no-op.
111
- let hasFired = false;
112
- function handleTransitionEnd() {
113
- var _onEnterFinishRef$cur;
114
- if (hasFired) {
115
- return;
116
- }
117
- hasFired = true;
118
- clearTimeout(fallbackId);
119
- unbind();
120
- (_onEnterFinishRef$cur = onEnterFinishRef.current) === null || _onEnterFinishRef$cur === void 0 ? void 0 : _onEnterFinishRef$cur.call(onEnterFinishRef);
121
- }
122
- const fallbackId = setTimeout(handleTransitionEnd, ((_preset$enterDuration = preset === null || preset === void 0 ? void 0 : preset.enterDurationMs) !== null && _preset$enterDuration !== void 0 ? _preset$enterDuration : 0) + 50);
123
- const unbind = bind(element, {
124
- type: 'transitionend',
125
- listener: handleTransitionEnd,
126
- options: {
127
- once: true
168
+ return runOnTransitionEndOrTimeout({
169
+ element,
170
+ safetyNetMs: ((_preset$enterDuration = preset === null || preset === void 0 ? void 0 : preset.enterDurationMs) !== null && _preset$enterDuration !== void 0 ? _preset$enterDuration : 0) + 50,
171
+ onSettled: () => {
172
+ var _onEnterFinishRef$cur;
173
+ // Fire the consumer callback before transitioning phase so any
174
+ // state the consumer reads (refs, DOM nodes) is still attached
175
+ // in the same synchronous tick, regardless of React batching
176
+ // semantics.
177
+ (_onEnterFinishRef$cur = onEnterFinishRef.current) === null || _onEnterFinishRef$cur === void 0 ? void 0 : _onEnterFinishRef$cur.call(onEnterFinishRef);
178
+ setPhase(current => current === 'entering' ? 'open' : current);
128
179
  }
129
180
  });
130
- return () => {
131
- hasFired = true;
132
- clearTimeout(fallbackId);
133
- unbind();
134
- };
135
- }, [isOpen, showChildren, willAnimate, preset === null || preset === void 0 ? void 0 : preset.enterDurationMs, elementRef]);
181
+ }, [phase, preset === null || preset === void 0 ? void 0 : preset.enterDurationMs, elementRef]);
136
182
 
137
- // Non-animated entry callback
138
- //
139
- // When there is no animation, fire `onEnterFinish` immediately on mount and after `isOpen` goes false->true.
140
- // The initial value is false to make sure it fires on mount if the current `isOpen` is true.
141
- const prevIsOpenForEntryRef = useRef(false);
183
+ // Non-animated entry callback:
184
+ // When animation is disabled, the lifecycle goes `closed → open`
185
+ // directly with no `entering` phase. Fire `onEnterFinish` on the
186
+ // transition into `open` from the non-animated path. We detect this
187
+ // via a previous-phase ref to avoid firing on the animated path's
188
+ // `entering → open` transition (which has its own callback above).
189
+ const prevPhaseForEntryRef = useRef('closed');
142
190
  useEffect(() => {
143
- const prevIsOpen = prevIsOpenForEntryRef.current;
144
- prevIsOpenForEntryRef.current = isOpen;
145
- if (!prevIsOpen && isOpen && !willAnimate) {
191
+ const prevPhase = prevPhaseForEntryRef.current;
192
+ prevPhaseForEntryRef.current = phase;
193
+
194
+ // Animated entry settle goes through `entering` and already fired
195
+ // onEnterFinish above; only handle the non-animated direct jumps.
196
+ if (prevPhase !== 'open' && phase === 'open' && prevPhase !== 'entering') {
146
197
  var _onEnterFinishRef$cur2;
147
198
  (_onEnterFinishRef$cur2 = onEnterFinishRef.current) === null || _onEnterFinishRef$cur2 === void 0 ? void 0 : _onEnterFinishRef$cur2.call(onEnterFinishRef);
148
199
  }
149
- }, [isOpen, willAnimate]);
200
+ }, [phase]);
150
201
 
151
- // Animated exit effect
152
- //
153
- // Runs when all three conditions are true:
154
- // • `isOpen` is false (consumer wants to close)
155
- // • `showChildren` is true (children are still mounted for exit anim)
156
- // • `willAnimate` is true (we have a preset and reduced motion is off)
157
- //
158
- // Listens for `transitionend` on the element to know when the CSS exit
159
- // transition has finished, then unmounts children and fires onExitFinish.
160
- //
161
- // Fallback timeout: `transitionend` can fail to fire in edge cases
162
- // (e.g. element removed from top layer by browser, zero-duration
163
- // transition, interrupted animation). The timeout is set to the
164
- // preset's exit duration + 50ms buffer to guarantee we always unmount.
202
+ // `exiting` (animation enabled):
203
+ // Wait for the exit transition to settle, then unmount the host element
204
+ // (set phase back to `closed`) and fire `onExitFinish`. The fallback
205
+ // timeout (exitDurationMs + 50ms buffer) guarantees we always unmount
206
+ // even if `transitionend` is never emitted (element removed from the
207
+ // top layer, zero-duration transition, interrupted animation).
165
208
  useEffect(() => {
166
209
  var _preset$exitDurationM;
167
- // Guard: only run during the "exiting" window
168
- if (isOpen || !showChildren || !willAnimate) {
210
+ if (phase !== 'exiting' || !willAnimate) {
169
211
  return;
170
212
  }
171
213
  const element = elementRef.current;
172
214
  if (!element) {
173
215
  return;
174
216
  }
175
-
176
- // Both the transitionend listener and the fallback timeout are guarded by
177
- // a shared `hasFired` flag to prevent double-firing. This handles the race
178
- // where the 350ms transition finishes after the 400ms fallback fires
179
- // (or vice versa): whichever fires first sets `hasFired = true`, and the
180
- // second path becomes a no-op.
181
- let hasFired = false;
182
- function handleTransitionEnd() {
183
- var _onExitFinishRef$curr;
184
- if (hasFired) {
185
- return;
186
- }
187
- hasFired = true;
188
- clearTimeout(fallbackId);
189
- unbind();
190
- setShowChildren(false);
191
- (_onExitFinishRef$curr = onExitFinishRef.current) === null || _onExitFinishRef$curr === void 0 ? void 0 : _onExitFinishRef$curr.call(onExitFinishRef);
192
- }
193
-
194
- // Fallback: unmount even if transitionend never fires
195
- const fallbackId = setTimeout(handleTransitionEnd, ((_preset$exitDurationM = preset === null || preset === void 0 ? void 0 : preset.exitDurationMs) !== null && _preset$exitDurationM !== void 0 ? _preset$exitDurationM : 0) + 50);
196
-
197
- // Primary: listen for the CSS transition to complete
198
- const unbind = bind(element, {
199
- type: 'transitionend',
200
- listener: handleTransitionEnd,
201
- options: {
202
- once: true
217
+ return runOnTransitionEndOrTimeout({
218
+ element,
219
+ safetyNetMs: ((_preset$exitDurationM = preset === null || preset === void 0 ? void 0 : preset.exitDurationMs) !== null && _preset$exitDurationM !== void 0 ? _preset$exitDurationM : 0) + 50,
220
+ onSettled: () => {
221
+ var _onExitFinishRef$curr;
222
+ // Fire the consumer callback before transitioning phase to
223
+ // `closed`. The phase change drives the host element unmount,
224
+ // and consumers expect to be able to read the element (or a
225
+ // forwarded ref to it) inside `onExitFinish`. Doing this in
226
+ // the safe order avoids relying on React's automatic batching
227
+ // to keep the element attached across the callback.
228
+ (_onExitFinishRef$curr = onExitFinishRef.current) === null || _onExitFinishRef$curr === void 0 ? void 0 : _onExitFinishRef$curr.call(onExitFinishRef);
229
+ setPhase(current => current === 'exiting' ? 'closed' : current);
203
230
  }
204
231
  });
232
+ }, [phase, willAnimate, preset === null || preset === void 0 ? void 0 : preset.exitDurationMs, elementRef]);
205
233
 
206
- // Cleanup on re-render or unmount: cancel pending timeout and listener
207
- return () => {
208
- hasFired = true;
209
- clearTimeout(fallbackId);
210
- unbind();
211
- };
212
- }, [isOpen, showChildren, willAnimate, preset === null || preset === void 0 ? void 0 : preset.exitDurationMs, elementRef]);
213
-
214
- // Non-animated exit callback
234
+ // `exiting` (animation disabled):
235
+ // Wait for the browser's `toggle` (closed) event (for `<div popover>`)
236
+ // or `close` event (for `<dialog>`) to fire before unmounting. This
237
+ // guarantees:
238
+ // - the consumer's toggle/close listener (close-reason capture,
239
+ // nested focus restoration, `onClose` notification) runs against
240
+ // the still-attached element,
241
+ // - the browser's native focus restoration triggered by
242
+ // `hidePopover()` / `<dialog>.close()` has fired,
243
+ // before React unmounts the node. A naive `setTimeout(0)` race
244
+ // occasionally beats the `toggle` task in Chromium, causing focus
245
+ // restoration to drop (the toggle event arrives at a detached element).
215
246
  //
216
- // When there is no animation, children unmount synchronously (handled
217
- // by the `if` block above). But `onExitFinish` still needs to fire -
218
- // we do that in a follow-up effect so the consumer can react to it.
247
+ // `useLayoutEffect` (not `useEffect`) so the listener is bound in the
248
+ // same commit as the consumer's show/hide layout effect that calls
249
+ // `hidePopover()` / `dialog.close()`. The browser queues the resulting
250
+ // `toggle`/`close` event as a task, which cannot run until the JS
251
+ // frame (including all effect flushes) completes, so the listener is
252
+ // always attached before the event fires. Promotion to layout timing
253
+ // removes any theoretical gap and future-proofs the ordering against
254
+ // effect reordering. Safe under SSR: `phase` starts at `'closed'`, so
255
+ // the body short-circuits and no DOM access happens on the server.
219
256
  //
220
- // Uses a previous-value ref (`wasOpenRef`) to detect the exact
221
- // true→false transition of `isOpen`, avoiding false positives on
222
- // mount or re-renders where `isOpen` was already false.
223
- const wasOpenRef = useRef(isOpen);
257
+ // `onExitFinish` is NOT called here. It is decoupled from the unmount
258
+ // handshake and fired by the dedicated non-animated exit-callback
259
+ // effect below, so consumers observe a synchronous "exit finished"
260
+ // signal that matches `isOpen` flipping to `false`.
261
+ useLayoutEffect(() => {
262
+ if (phase !== 'exiting' || willAnimate) {
263
+ return;
264
+ }
265
+ const element = elementRef.current;
266
+ if (!element) {
267
+ return;
268
+ }
269
+ function unmount() {
270
+ setPhase(current => current === 'exiting' ? 'closed' : current);
271
+ }
272
+ return bindAll(element, [{
273
+ type: 'toggle',
274
+ listener: event => {
275
+ if (event.newState === 'closed') {
276
+ unmount();
277
+ }
278
+ }
279
+ }, {
280
+ type: 'close',
281
+ listener: unmount
282
+ }]);
283
+ }, [phase, willAnimate, elementRef]);
284
+
285
+ // Non-animated exit callback:
286
+ // When animation is disabled there is no `transitionend` to wait for,
287
+ // so fire `onExitFinish` on the transition into `exiting`. The actual
288
+ // host-element unmount is gated on the browser `toggle`/`close` event
289
+ // by the effect above; `onExitFinish` is observably decoupled from
290
+ // that handshake so consumers see the callback fire at the same time
291
+ // `isOpen` flipped to `false`.
292
+ const prevPhaseForExitRef = useRef('closed');
224
293
  useEffect(() => {
225
- const wasOpen = wasOpenRef.current;
226
- wasOpenRef.current = isOpen;
227
- if (wasOpen && !isOpen && !willAnimate) {
294
+ const prevPhase = prevPhaseForExitRef.current;
295
+ prevPhaseForExitRef.current = phase;
296
+
297
+ // Only fire on the non-animated transition into `exiting`. The
298
+ // animated path's `exiting → closed` settle already fires
299
+ // `onExitFinish` from `runOnTransitionEndOrTimeout`.
300
+ if (prevPhase !== 'exiting' && phase === 'exiting' && !willAnimate) {
228
301
  var _onExitFinishRef$curr2;
229
302
  (_onExitFinishRef$curr2 = onExitFinishRef.current) === null || _onExitFinishRef$curr2 === void 0 ? void 0 : _onExitFinishRef$curr2.call(onExitFinishRef);
230
303
  }
231
- }, [isOpen, willAnimate]);
304
+ }, [phase, willAnimate]);
232
305
  return {
233
- showChildren,
306
+ phase,
234
307
  preset
235
308
  };
236
309
  }