@atlaskit/top-layer 0.10.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 (37) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +3 -3
  3. package/dist/cjs/animations/presets.js +8 -1
  4. package/dist/cjs/internal/use-animated-visibility.js +84 -7
  5. package/dist/cjs/popover/popover.js +2 -0
  6. package/dist/cjs/popup/popup-content.js +53 -13
  7. package/dist/cjs/popup/popup-context.js +17 -0
  8. package/dist/cjs/popup/popup-trigger-function.js +7 -4
  9. package/dist/cjs/popup/popup-trigger.js +12 -7
  10. package/dist/cjs/popup/popup.js +5 -5
  11. package/dist/es2019/animations/presets.js +19 -2
  12. package/dist/es2019/internal/use-animated-visibility.js +84 -7
  13. package/dist/es2019/popover/popover.js +2 -0
  14. package/dist/es2019/popup/popup-content.js +53 -13
  15. package/dist/es2019/popup/popup-context.js +18 -0
  16. package/dist/es2019/popup/popup-trigger-function.js +7 -4
  17. package/dist/es2019/popup/popup-trigger.js +12 -7
  18. package/dist/es2019/popup/popup.js +3 -3
  19. package/dist/esm/animations/presets.js +8 -1
  20. package/dist/esm/internal/use-animated-visibility.js +84 -7
  21. package/dist/esm/popover/popover.js +2 -0
  22. package/dist/esm/popup/popup-content.js +53 -13
  23. package/dist/esm/popup/popup-context.js +18 -0
  24. package/dist/esm/popup/popup-trigger-function.js +7 -4
  25. package/dist/esm/popup/popup-trigger.js +12 -7
  26. package/dist/esm/popup/popup.js +5 -5
  27. package/dist/types/animations/types.d.ts +5 -1
  28. package/dist/types/internal/use-animated-visibility.d.ts +8 -2
  29. package/dist/types/popover/types.d.ts +8 -0
  30. package/dist/types/popup/popup-context.d.ts +24 -4
  31. package/dist/types/popup/types.d.ts +12 -0
  32. package/dist/types-ts4.5/animations/types.d.ts +5 -1
  33. package/dist/types-ts4.5/internal/use-animated-visibility.d.ts +8 -2
  34. package/dist/types-ts4.5/popover/types.d.ts +8 -0
  35. package/dist/types-ts4.5/popup/popup-context.d.ts +24 -4
  36. package/dist/types-ts4.5/popup/types.d.ts +12 -0
  37. package/package.json +1 -1
@@ -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
 
@@ -3,6 +3,7 @@ import _extends from "@babel/runtime/helpers/extends";
3
3
  import { ax, ix } from "@compiled/react/runtime";
4
4
  import React, { forwardRef, useCallback, useLayoutEffect, useRef } from 'react';
5
5
  import mergeRefs from '@atlaskit/ds-lib/merge-refs';
6
+ import { prefersReducedMotion } from '../internal/reduced-motion';
6
7
  import { roleToAriaHasPopup, shouldFocusIntoPopover } from '../internal/role-types';
7
8
  import { useAnchorPosition } from '../internal/use-anchor-position';
8
9
  import { useWidthFromAnchor } from '../internal/use-width-from-anchor';
@@ -30,9 +31,11 @@ export const PopupContent = /*#__PURE__*/forwardRef(function PopupContent({
30
31
  onClose: onCloseProp,
31
32
  testId: testIdProp,
32
33
  forceFallbackPositioning: forceFallbackPositioningProp,
33
- id: idProp
34
+ id: idProp,
35
+ onEnterFinish,
36
+ onExitFinish
34
37
  }, ref) {
35
- var _ref, _ref2, _ctx$popoverRef;
38
+ var _ref, _ctx$popoverRef;
36
39
  // When inside <Popup>, context provides defaults. Props always win.
37
40
  const ctx = useMaybePopupContext();
38
41
  const triggerRef = triggerRefProp !== null && triggerRefProp !== void 0 ? triggerRefProp : ctx === null || ctx === void 0 ? void 0 : ctx.triggerRef;
@@ -41,16 +44,47 @@ export const PopupContent = /*#__PURE__*/forwardRef(function PopupContent({
41
44
  const testId = testIdProp !== null && testIdProp !== void 0 ? testIdProp : ctx === null || ctx === void 0 ? void 0 : ctx.testId;
42
45
  const forceFallbackPositioning = forceFallbackPositioningProp !== null && forceFallbackPositioningProp !== void 0 ? forceFallbackPositioningProp : ctx === null || ctx === void 0 ? void 0 : ctx.forceFallbackPositioning;
43
46
 
44
- // Resolve isOpen: prop wins, then context.
45
- // Inside the <Popup> compound, ctx.isOpen tracks the browser's toggle state.
46
- const isOpen = (_ref2 = isOpenProp !== null && isOpenProp !== void 0 ? isOpenProp : ctx === null || ctx === void 0 ? void 0 : ctx.isOpen) !== null && _ref2 !== void 0 ? _ref2 : false;
47
-
48
- // Combine internal state sync with consumer's onOpenChange callback.
49
- // The Popup compound needs setIsOpen to keep aria-expanded on the trigger
50
- // in sync, and the consumer may also want to respond to open/close events
51
- // (e.g. for focus management).
52
- const setIsOpen = ctx === null || ctx === void 0 ? void 0 : ctx.setIsOpen;
47
+ // isOpen: prop wins, otherwise derive from context popupState.
48
+ // 'animating-open' counts as open so <Popover> stays visible during entry animation.
49
+ const isOpen = isOpenProp !== null && isOpenProp !== void 0 ? isOpenProp : ctx !== null ? ctx.popupState === 'open' || ctx.popupState === 'animating-open' : false;
50
+ const setPopupState = ctx === null || ctx === void 0 ? void 0 : ctx.setPopupState;
53
51
  const consumerOnOpenChange = ctx === null || ctx === void 0 ? void 0 : ctx.onOpenChange;
52
+
53
+ // ── TPopupState ──
54
+ //
55
+ // popupState is driven by handleOpenChange (below), which fires on every browser
56
+ // toggle event (showPopover / hidePopover / light-dismiss).
57
+ //
58
+ // Exception — initial mount with isOpen=true (prop-controlled usage):
59
+ // handleOpenChange only fires from the Popover's toggle event listener, which
60
+ // is registered in a useEffect. On the first render, useEffect hasn't run yet,
61
+ // so handleOpenChange never fires. We sync popupState here instead.
62
+ //
63
+ // Transitions when animations are active:
64
+ // closed → animating-open → open (enter)
65
+ // open → animating-closed → closed (exit, via onExitFinish)
66
+ //
67
+ // Without animation: closed ↔ open directly.
68
+ const willAnimate = Boolean(animate) && !prefersReducedMotion();
69
+ const hasMountedRef = useRef(false);
70
+ useLayoutEffect(() => {
71
+ if (hasMountedRef.current) {
72
+ return;
73
+ }
74
+ hasMountedRef.current = true;
75
+ if (isOpen) {
76
+ setPopupState === null || setPopupState === void 0 ? void 0 : setPopupState(willAnimate ? 'animating-open' : 'open');
77
+ }
78
+ }, [isOpen, setPopupState, willAnimate]);
79
+ const handleEnterFinish = useCallback(() => {
80
+ setPopupState === null || setPopupState === void 0 ? void 0 : setPopupState('open');
81
+ onEnterFinish === null || onEnterFinish === void 0 ? void 0 : onEnterFinish();
82
+ }, [setPopupState, onEnterFinish]);
83
+ const handleExitFinish = useCallback(() => {
84
+ setPopupState === null || setPopupState === void 0 ? void 0 : setPopupState('closed');
85
+ onExitFinish === null || onExitFinish === void 0 ? void 0 : onExitFinish();
86
+ }, [setPopupState, onExitFinish]);
87
+
54
88
  // Note: onOpenChange composition is handled by handleOpenChange below,
55
89
  // which also adds nested-popover focus restoration.
56
90
 
@@ -82,7 +116,11 @@ export const PopupContent = /*#__PURE__*/forwardRef(function PopupContent({
82
116
  // See: notes/architecture/focus-restoration.md
83
117
  const handleOpenChange = useCallback(args => {
84
118
  // Sync compound state
85
- setIsOpen === null || setIsOpen === void 0 ? void 0 : setIsOpen(args.isOpen);
119
+ if (args.isOpen) {
120
+ setPopupState === null || setPopupState === void 0 ? void 0 : setPopupState(willAnimate ? 'animating-open' : 'open');
121
+ } else {
122
+ setPopupState === null || setPopupState === void 0 ? void 0 : setPopupState(willAnimate ? 'animating-closed' : 'closed');
123
+ }
86
124
  consumerOnOpenChange === null || consumerOnOpenChange === void 0 ? void 0 : consumerOnOpenChange(args);
87
125
 
88
126
  // ── Nested popover focus restoration fallback ──
@@ -112,7 +150,7 @@ export const PopupContent = /*#__PURE__*/forwardRef(function PopupContent({
112
150
  });
113
151
  }
114
152
  }
115
- }, [setIsOpen, consumerOnOpenChange, triggerRef, role]);
153
+ }, [consumerOnOpenChange, triggerRef, role, setPopupState, willAnimate]);
116
154
 
117
155
  // ── Anchor positioning ──
118
156
  // Compose useAnchorPosition with Popover. The hook positions the popover
@@ -151,6 +189,8 @@ export const PopupContent = /*#__PURE__*/forwardRef(function PopupContent({
151
189
  labelledBy,
152
190
  onClose: onClose !== null && onClose !== void 0 ? onClose : undefined,
153
191
  onOpenChange: handleOpenChange,
192
+ onEnterFinish: handleEnterFinish,
193
+ onExitFinish: handleExitFinish,
154
194
  testId,
155
195
  children,
156
196
  // Standalone consumers can pass an explicit `id` - used by their own
@@ -1,4 +1,22 @@
1
1
  import { createContext, useContext } from 'react';
2
+
3
+ /**
4
+ * Represents the lifecycle state of the popup.
5
+ *
6
+ * - `'closed'`: fully closed, not visible, no animation playing.
7
+ * - `'animating-open'`: entry animation is playing (popup is opening but not yet settled).
8
+ * - `'open'`: fully open, browser popover is showing, entry animation complete.
9
+ * - `'animating-closed'`: exit animation is playing (popup is closing but still visible).
10
+ *
11
+ * Used to derive `aria-expanded` on the trigger:
12
+ * - `aria-expanded` is `true` in `'animating-open'`, `'open'`, and `'animating-closed'`.
13
+ *
14
+ * This avoids screen readers announcing the popup as closed while it is
15
+ * still visually present on screen during animations, and prevents consumers
16
+ * that conditionally show/hide trigger UI based on `aria-expanded` from hiding
17
+ * the trigger (and thus the popup anchor) mid-animation.
18
+ */
19
+
2
20
  const PopupContext = /*#__PURE__*/createContext(null);
3
21
 
4
22
  /**
@@ -42,7 +42,7 @@ export function PopupTriggerFunction({
42
42
  triggerRef,
43
43
  popoverRef,
44
44
  popoverId,
45
- isOpen,
45
+ popupState,
46
46
  ariaHasPopup
47
47
  } = usePopupContext();
48
48
  const ref = useCallback(node => {
@@ -58,13 +58,16 @@ export function PopupTriggerFunction({
58
58
  }
59
59
  }, [popoverRef]);
60
60
  const ariaAttributes = useMemo(() => ({
61
- 'aria-expanded': isOpen,
61
+ // aria-expanded stays true throughout entry and exit animations so screen readers
62
+ // don't announce the popup as closed while it's still visible, and consumers
63
+ // relying on aria-expanded to show/hide trigger UI don't lose the anchor mid-animation.
64
+ 'aria-expanded': popupState === 'open' || popupState === 'animating-open' || popupState === 'animating-closed',
62
65
  'aria-controls': popoverId,
63
66
  'aria-haspopup': ariaHasPopup
64
- }), [isOpen, popoverId, ariaHasPopup]);
67
+ }), [popupState, popoverId, ariaHasPopup]);
65
68
  return children({
66
69
  ref,
67
- isOpen,
70
+ isOpen: popupState === 'open' || popupState === 'animating-open',
68
71
  popoverId,
69
72
  toggle,
70
73
  ariaAttributes
@@ -19,15 +19,16 @@ export function PopupTrigger({
19
19
  triggerRef,
20
20
  popoverRef,
21
21
  popoverId,
22
- isOpen,
22
+ popupState,
23
23
  ariaHasPopup
24
24
  } = usePopupContext();
25
25
  const childOnClick = /*#__PURE__*/isValidElement(children) ? children.props.onClick : undefined;
26
26
 
27
- // Track latest isOpen via ref so the click handler keeps a stable
28
- // identity (cloneElement relies on it).
29
- const isOpenRef = React.useRef(isOpen);
30
- isOpenRef.current = isOpen;
27
+ // Use a ref to track the latest isOpen value so the click handler
28
+ // does not need isOpen in its dependency array (which would recreate
29
+ // the handler on every open/close, breaking cloneElement identity).
30
+ const isOpenRef = React.useRef(popupState === 'open' || popupState === 'animating-open');
31
+ isOpenRef.current = popupState === 'open' || popupState === 'animating-open';
31
32
  const handleClick = useCallback(event => {
32
33
  const popoverEl = popoverRef.current;
33
34
  if (popoverEl) {
@@ -47,8 +48,12 @@ export function PopupTrigger({
47
48
  }, [popoverRef, childOnClick]);
48
49
  return /*#__PURE__*/React.createElement(Slot, {
49
50
  ref: triggerRef,
50
- onClick: handleClick,
51
- "aria-expanded": isOpen,
51
+ onClick: handleClick
52
+ // aria-expanded stays true throughout entry and exit animations so screen readers
53
+ // don't announce the popup as closed while it's still visible, and consumers
54
+ // relying on aria-expanded to show/hide trigger UI don't lose the anchor mid-animation.
55
+ ,
56
+ "aria-expanded": popupState === 'open' || popupState === 'animating-open' || popupState === 'animating-closed',
52
57
  "aria-controls": popoverId,
53
58
  "aria-haspopup": ariaHasPopup
54
59
  }, children);
@@ -36,7 +36,7 @@ export function PopupRoot(props) {
36
36
  const popoverId = `popover-${sanitizedId}`;
37
37
  const triggerRef = useRef(null);
38
38
  const popoverRef = useRef(null);
39
- const [isOpen, setIsOpen] = useState(false);
39
+ const [popupState, setPopupState] = useState('closed');
40
40
  // Initial seed before `Popup.Content`'s layout effect resolves the
41
41
  // real role on first paint.
42
42
  const [ariaHasPopup, setAriaHasPopup] = useState('dialog');
@@ -52,8 +52,8 @@ export function PopupRoot(props) {
52
52
  onClose: onClose ? handleClose : null,
53
53
  triggerRef,
54
54
  popoverRef,
55
- isOpen,
56
- setIsOpen,
55
+ popupState,
56
+ setPopupState,
57
57
  onOpenChange,
58
58
  mode,
59
59
  ariaHasPopup,
@@ -41,7 +41,9 @@ function getSlideAndFadeProperties(_ref) {
41
41
  '--ds-popover-ty': neg
42
42
  };
43
43
  }
44
- var SLIDE_AND_FADE_CSS = "\n[data-ds-popover-slide-and-fade] {\n opacity: 0;\n transform: translate3d(var(--ds-popover-tx, 0), var(--ds-popover-ty, 0), 0);\n transition:\n opacity 175ms cubic-bezier(0.15, 1, 0.3, 1),\n transform 175ms cubic-bezier(0.15, 1, 0.3, 1),\n overlay 175ms allow-discrete,\n display 175ms allow-discrete;\n}\n\n[data-ds-popover-slide-and-fade]:popover-open {\n opacity: 1;\n transform: none;\n transition-duration: 350ms;\n}\n\n@starting-style {\n [data-ds-popover-slide-and-fade]:popover-open {\n opacity: 0;\n transform: translate3d(var(--ds-popover-tx, 0), var(--ds-popover-ty, 0), 0);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n [data-ds-popover-slide-and-fade],\n [data-ds-popover-slide-and-fade]:popover-open {\n transition-duration: 0s;\n }\n}\n";
44
+
45
+ // ℹ️ Note: The `transform` x value is flipped in right-to-left mode using the `[dir='rtl']` selector.
46
+ var SLIDE_AND_FADE_CSS = "\n[data-ds-popover-slide-and-fade] {\n opacity: 0;\n transform: translate(var(--ds-popover-tx, 0), var(--ds-popover-ty, 0));\n transition:\n opacity 175ms cubic-bezier(0.15, 1, 0.3, 1),\n transform 175ms cubic-bezier(0.15, 1, 0.3, 1),\n overlay 175ms allow-discrete,\n display 175ms allow-discrete;\n}\n\n[dir='rtl'] [data-ds-popover-slide-and-fade] {\n transform: translate(calc(-1 * var(--ds-popover-tx, 0)), var(--ds-popover-ty, 0));\n}\n\n[data-ds-popover-slide-and-fade]:popover-open {\n opacity: 1;\n transform: none;\n transition-duration: 350ms;\n}\n\n@starting-style {\n [data-ds-popover-slide-and-fade]:popover-open {\n opacity: 0;\n transform: translate(var(--ds-popover-tx, 0), var(--ds-popover-ty, 0));\n }\n}\n\n@starting-style {\n [dir='rtl'] [data-ds-popover-slide-and-fade]:popover-open {\n transform: translate(calc(-1 * var(--ds-popover-tx, 0)), var(--ds-popover-ty, 0));\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n [data-ds-popover-slide-and-fade],\n [data-ds-popover-slide-and-fade]:popover-open {\n transition-duration: 0s;\n }\n}\n";
45
47
 
46
48
  /**
47
49
  * Directional slide + opacity animation.
@@ -75,6 +77,7 @@ export function slideAndFade(options) {
75
77
  distance: distance
76
78
  });
77
79
  },
80
+ enterDurationMs: 350,
78
81
  exitDurationMs: 175
79
82
  };
80
83
  }
@@ -100,6 +103,7 @@ export function fade() {
100
103
  return {
101
104
  name: 'fade',
102
105
  css: FADE_CSS,
106
+ enterDurationMs: 350,
103
107
  exitDurationMs: 175
104
108
  };
105
109
  }
@@ -125,6 +129,7 @@ export function scaleAndFade() {
125
129
  return {
126
130
  name: 'scale-and-fade',
127
131
  css: SCALE_AND_FADE_CSS,
132
+ enterDurationMs: 350,
128
133
  exitDurationMs: 175
129
134
  };
130
135
  }
@@ -164,6 +169,7 @@ export function dialogSlideUpAndFade(options) {
164
169
  return {
165
170
  name: distance === 12 ? 'slide-up-and-fade' : "slide-up-and-fade-".concat(distance),
166
171
  css: distance === 12 ? DIALOG_SLIDE_UP_AND_FADE_CSS : DIALOG_SLIDE_UP_AND_FADE_CSS.replace(/12px/g, "".concat(distance, "px")),
172
+ enterDurationMs: 350,
167
173
  exitDurationMs: 175
168
174
  };
169
175
  }
@@ -192,6 +198,7 @@ export function dialogFade() {
192
198
  return {
193
199
  name: 'fade',
194
200
  css: DIALOG_FADE_CSS,
201
+ enterDurationMs: 350,
195
202
  exitDurationMs: 175
196
203
  };
197
204
  }
@@ -44,6 +44,7 @@ export function useAnimatedVisibility(_ref) {
44
44
  var isOpen = _ref.isOpen,
45
45
  animate = _ref.animate,
46
46
  elementRef = _ref.elementRef,
47
+ onEnterFinish = _ref.onEnterFinish,
47
48
  onExitFinish = _ref.onExitFinish;
48
49
  // Resolve the animation preset and inject its CSS (idempotent).
49
50
  // Returns `null` when `animate` is falsy.
@@ -80,13 +81,76 @@ export function useAnimatedVisibility(_ref) {
80
81
  setShowChildren(false);
81
82
  }
82
83
 
83
- // Keep a stable ref to the latest `onExitFinish` so the effect and
84
- // timeout closures below always call the current callback without
85
- // needing `onExitFinish` in dependency arrays (which would reset
86
- // the timeout on every new closure).
84
+ // Keep stable refs to the latest callbacks so effects and timeout closures
85
+ // always call the current callback without needing them in dependency arrays.
86
+ var onEnterFinishRef = useRef(onEnterFinish);
87
+ onEnterFinishRef.current = onEnterFinish;
87
88
  var onExitFinishRef = useRef(onExitFinish);
88
89
  onExitFinishRef.current = onExitFinish;
89
90
 
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).
99
+ useEffect(function () {
100
+ var _preset$enterDuration;
101
+ if (!isOpen || !showChildren || !willAnimate) {
102
+ return;
103
+ }
104
+ var element = elementRef.current;
105
+ if (!element) {
106
+ return;
107
+ }
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
131
+ }
132
+ });
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]);
139
+
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);
145
+ useEffect(function () {
146
+ var prevIsOpen = prevIsOpenForEntryRef.current;
147
+ prevIsOpenForEntryRef.current = isOpen;
148
+ if (!prevIsOpen && isOpen && !willAnimate) {
149
+ var _onEnterFinishRef$cur2;
150
+ (_onEnterFinishRef$cur2 = onEnterFinishRef.current) === null || _onEnterFinishRef$cur2 === void 0 || _onEnterFinishRef$cur2.call(onEnterFinishRef);
151
+ }
152
+ }, [isOpen, willAnimate]);
153
+
90
154
  // ── Animated exit effect ──
91
155
  //
92
156
  // Runs when all three conditions are true:
@@ -111,20 +175,32 @@ export function useAnimatedVisibility(_ref) {
111
175
  if (!element) {
112
176
  return;
113
177
  }
114
- function finish() {
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() {
115
186
  var _onExitFinishRef$curr;
187
+ if (hasFired) {
188
+ return;
189
+ }
190
+ hasFired = true;
116
191
  clearTimeout(fallbackId);
192
+ unbind();
117
193
  setShowChildren(false);
118
194
  (_onExitFinishRef$curr = onExitFinishRef.current) === null || _onExitFinishRef$curr === void 0 || _onExitFinishRef$curr.call(onExitFinishRef);
119
195
  }
120
196
 
121
197
  // Fallback: unmount even if transitionend never fires
122
- var fallbackId = setTimeout(finish, ((_preset$exitDurationM = preset === null || preset === void 0 ? void 0 : preset.exitDurationMs) !== null && _preset$exitDurationM !== void 0 ? _preset$exitDurationM : 0) + 50);
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);
123
199
 
124
200
  // Primary: listen for the CSS transition to complete
125
201
  var unbind = bind(element, {
126
202
  type: 'transitionend',
127
- listener: finish,
203
+ listener: handleTransitionEnd,
128
204
  options: {
129
205
  once: true
130
206
  }
@@ -132,6 +208,7 @@ export function useAnimatedVisibility(_ref) {
132
208
 
133
209
  // Cleanup on re-render or unmount: cancel pending timeout and listener
134
210
  return function () {
211
+ hasFired = true;
135
212
  clearTimeout(fallbackId);
136
213
  unbind();
137
214
  };
@@ -66,6 +66,7 @@ export var Popover = /*#__PURE__*/forwardRef(function Popover(_ref, ref) {
66
66
  modeProp = _ref$mode === void 0 ? 'auto' : _ref$mode,
67
67
  onClose = _ref.onClose,
68
68
  onOpenChange = _ref.onOpenChange,
69
+ onEnterFinish = _ref.onEnterFinish,
69
70
  onExitFinish = _ref.onExitFinish,
70
71
  animate = _ref.animate,
71
72
  placement = _ref.placement,
@@ -89,6 +90,7 @@ export var Popover = /*#__PURE__*/forwardRef(function Popover(_ref, ref) {
89
90
  isOpen: isOpen,
90
91
  animate: animate,
91
92
  elementRef: ownRef,
93
+ onEnterFinish: onEnterFinish,
92
94
  onExitFinish: onExitFinish
93
95
  }),
94
96
  showChildren = _useAnimatedVisibilit.showChildren,