@atlaskit/top-layer 4.0.0 → 4.1.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 +9 -0
- package/dist/cjs/dialog/dialog-content.js +47 -20
- package/dist/cjs/internal/use-animated-visibility.js +36 -39
- package/dist/cjs/popover/popover.js +16 -18
- package/dist/es2019/dialog/dialog-content.js +48 -22
- package/dist/es2019/internal/use-animated-visibility.js +38 -39
- package/dist/es2019/popover/popover.js +14 -16
- package/dist/esm/dialog/dialog-content.js +49 -22
- package/dist/esm/internal/use-animated-visibility.js +38 -41
- package/dist/esm/popover/popover.js +16 -18
- package/dist/types/dialog/dialog-content.d.ts +2 -2
- package/dist/types/dialog/index.d.ts +1 -1
- package/dist/types/dialog/types.d.ts +17 -3
- package/dist/types/entry-points/dialog.d.ts +1 -1
- package/dist/types/internal/use-animated-visibility.d.ts +7 -1
- package/dist/types/popover/popover.d.ts +1 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# @atlaskit/top-layer
|
|
2
2
|
|
|
3
|
+
## 4.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`ccb9237bc10d9`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/ccb9237bc10d9) -
|
|
8
|
+
Add the `dismissedBy` API to control Dialog dismissal consistently across browsers. Align Dialog
|
|
9
|
+
with Popover by notifying `onClose` after native dismissal, ensure both primitives unmount after
|
|
10
|
+
native dismissal, and simplify Modal close handling behind `platform-dst-top-layer`.
|
|
11
|
+
|
|
3
12
|
## 4.0.0
|
|
4
13
|
|
|
5
14
|
### Major Changes
|
|
@@ -54,8 +54,8 @@ var backdropStyles = {
|
|
|
54
54
|
*
|
|
55
55
|
* Handles native `cancel` event (Escape) and backdrop click detection.
|
|
56
56
|
*
|
|
57
|
-
* Close flow:
|
|
58
|
-
*
|
|
57
|
+
* Close flow: allowed user dismissal closes the native dialog before `onClose`
|
|
58
|
+
* notifies the consumer. The consumer must synchronize `isOpen` in response.
|
|
59
59
|
*
|
|
60
60
|
* Accessibility: render at least one focusable element (typically a close
|
|
61
61
|
* button) when `isOpen` becomes `true`. Tab is always trapped inside the
|
|
@@ -65,6 +65,8 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
|
|
|
65
65
|
var children = _ref.children,
|
|
66
66
|
isOpen = _ref.isOpen,
|
|
67
67
|
onClose = _ref.onClose,
|
|
68
|
+
_ref$dismissedBy = _ref.dismissedBy,
|
|
69
|
+
dismissedBy = _ref$dismissedBy === void 0 ? 'escape-and-outside-click' : _ref$dismissedBy,
|
|
68
70
|
onEnterFinish = _ref.onEnterFinish,
|
|
69
71
|
onExitFinish = _ref.onExitFinish,
|
|
70
72
|
_ref$shouldAnimate = _ref.shouldAnimate,
|
|
@@ -82,6 +84,7 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
|
|
|
82
84
|
var dialogId = providedId !== null && providedId !== void 0 ? providedId : generatedId;
|
|
83
85
|
var ownRef = (0, _react.useRef)(null);
|
|
84
86
|
var combinedRef = (0, _mergeRefs.default)([ownRef, ref]);
|
|
87
|
+
var closeReasonRef = (0, _react.useRef)(null);
|
|
85
88
|
var _useAnimatedVisibilit = (0, _useAnimatedVisibility.useAnimatedVisibility)({
|
|
86
89
|
isOpen: isOpen,
|
|
87
90
|
animationKind: 'dialog',
|
|
@@ -90,7 +93,8 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
|
|
|
90
93
|
onEnterFinish: onEnterFinish,
|
|
91
94
|
onExitFinish: onExitFinish
|
|
92
95
|
}),
|
|
93
|
-
phase = _useAnimatedVisibilit.phase
|
|
96
|
+
phase = _useAnimatedVisibilit.phase,
|
|
97
|
+
onToggle = _useAnimatedVisibilit.onToggle;
|
|
94
98
|
|
|
95
99
|
// True while the host element is mounted (any phase except `closed`).
|
|
96
100
|
// Used as a dep so listener-rebind effects re-attach after a remount.
|
|
@@ -121,6 +125,7 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
|
|
|
121
125
|
return;
|
|
122
126
|
}
|
|
123
127
|
if (isOpen) {
|
|
128
|
+
closeReasonRef.current = null;
|
|
124
129
|
if (!dialog.open) {
|
|
125
130
|
dialog.showModal();
|
|
126
131
|
}
|
|
@@ -153,45 +158,67 @@ var Dialog = exports.Dialog = /*#__PURE__*/(0, _react.forwardRef)(function Dialo
|
|
|
153
158
|
if (event.target !== event.currentTarget) {
|
|
154
159
|
return;
|
|
155
160
|
}
|
|
156
|
-
|
|
161
|
+
|
|
157
162
|
// Spurious Safari `cancel`: the keydown snapshot saw an open child
|
|
158
163
|
// popover, so this Escape belongs to that popover (light-dismissed
|
|
159
164
|
// natively). Keep the dialog open instead of forwarding to `onClose`.
|
|
160
165
|
if (shouldIgnoreEscape()) {
|
|
166
|
+
event.preventDefault();
|
|
161
167
|
return;
|
|
162
168
|
}
|
|
163
|
-
onClose({
|
|
164
|
-
reason: 'escape'
|
|
165
|
-
});
|
|
166
|
-
}, [onClose, shouldIgnoreEscape]);
|
|
167
169
|
|
|
168
|
-
|
|
170
|
+
// Without native `closedby`, cancel Escape only when dismissal is disabled.
|
|
171
|
+
// Allowed dismissal follows Popover behavior: the browser closes the element
|
|
172
|
+
// and `onClose` notifies the controlled owner of the reason.
|
|
173
|
+
if (dismissedBy === 'none') {
|
|
174
|
+
event.preventDefault();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
closeReasonRef.current = 'escape';
|
|
178
|
+
}, [dismissedBy, shouldIgnoreEscape]);
|
|
179
|
+
|
|
180
|
+
// Handle backdrop interaction
|
|
169
181
|
// Attached via bind-event-listener rather than a React prop so we avoid
|
|
170
182
|
// a11y lint suppressions on the <dialog> element.
|
|
171
|
-
//
|
|
183
|
+
// The click listener provides light dismiss consistently across browsers.
|
|
172
184
|
// `isVisible` is in deps so the listener re-binds to the new <dialog>
|
|
173
185
|
// element after a host unmount / remount cycle.
|
|
174
|
-
(0, _react.
|
|
186
|
+
(0, _react.useLayoutEffect)(function () {
|
|
175
187
|
var dialog = ownRef.current;
|
|
176
188
|
if (!dialog) {
|
|
177
189
|
return;
|
|
178
190
|
}
|
|
179
|
-
return (0, _bindEventListener.
|
|
191
|
+
return (0, _bindEventListener.bindAll)(dialog, [{
|
|
180
192
|
type: 'click',
|
|
181
193
|
listener: function listener(event) {
|
|
182
|
-
if (event.target === event.currentTarget) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
194
|
+
if (event.target === event.currentTarget && dismissedBy === 'escape-and-outside-click') {
|
|
195
|
+
closeReasonRef.current = 'overlay-click';
|
|
196
|
+
// Calls native close because this is emulating the native light-dismiss behavior
|
|
197
|
+
dialog.close();
|
|
186
198
|
}
|
|
187
199
|
}
|
|
188
|
-
}
|
|
189
|
-
|
|
200
|
+
}, {
|
|
201
|
+
type: 'toggle',
|
|
202
|
+
listener: function listener(event) {
|
|
203
|
+
if (event.newState === 'closed') {
|
|
204
|
+
var reason = closeReasonRef.current;
|
|
205
|
+
closeReasonRef.current = null;
|
|
206
|
+
if (reason) {
|
|
207
|
+
onClose({
|
|
208
|
+
reason: reason
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Visibility cleanup runs after close-reason handling.
|
|
214
|
+
onToggle(event);
|
|
215
|
+
}
|
|
216
|
+
}]);
|
|
217
|
+
}, [dismissedBy, onClose, onToggle, isVisible]);
|
|
190
218
|
|
|
191
219
|
// Unmount the `<dialog>` once exit completes so it does not leave an
|
|
192
220
|
// empty `role="dialog"` element in the accessibility tree. On the next
|
|
193
|
-
// open the element remounts and
|
|
194
|
-
// effects re-run against the fresh element.
|
|
221
|
+
// open the element remounts and its effects run against the fresh element.
|
|
195
222
|
if (!isVisible) {
|
|
196
223
|
return null;
|
|
197
224
|
}
|
|
@@ -117,7 +117,7 @@ function runOnTransitionEndOrTimeout(_ref) {
|
|
|
117
117
|
* `entering`, `open`, `exiting`). The host element is rendered while
|
|
118
118
|
* `phase !== 'closed'`, so the `exiting` phase keeps the host mounted
|
|
119
119
|
* while the CSS exit transition plays (or, with animation disabled,
|
|
120
|
-
* while we wait for the browser `toggle`
|
|
120
|
+
* while we wait for the browser's closing `toggle` event to fire on the
|
|
121
121
|
* still-attached element):
|
|
122
122
|
*
|
|
123
123
|
* ```
|
|
@@ -136,6 +136,7 @@ function useAnimatedVisibility(_ref2) {
|
|
|
136
136
|
onEnterFinish = _ref2.onEnterFinish,
|
|
137
137
|
onExitFinish = _ref2.onExitFinish;
|
|
138
138
|
var willAnimate = shouldAnimate && !(0, _reducedMotion.prefersReducedMotion)();
|
|
139
|
+
var isNativeClosedRef = (0, _react.useRef)(false);
|
|
139
140
|
|
|
140
141
|
// Promote `closed → entering`/`closed → open` synchronously during
|
|
141
142
|
// render so the host element mounts in the same commit as the open
|
|
@@ -149,16 +150,23 @@ function useAnimatedVisibility(_ref2) {
|
|
|
149
150
|
_useState2 = (0, _slicedToArray2.default)(_useState, 2),
|
|
150
151
|
phase = _useState2[0],
|
|
151
152
|
setPhase = _useState2[1];
|
|
152
|
-
|
|
153
|
+
|
|
154
|
+
// Rearm only after controlled state and the native lifecycle have both
|
|
155
|
+
// reached closed. Resetting as soon as `isOpen` becomes false can erase a
|
|
156
|
+
// closing toggle that was handled in the same React batch.
|
|
157
|
+
if (!isOpen && phase === 'closed') {
|
|
158
|
+
isNativeClosedRef.current = false;
|
|
159
|
+
}
|
|
160
|
+
if (isOpen && phase === 'closed' && !isNativeClosedRef.current) {
|
|
153
161
|
setPhase(willAnimate ? 'entering' : 'open');
|
|
154
162
|
}
|
|
155
163
|
if (!isOpen && (phase === 'open' || phase === 'entering')) {
|
|
156
164
|
// Close intent. Move to `exiting` to gate the unmount on either
|
|
157
|
-
// the exit transition (animated) or the toggle
|
|
165
|
+
// the exit transition (animated) or the closing toggle event
|
|
158
166
|
// (non-animated). Includes interrupted entry (`entering → exiting`).
|
|
159
167
|
setPhase('exiting');
|
|
160
168
|
}
|
|
161
|
-
if (isOpen && phase === 'exiting') {
|
|
169
|
+
if (isOpen && phase === 'exiting' && !isNativeClosedRef.current) {
|
|
162
170
|
// Reopen mid-exit. With animation, jump back to `entering` and let
|
|
163
171
|
// the entry transition restart from current visual state. Without
|
|
164
172
|
// animation, jump straight to `open` (the handshake we were waiting
|
|
@@ -247,49 +255,37 @@ function useAnimatedVisibility(_ref2) {
|
|
|
247
255
|
});
|
|
248
256
|
}, [phase, willAnimate, animationKind, elementRef]);
|
|
249
257
|
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
// native focus restoration both run against the still-attached
|
|
254
|
-
// element. A naive `setTimeout(0)` occasionally beats the `toggle`
|
|
255
|
-
// task in Chromium and drops focus restoration.
|
|
256
|
-
//
|
|
257
|
-
// `useLayoutEffect` so the listener binds in the same commit as the
|
|
258
|
-
// show/hide layout effect that calls `hidePopover()` / `dialog.close()`.
|
|
259
|
-
// The toggle/close event is queued as a task and cannot run until the
|
|
260
|
-
// frame completes. SSR-safe: `phase` starts at `'closed'`.
|
|
261
|
-
//
|
|
262
|
-
// `onExitFinish` is fired separately (see effect below) so consumers
|
|
263
|
-
// observe it the moment `isOpen` flips to `false`.
|
|
258
|
+
// Native dismissal is authoritative. Its toggle can arrive while controlled
|
|
259
|
+
// state still says open, so remember that native state and do not
|
|
260
|
+
// reopen until `isOpen` has first moved to false.
|
|
264
261
|
(0, _react.useLayoutEffect)(function () {
|
|
265
|
-
if (phase
|
|
266
|
-
|
|
262
|
+
if (phase === 'exiting' && !willAnimate && isNativeClosedRef.current) {
|
|
263
|
+
setPhase('closed');
|
|
267
264
|
}
|
|
268
|
-
|
|
269
|
-
|
|
265
|
+
}, [phase, willAnimate]);
|
|
266
|
+
var onToggle = (0, _react.useCallback)(function (event) {
|
|
267
|
+
if (event.newState === 'open') {
|
|
268
|
+
isNativeClosedRef.current = false;
|
|
270
269
|
return;
|
|
271
270
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return current === 'exiting' ? 'closed' : current;
|
|
275
|
-
});
|
|
271
|
+
if (event.newState !== 'closed') {
|
|
272
|
+
return;
|
|
276
273
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
unmount();
|
|
282
|
-
}
|
|
274
|
+
isNativeClosedRef.current = true;
|
|
275
|
+
setPhase(function (current) {
|
|
276
|
+
if (current === 'open' || current === 'entering') {
|
|
277
|
+
return 'exiting';
|
|
283
278
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
279
|
+
if (current === 'exiting' && !willAnimate) {
|
|
280
|
+
return 'closed';
|
|
281
|
+
}
|
|
282
|
+
return current;
|
|
283
|
+
});
|
|
284
|
+
}, [willAnimate]);
|
|
289
285
|
|
|
290
286
|
// Non-animated exit callback. No `transitionend` to wait for, so fire
|
|
291
287
|
// `onExitFinish` on the transition into `exiting` (the unmount itself
|
|
292
|
-
// is gated by the toggle
|
|
288
|
+
// is gated by the closing toggle handshake above).
|
|
293
289
|
var prevPhaseForExitRef = (0, _react.useRef)('closed');
|
|
294
290
|
(0, _react.useEffect)(function () {
|
|
295
291
|
var prevPhase = prevPhaseForExitRef.current;
|
|
@@ -304,6 +300,7 @@ function useAnimatedVisibility(_ref2) {
|
|
|
304
300
|
}
|
|
305
301
|
}, [phase, willAnimate]);
|
|
306
302
|
return {
|
|
307
|
-
phase: phase
|
|
303
|
+
phase: phase,
|
|
304
|
+
onToggle: onToggle
|
|
308
305
|
};
|
|
309
306
|
}
|
|
@@ -97,11 +97,7 @@ var POPUP_ROLES = new Set(['menu', 'listbox', 'dialog', 'alertdialog', 'tree', '
|
|
|
97
97
|
* ### 📜 Browser dismiss is non-cancellable
|
|
98
98
|
*
|
|
99
99
|
* Per the popover spec, `beforetoggle` is cancellable on open transitions
|
|
100
|
-
* only, not on close.
|
|
101
|
-
* (e.g. a click on a logically-associated external trigger) must reconcile
|
|
102
|
-
* inside their own `onClose` handler by calling `showPopover()` again.
|
|
103
|
-
* `Popover` does not self-heal so consumers do not pay a reconcile cost
|
|
104
|
-
* by default.
|
|
100
|
+
* only, not on close.
|
|
105
101
|
*
|
|
106
102
|
* - Spec: <https://html.spec.whatwg.org/multipage/popover.html#the-popover-attribute>
|
|
107
103
|
* - <https://github.com/whatwg/html/issues/8973>
|
|
@@ -148,7 +144,8 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
|
|
|
148
144
|
onEnterFinish: onEnterFinish,
|
|
149
145
|
onExitFinish: onExitFinish
|
|
150
146
|
}),
|
|
151
|
-
phase = _useAnimatedVisibilit.phase
|
|
147
|
+
phase = _useAnimatedVisibilit.phase,
|
|
148
|
+
onToggle = _useAnimatedVisibilit.onToggle;
|
|
152
149
|
|
|
153
150
|
// True while the host element is mounted (any phase except `closed`).
|
|
154
151
|
// Used as a dep so listener-rebind effects re-attach after a remount.
|
|
@@ -221,6 +218,7 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
|
|
|
221
218
|
var handleToggle = (0, _react.useCallback)(function (event) {
|
|
222
219
|
var element = ownRef.current;
|
|
223
220
|
if (event.newState === 'open') {
|
|
221
|
+
onToggle(event);
|
|
224
222
|
return;
|
|
225
223
|
}
|
|
226
224
|
if (event.newState === 'closed') {
|
|
@@ -232,11 +230,11 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
|
|
|
232
230
|
// click target keeps focus per HTML spec
|
|
233
231
|
// (`focusPreviousElement=false`).
|
|
234
232
|
var previouslyFocused = previouslyFocusedElementRef.current;
|
|
235
|
-
var
|
|
233
|
+
var reason = closeReasonRef.current;
|
|
236
234
|
var isProgrammatic = programmaticCloseRef.current;
|
|
237
235
|
var shouldRestore = previouslyFocused !== null && previouslyFocused.isConnected && (0, _shouldFocusIntoPopover.shouldFocusIntoPopover)({
|
|
238
236
|
role: role
|
|
239
|
-
}) && (
|
|
237
|
+
}) && (reason === 'escape' || isProgrammatic);
|
|
240
238
|
if (shouldRestore && previouslyFocused) {
|
|
241
239
|
previouslyFocused.focus({
|
|
242
240
|
preventScroll: true
|
|
@@ -250,18 +248,18 @@ var Popover = exports.Popover = /*#__PURE__*/(0, _react.forwardRef)(function Pop
|
|
|
250
248
|
// Reset reason so a stale 'escape' from a race with Escape keydown
|
|
251
249
|
// does not corrupt the next browser-dismiss cycle.
|
|
252
250
|
closeReasonRef.current = 'light-dismiss';
|
|
253
|
-
|
|
251
|
+
} else {
|
|
252
|
+
// Browser dismiss (Escape/click-outside).
|
|
253
|
+
var _reason = closeReasonRef.current;
|
|
254
|
+
closeReasonRef.current = 'light-dismiss';
|
|
255
|
+
// Optional: `manual` mode has no `onClose` in the forwarded type.
|
|
256
|
+
onClose === null || onClose === void 0 || onClose({
|
|
257
|
+
reason: _reason
|
|
258
|
+
});
|
|
254
259
|
}
|
|
255
|
-
|
|
256
|
-
// Browser dismiss (Escape/click-outside).
|
|
257
|
-
var reason = closeReasonRef.current;
|
|
258
|
-
closeReasonRef.current = 'light-dismiss';
|
|
259
|
-
// Optional: `manual` mode has no `onClose` in the forwarded type.
|
|
260
|
-
onClose === null || onClose === void 0 || onClose({
|
|
261
|
-
reason: reason
|
|
262
|
-
});
|
|
263
260
|
}
|
|
264
|
-
|
|
261
|
+
onToggle(event);
|
|
262
|
+
}, [onClose, onToggle, role]);
|
|
265
263
|
|
|
266
264
|
// Bind via `useLayoutEffect`, and BEFORE the show/hide layout effect
|
|
267
265
|
// below, so listeners are attached when `showPopover()` synchronously
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/* dialog-content.tsx generated by @compiled/babel-plugin v2.0.0 */
|
|
2
2
|
import "./dialog-content.compiled.css";
|
|
3
3
|
import { ax, ix } from "@compiled/react/runtime";
|
|
4
|
-
import React, { forwardRef, useCallback,
|
|
4
|
+
import React, { forwardRef, useCallback, useId, useLayoutEffect, useRef } from 'react';
|
|
5
5
|
import { cx } from '@compiled/react';
|
|
6
|
-
import {
|
|
6
|
+
import { bindAll } from 'bind-event-listener';
|
|
7
7
|
import mergeRefs from '@atlaskit/ds-lib/merge-refs';
|
|
8
8
|
import noop from '@atlaskit/ds-lib/noop';
|
|
9
9
|
import { useNotifyOpenLayerObserver } from '@atlaskit/layering/use-notify-open-layer-observer';
|
|
@@ -45,8 +45,8 @@ const backdropStyles = {
|
|
|
45
45
|
*
|
|
46
46
|
* Handles native `cancel` event (Escape) and backdrop click detection.
|
|
47
47
|
*
|
|
48
|
-
* Close flow:
|
|
49
|
-
*
|
|
48
|
+
* Close flow: allowed user dismissal closes the native dialog before `onClose`
|
|
49
|
+
* notifies the consumer. The consumer must synchronize `isOpen` in response.
|
|
50
50
|
*
|
|
51
51
|
* Accessibility: render at least one focusable element (typically a close
|
|
52
52
|
* button) when `isOpen` becomes `true`. Tab is always trapped inside the
|
|
@@ -56,6 +56,7 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
|
|
|
56
56
|
children,
|
|
57
57
|
isOpen,
|
|
58
58
|
onClose,
|
|
59
|
+
dismissedBy = 'escape-and-outside-click',
|
|
59
60
|
onEnterFinish,
|
|
60
61
|
onExitFinish,
|
|
61
62
|
shouldAnimate = false,
|
|
@@ -73,8 +74,10 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
|
|
|
73
74
|
const dialogId = providedId !== null && providedId !== void 0 ? providedId : generatedId;
|
|
74
75
|
const ownRef = useRef(null);
|
|
75
76
|
const combinedRef = mergeRefs([ownRef, ref]);
|
|
77
|
+
const closeReasonRef = useRef(null);
|
|
76
78
|
const {
|
|
77
|
-
phase
|
|
79
|
+
phase,
|
|
80
|
+
onToggle
|
|
78
81
|
} = useAnimatedVisibility({
|
|
79
82
|
isOpen,
|
|
80
83
|
animationKind: 'dialog',
|
|
@@ -113,6 +116,7 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
|
|
|
113
116
|
return;
|
|
114
117
|
}
|
|
115
118
|
if (isOpen) {
|
|
119
|
+
closeReasonRef.current = null;
|
|
116
120
|
if (!dialog.open) {
|
|
117
121
|
dialog.showModal();
|
|
118
122
|
}
|
|
@@ -146,45 +150,67 @@ export const Dialog = /*#__PURE__*/forwardRef(function Dialog({
|
|
|
146
150
|
if (event.target !== event.currentTarget) {
|
|
147
151
|
return;
|
|
148
152
|
}
|
|
149
|
-
|
|
153
|
+
|
|
150
154
|
// Spurious Safari `cancel`: the keydown snapshot saw an open child
|
|
151
155
|
// popover, so this Escape belongs to that popover (light-dismissed
|
|
152
156
|
// natively). Keep the dialog open instead of forwarding to `onClose`.
|
|
153
157
|
if (shouldIgnoreEscape()) {
|
|
158
|
+
event.preventDefault();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Without native `closedby`, cancel Escape only when dismissal is disabled.
|
|
163
|
+
// Allowed dismissal follows Popover behavior: the browser closes the element
|
|
164
|
+
// and `onClose` notifies the controlled owner of the reason.
|
|
165
|
+
if (dismissedBy === 'none') {
|
|
166
|
+
event.preventDefault();
|
|
154
167
|
return;
|
|
155
168
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
});
|
|
159
|
-
}, [onClose, shouldIgnoreEscape]);
|
|
169
|
+
closeReasonRef.current = 'escape';
|
|
170
|
+
}, [dismissedBy, shouldIgnoreEscape]);
|
|
160
171
|
|
|
161
|
-
// Handle backdrop
|
|
172
|
+
// Handle backdrop interaction
|
|
162
173
|
// Attached via bind-event-listener rather than a React prop so we avoid
|
|
163
174
|
// a11y lint suppressions on the <dialog> element.
|
|
164
|
-
//
|
|
175
|
+
// The click listener provides light dismiss consistently across browsers.
|
|
165
176
|
// `isVisible` is in deps so the listener re-binds to the new <dialog>
|
|
166
177
|
// element after a host unmount / remount cycle.
|
|
167
|
-
|
|
178
|
+
useLayoutEffect(() => {
|
|
168
179
|
const dialog = ownRef.current;
|
|
169
180
|
if (!dialog) {
|
|
170
181
|
return;
|
|
171
182
|
}
|
|
172
|
-
return
|
|
183
|
+
return bindAll(dialog, [{
|
|
173
184
|
type: 'click',
|
|
174
185
|
listener(event) {
|
|
175
|
-
if (event.target === event.currentTarget) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
186
|
+
if (event.target === event.currentTarget && dismissedBy === 'escape-and-outside-click') {
|
|
187
|
+
closeReasonRef.current = 'overlay-click';
|
|
188
|
+
// Calls native close because this is emulating the native light-dismiss behavior
|
|
189
|
+
dialog.close();
|
|
179
190
|
}
|
|
180
191
|
}
|
|
181
|
-
}
|
|
182
|
-
|
|
192
|
+
}, {
|
|
193
|
+
type: 'toggle',
|
|
194
|
+
listener(event) {
|
|
195
|
+
if (event.newState === 'closed') {
|
|
196
|
+
const reason = closeReasonRef.current;
|
|
197
|
+
closeReasonRef.current = null;
|
|
198
|
+
if (reason) {
|
|
199
|
+
onClose({
|
|
200
|
+
reason
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Visibility cleanup runs after close-reason handling.
|
|
206
|
+
onToggle(event);
|
|
207
|
+
}
|
|
208
|
+
}]);
|
|
209
|
+
}, [dismissedBy, onClose, onToggle, isVisible]);
|
|
183
210
|
|
|
184
211
|
// Unmount the `<dialog>` once exit completes so it does not leave an
|
|
185
212
|
// empty `role="dialog"` element in the accessibility tree. On the next
|
|
186
|
-
// open the element remounts and
|
|
187
|
-
// effects re-run against the fresh element.
|
|
213
|
+
// open the element remounts and its effects run against the fresh element.
|
|
188
214
|
if (!isVisible) {
|
|
189
215
|
return null;
|
|
190
216
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
-
import { bind
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { bind } from 'bind-event-listener';
|
|
3
3
|
import { prefersReducedMotion } from './reduced-motion';
|
|
4
4
|
/**
|
|
5
5
|
* Using the recommended motion timings for safety net.
|
|
@@ -110,7 +110,7 @@ function runOnTransitionEndOrTimeout({
|
|
|
110
110
|
* `entering`, `open`, `exiting`). The host element is rendered while
|
|
111
111
|
* `phase !== 'closed'`, so the `exiting` phase keeps the host mounted
|
|
112
112
|
* while the CSS exit transition plays (or, with animation disabled,
|
|
113
|
-
* while we wait for the browser `toggle`
|
|
113
|
+
* while we wait for the browser's closing `toggle` event to fire on the
|
|
114
114
|
* still-attached element):
|
|
115
115
|
*
|
|
116
116
|
* ```
|
|
@@ -130,6 +130,7 @@ export function useAnimatedVisibility({
|
|
|
130
130
|
onExitFinish
|
|
131
131
|
}) {
|
|
132
132
|
const willAnimate = shouldAnimate && !prefersReducedMotion();
|
|
133
|
+
const isNativeClosedRef = useRef(false);
|
|
133
134
|
|
|
134
135
|
// Promote `closed → entering`/`closed → open` synchronously during
|
|
135
136
|
// render so the host element mounts in the same commit as the open
|
|
@@ -140,16 +141,23 @@ export function useAnimatedVisibility({
|
|
|
140
141
|
}
|
|
141
142
|
return willAnimate ? 'entering' : 'open';
|
|
142
143
|
});
|
|
143
|
-
|
|
144
|
+
|
|
145
|
+
// Rearm only after controlled state and the native lifecycle have both
|
|
146
|
+
// reached closed. Resetting as soon as `isOpen` becomes false can erase a
|
|
147
|
+
// closing toggle that was handled in the same React batch.
|
|
148
|
+
if (!isOpen && phase === 'closed') {
|
|
149
|
+
isNativeClosedRef.current = false;
|
|
150
|
+
}
|
|
151
|
+
if (isOpen && phase === 'closed' && !isNativeClosedRef.current) {
|
|
144
152
|
setPhase(willAnimate ? 'entering' : 'open');
|
|
145
153
|
}
|
|
146
154
|
if (!isOpen && (phase === 'open' || phase === 'entering')) {
|
|
147
155
|
// Close intent. Move to `exiting` to gate the unmount on either
|
|
148
|
-
// the exit transition (animated) or the toggle
|
|
156
|
+
// the exit transition (animated) or the closing toggle event
|
|
149
157
|
// (non-animated). Includes interrupted entry (`entering → exiting`).
|
|
150
158
|
setPhase('exiting');
|
|
151
159
|
}
|
|
152
|
-
if (isOpen && phase === 'exiting') {
|
|
160
|
+
if (isOpen && phase === 'exiting' && !isNativeClosedRef.current) {
|
|
153
161
|
// Reopen mid-exit. With animation, jump back to `entering` and let
|
|
154
162
|
// the entry transition restart from current visual state. Without
|
|
155
163
|
// animation, jump straight to `open` (the handshake we were waiting
|
|
@@ -234,47 +242,37 @@ export function useAnimatedVisibility({
|
|
|
234
242
|
});
|
|
235
243
|
}, [phase, willAnimate, animationKind, elementRef]);
|
|
236
244
|
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
// native focus restoration both run against the still-attached
|
|
241
|
-
// element. A naive `setTimeout(0)` occasionally beats the `toggle`
|
|
242
|
-
// task in Chromium and drops focus restoration.
|
|
243
|
-
//
|
|
244
|
-
// `useLayoutEffect` so the listener binds in the same commit as the
|
|
245
|
-
// show/hide layout effect that calls `hidePopover()` / `dialog.close()`.
|
|
246
|
-
// The toggle/close event is queued as a task and cannot run until the
|
|
247
|
-
// frame completes. SSR-safe: `phase` starts at `'closed'`.
|
|
248
|
-
//
|
|
249
|
-
// `onExitFinish` is fired separately (see effect below) so consumers
|
|
250
|
-
// observe it the moment `isOpen` flips to `false`.
|
|
245
|
+
// Native dismissal is authoritative. Its toggle can arrive while controlled
|
|
246
|
+
// state still says open, so remember that native state and do not
|
|
247
|
+
// reopen until `isOpen` has first moved to false.
|
|
251
248
|
useLayoutEffect(() => {
|
|
252
|
-
if (phase
|
|
253
|
-
|
|
249
|
+
if (phase === 'exiting' && !willAnimate && isNativeClosedRef.current) {
|
|
250
|
+
setPhase('closed');
|
|
254
251
|
}
|
|
255
|
-
|
|
256
|
-
|
|
252
|
+
}, [phase, willAnimate]);
|
|
253
|
+
const onToggle = useCallback(event => {
|
|
254
|
+
if (event.newState === 'open') {
|
|
255
|
+
isNativeClosedRef.current = false;
|
|
257
256
|
return;
|
|
258
257
|
}
|
|
259
|
-
|
|
260
|
-
|
|
258
|
+
if (event.newState !== 'closed') {
|
|
259
|
+
return;
|
|
261
260
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
unmount();
|
|
267
|
-
}
|
|
261
|
+
isNativeClosedRef.current = true;
|
|
262
|
+
setPhase(current => {
|
|
263
|
+
if (current === 'open' || current === 'entering') {
|
|
264
|
+
return 'exiting';
|
|
268
265
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
266
|
+
if (current === 'exiting' && !willAnimate) {
|
|
267
|
+
return 'closed';
|
|
268
|
+
}
|
|
269
|
+
return current;
|
|
270
|
+
});
|
|
271
|
+
}, [willAnimate]);
|
|
274
272
|
|
|
275
273
|
// Non-animated exit callback. No `transitionend` to wait for, so fire
|
|
276
274
|
// `onExitFinish` on the transition into `exiting` (the unmount itself
|
|
277
|
-
// is gated by the toggle
|
|
275
|
+
// is gated by the closing toggle handshake above).
|
|
278
276
|
const prevPhaseForExitRef = useRef('closed');
|
|
279
277
|
useEffect(() => {
|
|
280
278
|
const prevPhase = prevPhaseForExitRef.current;
|
|
@@ -289,6 +287,7 @@ export function useAnimatedVisibility({
|
|
|
289
287
|
}
|
|
290
288
|
}, [phase, willAnimate]);
|
|
291
289
|
return {
|
|
292
|
-
phase
|
|
290
|
+
phase,
|
|
291
|
+
onToggle
|
|
293
292
|
};
|
|
294
293
|
}
|
|
@@ -88,11 +88,7 @@ const POPUP_ROLES = new Set(['menu', 'listbox', 'dialog', 'alertdialog', 'tree',
|
|
|
88
88
|
* ### 📜 Browser dismiss is non-cancellable
|
|
89
89
|
*
|
|
90
90
|
* Per the popover spec, `beforetoggle` is cancellable on open transitions
|
|
91
|
-
* only, not on close.
|
|
92
|
-
* (e.g. a click on a logically-associated external trigger) must reconcile
|
|
93
|
-
* inside their own `onClose` handler by calling `showPopover()` again.
|
|
94
|
-
* `Popover` does not self-heal so consumers do not pay a reconcile cost
|
|
95
|
-
* by default.
|
|
91
|
+
* only, not on close.
|
|
96
92
|
*
|
|
97
93
|
* - Spec: <https://html.spec.whatwg.org/multipage/popover.html#the-popover-attribute>
|
|
98
94
|
* - <https://github.com/whatwg/html/issues/8973>
|
|
@@ -132,7 +128,8 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
|
|
|
132
128
|
// `useId()` colons are invalid in CSS selectors and popover target attributes.
|
|
133
129
|
const popoverId = idProp !== null && idProp !== void 0 ? idProp : `popover-${autoId.replace(/:/g, '')}`;
|
|
134
130
|
const {
|
|
135
|
-
phase
|
|
131
|
+
phase,
|
|
132
|
+
onToggle
|
|
136
133
|
} = useAnimatedVisibility({
|
|
137
134
|
isOpen,
|
|
138
135
|
animationKind: 'popover',
|
|
@@ -214,6 +211,7 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
|
|
|
214
211
|
const handleToggle = useCallback(event => {
|
|
215
212
|
const element = ownRef.current;
|
|
216
213
|
if (event.newState === 'open') {
|
|
214
|
+
onToggle(event);
|
|
217
215
|
return;
|
|
218
216
|
}
|
|
219
217
|
if (event.newState === 'closed') {
|
|
@@ -243,18 +241,18 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
|
|
|
243
241
|
// Reset reason so a stale 'escape' from a race with Escape keydown
|
|
244
242
|
// does not corrupt the next browser-dismiss cycle.
|
|
245
243
|
closeReasonRef.current = 'light-dismiss';
|
|
246
|
-
|
|
244
|
+
} else {
|
|
245
|
+
// Browser dismiss (Escape/click-outside).
|
|
246
|
+
const reason = closeReasonRef.current;
|
|
247
|
+
closeReasonRef.current = 'light-dismiss';
|
|
248
|
+
// Optional: `manual` mode has no `onClose` in the forwarded type.
|
|
249
|
+
onClose === null || onClose === void 0 ? void 0 : onClose({
|
|
250
|
+
reason
|
|
251
|
+
});
|
|
247
252
|
}
|
|
248
|
-
|
|
249
|
-
// Browser dismiss (Escape/click-outside).
|
|
250
|
-
const reason = closeReasonRef.current;
|
|
251
|
-
closeReasonRef.current = 'light-dismiss';
|
|
252
|
-
// Optional: `manual` mode has no `onClose` in the forwarded type.
|
|
253
|
-
onClose === null || onClose === void 0 ? void 0 : onClose({
|
|
254
|
-
reason
|
|
255
|
-
});
|
|
256
253
|
}
|
|
257
|
-
|
|
254
|
+
onToggle(event);
|
|
255
|
+
}, [onClose, onToggle, role]);
|
|
258
256
|
|
|
259
257
|
// Bind via `useLayoutEffect`, and BEFORE the show/hide layout effect
|
|
260
258
|
// below, so listeners are attached when `showPopover()` synchronously
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/* dialog-content.tsx generated by @compiled/babel-plugin v2.0.0 */
|
|
2
2
|
import "./dialog-content.compiled.css";
|
|
3
3
|
import { ax, ix } from "@compiled/react/runtime";
|
|
4
|
-
import React, { forwardRef, useCallback,
|
|
4
|
+
import React, { forwardRef, useCallback, useId, useLayoutEffect, useRef } from 'react';
|
|
5
5
|
import { cx } from '@compiled/react';
|
|
6
|
-
import {
|
|
6
|
+
import { bindAll } from 'bind-event-listener';
|
|
7
7
|
import mergeRefs from '@atlaskit/ds-lib/merge-refs';
|
|
8
8
|
import noop from '@atlaskit/ds-lib/noop';
|
|
9
9
|
import { useNotifyOpenLayerObserver } from '@atlaskit/layering/use-notify-open-layer-observer';
|
|
@@ -45,8 +45,8 @@ var backdropStyles = {
|
|
|
45
45
|
*
|
|
46
46
|
* Handles native `cancel` event (Escape) and backdrop click detection.
|
|
47
47
|
*
|
|
48
|
-
* Close flow:
|
|
49
|
-
*
|
|
48
|
+
* Close flow: allowed user dismissal closes the native dialog before `onClose`
|
|
49
|
+
* notifies the consumer. The consumer must synchronize `isOpen` in response.
|
|
50
50
|
*
|
|
51
51
|
* Accessibility: render at least one focusable element (typically a close
|
|
52
52
|
* button) when `isOpen` becomes `true`. Tab is always trapped inside the
|
|
@@ -56,6 +56,8 @@ export var Dialog = /*#__PURE__*/forwardRef(function Dialog(_ref, ref) {
|
|
|
56
56
|
var children = _ref.children,
|
|
57
57
|
isOpen = _ref.isOpen,
|
|
58
58
|
onClose = _ref.onClose,
|
|
59
|
+
_ref$dismissedBy = _ref.dismissedBy,
|
|
60
|
+
dismissedBy = _ref$dismissedBy === void 0 ? 'escape-and-outside-click' : _ref$dismissedBy,
|
|
59
61
|
onEnterFinish = _ref.onEnterFinish,
|
|
60
62
|
onExitFinish = _ref.onExitFinish,
|
|
61
63
|
_ref$shouldAnimate = _ref.shouldAnimate,
|
|
@@ -73,6 +75,7 @@ export var Dialog = /*#__PURE__*/forwardRef(function Dialog(_ref, ref) {
|
|
|
73
75
|
var dialogId = providedId !== null && providedId !== void 0 ? providedId : generatedId;
|
|
74
76
|
var ownRef = useRef(null);
|
|
75
77
|
var combinedRef = mergeRefs([ownRef, ref]);
|
|
78
|
+
var closeReasonRef = useRef(null);
|
|
76
79
|
var _useAnimatedVisibilit = useAnimatedVisibility({
|
|
77
80
|
isOpen: isOpen,
|
|
78
81
|
animationKind: 'dialog',
|
|
@@ -81,7 +84,8 @@ export var Dialog = /*#__PURE__*/forwardRef(function Dialog(_ref, ref) {
|
|
|
81
84
|
onEnterFinish: onEnterFinish,
|
|
82
85
|
onExitFinish: onExitFinish
|
|
83
86
|
}),
|
|
84
|
-
phase = _useAnimatedVisibilit.phase
|
|
87
|
+
phase = _useAnimatedVisibilit.phase,
|
|
88
|
+
onToggle = _useAnimatedVisibilit.onToggle;
|
|
85
89
|
|
|
86
90
|
// True while the host element is mounted (any phase except `closed`).
|
|
87
91
|
// Used as a dep so listener-rebind effects re-attach after a remount.
|
|
@@ -112,6 +116,7 @@ export var Dialog = /*#__PURE__*/forwardRef(function Dialog(_ref, ref) {
|
|
|
112
116
|
return;
|
|
113
117
|
}
|
|
114
118
|
if (isOpen) {
|
|
119
|
+
closeReasonRef.current = null;
|
|
115
120
|
if (!dialog.open) {
|
|
116
121
|
dialog.showModal();
|
|
117
122
|
}
|
|
@@ -144,45 +149,67 @@ export var Dialog = /*#__PURE__*/forwardRef(function Dialog(_ref, ref) {
|
|
|
144
149
|
if (event.target !== event.currentTarget) {
|
|
145
150
|
return;
|
|
146
151
|
}
|
|
147
|
-
|
|
152
|
+
|
|
148
153
|
// Spurious Safari `cancel`: the keydown snapshot saw an open child
|
|
149
154
|
// popover, so this Escape belongs to that popover (light-dismissed
|
|
150
155
|
// natively). Keep the dialog open instead of forwarding to `onClose`.
|
|
151
156
|
if (shouldIgnoreEscape()) {
|
|
157
|
+
event.preventDefault();
|
|
152
158
|
return;
|
|
153
159
|
}
|
|
154
|
-
onClose({
|
|
155
|
-
reason: 'escape'
|
|
156
|
-
});
|
|
157
|
-
}, [onClose, shouldIgnoreEscape]);
|
|
158
160
|
|
|
159
|
-
|
|
161
|
+
// Without native `closedby`, cancel Escape only when dismissal is disabled.
|
|
162
|
+
// Allowed dismissal follows Popover behavior: the browser closes the element
|
|
163
|
+
// and `onClose` notifies the controlled owner of the reason.
|
|
164
|
+
if (dismissedBy === 'none') {
|
|
165
|
+
event.preventDefault();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
closeReasonRef.current = 'escape';
|
|
169
|
+
}, [dismissedBy, shouldIgnoreEscape]);
|
|
170
|
+
|
|
171
|
+
// Handle backdrop interaction
|
|
160
172
|
// Attached via bind-event-listener rather than a React prop so we avoid
|
|
161
173
|
// a11y lint suppressions on the <dialog> element.
|
|
162
|
-
//
|
|
174
|
+
// The click listener provides light dismiss consistently across browsers.
|
|
163
175
|
// `isVisible` is in deps so the listener re-binds to the new <dialog>
|
|
164
176
|
// element after a host unmount / remount cycle.
|
|
165
|
-
|
|
177
|
+
useLayoutEffect(function () {
|
|
166
178
|
var dialog = ownRef.current;
|
|
167
179
|
if (!dialog) {
|
|
168
180
|
return;
|
|
169
181
|
}
|
|
170
|
-
return
|
|
182
|
+
return bindAll(dialog, [{
|
|
171
183
|
type: 'click',
|
|
172
184
|
listener: function listener(event) {
|
|
173
|
-
if (event.target === event.currentTarget) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
185
|
+
if (event.target === event.currentTarget && dismissedBy === 'escape-and-outside-click') {
|
|
186
|
+
closeReasonRef.current = 'overlay-click';
|
|
187
|
+
// Calls native close because this is emulating the native light-dismiss behavior
|
|
188
|
+
dialog.close();
|
|
177
189
|
}
|
|
178
190
|
}
|
|
179
|
-
}
|
|
180
|
-
|
|
191
|
+
}, {
|
|
192
|
+
type: 'toggle',
|
|
193
|
+
listener: function listener(event) {
|
|
194
|
+
if (event.newState === 'closed') {
|
|
195
|
+
var reason = closeReasonRef.current;
|
|
196
|
+
closeReasonRef.current = null;
|
|
197
|
+
if (reason) {
|
|
198
|
+
onClose({
|
|
199
|
+
reason: reason
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Visibility cleanup runs after close-reason handling.
|
|
205
|
+
onToggle(event);
|
|
206
|
+
}
|
|
207
|
+
}]);
|
|
208
|
+
}, [dismissedBy, onClose, onToggle, isVisible]);
|
|
181
209
|
|
|
182
210
|
// Unmount the `<dialog>` once exit completes so it does not leave an
|
|
183
211
|
// empty `role="dialog"` element in the accessibility tree. On the next
|
|
184
|
-
// open the element remounts and
|
|
185
|
-
// effects re-run against the fresh element.
|
|
212
|
+
// open the element remounts and its effects run against the fresh element.
|
|
186
213
|
if (!isVisible) {
|
|
187
214
|
return null;
|
|
188
215
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
|
|
2
|
-
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
-
import { bind
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
import { bind } from 'bind-event-listener';
|
|
4
4
|
import { prefersReducedMotion } from './reduced-motion';
|
|
5
5
|
/**
|
|
6
6
|
* Using the recommended motion timings for safety net.
|
|
@@ -110,7 +110,7 @@ function runOnTransitionEndOrTimeout(_ref) {
|
|
|
110
110
|
* `entering`, `open`, `exiting`). The host element is rendered while
|
|
111
111
|
* `phase !== 'closed'`, so the `exiting` phase keeps the host mounted
|
|
112
112
|
* while the CSS exit transition plays (or, with animation disabled,
|
|
113
|
-
* while we wait for the browser `toggle`
|
|
113
|
+
* while we wait for the browser's closing `toggle` event to fire on the
|
|
114
114
|
* still-attached element):
|
|
115
115
|
*
|
|
116
116
|
* ```
|
|
@@ -129,6 +129,7 @@ export function useAnimatedVisibility(_ref2) {
|
|
|
129
129
|
onEnterFinish = _ref2.onEnterFinish,
|
|
130
130
|
onExitFinish = _ref2.onExitFinish;
|
|
131
131
|
var willAnimate = shouldAnimate && !prefersReducedMotion();
|
|
132
|
+
var isNativeClosedRef = useRef(false);
|
|
132
133
|
|
|
133
134
|
// Promote `closed → entering`/`closed → open` synchronously during
|
|
134
135
|
// render so the host element mounts in the same commit as the open
|
|
@@ -142,16 +143,23 @@ export function useAnimatedVisibility(_ref2) {
|
|
|
142
143
|
_useState2 = _slicedToArray(_useState, 2),
|
|
143
144
|
phase = _useState2[0],
|
|
144
145
|
setPhase = _useState2[1];
|
|
145
|
-
|
|
146
|
+
|
|
147
|
+
// Rearm only after controlled state and the native lifecycle have both
|
|
148
|
+
// reached closed. Resetting as soon as `isOpen` becomes false can erase a
|
|
149
|
+
// closing toggle that was handled in the same React batch.
|
|
150
|
+
if (!isOpen && phase === 'closed') {
|
|
151
|
+
isNativeClosedRef.current = false;
|
|
152
|
+
}
|
|
153
|
+
if (isOpen && phase === 'closed' && !isNativeClosedRef.current) {
|
|
146
154
|
setPhase(willAnimate ? 'entering' : 'open');
|
|
147
155
|
}
|
|
148
156
|
if (!isOpen && (phase === 'open' || phase === 'entering')) {
|
|
149
157
|
// Close intent. Move to `exiting` to gate the unmount on either
|
|
150
|
-
// the exit transition (animated) or the toggle
|
|
158
|
+
// the exit transition (animated) or the closing toggle event
|
|
151
159
|
// (non-animated). Includes interrupted entry (`entering → exiting`).
|
|
152
160
|
setPhase('exiting');
|
|
153
161
|
}
|
|
154
|
-
if (isOpen && phase === 'exiting') {
|
|
162
|
+
if (isOpen && phase === 'exiting' && !isNativeClosedRef.current) {
|
|
155
163
|
// Reopen mid-exit. With animation, jump back to `entering` and let
|
|
156
164
|
// the entry transition restart from current visual state. Without
|
|
157
165
|
// animation, jump straight to `open` (the handshake we were waiting
|
|
@@ -240,49 +248,37 @@ export function useAnimatedVisibility(_ref2) {
|
|
|
240
248
|
});
|
|
241
249
|
}, [phase, willAnimate, animationKind, elementRef]);
|
|
242
250
|
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
// native focus restoration both run against the still-attached
|
|
247
|
-
// element. A naive `setTimeout(0)` occasionally beats the `toggle`
|
|
248
|
-
// task in Chromium and drops focus restoration.
|
|
249
|
-
//
|
|
250
|
-
// `useLayoutEffect` so the listener binds in the same commit as the
|
|
251
|
-
// show/hide layout effect that calls `hidePopover()` / `dialog.close()`.
|
|
252
|
-
// The toggle/close event is queued as a task and cannot run until the
|
|
253
|
-
// frame completes. SSR-safe: `phase` starts at `'closed'`.
|
|
254
|
-
//
|
|
255
|
-
// `onExitFinish` is fired separately (see effect below) so consumers
|
|
256
|
-
// observe it the moment `isOpen` flips to `false`.
|
|
251
|
+
// Native dismissal is authoritative. Its toggle can arrive while controlled
|
|
252
|
+
// state still says open, so remember that native state and do not
|
|
253
|
+
// reopen until `isOpen` has first moved to false.
|
|
257
254
|
useLayoutEffect(function () {
|
|
258
|
-
if (phase
|
|
259
|
-
|
|
255
|
+
if (phase === 'exiting' && !willAnimate && isNativeClosedRef.current) {
|
|
256
|
+
setPhase('closed');
|
|
260
257
|
}
|
|
261
|
-
|
|
262
|
-
|
|
258
|
+
}, [phase, willAnimate]);
|
|
259
|
+
var onToggle = useCallback(function (event) {
|
|
260
|
+
if (event.newState === 'open') {
|
|
261
|
+
isNativeClosedRef.current = false;
|
|
263
262
|
return;
|
|
264
263
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
return current === 'exiting' ? 'closed' : current;
|
|
268
|
-
});
|
|
264
|
+
if (event.newState !== 'closed') {
|
|
265
|
+
return;
|
|
269
266
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
unmount();
|
|
275
|
-
}
|
|
267
|
+
isNativeClosedRef.current = true;
|
|
268
|
+
setPhase(function (current) {
|
|
269
|
+
if (current === 'open' || current === 'entering') {
|
|
270
|
+
return 'exiting';
|
|
276
271
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
272
|
+
if (current === 'exiting' && !willAnimate) {
|
|
273
|
+
return 'closed';
|
|
274
|
+
}
|
|
275
|
+
return current;
|
|
276
|
+
});
|
|
277
|
+
}, [willAnimate]);
|
|
282
278
|
|
|
283
279
|
// Non-animated exit callback. No `transitionend` to wait for, so fire
|
|
284
280
|
// `onExitFinish` on the transition into `exiting` (the unmount itself
|
|
285
|
-
// is gated by the toggle
|
|
281
|
+
// is gated by the closing toggle handshake above).
|
|
286
282
|
var prevPhaseForExitRef = useRef('closed');
|
|
287
283
|
useEffect(function () {
|
|
288
284
|
var prevPhase = prevPhaseForExitRef.current;
|
|
@@ -297,6 +293,7 @@ export function useAnimatedVisibility(_ref2) {
|
|
|
297
293
|
}
|
|
298
294
|
}, [phase, willAnimate]);
|
|
299
295
|
return {
|
|
300
|
-
phase: phase
|
|
296
|
+
phase: phase,
|
|
297
|
+
onToggle: onToggle
|
|
301
298
|
};
|
|
302
299
|
}
|
|
@@ -88,11 +88,7 @@ var POPUP_ROLES = new Set(['menu', 'listbox', 'dialog', 'alertdialog', 'tree', '
|
|
|
88
88
|
* ### 📜 Browser dismiss is non-cancellable
|
|
89
89
|
*
|
|
90
90
|
* Per the popover spec, `beforetoggle` is cancellable on open transitions
|
|
91
|
-
* only, not on close.
|
|
92
|
-
* (e.g. a click on a logically-associated external trigger) must reconcile
|
|
93
|
-
* inside their own `onClose` handler by calling `showPopover()` again.
|
|
94
|
-
* `Popover` does not self-heal so consumers do not pay a reconcile cost
|
|
95
|
-
* by default.
|
|
91
|
+
* only, not on close.
|
|
96
92
|
*
|
|
97
93
|
* - Spec: <https://html.spec.whatwg.org/multipage/popover.html#the-popover-attribute>
|
|
98
94
|
* - <https://github.com/whatwg/html/issues/8973>
|
|
@@ -139,7 +135,8 @@ export var Popover = /*#__PURE__*/forwardRef(function Popover(_ref, ref) {
|
|
|
139
135
|
onEnterFinish: onEnterFinish,
|
|
140
136
|
onExitFinish: onExitFinish
|
|
141
137
|
}),
|
|
142
|
-
phase = _useAnimatedVisibilit.phase
|
|
138
|
+
phase = _useAnimatedVisibilit.phase,
|
|
139
|
+
onToggle = _useAnimatedVisibilit.onToggle;
|
|
143
140
|
|
|
144
141
|
// True while the host element is mounted (any phase except `closed`).
|
|
145
142
|
// Used as a dep so listener-rebind effects re-attach after a remount.
|
|
@@ -212,6 +209,7 @@ export var Popover = /*#__PURE__*/forwardRef(function Popover(_ref, ref) {
|
|
|
212
209
|
var handleToggle = useCallback(function (event) {
|
|
213
210
|
var element = ownRef.current;
|
|
214
211
|
if (event.newState === 'open') {
|
|
212
|
+
onToggle(event);
|
|
215
213
|
return;
|
|
216
214
|
}
|
|
217
215
|
if (event.newState === 'closed') {
|
|
@@ -223,11 +221,11 @@ export var Popover = /*#__PURE__*/forwardRef(function Popover(_ref, ref) {
|
|
|
223
221
|
// click target keeps focus per HTML spec
|
|
224
222
|
// (`focusPreviousElement=false`).
|
|
225
223
|
var previouslyFocused = previouslyFocusedElementRef.current;
|
|
226
|
-
var
|
|
224
|
+
var reason = closeReasonRef.current;
|
|
227
225
|
var isProgrammatic = programmaticCloseRef.current;
|
|
228
226
|
var shouldRestore = previouslyFocused !== null && previouslyFocused.isConnected && shouldFocusIntoPopover({
|
|
229
227
|
role: role
|
|
230
|
-
}) && (
|
|
228
|
+
}) && (reason === 'escape' || isProgrammatic);
|
|
231
229
|
if (shouldRestore && previouslyFocused) {
|
|
232
230
|
previouslyFocused.focus({
|
|
233
231
|
preventScroll: true
|
|
@@ -241,18 +239,18 @@ export var Popover = /*#__PURE__*/forwardRef(function Popover(_ref, ref) {
|
|
|
241
239
|
// Reset reason so a stale 'escape' from a race with Escape keydown
|
|
242
240
|
// does not corrupt the next browser-dismiss cycle.
|
|
243
241
|
closeReasonRef.current = 'light-dismiss';
|
|
244
|
-
|
|
242
|
+
} else {
|
|
243
|
+
// Browser dismiss (Escape/click-outside).
|
|
244
|
+
var _reason = closeReasonRef.current;
|
|
245
|
+
closeReasonRef.current = 'light-dismiss';
|
|
246
|
+
// Optional: `manual` mode has no `onClose` in the forwarded type.
|
|
247
|
+
onClose === null || onClose === void 0 || onClose({
|
|
248
|
+
reason: _reason
|
|
249
|
+
});
|
|
245
250
|
}
|
|
246
|
-
|
|
247
|
-
// Browser dismiss (Escape/click-outside).
|
|
248
|
-
var reason = closeReasonRef.current;
|
|
249
|
-
closeReasonRef.current = 'light-dismiss';
|
|
250
|
-
// Optional: `manual` mode has no `onClose` in the forwarded type.
|
|
251
|
-
onClose === null || onClose === void 0 || onClose({
|
|
252
|
-
reason: reason
|
|
253
|
-
});
|
|
254
251
|
}
|
|
255
|
-
|
|
252
|
+
onToggle(event);
|
|
253
|
+
}, [onClose, onToggle, role]);
|
|
256
254
|
|
|
257
255
|
// Bind via `useLayoutEffect`, and BEFORE the show/hide layout effect
|
|
258
256
|
// below, so listeners are attached when `showPopover()` synchronously
|
|
@@ -14,8 +14,8 @@ import { type TDialogProps } from './types';
|
|
|
14
14
|
*
|
|
15
15
|
* Handles native `cancel` event (Escape) and backdrop click detection.
|
|
16
16
|
*
|
|
17
|
-
* Close flow:
|
|
18
|
-
*
|
|
17
|
+
* Close flow: allowed user dismissal closes the native dialog before `onClose`
|
|
18
|
+
* notifies the consumer. The consumer must synchronize `isOpen` in response.
|
|
19
19
|
*
|
|
20
20
|
* Accessibility: render at least one focusable element (typically a close
|
|
21
21
|
* button) when `isOpen` becomes `true`. Tab is always trapped inside the
|
|
@@ -11,6 +11,10 @@ import type { StrictXCSSProp } from '@atlaskit/css';
|
|
|
11
11
|
* produces. The consumer simply sets `isOpen={false}`.
|
|
12
12
|
*/
|
|
13
13
|
export type TDialogCloseReason = 'escape' | 'overlay-click';
|
|
14
|
+
/**
|
|
15
|
+
* User interactions that are allowed to dismiss the dialog.
|
|
16
|
+
*/
|
|
17
|
+
export type TDialogDismissedBy = 'none' | 'escape' | 'escape-and-outside-click';
|
|
14
18
|
/**
|
|
15
19
|
* Props shared across `TDialogProps` variants.
|
|
16
20
|
*
|
|
@@ -40,14 +44,24 @@ type TDialogBaseProps = {
|
|
|
40
44
|
*/
|
|
41
45
|
isOpen: boolean;
|
|
42
46
|
/**
|
|
43
|
-
* Called
|
|
47
|
+
* Called after the dialog closes through an allowed Escape or backdrop interaction.
|
|
44
48
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
49
|
+
* User dismissal cannot be rejected from this callback. The consumer must set
|
|
50
|
+
* `isOpen={false}` in response to synchronize controlled state with the native dialog.
|
|
47
51
|
*/
|
|
48
52
|
onClose: (args: {
|
|
49
53
|
reason: TDialogCloseReason;
|
|
50
54
|
}) => void;
|
|
55
|
+
/**
|
|
56
|
+
* Controls which user actions can dismiss the dialog.
|
|
57
|
+
*
|
|
58
|
+
* - `'escape-and-outside-click'`: Escape and outside click.
|
|
59
|
+
* - `'escape'`: Escape only.
|
|
60
|
+
* - `'none'`: no user action.
|
|
61
|
+
*
|
|
62
|
+
* Defaults to `'escape-and-outside-click'`.
|
|
63
|
+
*/
|
|
64
|
+
dismissedBy?: TDialogDismissedBy;
|
|
51
65
|
/**
|
|
52
66
|
* Whether the dialog should animate in and out.
|
|
53
67
|
*
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { createCloseEvent, Dialog } from '../dialog';
|
|
2
|
-
export type { TDialogCloseReason, TDialogProps } from '../dialog';
|
|
2
|
+
export type { TDialogCloseReason, TDialogDismissedBy, TDialogProps } from '../dialog';
|
|
@@ -42,6 +42,12 @@ type TUseAnimatedVisibilityResult = {
|
|
|
42
42
|
* trapping, listeners that must outlive the exit).
|
|
43
43
|
*/
|
|
44
44
|
phase: TPhase;
|
|
45
|
+
/**
|
|
46
|
+
* Notifies the visibility lifecycle after the primitive has finished its
|
|
47
|
+
* own native toggle handling. Closing toggles make native dismissal
|
|
48
|
+
* authoritative while preserving primitive-specific handler ordering.
|
|
49
|
+
*/
|
|
50
|
+
onToggle: (event: ToggleEvent) => void;
|
|
45
51
|
};
|
|
46
52
|
/**
|
|
47
53
|
* Visibility lifecycle phase shared between `useAnimatedVisibility`,
|
|
@@ -93,7 +99,7 @@ export type TPhase = 'closed' | 'entering' | 'open' | 'exiting';
|
|
|
93
99
|
* `entering`, `open`, `exiting`). The host element is rendered while
|
|
94
100
|
* `phase !== 'closed'`, so the `exiting` phase keeps the host mounted
|
|
95
101
|
* while the CSS exit transition plays (or, with animation disabled,
|
|
96
|
-
* while we wait for the browser `toggle`
|
|
102
|
+
* while we wait for the browser's closing `toggle` event to fire on the
|
|
97
103
|
* still-attached element):
|
|
98
104
|
*
|
|
99
105
|
* ```
|
|
@@ -16,11 +16,7 @@ import { type TPopoverForwardedProps } from './types';
|
|
|
16
16
|
* ### 📜 Browser dismiss is non-cancellable
|
|
17
17
|
*
|
|
18
18
|
* Per the popover spec, `beforetoggle` is cancellable on open transitions
|
|
19
|
-
* only, not on close.
|
|
20
|
-
* (e.g. a click on a logically-associated external trigger) must reconcile
|
|
21
|
-
* inside their own `onClose` handler by calling `showPopover()` again.
|
|
22
|
-
* `Popover` does not self-heal so consumers do not pay a reconcile cost
|
|
23
|
-
* by default.
|
|
19
|
+
* only, not on close.
|
|
24
20
|
*
|
|
25
21
|
* - Spec: <https://html.spec.whatwg.org/multipage/popover.html#the-popover-attribute>
|
|
26
22
|
* - <https://github.com/whatwg/html/issues/8973>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atlaskit/top-layer",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Low-level layering primitives based on popover and dialog",
|
|
5
5
|
"homepage": "https://atlassian.design/components/top-layer",
|
|
6
6
|
"repository": "https://bitbucket.org/atlassian/atlassian-frontend-mirror",
|