@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.
- package/CHANGELOG.md +16 -0
- package/dist/cjs/animations/presets.js +5 -0
- package/dist/cjs/internal/use-animated-visibility.js +84 -7
- package/dist/cjs/popover/popover.js +2 -0
- package/dist/cjs/popup/popup-content.js +53 -13
- package/dist/cjs/popup/popup-context.js +17 -0
- package/dist/cjs/popup/popup-trigger-function.js +7 -4
- package/dist/cjs/popup/popup-trigger.js +12 -7
- package/dist/cjs/popup/popup.js +5 -5
- package/dist/es2019/animations/presets.js +5 -0
- package/dist/es2019/internal/use-animated-visibility.js +84 -7
- package/dist/es2019/popover/popover.js +2 -0
- package/dist/es2019/popup/popup-content.js +53 -13
- package/dist/es2019/popup/popup-context.js +18 -0
- package/dist/es2019/popup/popup-trigger-function.js +7 -4
- package/dist/es2019/popup/popup-trigger.js +12 -7
- package/dist/es2019/popup/popup.js +3 -3
- package/dist/esm/animations/presets.js +5 -0
- package/dist/esm/internal/use-animated-visibility.js +84 -7
- package/dist/esm/popover/popover.js +2 -0
- package/dist/esm/popup/popup-content.js +53 -13
- package/dist/esm/popup/popup-context.js +18 -0
- package/dist/esm/popup/popup-trigger-function.js +7 -4
- package/dist/esm/popup/popup-trigger.js +12 -7
- package/dist/esm/popup/popup.js +5 -5
- package/dist/types/animations/types.d.ts +5 -1
- package/dist/types/internal/use-animated-visibility.d.ts +8 -2
- package/dist/types/popover/types.d.ts +8 -0
- package/dist/types/popup/popup-context.d.ts +24 -4
- package/dist/types/popup/types.d.ts +12 -0
- package/dist/types-ts4.5/animations/types.d.ts +5 -1
- package/dist/types-ts4.5/internal/use-animated-visibility.d.ts +8 -2
- package/dist/types-ts4.5/popover/types.d.ts +8 -0
- package/dist/types-ts4.5/popup/popup-context.d.ts +24 -4
- package/dist/types-ts4.5/popup/types.d.ts +12 -0
- 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,
|
|
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
|
-
//
|
|
45
|
-
//
|
|
46
|
-
const isOpen =
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}), [
|
|
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
|
-
|
|
22
|
+
popupState,
|
|
23
23
|
ariaHasPopup
|
|
24
24
|
} = usePopupContext();
|
|
25
25
|
const childOnClick = /*#__PURE__*/isValidElement(children) ? children.props.onClick : undefined;
|
|
26
26
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
isOpenRef
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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,
|
|
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
|
-
//
|
|
45
|
-
//
|
|
46
|
-
var isOpen =
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
67
|
+
}, [popupState, popoverId, ariaHasPopup]);
|
|
65
68
|
return children({
|
|
66
69
|
ref: ref,
|
|
67
|
-
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
|
-
|
|
21
|
+
popupState = _usePopupContext.popupState,
|
|
22
22
|
ariaHasPopup = _usePopupContext.ariaHasPopup;
|
|
23
23
|
var childOnClick = /*#__PURE__*/isValidElement(children) ? children.props.onClick : undefined;
|
|
24
24
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
isOpenRef
|
|
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
|
-
|
|
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);
|
package/dist/esm/popup/popup.js
CHANGED
|
@@ -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(
|
|
38
|
+
var _useState = useState('closed'),
|
|
39
39
|
_useState2 = _slicedToArray(_useState, 2),
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
*
|
|
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
|
};
|