@atlaskit/top-layer 0.11.0 → 0.12.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 (36) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cjs/animations/presets.js +5 -0
  3. package/dist/cjs/internal/use-animated-visibility.js +84 -7
  4. package/dist/cjs/popover/popover.js +2 -0
  5. package/dist/cjs/popup/popup-content.js +53 -13
  6. package/dist/cjs/popup/popup-context.js +17 -0
  7. package/dist/cjs/popup/popup-trigger-function.js +7 -4
  8. package/dist/cjs/popup/popup-trigger.js +12 -7
  9. package/dist/cjs/popup/popup.js +5 -5
  10. package/dist/es2019/animations/presets.js +5 -0
  11. package/dist/es2019/internal/use-animated-visibility.js +84 -7
  12. package/dist/es2019/popover/popover.js +2 -0
  13. package/dist/es2019/popup/popup-content.js +53 -13
  14. package/dist/es2019/popup/popup-context.js +18 -0
  15. package/dist/es2019/popup/popup-trigger-function.js +7 -4
  16. package/dist/es2019/popup/popup-trigger.js +12 -7
  17. package/dist/es2019/popup/popup.js +3 -3
  18. package/dist/esm/animations/presets.js +5 -0
  19. package/dist/esm/internal/use-animated-visibility.js +84 -7
  20. package/dist/esm/popover/popover.js +2 -0
  21. package/dist/esm/popup/popup-content.js +53 -13
  22. package/dist/esm/popup/popup-context.js +18 -0
  23. package/dist/esm/popup/popup-trigger-function.js +7 -4
  24. package/dist/esm/popup/popup-trigger.js +12 -7
  25. package/dist/esm/popup/popup.js +5 -5
  26. package/dist/types/animations/types.d.ts +5 -1
  27. package/dist/types/internal/use-animated-visibility.d.ts +8 -2
  28. package/dist/types/popover/types.d.ts +8 -0
  29. package/dist/types/popup/popup-context.d.ts +24 -4
  30. package/dist/types/popup/types.d.ts +12 -0
  31. package/dist/types-ts4.5/animations/types.d.ts +5 -1
  32. package/dist/types-ts4.5/internal/use-animated-visibility.d.ts +8 -2
  33. package/dist/types-ts4.5/popover/types.d.ts +8 -0
  34. package/dist/types-ts4.5/popup/popup-context.d.ts +24 -4
  35. package/dist/types-ts4.5/popup/types.d.ts +12 -0
  36. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @atlaskit/top-layer
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`cc76d2e733b71`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/cc76d2e733b71) - -
8
+ The prop `onEnterFinish` has been added to `Popup.Content` and `Popover`, for lifecycle
9
+ coordination after the entry animation completes.
10
+ - The prop `onExitFinish` has been added to `Popup.Content`, for lifecycle coordination after the
11
+ exit animation completes. `Popover` already exposed this prop.
12
+
13
+ - Updates the popup trigger's `aria-expanded` attribute, so it stays `"true"` for the full
14
+ duration of the popup's open and close animations, only going `false` after the exit animation
15
+ completes.
16
+
17
+ - Internal refactor to the popup context to use a string union for state.
18
+
3
19
  ## 0.11.0
4
20
 
5
21
  ### Minor Changes
@@ -86,6 +86,7 @@ function slideAndFade(options) {
86
86
  distance: distance
87
87
  });
88
88
  },
89
+ enterDurationMs: 350,
89
90
  exitDurationMs: 175
90
91
  };
91
92
  }
@@ -111,6 +112,7 @@ function fade() {
111
112
  return {
112
113
  name: 'fade',
113
114
  css: FADE_CSS,
115
+ enterDurationMs: 350,
114
116
  exitDurationMs: 175
115
117
  };
116
118
  }
@@ -136,6 +138,7 @@ function scaleAndFade() {
136
138
  return {
137
139
  name: 'scale-and-fade',
138
140
  css: SCALE_AND_FADE_CSS,
141
+ enterDurationMs: 350,
139
142
  exitDurationMs: 175
140
143
  };
141
144
  }
@@ -175,6 +178,7 @@ function dialogSlideUpAndFade(options) {
175
178
  return {
176
179
  name: distance === 12 ? 'slide-up-and-fade' : "slide-up-and-fade-".concat(distance),
177
180
  css: distance === 12 ? DIALOG_SLIDE_UP_AND_FADE_CSS : DIALOG_SLIDE_UP_AND_FADE_CSS.replace(/12px/g, "".concat(distance, "px")),
181
+ enterDurationMs: 350,
178
182
  exitDurationMs: 175
179
183
  };
180
184
  }
@@ -203,6 +207,7 @@ function dialogFade() {
203
207
  return {
204
208
  name: 'fade',
205
209
  css: DIALOG_FADE_CSS,
210
+ enterDurationMs: 350,
206
211
  exitDurationMs: 175
207
212
  };
208
213
  }
@@ -51,6 +51,7 @@ function useAnimatedVisibility(_ref) {
51
51
  var isOpen = _ref.isOpen,
52
52
  animate = _ref.animate,
53
53
  elementRef = _ref.elementRef,
54
+ onEnterFinish = _ref.onEnterFinish,
54
55
  onExitFinish = _ref.onExitFinish;
55
56
  // Resolve the animation preset and inject its CSS (idempotent).
56
57
  // Returns `null` when `animate` is falsy.
@@ -87,13 +88,76 @@ function useAnimatedVisibility(_ref) {
87
88
  setShowChildren(false);
88
89
  }
89
90
 
90
- // Keep a stable ref to the latest `onExitFinish` so the effect and
91
- // timeout closures below always call the current callback without
92
- // needing `onExitFinish` in dependency arrays (which would reset
93
- // the timeout on every new closure).
91
+ // Keep stable refs to the latest callbacks so effects and timeout closures
92
+ // always call the current callback without needing them in dependency arrays.
93
+ var onEnterFinishRef = (0, _react.useRef)(onEnterFinish);
94
+ onEnterFinishRef.current = onEnterFinish;
94
95
  var onExitFinishRef = (0, _react.useRef)(onExitFinish);
95
96
  onExitFinishRef.current = onExitFinish;
96
97
 
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).
106
+ (0, _react.useEffect)(function () {
107
+ var _preset$enterDuration;
108
+ if (!isOpen || !showChildren || !willAnimate) {
109
+ return;
110
+ }
111
+ var element = elementRef.current;
112
+ if (!element) {
113
+ return;
114
+ }
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
138
+ }
139
+ });
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]);
146
+
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);
152
+ (0, _react.useEffect)(function () {
153
+ var prevIsOpen = prevIsOpenForEntryRef.current;
154
+ prevIsOpenForEntryRef.current = isOpen;
155
+ if (!prevIsOpen && isOpen && !willAnimate) {
156
+ var _onEnterFinishRef$cur2;
157
+ (_onEnterFinishRef$cur2 = onEnterFinishRef.current) === null || _onEnterFinishRef$cur2 === void 0 || _onEnterFinishRef$cur2.call(onEnterFinishRef);
158
+ }
159
+ }, [isOpen, willAnimate]);
160
+
97
161
  // ── Animated exit effect ──
98
162
  //
99
163
  // Runs when all three conditions are true:
@@ -118,20 +182,32 @@ function useAnimatedVisibility(_ref) {
118
182
  if (!element) {
119
183
  return;
120
184
  }
121
- function finish() {
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() {
122
193
  var _onExitFinishRef$curr;
194
+ if (hasFired) {
195
+ return;
196
+ }
197
+ hasFired = true;
123
198
  clearTimeout(fallbackId);
199
+ unbind();
124
200
  setShowChildren(false);
125
201
  (_onExitFinishRef$curr = onExitFinishRef.current) === null || _onExitFinishRef$curr === void 0 || _onExitFinishRef$curr.call(onExitFinishRef);
126
202
  }
127
203
 
128
204
  // Fallback: unmount even if transitionend never fires
129
- var fallbackId = setTimeout(finish, ((_preset$exitDurationM = preset === null || preset === void 0 ? void 0 : preset.exitDurationMs) !== null && _preset$exitDurationM !== void 0 ? _preset$exitDurationM : 0) + 50);
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);
130
206
 
131
207
  // Primary: listen for the CSS transition to complete
132
208
  var unbind = (0, _bindEventListener.bind)(element, {
133
209
  type: 'transitionend',
134
- listener: finish,
210
+ listener: handleTransitionEnd,
135
211
  options: {
136
212
  once: true
137
213
  }
@@ -139,6 +215,7 @@ function useAnimatedVisibility(_ref) {
139
215
 
140
216
  // Cleanup on re-render or unmount: cancel pending timeout and listener
141
217
  return function () {
218
+ hasFired = true;
142
219
  clearTimeout(fallbackId);
143
220
  unbind();
144
221
  };
@@ -75,6 +75,7 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
75
75
  modeProp = _ref$mode === void 0 ? 'auto' : _ref$mode,
76
76
  onClose = _ref.onClose,
77
77
  onOpenChange = _ref.onOpenChange,
78
+ onEnterFinish = _ref.onEnterFinish,
78
79
  onExitFinish = _ref.onExitFinish,
79
80
  animate = _ref.animate,
80
81
  placement = _ref.placement,
@@ -98,6 +99,7 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
98
99
  isOpen: isOpen,
99
100
  animate: animate,
100
101
  elementRef: ownRef,
102
+ onEnterFinish: onEnterFinish,
101
103
  onExitFinish: onExitFinish
102
104
  }),
103
105
  showChildren = _useAnimatedVisibilit.showChildren,
@@ -11,6 +11,7 @@ var _runtime = require("@compiled/react/runtime");
11
11
  var _extends2 = _interopRequireDefault(require("@babel/runtime/helpers/extends"));
12
12
  var _react = _interopRequireWildcard(require("react"));
13
13
  var _mergeRefs = _interopRequireDefault(require("@atlaskit/ds-lib/merge-refs"));
14
+ var _reducedMotion = require("../internal/reduced-motion");
14
15
  var _roleTypes = require("../internal/role-types");
15
16
  var _useAnchorPosition = require("../internal/use-anchor-position");
16
17
  var _useWidthFromAnchor = require("../internal/use-width-from-anchor");
@@ -25,7 +26,7 @@ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r
25
26
  * use `Popover` + `useAnchorPosition` directly.
26
27
  */
27
28
  var PopupContent = exports.PopupContent = /*#__PURE__*/(0, _react.forwardRef)(function PopupContent(_ref, ref) {
28
- var _ref2, _ref3, _ctx$popoverRef;
29
+ var _ref2, _ctx$popoverRef;
29
30
  var children = _ref.children,
30
31
  role = _ref.role,
31
32
  label = _ref.label,
@@ -41,7 +42,9 @@ var PopupContent = exports.PopupContent = /*#__PURE__*/(0, _react.forwardRef)(fu
41
42
  onCloseProp = _ref.onClose,
42
43
  testIdProp = _ref.testId,
43
44
  forceFallbackPositioningProp = _ref.forceFallbackPositioning,
44
- idProp = _ref.id;
45
+ idProp = _ref.id,
46
+ onEnterFinish = _ref.onEnterFinish,
47
+ onExitFinish = _ref.onExitFinish;
45
48
  // When inside <Popup>, context provides defaults. Props always win.
46
49
  var ctx = (0, _popupContext.useMaybePopupContext)();
47
50
  var triggerRef = triggerRefProp !== null && triggerRefProp !== void 0 ? triggerRefProp : ctx === null || ctx === void 0 ? void 0 : ctx.triggerRef;
@@ -50,16 +53,47 @@ var PopupContent = exports.PopupContent = /*#__PURE__*/(0, _react.forwardRef)(fu
50
53
  var testId = testIdProp !== null && testIdProp !== void 0 ? testIdProp : ctx === null || ctx === void 0 ? void 0 : ctx.testId;
51
54
  var forceFallbackPositioning = forceFallbackPositioningProp !== null && forceFallbackPositioningProp !== void 0 ? forceFallbackPositioningProp : ctx === null || ctx === void 0 ? void 0 : ctx.forceFallbackPositioning;
52
55
 
53
- // Resolve isOpen: prop wins, then context.
54
- // Inside the <Popup> compound, ctx.isOpen tracks the browser's toggle state.
55
- var isOpen = (_ref3 = isOpenProp !== null && isOpenProp !== void 0 ? isOpenProp : ctx === null || ctx === void 0 ? void 0 : ctx.isOpen) !== null && _ref3 !== void 0 ? _ref3 : false;
56
-
57
- // Combine internal state sync with consumer's onOpenChange callback.
58
- // The Popup compound needs setIsOpen to keep aria-expanded on the trigger
59
- // in sync, and the consumer may also want to respond to open/close events
60
- // (e.g. for focus management).
61
- var setIsOpen = ctx === null || ctx === void 0 ? void 0 : ctx.setIsOpen;
56
+ // isOpen: prop wins, otherwise derive from context popupState.
57
+ // 'animating-open' counts as open so <Popover> stays visible during entry animation.
58
+ var isOpen = isOpenProp !== null && isOpenProp !== void 0 ? isOpenProp : ctx !== null ? ctx.popupState === 'open' || ctx.popupState === 'animating-open' : false;
59
+ var setPopupState = ctx === null || ctx === void 0 ? void 0 : ctx.setPopupState;
62
60
  var consumerOnOpenChange = ctx === null || ctx === void 0 ? void 0 : ctx.onOpenChange;
61
+
62
+ // ── TPopupState ──
63
+ //
64
+ // popupState is driven by handleOpenChange (below), which fires on every browser
65
+ // toggle event (showPopover / hidePopover / light-dismiss).
66
+ //
67
+ // Exception — initial mount with isOpen=true (prop-controlled usage):
68
+ // handleOpenChange only fires from the Popover's toggle event listener, which
69
+ // is registered in a useEffect. On the first render, useEffect hasn't run yet,
70
+ // so handleOpenChange never fires. We sync popupState here instead.
71
+ //
72
+ // Transitions when animations are active:
73
+ // closed → animating-open → open (enter)
74
+ // open → animating-closed → closed (exit, via onExitFinish)
75
+ //
76
+ // Without animation: closed ↔ open directly.
77
+ var willAnimate = Boolean(animate) && !(0, _reducedMotion.prefersReducedMotion)();
78
+ var hasMountedRef = (0, _react.useRef)(false);
79
+ (0, _react.useLayoutEffect)(function () {
80
+ if (hasMountedRef.current) {
81
+ return;
82
+ }
83
+ hasMountedRef.current = true;
84
+ if (isOpen) {
85
+ setPopupState === null || setPopupState === void 0 || setPopupState(willAnimate ? 'animating-open' : 'open');
86
+ }
87
+ }, [isOpen, setPopupState, willAnimate]);
88
+ var handleEnterFinish = (0, _react.useCallback)(function () {
89
+ setPopupState === null || setPopupState === void 0 || setPopupState('open');
90
+ onEnterFinish === null || onEnterFinish === void 0 || onEnterFinish();
91
+ }, [setPopupState, onEnterFinish]);
92
+ var handleExitFinish = (0, _react.useCallback)(function () {
93
+ setPopupState === null || setPopupState === void 0 || setPopupState('closed');
94
+ onExitFinish === null || onExitFinish === void 0 || onExitFinish();
95
+ }, [setPopupState, onExitFinish]);
96
+
63
97
  // Note: onOpenChange composition is handled by handleOpenChange below,
64
98
  // which also adds nested-popover focus restoration.
65
99
 
@@ -91,7 +125,11 @@ var PopupContent = exports.PopupContent = /*#__PURE__*/(0, _react.forwardRef)(fu
91
125
  // See: notes/architecture/focus-restoration.md
92
126
  var handleOpenChange = (0, _react.useCallback)(function (args) {
93
127
  // Sync compound state
94
- setIsOpen === null || setIsOpen === void 0 || setIsOpen(args.isOpen);
128
+ if (args.isOpen) {
129
+ setPopupState === null || setPopupState === void 0 || setPopupState(willAnimate ? 'animating-open' : 'open');
130
+ } else {
131
+ setPopupState === null || setPopupState === void 0 || setPopupState(willAnimate ? 'animating-closed' : 'closed');
132
+ }
95
133
  consumerOnOpenChange === null || consumerOnOpenChange === void 0 || consumerOnOpenChange(args);
96
134
 
97
135
  // ── Nested popover focus restoration fallback ──
@@ -121,7 +159,7 @@ var PopupContent = exports.PopupContent = /*#__PURE__*/(0, _react.forwardRef)(fu
121
159
  });
122
160
  }
123
161
  }
124
- }, [setIsOpen, consumerOnOpenChange, triggerRef, role]);
162
+ }, [consumerOnOpenChange, triggerRef, role, setPopupState, willAnimate]);
125
163
 
126
164
  // ── Anchor positioning ──
127
165
  // Compose useAnchorPosition with Popover. The hook positions the popover
@@ -160,6 +198,8 @@ var PopupContent = exports.PopupContent = /*#__PURE__*/(0, _react.forwardRef)(fu
160
198
  labelledBy: labelledBy,
161
199
  onClose: onClose !== null && onClose !== void 0 ? onClose : undefined,
162
200
  onOpenChange: handleOpenChange,
201
+ onEnterFinish: handleEnterFinish,
202
+ onExitFinish: handleExitFinish,
163
203
  testId: testId,
164
204
  children: children,
165
205
  // Standalone consumers can pass an explicit `id` - used by their own
@@ -7,6 +7,23 @@ exports.PopupProvider = void 0;
7
7
  exports.useMaybePopupContext = useMaybePopupContext;
8
8
  exports.usePopupContext = usePopupContext;
9
9
  var _react = require("react");
10
+ /**
11
+ * Represents the lifecycle state of the popup.
12
+ *
13
+ * - `'closed'`: fully closed, not visible, no animation playing.
14
+ * - `'animating-open'`: entry animation is playing (popup is opening but not yet settled).
15
+ * - `'open'`: fully open, browser popover is showing, entry animation complete.
16
+ * - `'animating-closed'`: exit animation is playing (popup is closing but still visible).
17
+ *
18
+ * Used to derive `aria-expanded` on the trigger:
19
+ * - `aria-expanded` is `true` in `'animating-open'`, `'open'`, and `'animating-closed'`.
20
+ *
21
+ * This avoids screen readers announcing the popup as closed while it is
22
+ * still visually present on screen during animations, and prevents consumers
23
+ * that conditionally show/hide trigger UI based on `aria-expanded` from hiding
24
+ * the trigger (and thus the popup anchor) mid-animation.
25
+ */
26
+
10
27
  var PopupContext = /*#__PURE__*/(0, _react.createContext)(null);
11
28
 
12
29
  /**
@@ -46,7 +46,7 @@ function PopupTriggerFunction(_ref) {
46
46
  triggerRef = _usePopupContext.triggerRef,
47
47
  popoverRef = _usePopupContext.popoverRef,
48
48
  popoverId = _usePopupContext.popoverId,
49
- isOpen = _usePopupContext.isOpen,
49
+ popupState = _usePopupContext.popupState,
50
50
  ariaHasPopup = _usePopupContext.ariaHasPopup;
51
51
  var ref = (0, _react.useCallback)(function (node) {
52
52
  triggerRef.current = node;
@@ -62,14 +62,17 @@ function PopupTriggerFunction(_ref) {
62
62
  }, [popoverRef]);
63
63
  var ariaAttributes = (0, _react.useMemo)(function () {
64
64
  return {
65
- 'aria-expanded': isOpen,
65
+ // aria-expanded stays true throughout entry and exit animations so screen readers
66
+ // don't announce the popup as closed while it's still visible, and consumers
67
+ // relying on aria-expanded to show/hide trigger UI don't lose the anchor mid-animation.
68
+ 'aria-expanded': popupState === 'open' || popupState === 'animating-open' || popupState === 'animating-closed',
66
69
  'aria-controls': popoverId,
67
70
  'aria-haspopup': ariaHasPopup
68
71
  };
69
- }, [isOpen, popoverId, ariaHasPopup]);
72
+ }, [popupState, popoverId, ariaHasPopup]);
70
73
  return children({
71
74
  ref: ref,
72
- isOpen: isOpen,
75
+ isOpen: popupState === 'open' || popupState === 'animating-open',
73
76
  popoverId: popoverId,
74
77
  toggle: toggle,
75
78
  ariaAttributes: ariaAttributes
@@ -26,14 +26,15 @@ function PopupTrigger(_ref) {
26
26
  triggerRef = _usePopupContext.triggerRef,
27
27
  popoverRef = _usePopupContext.popoverRef,
28
28
  popoverId = _usePopupContext.popoverId,
29
- isOpen = _usePopupContext.isOpen,
29
+ popupState = _usePopupContext.popupState,
30
30
  ariaHasPopup = _usePopupContext.ariaHasPopup;
31
31
  var childOnClick = /*#__PURE__*/(0, _react.isValidElement)(children) ? children.props.onClick : undefined;
32
32
 
33
- // Track latest isOpen via ref so the click handler keeps a stable
34
- // identity (cloneElement relies on it).
35
- var isOpenRef = _react.default.useRef(isOpen);
36
- isOpenRef.current = isOpen;
33
+ // Use a ref to track the latest isOpen value so the click handler
34
+ // does not need isOpen in its dependency array (which would recreate
35
+ // the handler on every open/close, breaking cloneElement identity).
36
+ var isOpenRef = _react.default.useRef(popupState === 'open' || popupState === 'animating-open');
37
+ isOpenRef.current = popupState === 'open' || popupState === 'animating-open';
37
38
  var handleClick = (0, _react.useCallback)(function (event) {
38
39
  var popoverEl = popoverRef.current;
39
40
  if (popoverEl) {
@@ -53,8 +54,12 @@ function PopupTrigger(_ref) {
53
54
  }, [popoverRef, childOnClick]);
54
55
  return /*#__PURE__*/_react.default.createElement(_slot.Slot, {
55
56
  ref: triggerRef,
56
- onClick: handleClick,
57
- "aria-expanded": isOpen,
57
+ onClick: handleClick
58
+ // aria-expanded stays true throughout entry and exit animations so screen readers
59
+ // don't announce the popup as closed while it's still visible, and consumers
60
+ // relying on aria-expanded to show/hide trigger UI don't lose the anchor mid-animation.
61
+ ,
62
+ "aria-expanded": popupState === 'open' || popupState === 'animating-open' || popupState === 'animating-closed',
58
63
  "aria-controls": popoverId,
59
64
  "aria-haspopup": ariaHasPopup
60
65
  }, children);
@@ -44,10 +44,10 @@ function PopupRoot(props) {
44
44
  var popoverId = "popover-".concat(sanitizedId);
45
45
  var triggerRef = (0, _react.useRef)(null);
46
46
  var popoverRef = (0, _react.useRef)(null);
47
- var _useState = (0, _react.useState)(false),
47
+ var _useState = (0, _react.useState)('closed'),
48
48
  _useState2 = (0, _slicedToArray2.default)(_useState, 2),
49
- isOpen = _useState2[0],
50
- setIsOpen = _useState2[1];
49
+ popupState = _useState2[0],
50
+ setPopupState = _useState2[1];
51
51
  // Initial seed before `Popup.Content`'s layout effect resolves the
52
52
  // real role on first paint.
53
53
  var _useState3 = (0, _react.useState)('dialog'),
@@ -66,8 +66,8 @@ function PopupRoot(props) {
66
66
  onClose: onClose ? handleClose : null,
67
67
  triggerRef: triggerRef,
68
68
  popoverRef: popoverRef,
69
- isOpen: isOpen,
70
- setIsOpen: setIsOpen,
69
+ popupState: popupState,
70
+ setPopupState: setPopupState,
71
71
  onOpenChange: onOpenChange,
72
72
  mode: mode,
73
73
  ariaHasPopup: ariaHasPopup,
@@ -118,6 +118,7 @@ export function slideAndFade(options) {
118
118
  placement,
119
119
  distance
120
120
  }),
121
+ enterDurationMs: 350,
121
122
  exitDurationMs: 175
122
123
  };
123
124
  }
@@ -169,6 +170,7 @@ export function fade() {
169
170
  return {
170
171
  name: 'fade',
171
172
  css: FADE_CSS,
173
+ enterDurationMs: 350,
172
174
  exitDurationMs: 175
173
175
  };
174
176
  }
@@ -224,6 +226,7 @@ export function scaleAndFade() {
224
226
  return {
225
227
  name: 'scale-and-fade',
226
228
  css: SCALE_AND_FADE_CSS,
229
+ enterDurationMs: 350,
227
230
  exitDurationMs: 175
228
231
  };
229
232
  }
@@ -314,6 +317,7 @@ export function dialogSlideUpAndFade(options) {
314
317
  return {
315
318
  name: distance === 12 ? 'slide-up-and-fade' : `slide-up-and-fade-${distance}`,
316
319
  css: distance === 12 ? DIALOG_SLIDE_UP_AND_FADE_CSS : DIALOG_SLIDE_UP_AND_FADE_CSS.replace(/12px/g, `${distance}px`),
320
+ enterDurationMs: 350,
317
321
  exitDurationMs: 175
318
322
  };
319
323
  }
@@ -389,6 +393,7 @@ export function dialogFade() {
389
393
  return {
390
394
  name: 'fade',
391
395
  css: DIALOG_FADE_CSS,
396
+ enterDurationMs: 350,
392
397
  exitDurationMs: 175
393
398
  };
394
399
  }
@@ -43,6 +43,7 @@ export function useAnimatedVisibility({
43
43
  isOpen,
44
44
  animate,
45
45
  elementRef,
46
+ onEnterFinish,
46
47
  onExitFinish
47
48
  }) {
48
49
  // Resolve the animation preset and inject its CSS (idempotent).
@@ -77,13 +78,76 @@ export function useAnimatedVisibility({
77
78
  setShowChildren(false);
78
79
  }
79
80
 
80
- // Keep a stable ref to the latest `onExitFinish` so the effect and
81
- // timeout closures below always call the current callback without
82
- // needing `onExitFinish` in dependency arrays (which would reset
83
- // the timeout on every new closure).
81
+ // Keep stable refs to the latest callbacks so effects and timeout closures
82
+ // always call the current callback without needing them in dependency arrays.
83
+ const onEnterFinishRef = useRef(onEnterFinish);
84
+ onEnterFinishRef.current = onEnterFinish;
84
85
  const onExitFinishRef = useRef(onExitFinish);
85
86
  onExitFinishRef.current = onExitFinish;
86
87
 
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).
96
+ useEffect(() => {
97
+ var _preset$enterDuration;
98
+ if (!isOpen || !showChildren || !willAnimate) {
99
+ return;
100
+ }
101
+ const element = elementRef.current;
102
+ if (!element) {
103
+ return;
104
+ }
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
128
+ }
129
+ });
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]);
136
+
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);
142
+ useEffect(() => {
143
+ const prevIsOpen = prevIsOpenForEntryRef.current;
144
+ prevIsOpenForEntryRef.current = isOpen;
145
+ if (!prevIsOpen && isOpen && !willAnimate) {
146
+ var _onEnterFinishRef$cur2;
147
+ (_onEnterFinishRef$cur2 = onEnterFinishRef.current) === null || _onEnterFinishRef$cur2 === void 0 ? void 0 : _onEnterFinishRef$cur2.call(onEnterFinishRef);
148
+ }
149
+ }, [isOpen, willAnimate]);
150
+
87
151
  // ── Animated exit effect ──
88
152
  //
89
153
  // Runs when all three conditions are true:
@@ -108,20 +172,32 @@ export function useAnimatedVisibility({
108
172
  if (!element) {
109
173
  return;
110
174
  }
111
- function finish() {
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() {
112
183
  var _onExitFinishRef$curr;
184
+ if (hasFired) {
185
+ return;
186
+ }
187
+ hasFired = true;
113
188
  clearTimeout(fallbackId);
189
+ unbind();
114
190
  setShowChildren(false);
115
191
  (_onExitFinishRef$curr = onExitFinishRef.current) === null || _onExitFinishRef$curr === void 0 ? void 0 : _onExitFinishRef$curr.call(onExitFinishRef);
116
192
  }
117
193
 
118
194
  // Fallback: unmount even if transitionend never fires
119
- const fallbackId = setTimeout(finish, ((_preset$exitDurationM = preset === null || preset === void 0 ? void 0 : preset.exitDurationMs) !== null && _preset$exitDurationM !== void 0 ? _preset$exitDurationM : 0) + 50);
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);
120
196
 
121
197
  // Primary: listen for the CSS transition to complete
122
198
  const unbind = bind(element, {
123
199
  type: 'transitionend',
124
- listener: finish,
200
+ listener: handleTransitionEnd,
125
201
  options: {
126
202
  once: true
127
203
  }
@@ -129,6 +205,7 @@ export function useAnimatedVisibility({
129
205
 
130
206
  // Cleanup on re-render or unmount: cancel pending timeout and listener
131
207
  return () => {
208
+ hasFired = true;
132
209
  clearTimeout(fallbackId);
133
210
  unbind();
134
211
  };
@@ -63,6 +63,7 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
63
63
  mode: modeProp = 'auto',
64
64
  onClose,
65
65
  onOpenChange,
66
+ onEnterFinish,
66
67
  onExitFinish,
67
68
  animate,
68
69
  placement,
@@ -91,6 +92,7 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
91
92
  isOpen,
92
93
  animate,
93
94
  elementRef: ownRef,
95
+ onEnterFinish,
94
96
  onExitFinish
95
97
  });
96
98