@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
@@ -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,
@@ -77,6 +77,7 @@ export function slideAndFade(options) {
77
77
  distance: distance
78
78
  });
79
79
  },
80
+ enterDurationMs: 350,
80
81
  exitDurationMs: 175
81
82
  };
82
83
  }
@@ -102,6 +103,7 @@ export function fade() {
102
103
  return {
103
104
  name: 'fade',
104
105
  css: FADE_CSS,
106
+ enterDurationMs: 350,
105
107
  exitDurationMs: 175
106
108
  };
107
109
  }
@@ -127,6 +129,7 @@ export function scaleAndFade() {
127
129
  return {
128
130
  name: 'scale-and-fade',
129
131
  css: SCALE_AND_FADE_CSS,
132
+ enterDurationMs: 350,
130
133
  exitDurationMs: 175
131
134
  };
132
135
  }
@@ -166,6 +169,7 @@ export function dialogSlideUpAndFade(options) {
166
169
  return {
167
170
  name: distance === 12 ? 'slide-up-and-fade' : "slide-up-and-fade-".concat(distance),
168
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,
169
173
  exitDurationMs: 175
170
174
  };
171
175
  }
@@ -194,6 +198,7 @@ export function dialogFade() {
194
198
  return {
195
199
  name: 'fade',
196
200
  css: DIALOG_FADE_CSS,
201
+ enterDurationMs: 350,
197
202
  exitDurationMs: 175
198
203
  };
199
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,
@@ -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';
@@ -16,7 +17,7 @@ import { useMaybePopupContext } from './popup-context';
16
17
  * use `Popover` + `useAnchorPosition` directly.
17
18
  */
18
19
  export var PopupContent = /*#__PURE__*/forwardRef(function PopupContent(_ref, ref) {
19
- var _ref2, _ref3, _ctx$popoverRef;
20
+ var _ref2, _ctx$popoverRef;
20
21
  var children = _ref.children,
21
22
  role = _ref.role,
22
23
  label = _ref.label,
@@ -32,7 +33,9 @@ export var PopupContent = /*#__PURE__*/forwardRef(function PopupContent(_ref, re
32
33
  onCloseProp = _ref.onClose,
33
34
  testIdProp = _ref.testId,
34
35
  forceFallbackPositioningProp = _ref.forceFallbackPositioning,
35
- idProp = _ref.id;
36
+ idProp = _ref.id,
37
+ onEnterFinish = _ref.onEnterFinish,
38
+ onExitFinish = _ref.onExitFinish;
36
39
  // When inside <Popup>, context provides defaults. Props always win.
37
40
  var ctx = useMaybePopupContext();
38
41
  var triggerRef = triggerRefProp !== null && triggerRefProp !== void 0 ? triggerRefProp : ctx === null || ctx === void 0 ? void 0 : ctx.triggerRef;
@@ -41,16 +44,47 @@ export var PopupContent = /*#__PURE__*/forwardRef(function PopupContent(_ref, re
41
44
  var testId = testIdProp !== null && testIdProp !== void 0 ? testIdProp : ctx === null || ctx === void 0 ? void 0 : ctx.testId;
42
45
  var 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
- var isOpen = (_ref3 = isOpenProp !== null && isOpenProp !== void 0 ? isOpenProp : ctx === null || ctx === void 0 ? void 0 : ctx.isOpen) !== null && _ref3 !== void 0 ? _ref3 : 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
- var 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
+ var isOpen = isOpenProp !== null && isOpenProp !== void 0 ? isOpenProp : ctx !== null ? ctx.popupState === 'open' || ctx.popupState === 'animating-open' : false;
50
+ var setPopupState = ctx === null || ctx === void 0 ? void 0 : ctx.setPopupState;
53
51
  var 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
+ var willAnimate = Boolean(animate) && !prefersReducedMotion();
69
+ var hasMountedRef = useRef(false);
70
+ useLayoutEffect(function () {
71
+ if (hasMountedRef.current) {
72
+ return;
73
+ }
74
+ hasMountedRef.current = true;
75
+ if (isOpen) {
76
+ setPopupState === null || setPopupState === void 0 || setPopupState(willAnimate ? 'animating-open' : 'open');
77
+ }
78
+ }, [isOpen, setPopupState, willAnimate]);
79
+ var handleEnterFinish = useCallback(function () {
80
+ setPopupState === null || setPopupState === void 0 || setPopupState('open');
81
+ onEnterFinish === null || onEnterFinish === void 0 || onEnterFinish();
82
+ }, [setPopupState, onEnterFinish]);
83
+ var handleExitFinish = useCallback(function () {
84
+ setPopupState === null || setPopupState === void 0 || setPopupState('closed');
85
+ onExitFinish === null || onExitFinish === 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 var PopupContent = /*#__PURE__*/forwardRef(function PopupContent(_ref, re
82
116
  // See: notes/architecture/focus-restoration.md
83
117
  var handleOpenChange = useCallback(function (args) {
84
118
  // Sync compound state
85
- setIsOpen === null || setIsOpen === void 0 || setIsOpen(args.isOpen);
119
+ if (args.isOpen) {
120
+ setPopupState === null || setPopupState === void 0 || setPopupState(willAnimate ? 'animating-open' : 'open');
121
+ } else {
122
+ setPopupState === null || setPopupState === void 0 || setPopupState(willAnimate ? 'animating-closed' : 'closed');
123
+ }
86
124
  consumerOnOpenChange === null || consumerOnOpenChange === void 0 || consumerOnOpenChange(args);
87
125
 
88
126
  // ── Nested popover focus restoration fallback ──
@@ -112,7 +150,7 @@ export var PopupContent = /*#__PURE__*/forwardRef(function PopupContent(_ref, re
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 var PopupContent = /*#__PURE__*/forwardRef(function PopupContent(_ref, re
151
189
  labelledBy: labelledBy,
152
190
  onClose: onClose !== null && onClose !== void 0 ? onClose : undefined,
153
191
  onOpenChange: handleOpenChange,
192
+ onEnterFinish: handleEnterFinish,
193
+ onExitFinish: handleExitFinish,
154
194
  testId: testId,
155
195
  children: 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
  var PopupContext = /*#__PURE__*/createContext(null);
3
21
 
4
22
  /**
@@ -41,7 +41,7 @@ export function PopupTriggerFunction(_ref) {
41
41
  triggerRef = _usePopupContext.triggerRef,
42
42
  popoverRef = _usePopupContext.popoverRef,
43
43
  popoverId = _usePopupContext.popoverId,
44
- isOpen = _usePopupContext.isOpen,
44
+ popupState = _usePopupContext.popupState,
45
45
  ariaHasPopup = _usePopupContext.ariaHasPopup;
46
46
  var ref = useCallback(function (node) {
47
47
  triggerRef.current = node;
@@ -57,14 +57,17 @@ export function PopupTriggerFunction(_ref) {
57
57
  }, [popoverRef]);
58
58
  var ariaAttributes = useMemo(function () {
59
59
  return {
60
- 'aria-expanded': isOpen,
60
+ // aria-expanded stays true throughout entry and exit animations so screen readers
61
+ // don't announce the popup as closed while it's still visible, and consumers
62
+ // relying on aria-expanded to show/hide trigger UI don't lose the anchor mid-animation.
63
+ 'aria-expanded': popupState === 'open' || popupState === 'animating-open' || popupState === 'animating-closed',
61
64
  'aria-controls': popoverId,
62
65
  'aria-haspopup': ariaHasPopup
63
66
  };
64
- }, [isOpen, popoverId, ariaHasPopup]);
67
+ }, [popupState, popoverId, ariaHasPopup]);
65
68
  return children({
66
69
  ref: ref,
67
- isOpen: isOpen,
70
+ isOpen: popupState === 'open' || popupState === 'animating-open',
68
71
  popoverId: popoverId,
69
72
  toggle: toggle,
70
73
  ariaAttributes: ariaAttributes
@@ -18,14 +18,15 @@ export function PopupTrigger(_ref) {
18
18
  triggerRef = _usePopupContext.triggerRef,
19
19
  popoverRef = _usePopupContext.popoverRef,
20
20
  popoverId = _usePopupContext.popoverId,
21
- isOpen = _usePopupContext.isOpen,
21
+ popupState = _usePopupContext.popupState,
22
22
  ariaHasPopup = _usePopupContext.ariaHasPopup;
23
23
  var childOnClick = /*#__PURE__*/isValidElement(children) ? children.props.onClick : undefined;
24
24
 
25
- // Track latest isOpen via ref so the click handler keeps a stable
26
- // identity (cloneElement relies on it).
27
- var isOpenRef = React.useRef(isOpen);
28
- isOpenRef.current = isOpen;
25
+ // Use a ref to track the latest isOpen value so the click handler
26
+ // does not need isOpen in its dependency array (which would recreate
27
+ // the handler on every open/close, breaking cloneElement identity).
28
+ var isOpenRef = React.useRef(popupState === 'open' || popupState === 'animating-open');
29
+ isOpenRef.current = popupState === 'open' || popupState === 'animating-open';
29
30
  var handleClick = useCallback(function (event) {
30
31
  var popoverEl = popoverRef.current;
31
32
  if (popoverEl) {
@@ -45,8 +46,12 @@ export function PopupTrigger(_ref) {
45
46
  }, [popoverRef, childOnClick]);
46
47
  return /*#__PURE__*/React.createElement(Slot, {
47
48
  ref: triggerRef,
48
- onClick: handleClick,
49
- "aria-expanded": isOpen,
49
+ onClick: handleClick
50
+ // aria-expanded stays true throughout entry and exit animations so screen readers
51
+ // don't announce the popup as closed while it's still visible, and consumers
52
+ // relying on aria-expanded to show/hide trigger UI don't lose the anchor mid-animation.
53
+ ,
54
+ "aria-expanded": popupState === 'open' || popupState === 'animating-open' || popupState === 'animating-closed',
50
55
  "aria-controls": popoverId,
51
56
  "aria-haspopup": ariaHasPopup
52
57
  }, children);
@@ -35,10 +35,10 @@ export function PopupRoot(props) {
35
35
  var popoverId = "popover-".concat(sanitizedId);
36
36
  var triggerRef = useRef(null);
37
37
  var popoverRef = useRef(null);
38
- var _useState = useState(false),
38
+ var _useState = useState('closed'),
39
39
  _useState2 = _slicedToArray(_useState, 2),
40
- isOpen = _useState2[0],
41
- setIsOpen = _useState2[1];
40
+ popupState = _useState2[0],
41
+ setPopupState = _useState2[1];
42
42
  // Initial seed before `Popup.Content`'s layout effect resolves the
43
43
  // real role on first paint.
44
44
  var _useState3 = useState('dialog'),
@@ -57,8 +57,8 @@ export function PopupRoot(props) {
57
57
  onClose: onClose ? handleClose : null,
58
58
  triggerRef: triggerRef,
59
59
  popoverRef: popoverRef,
60
- isOpen: isOpen,
61
- setIsOpen: setIsOpen,
60
+ popupState: popupState,
61
+ setPopupState: setPopupState,
62
62
  onOpenChange: onOpenChange,
63
63
  mode: mode,
64
64
  ariaHasPopup: ariaHasPopup,
@@ -28,7 +28,11 @@ export type TAnimationPreset = {
28
28
  placement: TPlacementOptions;
29
29
  }) => Record<string, string>;
30
30
  /**
31
- * Exit duration in ms (for `transitionend` fallback timeout).
31
+ * Entry duration in ms (for `transitionend` fallback timeout on open).
32
+ */
33
+ enterDurationMs: number;
34
+ /**
35
+ * Exit duration in ms (for `transitionend` fallback timeout on close).
32
36
  */
33
37
  exitDurationMs: number;
34
38
  };