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