@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
|
@@ -4,7 +4,6 @@ import { getFirstFocusable } from '../focus/get-first-focusable';
|
|
|
4
4
|
import { getLastFocusable } from '../focus/get-last-focusable';
|
|
5
5
|
import { getNextFocusable } from '../focus/get-next-focusable';
|
|
6
6
|
import { isNestedLayerFocused } from '../focus/is-nested-layer-focused';
|
|
7
|
-
|
|
8
7
|
/**
|
|
9
8
|
* Roles that require focus wrapping per WAI-ARIA APG.
|
|
10
9
|
*
|
|
@@ -45,11 +44,13 @@ function roleRequiresFocusWrap(role) {
|
|
|
45
44
|
*/
|
|
46
45
|
export function useFocusWrap({
|
|
47
46
|
elementRef,
|
|
48
|
-
role
|
|
47
|
+
role,
|
|
48
|
+
phase
|
|
49
49
|
}) {
|
|
50
|
+
const isVisible = phase !== 'closed';
|
|
50
51
|
useEffect(() => {
|
|
51
52
|
const element = elementRef.current;
|
|
52
|
-
if (!element || !roleRequiresFocusWrap(role)) {
|
|
53
|
+
if (!element || !roleRequiresFocusWrap(role) || !isVisible) {
|
|
53
54
|
return;
|
|
54
55
|
}
|
|
55
56
|
const unbind = bind(element, {
|
|
@@ -100,5 +101,5 @@ export function useFocusWrap({
|
|
|
100
101
|
}
|
|
101
102
|
});
|
|
102
103
|
return unbind;
|
|
103
|
-
}, [elementRef, role]);
|
|
104
|
+
}, [elementRef, role, isVisible]);
|
|
104
105
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react';
|
|
2
2
|
import { getFirstFocusable } from '../focus/get-first-focusable';
|
|
3
|
-
|
|
4
3
|
/**
|
|
5
4
|
* Determines the correct element to receive initial focus within a popover,
|
|
6
5
|
* based on its ARIA role.
|
|
@@ -17,7 +16,102 @@ import { getFirstFocusable } from '../focus/get-first-focusable';
|
|
|
17
16
|
* has no effect on hidden popover content. Consumers must set the native
|
|
18
17
|
* attribute via a ref callback (e.g. `node.setAttribute('autofocus', '')`).
|
|
19
18
|
* React 19+ reflects the JSX prop as the HTML attribute automatically.
|
|
19
|
+
*
|
|
20
|
+
* Combobox pattern exception: if the popup is controlled by a focused
|
|
21
|
+
* combobox-like trigger (its `aria-controls` references this popup),
|
|
22
|
+
* focus stays on the textbox regardless of role. See
|
|
23
|
+
* `isComboboxControllingPopup`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* WAI-ARIA APG Combobox Pattern: when a combobox controls a popup (listbox,
|
|
28
|
+
* grid, tree, or menu), DOM focus must remain on the textbox. Subsequent
|
|
29
|
+
* navigation through popup items uses `aria-activedescendant` (or proxied
|
|
30
|
+
* keyboard events), never DOM focus movement.
|
|
31
|
+
*
|
|
32
|
+
* Detects both variants in a single pass:
|
|
33
|
+
* - In-popup combobox: the textbox lives inside the popup `container`
|
|
34
|
+
* (the popup is rendered as a child of the combobox wrapper).
|
|
35
|
+
* - External combobox: the textbox is rendered OUTSIDE the popup
|
|
36
|
+
* (`react-select` pattern, where the listbox is portalled separately
|
|
37
|
+
* from the textbox).
|
|
38
|
+
*
|
|
39
|
+
* Acceptance criteria for the active element:
|
|
40
|
+
* - Role / element-type filter: `role="combobox"`, `role="textbox"`,
|
|
41
|
+
* `TEXTAREA`, `contentEditable`, or `INPUT` of text-like type
|
|
42
|
+
* (`text`, `search`, `email`, `url`, `tel`, `password`, or
|
|
43
|
+
* unspecified). Non-text input types (`button`, `checkbox`, etc.)
|
|
44
|
+
* are rejected.
|
|
45
|
+
* - Reference filter: `aria-controls` (or legacy `aria-owns`) IDREFS
|
|
46
|
+
* must resolve to an element that IS or CONTAINS the popup container.
|
|
47
|
+
*
|
|
48
|
+
* See: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
|
|
20
49
|
*/
|
|
50
|
+
// `<input>` types treated as text-like for combobox detection.
|
|
51
|
+
// Non-text input types (`button`, `checkbox`, `submit`, etc.) are rejected.
|
|
52
|
+
// A missing `type` attribute defaults to `text` per the HTML spec, so we
|
|
53
|
+
// normalize that case before lookup rather than including an empty string here.
|
|
54
|
+
const TEXT_LIKE_INPUT_TYPES = new Set(['text', 'search', 'email', 'url', 'tel', 'password']);
|
|
55
|
+
function isTextboxLikeElement(element) {
|
|
56
|
+
const role = element.getAttribute('role');
|
|
57
|
+
if (role === 'combobox' || role === 'textbox') {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
// `tagName` is uppercase for HTML elements but can be mixed-case for
|
|
61
|
+
// XML/SVG. Normalize once so downstream comparisons stay lowercase.
|
|
62
|
+
const tag = element.tagName.toLowerCase();
|
|
63
|
+
if (tag === 'textarea') {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (element.isContentEditable) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (tag === 'input') {
|
|
70
|
+
var _element$getAttribute;
|
|
71
|
+
const type = ((_element$getAttribute = element.getAttribute('type')) !== null && _element$getAttribute !== void 0 ? _element$getAttribute : 'text').toLowerCase();
|
|
72
|
+
return TEXT_LIKE_INPUT_TYPES.has(type);
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
function isComboboxControllingPopup({
|
|
77
|
+
container
|
|
78
|
+
}) {
|
|
79
|
+
var _active$getAttribute;
|
|
80
|
+
const ownerDocument = container.ownerDocument;
|
|
81
|
+
const active = ownerDocument.activeElement;
|
|
82
|
+
if (!(active instanceof HTMLElement)) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if (!isTextboxLikeElement(active)) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Accept legacy `aria-owns` in addition to `aria-controls`.
|
|
90
|
+
// `aria-controls` / `aria-owns` are IDREFS (space-separated list of IDs)
|
|
91
|
+
// per the WAI-ARIA spec, so we must split and check membership rather
|
|
92
|
+
// than comparing with strict equality. A combobox that controls both
|
|
93
|
+
// this popup and another element (e.g. a results status region) must
|
|
94
|
+
// still be recognised as controlling this popup.
|
|
95
|
+
const idRefs = (_active$getAttribute = active.getAttribute('aria-controls')) !== null && _active$getAttribute !== void 0 ? _active$getAttribute : active.getAttribute('aria-owns');
|
|
96
|
+
if (!idRefs) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Match if any referenced element IS, CONTAINS, or IS CONTAINED BY the
|
|
101
|
+
// popup container. The "contained by" case covers wrapper-style adopters
|
|
102
|
+
// like react-select, where the combobox input's `aria-controls` points at
|
|
103
|
+
// a listbox element that is a descendant of the Popover host (the
|
|
104
|
+
// listbox role lives on a child the wrapped library owns, not on the
|
|
105
|
+
// host we render).
|
|
106
|
+
const referencedIds = idRefs.split(/\s+/).filter(id => id !== '');
|
|
107
|
+
return referencedIds.some(id => {
|
|
108
|
+
const referenced = ownerDocument.getElementById(id);
|
|
109
|
+
if (!referenced) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return referenced === container || referenced.contains(container) || container.contains(referenced);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
21
115
|
function getInitialFocusTarget({
|
|
22
116
|
container,
|
|
23
117
|
role
|
|
@@ -34,6 +128,13 @@ function getInitialFocusTarget({
|
|
|
34
128
|
});
|
|
35
129
|
}
|
|
36
130
|
if (role === 'menu') {
|
|
131
|
+
// ARIA Combobox Pattern: if the popup is owned by a focused combobox,
|
|
132
|
+
// leave focus on the textbox. Navigation is proxied via the combobox.
|
|
133
|
+
if (isComboboxControllingPopup({
|
|
134
|
+
container
|
|
135
|
+
})) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
37
138
|
// Initial focus placement is the extent of what top-layer provides
|
|
38
139
|
// for menus. All subsequent keyboard navigation (arrow keys, Home/End,
|
|
39
140
|
// type-ahead, Enter/Space, submenu coordination) is the responsibility
|
|
@@ -45,11 +146,31 @@ function getInitialFocusTarget({
|
|
|
45
146
|
});
|
|
46
147
|
}
|
|
47
148
|
if (role === 'listbox') {
|
|
48
|
-
//
|
|
149
|
+
// ARIA Combobox Pattern: if the popup is owned by a focused
|
|
150
|
+
// combobox-like textbox (in-popup or external e.g. react-select),
|
|
151
|
+
// leave focus on the textbox. Navigation is proxied via the combobox.
|
|
152
|
+
if (isComboboxControllingPopup({
|
|
153
|
+
container
|
|
154
|
+
})) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
// Prefer the selected option, then fall back to the first option.
|
|
158
|
+
// We intentionally match by `[role="option"]` rather than by
|
|
159
|
+
// `getFirstFocusable`. The standard WAI-ARIA APG listbox pattern
|
|
160
|
+
// renders options with `tabindex="-1"` (managed via roving
|
|
161
|
+
// tabindex); `getFirstFocusable` excludes `tabindex="-1"` elements
|
|
162
|
+
// and would silently lose initial focus for spec-conformant
|
|
163
|
+
// listboxes. Options remain programmatically focusable.
|
|
49
164
|
const selectedOption = container.querySelector('[role="option"][aria-selected="true"]');
|
|
50
165
|
if (selectedOption) {
|
|
51
166
|
return selectedOption;
|
|
52
167
|
}
|
|
168
|
+
const firstOption = container.querySelector('[role="option"]');
|
|
169
|
+
if (firstOption) {
|
|
170
|
+
return firstOption;
|
|
171
|
+
}
|
|
172
|
+
// Last resort for non-standard listboxes that render no options
|
|
173
|
+
// but do contain a focusable element (e.g. an empty-state action).
|
|
53
174
|
return getFirstFocusable({
|
|
54
175
|
container
|
|
55
176
|
});
|
|
@@ -72,59 +193,82 @@ function getInitialFocusTarget({
|
|
|
72
193
|
* Role is taken from the typed `role` prop - this is the consumer's contract
|
|
73
194
|
* and is not subject to drift from a directly-mutated DOM attribute.
|
|
74
195
|
*
|
|
75
|
-
* Always call this hook unconditionally. Focus is
|
|
76
|
-
*
|
|
196
|
+
* Always call this hook unconditionally. Focus is moved on each transition
|
|
197
|
+
* into a visible phase, the moment the host element first becomes visible
|
|
198
|
+
* for that open intent:
|
|
199
|
+
*
|
|
200
|
+
* - Animated path: on `closed → entering`. The host has just been mounted
|
|
201
|
+
* and the layout effect that calls `showPopover()` / `showModal()` has
|
|
202
|
+
* already run, so the element is in the top layer. We focus before the
|
|
203
|
+
* entry transition settles to match the WAI-ARIA APG expectation that
|
|
204
|
+
* focus lands on the new menu / dialog the instant it opens, not after
|
|
205
|
+
* the CSS finishes animating.
|
|
206
|
+
* - Non-animated path: on `closed → open` (the entering phase is skipped).
|
|
207
|
+
* - Reopen mid-exit: on `exiting → entering`. When a user dismisses the
|
|
208
|
+
* popup (Escape, close button, light dismiss) the browser natively
|
|
209
|
+
* restores focus to the trigger as part of `hidePopover()` /
|
|
210
|
+
* `<dialog>.close()`. If the consumer reopens before the exit transition
|
|
211
|
+
* has settled, the phase machine jumps `exiting → entering` without
|
|
212
|
+
* passing through `closed`, so without this branch the popup would be
|
|
213
|
+
* visible again with focus stranded on the trigger — a keyboard usability
|
|
214
|
+
* gap for menu/dialog/listbox roles.
|
|
215
|
+
*
|
|
216
|
+
* Note: there is a rare programmatic path where a consumer flips `isOpen`
|
|
217
|
+
* false → true without any user-initiated close gesture and had moved focus
|
|
218
|
+
* to a non-default target inside the popup. In that scenario this hook
|
|
219
|
+
* re-focuses the role-appropriate default (e.g. first menu item) rather
|
|
220
|
+
* than preserving the consumer-chosen target. This matches the documented
|
|
221
|
+
* `closed → entering` behavior and is considered acceptable given how
|
|
222
|
+
* abstract the scenario is. See `notes/architecture/focus.md`.
|
|
223
|
+
*
|
|
224
|
+
* Pure `entering → exiting → entering` stutters where the popup never
|
|
225
|
+
* reaches `closed` and the consumer toggles in the same frame are still
|
|
226
|
+
* collapsed: the `prevPhase` ref guard rejects any transition whose
|
|
227
|
+
* previous phase was not `closed` or `exiting`.
|
|
228
|
+
*
|
|
229
|
+
* Tradeoff vs the previous `requestAnimationFrame` based approach: we no
|
|
230
|
+
* longer wait one paint before focusing. The element is already attached
|
|
231
|
+
* and in the top layer when this effect runs (the show / hide layout
|
|
232
|
+
* effect runs first), so screen-reader announcement and visible focus
|
|
233
|
+
* land together. If a regression appears that needs the extra paint
|
|
234
|
+
* (e.g. browsers that require a layout pass before `focus()` works on a
|
|
235
|
+
* freshly promoted top-layer element), wrap the `focus()` call in a
|
|
236
|
+
* single `requestAnimationFrame` here and re-add a same-element ref
|
|
237
|
+
* check inside the RAF callback.
|
|
77
238
|
*/
|
|
78
239
|
export function useInitialFocus({
|
|
79
240
|
elementRef,
|
|
80
|
-
|
|
241
|
+
phase,
|
|
81
242
|
role
|
|
82
243
|
}) {
|
|
83
|
-
// Track the
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
const
|
|
244
|
+
// Track the previous phase so we only focus on transitions into a
|
|
245
|
+
// visible phase from either `'closed'` (fresh mount) or `'exiting'`
|
|
246
|
+
// (mid-exit reopen where the browser has already restored focus to
|
|
247
|
+
// the trigger). Pure `entering → exiting → entering` stutters where
|
|
248
|
+
// the consumer toggles in the same frame are still collapsed.
|
|
249
|
+
const prevPhaseRef = useRef('closed');
|
|
89
250
|
useEffect(() => {
|
|
90
|
-
|
|
251
|
+
var _getInitialFocusTarge;
|
|
252
|
+
const prevPhase = prevPhaseRef.current;
|
|
253
|
+
prevPhaseRef.current = phase;
|
|
254
|
+
|
|
255
|
+
// Skip the `phase === 'closed'` transitions (host is going away,
|
|
256
|
+
// nothing to focus) and any transition whose previous phase was
|
|
257
|
+
// neither `'closed'` (fresh open) nor `'exiting'` (mid-exit
|
|
258
|
+
// reopen, focus already on the trigger).
|
|
259
|
+
if (phase === 'closed') {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (prevPhase !== 'closed' && prevPhase !== 'exiting') {
|
|
91
263
|
return;
|
|
92
264
|
}
|
|
93
265
|
const el = elementRef.current;
|
|
94
266
|
if (!el) {
|
|
95
267
|
return;
|
|
96
268
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// Use requestAnimationFrame to ensure the popover is fully rendered
|
|
104
|
-
// and visible in the top layer before attempting to move focus.
|
|
105
|
-
// showPopover() is called in useLayoutEffect (which runs before this
|
|
106
|
-
// useEffect), so by the time the RAF fires after the next paint the
|
|
107
|
-
// element is guaranteed to be in the top layer.
|
|
108
|
-
const rafId = requestAnimationFrame(() => {
|
|
109
|
-
var _getInitialFocusTarge;
|
|
110
|
-
pendingRafRef.current = null;
|
|
111
|
-
// Verify the element we captured is still the live popover. In
|
|
112
|
-
// rapid-toggle scenarios the ref may already point to a different
|
|
113
|
-
// instance - skip rather than focus the wrong popover.
|
|
114
|
-
if (elementRef.current !== el) {
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
(_getInitialFocusTarge = getInitialFocusTarget({
|
|
118
|
-
container: el,
|
|
119
|
-
role
|
|
120
|
-
})) === null || _getInitialFocusTarge === void 0 ? void 0 : _getInitialFocusTarge.focus();
|
|
121
|
-
});
|
|
122
|
-
pendingRafRef.current = rafId;
|
|
123
|
-
return () => {
|
|
124
|
-
cancelAnimationFrame(rafId);
|
|
125
|
-
if (pendingRafRef.current === rafId) {
|
|
126
|
-
pendingRafRef.current = null;
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
}, [elementRef, isOpen, role]);
|
|
269
|
+
(_getInitialFocusTarge = getInitialFocusTarget({
|
|
270
|
+
container: el,
|
|
271
|
+
role
|
|
272
|
+
})) === null || _getInitialFocusTarge === void 0 ? void 0 : _getInitialFocusTarge.focus();
|
|
273
|
+
}, [elementRef, phase, role]);
|
|
130
274
|
}
|
|
@@ -17,7 +17,8 @@ import { setStyle } from './set-style';
|
|
|
17
17
|
export function useWidthFromAnchor({
|
|
18
18
|
mode,
|
|
19
19
|
popoverRef,
|
|
20
|
-
anchorRef
|
|
20
|
+
anchorRef,
|
|
21
|
+
isOpen
|
|
21
22
|
}) {
|
|
22
23
|
useLayoutEffect(() => {
|
|
23
24
|
const node = popoverRef.current;
|
|
@@ -44,7 +45,7 @@ export function useWidthFromAnchor({
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// JS fallback: one-off read of the anchor's width.
|
|
47
|
-
const anchor = anchorRef
|
|
48
|
+
const anchor = anchorRef.current;
|
|
48
49
|
if (anchor) {
|
|
49
50
|
return setStyle({
|
|
50
51
|
el: node,
|
|
@@ -76,7 +77,7 @@ export function useWidthFromAnchor({
|
|
|
76
77
|
}]
|
|
77
78
|
});
|
|
78
79
|
}
|
|
79
|
-
const anchor = anchorRef
|
|
80
|
+
const anchor = anchorRef.current;
|
|
80
81
|
if (anchor) {
|
|
81
82
|
return setStyle({
|
|
82
83
|
el: node,
|
|
@@ -99,5 +100,5 @@ export function useWidthFromAnchor({
|
|
|
99
100
|
el: node,
|
|
100
101
|
styles: [minInlineSizeStyle]
|
|
101
102
|
});
|
|
102
|
-
}, [mode, popoverRef, anchorRef]);
|
|
103
|
+
}, [mode, popoverRef, anchorRef, isOpen]);
|
|
103
104
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import _extends from "@babel/runtime/helpers/extends";
|
|
3
3
|
import "./popover.compiled.css";
|
|
4
4
|
import { ax, ix } from "@compiled/react/runtime";
|
|
5
|
-
import React, { forwardRef, useCallback,
|
|
5
|
+
import React, { forwardRef, useCallback, useId, useLayoutEffect, useRef } from 'react';
|
|
6
6
|
import { bind } from 'bind-event-listener';
|
|
7
7
|
import mergeRefs from '@atlaskit/ds-lib/merge-refs';
|
|
8
8
|
import once from '@atlaskit/ds-lib/once';
|
|
@@ -70,7 +70,7 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
|
|
|
70
70
|
// dropped (React 19.2 uses underscores).
|
|
71
71
|
const popoverId = idProp !== null && idProp !== void 0 ? idProp : `popover-${autoId.replace(/:/g, '')}`;
|
|
72
72
|
const {
|
|
73
|
-
|
|
73
|
+
phase,
|
|
74
74
|
preset
|
|
75
75
|
} = useAnimatedVisibility({
|
|
76
76
|
isOpen,
|
|
@@ -80,17 +80,29 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
|
|
|
80
80
|
onExitFinish
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
//
|
|
83
|
+
// Pre-computed phase predicate. `isVisible` mirrors "host element is
|
|
84
|
+
// mounted and on screen" (any phase except `closed`); used as a dep
|
|
85
|
+
// signal for listener-re-bind effects so they re-attach to the fresh
|
|
86
|
+
// host element after a close → reopen unmount/remount cycle.
|
|
87
|
+
const isVisible = phase !== 'closed';
|
|
88
|
+
|
|
89
|
+
// Focus management: initial focus on entry (role-dependent), Tab cycling
|
|
84
90
|
// for dialog roles. Restoration is native for outermost popovers; nested
|
|
85
91
|
// focus-capturing roles are handled below via `beforetoggle` snapshot.
|
|
86
92
|
// See: notes/architecture/focus-restoration.md
|
|
93
|
+
//
|
|
94
|
+
// Both hooks take `phase` directly: `useFocusWrap` keeps its listener
|
|
95
|
+
// attached while `phase !== 'closed'` (so focus stays trapped through
|
|
96
|
+
// the animated-exit window — WCAG 2.4.3 regression guard);
|
|
97
|
+
// `useInitialFocus` moves focus on the transition into `'open'`.
|
|
87
98
|
useFocusWrap({
|
|
88
99
|
elementRef: ownRef,
|
|
89
|
-
role
|
|
100
|
+
role,
|
|
101
|
+
phase
|
|
90
102
|
});
|
|
91
103
|
useInitialFocus({
|
|
92
104
|
elementRef: ownRef,
|
|
93
|
-
|
|
105
|
+
phase,
|
|
94
106
|
role
|
|
95
107
|
});
|
|
96
108
|
|
|
@@ -187,7 +199,23 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
|
|
|
187
199
|
});
|
|
188
200
|
}
|
|
189
201
|
}, [onClose, onOpenChange, role]);
|
|
190
|
-
|
|
202
|
+
|
|
203
|
+
// Bind toggle/beforetoggle/keydown listeners via `useLayoutEffect` so
|
|
204
|
+
// they are attached BEFORE the show/hide layout effect calls
|
|
205
|
+
// `el.showPopover()`. `showPopover()` dispatches `beforetoggle`
|
|
206
|
+
// synchronously; binding the listener in a regular `useEffect`
|
|
207
|
+
// (which runs after layout effects) misses that first dispatch and
|
|
208
|
+
// the `previouslyFocusedElementRef` snapshot never gets taken — which
|
|
209
|
+
// breaks nested-popover focus restoration on close.
|
|
210
|
+
//
|
|
211
|
+
// `isVisible` is in deps so the listeners re-bind to the new host
|
|
212
|
+
// element after a host unmount/remount cycle. The cleanup detaches
|
|
213
|
+
// the old listeners.
|
|
214
|
+
//
|
|
215
|
+
// This layout effect MUST appear in the file before the show/hide
|
|
216
|
+
// layout effect — React runs layout effects in source order on the
|
|
217
|
+
// same commit, and we need listeners attached first.
|
|
218
|
+
useLayoutEffect(() => {
|
|
191
219
|
const element = ownRef.current;
|
|
192
220
|
if (!element) {
|
|
193
221
|
return;
|
|
@@ -219,10 +247,13 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
|
|
|
219
247
|
unbindToggle();
|
|
220
248
|
unbindBeforeToggle();
|
|
221
249
|
};
|
|
222
|
-
}, [handleToggle, handleBeforeToggle]);
|
|
250
|
+
}, [handleToggle, handleBeforeToggle, isVisible]);
|
|
223
251
|
|
|
224
252
|
// Placement-dependent animation CSS vars. Kept separate from show/hide
|
|
225
|
-
// so preset reference changes do not re-trigger visibility.
|
|
253
|
+
// so preset reference changes do not re-trigger visibility. `isVisible`
|
|
254
|
+
// is in deps so the CSS vars are re-applied to the new host element after
|
|
255
|
+
// a remount cycle (otherwise the freshly mounted element would have no
|
|
256
|
+
// transform / placement vars set).
|
|
226
257
|
useLayoutEffect(() => {
|
|
227
258
|
const el = ownRef.current;
|
|
228
259
|
if (!el || !(preset !== null && preset !== void 0 && preset.getProperties) || !placement) {
|
|
@@ -234,7 +265,7 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
|
|
|
234
265
|
Object.entries(props).forEach(([key, value]) => {
|
|
235
266
|
el.style.setProperty(key, String(value));
|
|
236
267
|
});
|
|
237
|
-
}, [preset, placement]);
|
|
268
|
+
}, [preset, placement, isVisible]);
|
|
238
269
|
|
|
239
270
|
// Show/hide based on isOpen. `showPopover`/`hidePopover` are no-ops when
|
|
240
271
|
// already in the target state. Try/catch guards `InvalidStateError` on
|
|
@@ -246,6 +277,13 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
|
|
|
246
277
|
}
|
|
247
278
|
if (isOpen) {
|
|
248
279
|
programmaticCloseRef.current = false;
|
|
280
|
+
// Reset the close-reason snapshot at the start of every open
|
|
281
|
+
// cycle. The ref outlives the host element (lives on the
|
|
282
|
+
// component, not the DOM), so a stale 'escape' from a prior
|
|
283
|
+
// cycle would otherwise leak into the next close — especially
|
|
284
|
+
// in the no-animation path where the host unmounts before
|
|
285
|
+
// `handleToggle` has a chance to reset the ref itself.
|
|
286
|
+
closeReasonRef.current = 'light-dismiss';
|
|
249
287
|
try {
|
|
250
288
|
el.showPopover();
|
|
251
289
|
} catch {}
|
|
@@ -261,6 +299,17 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
|
|
|
261
299
|
el.hidePopover();
|
|
262
300
|
} catch {}
|
|
263
301
|
}, [isOpen]);
|
|
302
|
+
|
|
303
|
+
// The host element is only rendered while the popover is open or its exit
|
|
304
|
+
// animation is playing. Once the phase returns to `closed`, we unmount
|
|
305
|
+
// the host entirely so it does not leave an empty `role="..."` /
|
|
306
|
+
// `popover` element in the accessibility tree. The element re-mounts on
|
|
307
|
+
// the next `isOpen=true` commit; refs and event listeners re-bind via
|
|
308
|
+
// the existing layout effect and `useEffect`. The popover `id` is stable
|
|
309
|
+
// across opens (derived from `useId()` or the consumer-supplied `idProp`).
|
|
310
|
+
if (!isVisible) {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
264
313
|
return /*#__PURE__*/React.createElement("div", _extends({
|
|
265
314
|
ref: combinedRef,
|
|
266
315
|
id: popoverId
|
|
@@ -277,5 +326,5 @@ export const Popover = /*#__PURE__*/forwardRef(function Popover({
|
|
|
277
326
|
} : undefined, {
|
|
278
327
|
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop -- xcss prop passes compiled atomic class names
|
|
279
328
|
className: ax([styles.root, xcssFromProps])
|
|
280
|
-
}),
|
|
329
|
+
}), children);
|
|
281
330
|
});
|
|
@@ -1,17 +1,71 @@
|
|
|
1
1
|
import { getPlacement } from '../internal/resolve-placement';
|
|
2
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
3
|
+
// Popover presets
|
|
4
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2
5
|
|
|
3
|
-
//
|
|
6
|
+
// ── popupMotion ──
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
var pos = "".concat(distance, "px");
|
|
9
|
-
var neg = "-".concat(distance, "px");
|
|
8
|
+
var POPUP_MOTION_CSS = "\n [data-ds-popover-popup-motion] {\n animation: var(--ds-popover-motion-exit);\n animation-fill-mode: forwards;\n transition:\n overlay 150ms allow-discrete,\n display 150ms allow-discrete;\n }\n\n [data-ds-popover-popup-motion]:popover-open {\n animation: var(--ds-popover-motion-enter);\n animation-fill-mode: backwards;\n }\n\n @media (prefers-reduced-motion: reduce) {\n [data-ds-popover-popup-motion],\n [data-ds-popover-popup-motion]:popover-open {\n animation: none;\n transition-duration: 0s;\n }\n }\n";
|
|
9
|
+
function getPopupMotionProperties(_ref) {
|
|
10
|
+
var placement = _ref.placement;
|
|
10
11
|
var _getPlacement = getPlacement({
|
|
11
12
|
placement: placement
|
|
12
13
|
}),
|
|
13
14
|
axis = _getPlacement.axis,
|
|
14
15
|
edge = _getPlacement.edge;
|
|
16
|
+
if (axis === 'block' && edge === 'start') {
|
|
17
|
+
return {
|
|
18
|
+
'--ds-popover-motion-enter': "var(--ds-popup-enter-top, 150ms cubic-bezier(0.4, 1, 0.6, 1) SlideInTop8px, 150ms cubic-bezier(0.4, 1, 0.6, 1) FadeIn0to100)",
|
|
19
|
+
'--ds-popover-motion-exit': "var(--ds-popup-exit-top, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) SlideOutTop8px, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) FadeOut100to0)"
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (axis === 'inline' && edge === 'start') {
|
|
23
|
+
return {
|
|
24
|
+
'--ds-popover-motion-enter': "var(--ds-popup-enter-left, 150ms cubic-bezier(0.4, 1, 0.6, 1) SlideInLeft8px, 150ms cubic-bezier(0.4, 1, 0.6, 1) FadeIn0to100)",
|
|
25
|
+
'--ds-popover-motion-exit': "var(--ds-popup-exit-left, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) SlideOutLeft8px, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) FadeOut100to0)"
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (axis === 'inline' && edge === 'end') {
|
|
29
|
+
return {
|
|
30
|
+
'--ds-popover-motion-enter': "var(--ds-popup-enter-right, 150ms cubic-bezier(0.4, 1, 0.6, 1) SlideInRight8px, 150ms cubic-bezier(0.4, 1, 0.6, 1) FadeIn0to100)",
|
|
31
|
+
'--ds-popover-motion-exit': "var(--ds-popup-exit-right, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) SlideOutRight8px, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) FadeOut100to0)"
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
// Default: block/end (popover below trigger)
|
|
35
|
+
return {
|
|
36
|
+
'--ds-popover-motion-enter': "var(--ds-popup-enter-bottom, 150ms cubic-bezier(0.4, 1, 0.6, 1) SlideInBottom8px, 150ms cubic-bezier(0.4, 1, 0.6, 1) FadeIn0to100)",
|
|
37
|
+
'--ds-popover-motion-exit': "var(--ds-popup-exit-bottom, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) SlideOutBottom8px, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) FadeOut100to0)"
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
|
|
42
|
+
export function popupMotion() {
|
|
43
|
+
return {
|
|
44
|
+
name: 'popup-motion',
|
|
45
|
+
css: POPUP_MOTION_CSS,
|
|
46
|
+
enterDurationMs: 150,
|
|
47
|
+
exitDurationMs: 100,
|
|
48
|
+
getProperties: function getProperties(_ref2) {
|
|
49
|
+
var placement = _ref2.placement;
|
|
50
|
+
return getPopupMotionProperties({
|
|
51
|
+
placement: placement
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── slideAndFade ──
|
|
58
|
+
|
|
59
|
+
function getSlideAndFadeProperties(_ref3) {
|
|
60
|
+
var placement = _ref3.placement,
|
|
61
|
+
distance = _ref3.distance;
|
|
62
|
+
var pos = "".concat(distance, "px");
|
|
63
|
+
var neg = "-".concat(distance, "px");
|
|
64
|
+
var _getPlacement2 = getPlacement({
|
|
65
|
+
placement: placement
|
|
66
|
+
}),
|
|
67
|
+
axis = _getPlacement2.axis,
|
|
68
|
+
edge = _getPlacement2.edge;
|
|
15
69
|
if (axis === 'block' && edge === 'start') {
|
|
16
70
|
return {
|
|
17
71
|
'--ds-popover-tx': '0',
|
|
@@ -64,14 +118,15 @@ var SLIDE_AND_FADE_CSS = "\n[data-ds-popover-slide-and-fade] {\n opacity: 0;\n
|
|
|
64
118
|
* <Popover animate={slideAndFade({ distance: 8 })} />
|
|
65
119
|
* ```
|
|
66
120
|
*/
|
|
121
|
+
// eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
|
|
67
122
|
export function slideAndFade(options) {
|
|
68
123
|
var _options$distance;
|
|
69
124
|
var distance = (_options$distance = options === null || options === void 0 ? void 0 : options.distance) !== null && _options$distance !== void 0 ? _options$distance : 4;
|
|
70
125
|
return {
|
|
71
126
|
name: 'slide-and-fade',
|
|
72
127
|
css: SLIDE_AND_FADE_CSS,
|
|
73
|
-
getProperties: function getProperties(
|
|
74
|
-
var placement =
|
|
128
|
+
getProperties: function getProperties(_ref4) {
|
|
129
|
+
var placement = _ref4.placement;
|
|
75
130
|
return getSlideAndFadeProperties({
|
|
76
131
|
placement: placement,
|
|
77
132
|
distance: distance
|
|
@@ -138,7 +193,36 @@ export function scaleAndFade() {
|
|
|
138
193
|
// Dialog presets
|
|
139
194
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
140
195
|
|
|
141
|
-
//
|
|
196
|
+
// ── dialogMotion ──
|
|
197
|
+
|
|
198
|
+
var DIALOG_MOTION_CSS = "\n [data-ds-dialog-motion] {\n animation: ".concat("var(--ds-modal-exit, 200ms cubic-bezier(0.6, 0, 0.8, 0.6) ScaleOut100to95)", ";\n animation-fill-mode: forwards;\n transition:\n overlay 200ms allow-discrete,\n display 200ms allow-discrete;\n }\n\n [data-ds-dialog-motion][open] {\n animation: ", "var(--ds-modal-enter, 250ms cubic-bezier(0.4, 0, 0, 1) ScaleIn95to100)", ";\n animation-fill-mode: backwards;\n }\n\n [data-ds-dialog-motion]::backdrop {\n animation: ", "var(--ds-blanket-exit, 200ms cubic-bezier(0.6, 0, 0.8, 0.6) FadeOut100to0)", ";\n animation-fill-mode: forwards;\n background-color: var(--ds-blanket, #050C1F75);\n transition:\n overlay 200ms allow-discrete,\n display 200ms allow-discrete;\n }\n\n [data-ds-dialog-motion][open]::backdrop {\n animation: ", "var(--ds-blanket-enter, 250ms cubic-bezier(0.4, 0, 0, 1) FadeIn0to100)", ";\n animation-fill-mode: backwards;\n }\n\n @media (prefers-reduced-motion: reduce) {\n [data-ds-dialog-motion],\n [data-ds-dialog-motion][open],\n [data-ds-dialog-motion]::backdrop,\n [data-ds-dialog-motion][open]::backdrop {\n animation: none;\n transition-duration: 0s;\n }\n }\n");
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Dialog motion animation.
|
|
202
|
+
*
|
|
203
|
+
* Includes backdrop fade animation.
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* ```tsx
|
|
207
|
+
* import { dialogMotion } from '@atlaskit/top-layer/animations';
|
|
208
|
+
*
|
|
209
|
+
* <Dialog animate={dialogMotion()} isOpen={isOpen} onClose={handleClose} label="...">
|
|
210
|
+
* ...
|
|
211
|
+
* </Dialog>
|
|
212
|
+
* ```
|
|
213
|
+
*/
|
|
214
|
+
// eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
|
|
215
|
+
export function dialogMotion() {
|
|
216
|
+
// Bare preset name; `Dialog` prefixes it via `data-ds-dialog-{name}`.
|
|
217
|
+
return {
|
|
218
|
+
name: 'motion',
|
|
219
|
+
css: DIALOG_MOTION_CSS,
|
|
220
|
+
enterDurationMs: 250,
|
|
221
|
+
exitDurationMs: 200
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── dialogSlideUpAndFade ──
|
|
142
226
|
|
|
143
227
|
var DIALOG_SLIDE_UP_AND_FADE_CSS = "\n[data-ds-dialog-slide-up-and-fade] {\n opacity: 0;\n transform: translateY(calc(-1 * var(--ds-dialog-ty, 12px)));\n transition:\n opacity 175ms cubic-bezier(0.15, 1, 0.3, 1),\n transform 175ms cubic-bezier(0.15, 1, 0.3, 1),\n overlay 175ms allow-discrete,\n display 175ms allow-discrete;\n}\n\n[data-ds-dialog-slide-up-and-fade][open] {\n opacity: 1;\n transform: none;\n transition-duration: 350ms;\n}\n\n@starting-style {\n [data-ds-dialog-slide-up-and-fade][open] {\n opacity: 0;\n transform: translateY(var(--ds-dialog-ty, 12px));\n }\n}\n\n[data-ds-dialog-slide-up-and-fade]::backdrop {\n background-color: transparent;\n transition:\n background-color 175ms cubic-bezier(0.15, 1, 0.3, 1),\n overlay 175ms allow-discrete,\n display 175ms allow-discrete;\n}\n\n[data-ds-dialog-slide-up-and-fade][open]::backdrop {\n background-color: var(--ds-blanket, #050C1F75);\n transition-duration: 350ms;\n}\n\n@starting-style {\n [data-ds-dialog-slide-up-and-fade][open]::backdrop {\n background-color: transparent;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n [data-ds-dialog-slide-up-and-fade],\n [data-ds-dialog-slide-up-and-fade][open],\n [data-ds-dialog-slide-up-and-fade]::backdrop,\n [data-ds-dialog-slide-up-and-fade][open]::backdrop {\n transition-duration: 0s;\n }\n}\n";
|
|
144
228
|
|