@atlaskit/top-layer 0.9.0 → 0.10.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 +26 -0
- package/README.md +5 -1
- package/dialog/create-close-event/package.json +17 -0
- package/dist/cjs/animations/presets.js +7 -4
- package/dist/cjs/arrow/index.js +131 -0
- package/dist/cjs/arrow/types.js +5 -0
- package/dist/cjs/dialog/dialog-content.js +11 -6
- package/dist/cjs/entry-points/dialog/create-close-event.js +12 -0
- package/dist/cjs/entry-points/placement-map.js +6 -5
- package/dist/cjs/entry-points/popover/create-close-event.js +12 -0
- package/dist/cjs/focus/focus.js +71 -19
- package/dist/cjs/index.js +3 -4
- package/dist/cjs/internal/resolve-css-length-to-pixels.js +61 -10
- package/dist/cjs/internal/role-types.js +40 -9
- package/dist/cjs/internal/set-style.js +23 -25
- package/dist/cjs/internal/use-anchor-position.js +107 -68
- package/dist/cjs/internal/use-focus-wrap.js +13 -2
- package/dist/cjs/internal/use-initial-focus.js +31 -10
- package/dist/cjs/placement-map/index.js +9 -111
- package/dist/cjs/placement-map/legacy-placements.js +113 -0
- package/dist/cjs/popover/popover.js +14 -12
- package/dist/cjs/popup/popup-content.js +19 -13
- package/dist/cjs/popup/popup-surface.js +6 -0
- package/dist/cjs/popup/popup-trigger-function.js +1 -1
- package/dist/cjs/popup/popup-trigger.js +17 -37
- package/dist/cjs/popup/popup.js +21 -10
- package/dist/cjs/use-arrow-navigation/use-arrow-navigation.js +26 -10
- package/dist/es2019/animations/presets.js +6 -3
- package/dist/es2019/arrow/index.js +269 -0
- package/dist/es2019/arrow/types.js +1 -0
- package/dist/es2019/dialog/dialog-content.js +11 -6
- package/dist/es2019/entry-points/create-close-event.js +7 -0
- package/dist/es2019/entry-points/dialog/create-close-event.js +1 -0
- package/dist/es2019/entry-points/placement-map.js +2 -1
- package/dist/es2019/entry-points/popover/create-close-event.js +1 -0
- package/dist/es2019/focus/focus.js +56 -7
- package/dist/es2019/index.js +3 -4
- package/dist/es2019/internal/resolve-css-length-to-pixels.js +60 -7
- package/dist/es2019/internal/role-types.js +40 -9
- package/dist/es2019/internal/set-style.js +19 -22
- package/dist/es2019/internal/use-anchor-position.js +95 -54
- package/dist/es2019/internal/use-focus-wrap.js +14 -3
- package/dist/es2019/internal/use-initial-focus.js +32 -11
- package/dist/es2019/placement-map/index.js +8 -109
- package/dist/es2019/placement-map/legacy-placements.js +107 -0
- package/dist/es2019/popover/popover.js +14 -11
- package/dist/es2019/popup/popup-content.js +20 -14
- package/dist/es2019/popup/popup-surface.js +6 -0
- package/dist/es2019/popup/popup-trigger-function.js +1 -1
- package/dist/es2019/popup/popup-trigger.js +17 -37
- package/dist/es2019/popup/popup.js +20 -9
- package/dist/es2019/use-arrow-navigation/use-arrow-navigation.js +26 -10
- package/dist/esm/animations/presets.js +6 -3
- package/dist/esm/arrow/index.js +124 -0
- package/dist/esm/arrow/types.js +1 -0
- package/dist/esm/dialog/dialog-content.js +11 -6
- package/dist/esm/entry-points/create-close-event.js +7 -0
- package/dist/esm/entry-points/dialog/create-close-event.js +1 -0
- package/dist/esm/entry-points/placement-map.js +2 -1
- package/dist/esm/entry-points/popover/create-close-event.js +1 -0
- package/dist/esm/focus/focus.js +70 -19
- package/dist/esm/index.js +3 -4
- package/dist/esm/internal/resolve-css-length-to-pixels.js +61 -10
- package/dist/esm/internal/role-types.js +39 -9
- package/dist/esm/internal/set-style.js +23 -25
- package/dist/esm/internal/use-anchor-position.js +108 -64
- package/dist/esm/internal/use-focus-wrap.js +14 -3
- package/dist/esm/internal/use-initial-focus.js +32 -11
- package/dist/esm/placement-map/index.js +8 -109
- package/dist/esm/placement-map/legacy-placements.js +107 -0
- package/dist/esm/popover/popover.js +14 -12
- package/dist/esm/popup/popup-content.js +20 -14
- package/dist/esm/popup/popup-surface.js +6 -0
- package/dist/esm/popup/popup-trigger-function.js +1 -1
- package/dist/esm/popup/popup-trigger.js +17 -37
- package/dist/esm/popup/popup.js +21 -10
- package/dist/esm/use-arrow-navigation/use-arrow-navigation.js +26 -10
- package/dist/types/arrow/index.d.ts +24 -0
- package/dist/types/arrow/types.d.ts +29 -0
- package/dist/types/dialog/dialog-content.d.ts +5 -1
- package/dist/types/dialog/types.d.ts +50 -24
- package/dist/types/entry-points/create-close-event.d.ts +7 -0
- package/dist/types/entry-points/dialog/create-close-event.d.ts +2 -0
- package/dist/types/entry-points/placement-map.d.ts +2 -1
- package/dist/types/entry-points/popover/create-close-event.d.ts +2 -0
- package/dist/types/focus/focus.d.ts +8 -0
- package/dist/types/internal/resolve-css-length-to-pixels.d.ts +4 -6
- package/dist/types/internal/role-types.d.ts +25 -4
- package/dist/types/internal/set-style.d.ts +2 -19
- package/dist/types/internal/use-anchor-position.d.ts +2 -3
- package/dist/types/internal/use-initial-focus.d.ts +5 -4
- package/dist/types/placement-map/index.d.ts +8 -21
- package/dist/types/placement-map/legacy-placements.d.ts +21 -0
- package/dist/types/popover/popover.d.ts +1 -1
- package/dist/types/popover/types.d.ts +17 -17
- package/dist/types/popup/popup-surface.d.ts +6 -0
- package/dist/types/popup/popup-trigger-function.d.ts +10 -6
- package/dist/types/popup/popup-trigger.d.ts +8 -17
- package/dist/types/popup/popup.d.ts +2 -2
- package/dist/types/popup/types.d.ts +68 -22
- package/dist/types/use-arrow-navigation/use-arrow-navigation.d.ts +19 -5
- package/dist/types-ts4.5/arrow/index.d.ts +24 -0
- package/dist/types-ts4.5/arrow/types.d.ts +29 -0
- package/dist/types-ts4.5/dialog/dialog-content.d.ts +5 -1
- package/dist/types-ts4.5/dialog/types.d.ts +50 -24
- package/dist/types-ts4.5/entry-points/create-close-event.d.ts +7 -0
- package/dist/types-ts4.5/entry-points/dialog/create-close-event.d.ts +2 -0
- package/dist/types-ts4.5/entry-points/placement-map.d.ts +2 -1
- package/dist/types-ts4.5/entry-points/popover/create-close-event.d.ts +2 -0
- package/dist/types-ts4.5/focus/focus.d.ts +8 -0
- package/dist/types-ts4.5/internal/resolve-css-length-to-pixels.d.ts +4 -6
- package/dist/types-ts4.5/internal/role-types.d.ts +25 -4
- package/dist/types-ts4.5/internal/set-style.d.ts +2 -19
- package/dist/types-ts4.5/internal/use-anchor-position.d.ts +2 -3
- package/dist/types-ts4.5/internal/use-initial-focus.d.ts +5 -4
- package/dist/types-ts4.5/placement-map/index.d.ts +8 -21
- package/dist/types-ts4.5/placement-map/legacy-placements.d.ts +39 -0
- package/dist/types-ts4.5/popover/popover.d.ts +1 -1
- package/dist/types-ts4.5/popover/types.d.ts +17 -17
- package/dist/types-ts4.5/popup/popup-surface.d.ts +6 -0
- package/dist/types-ts4.5/popup/popup-trigger-function.d.ts +10 -6
- package/dist/types-ts4.5/popup/popup-trigger.d.ts +8 -17
- package/dist/types-ts4.5/popup/popup.d.ts +2 -2
- package/dist/types-ts4.5/popup/types.d.ts +68 -22
- package/dist/types-ts4.5/use-arrow-navigation/use-arrow-navigation.d.ts +19 -5
- package/package.json +1 -1
- package/popover/create-close-event/package.json +17 -0
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Valid values for the `aria-haspopup` attribute.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Derived from the HTML spec - maps popover roles to what the trigger
|
|
5
|
+
* announces. `undefined` means the attribute is omitted entirely (used for
|
|
6
|
+
* non-popup roles like `tooltip`/`status`/`alert`/`note`/`log` where
|
|
7
|
+
* `aria-haspopup` would be misleading).
|
|
8
|
+
*
|
|
9
|
+
* `true` is intentionally NOT in the union - the runtime only ever produces
|
|
10
|
+
* the explicit string forms, so widening the type would invite consumers to
|
|
11
|
+
* pass `true` and get an `aria-haspopup="true"` serialisation that the
|
|
12
|
+
* runtime no longer emits.
|
|
4
13
|
*/
|
|
5
14
|
|
|
6
15
|
/**
|
|
@@ -22,21 +31,27 @@
|
|
|
22
31
|
/**
|
|
23
32
|
* ARIA role props where a role is always required.
|
|
24
33
|
*
|
|
25
|
-
* Used by `TPopupContentProps
|
|
34
|
+
* Used by `TPopupContentProps`. Popup content must always declare
|
|
26
35
|
* its role so assistive technology can identify the layer.
|
|
27
36
|
*/
|
|
28
37
|
|
|
29
38
|
/**
|
|
30
39
|
* ARIA role props where a role is optional.
|
|
31
40
|
*
|
|
32
|
-
* Used by `TPopoverProps
|
|
41
|
+
* Used by `TPopoverProps`. The low-level primitive allows omitting
|
|
33
42
|
* the role for cases where it is set externally or not needed.
|
|
34
43
|
*/
|
|
35
44
|
|
|
36
45
|
/**
|
|
37
46
|
* Maps a popover content role to the correct `aria-haspopup` value
|
|
38
47
|
* for its trigger element.
|
|
48
|
+
*
|
|
49
|
+
* Returns `undefined` for roles that are not popups in the ARIA sense
|
|
50
|
+
* (`tooltip`, `note`, `status`, `alert`, `log`). Triggers for those roles
|
|
51
|
+
* should not announce a popup at all - emitting `aria-haspopup="dialog"`
|
|
52
|
+
* on a tooltip trigger would mislead assistive technology.
|
|
39
53
|
*/
|
|
54
|
+
// eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
|
|
40
55
|
export function roleToAriaHasPopup({
|
|
41
56
|
role
|
|
42
57
|
}) {
|
|
@@ -52,10 +67,26 @@ export function roleToAriaHasPopup({
|
|
|
52
67
|
if (role === 'grid') {
|
|
53
68
|
return 'grid';
|
|
54
69
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
70
|
+
if (role === 'dialog' || role === 'alertdialog') {
|
|
71
|
+
return 'dialog';
|
|
72
|
+
}
|
|
73
|
+
// Non-popup roles (tooltip, note, status, alert, log) and the
|
|
74
|
+
// no-role case: omit `aria-haspopup` entirely. React drops the
|
|
75
|
+
// attribute when the value is `undefined`.
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const rolesThatMoveFocus = new Set(['dialog', 'alertdialog', 'menu', 'listbox', 'tree', 'grid']);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Returns `true` when the given popover role moves focus into the popover
|
|
82
|
+
* on open, and `false` otherwise (including when `role` is `undefined`).
|
|
83
|
+
*/
|
|
84
|
+
// eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
|
|
85
|
+
export function shouldFocusIntoPopover({
|
|
86
|
+
role
|
|
87
|
+
}) {
|
|
88
|
+
if (!role) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return rolesThatMoveFocus.has(role);
|
|
61
92
|
}
|
|
@@ -1,28 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sets inline styles on an element and returns a cleanup function
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Uses `style.setProperty()` / `style.removeProperty()` so that
|
|
6
|
-
* hyphenated CSS property names (e.g. `position-area`, `anchor-name`)
|
|
7
|
-
* work correctly. These newer properties do not have camelCase
|
|
8
|
-
* mappings in `CSSStyleDeclaration` yet.
|
|
9
|
-
*
|
|
10
|
-
* @example
|
|
11
|
-
* ```ts
|
|
12
|
-
* const cleanup = setStyle({ el: popover, styles: [
|
|
13
|
-
* { property: 'position-anchor', value: '--my-anchor' },
|
|
14
|
-
* { property: 'position-area', value: 'block-end' },
|
|
15
|
-
* { property: 'margin', value: '0' },
|
|
16
|
-
* ] });
|
|
17
|
-
*
|
|
18
|
-
* // Later: removes all three properties
|
|
19
|
-
* cleanup();
|
|
20
|
-
* ```
|
|
2
|
+
* Sets inline styles on an element and returns a cleanup function that
|
|
3
|
+
* restores the prior inline values (so we do not stomp consumer styles).
|
|
21
4
|
*/
|
|
22
5
|
export function setStyle({
|
|
23
6
|
el,
|
|
24
7
|
styles
|
|
25
8
|
}) {
|
|
9
|
+
// Snapshot the prior inline value (NOT the computed style - we only want
|
|
10
|
+
// to restore values that the consumer/our previous run inlined). An empty
|
|
11
|
+
// string means "no inline value", in which case cleanup uses removeProperty.
|
|
12
|
+
const priorValues = styles.map(({
|
|
13
|
+
property
|
|
14
|
+
}) => ({
|
|
15
|
+
property,
|
|
16
|
+
value: el.style.getPropertyValue(property)
|
|
17
|
+
}));
|
|
26
18
|
styles.forEach(({
|
|
27
19
|
property,
|
|
28
20
|
value
|
|
@@ -30,10 +22,15 @@ export function setStyle({
|
|
|
30
22
|
el.style.setProperty(property, value);
|
|
31
23
|
});
|
|
32
24
|
return function cleanup() {
|
|
33
|
-
|
|
34
|
-
property
|
|
25
|
+
priorValues.forEach(({
|
|
26
|
+
property,
|
|
27
|
+
value
|
|
35
28
|
}) => {
|
|
36
|
-
|
|
29
|
+
if (value === '') {
|
|
30
|
+
el.style.removeProperty(property);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
el.style.setProperty(property, value);
|
|
37
34
|
});
|
|
38
35
|
};
|
|
39
36
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* popover with `opacity: 0` until the first measurement completes, so it
|
|
13
13
|
* is never painted at the wrong location.
|
|
14
14
|
*/
|
|
15
|
-
import { useId, useLayoutEffect,
|
|
15
|
+
import { useId, useLayoutEffect, useMemo } from 'react';
|
|
16
16
|
import { bind } from 'bind-event-listener';
|
|
17
17
|
import rafSchedule from 'raf-schd';
|
|
18
18
|
import once from '@atlaskit/ds-lib/once';
|
|
@@ -21,7 +21,20 @@ import { combine } from './combine';
|
|
|
21
21
|
import { resolveCssLengthToPixels } from './resolve-css-length-to-pixels';
|
|
22
22
|
import { getPlacement } from './resolve-placement';
|
|
23
23
|
import { setStyle } from './set-style';
|
|
24
|
-
|
|
24
|
+
|
|
25
|
+
// `getPlacement` is intentionally NOT re-exported here - import it directly
|
|
26
|
+
// from `./resolve-placement` so search-and-jump lands at the source of truth.
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Module-scope no-op `ResizeObserver` used in non-DOM jest environments where
|
|
30
|
+
* `ResizeObserver` is `undefined`. Hoisted out of the effect to avoid
|
|
31
|
+
* allocating a new class declaration on every run.
|
|
32
|
+
*/
|
|
33
|
+
const NoopResizeObserver = class {
|
|
34
|
+
observe() {}
|
|
35
|
+
unobserve() {}
|
|
36
|
+
disconnect() {}
|
|
37
|
+
};
|
|
25
38
|
|
|
26
39
|
/**
|
|
27
40
|
* Resolves placement options to CSS `position-area` syntax.
|
|
@@ -86,7 +99,7 @@ export function placementToPositionArea({
|
|
|
86
99
|
* top-right), flipping only the primary axis (`flip-inline`) moves the popup
|
|
87
100
|
* to the left but keeps the same cross-axis span direction (e.g. upward),
|
|
88
101
|
* which is still off-screen. The combined keyword `flip-inline flip-block`
|
|
89
|
-
* mirrors the popup diagonally
|
|
102
|
+
* mirrors the popup diagonally. It flips BOTH the position-area AND both
|
|
90
103
|
* inline and block margins simultaneously, placing the popup at the
|
|
91
104
|
* diagonally opposite corner where there is always space.
|
|
92
105
|
*
|
|
@@ -107,19 +120,36 @@ export function placementToTryFallbacks({
|
|
|
107
120
|
});
|
|
108
121
|
const crossAxis = axis === 'block' ? 'inline' : 'block';
|
|
109
122
|
const flippedEdge = edge === 'start' ? 'end' : 'start';
|
|
110
|
-
if (align !== 'center') {
|
|
111
|
-
const sameEdge = `${axis}-${edge}`;
|
|
112
|
-
const oppositeEdge = `${axis}-${flippedEdge}`;
|
|
113
|
-
const flipKeyword = axis === 'block' ? 'flip-block' : 'flip-inline';
|
|
114
|
-
const flipCrossKeyword = axis === 'block' ? 'flip-inline' : 'flip-block';
|
|
115
|
-
// Try same-edge shifts first; two flip entries cover single-edge then corner overflow.
|
|
116
|
-
// Legacy Popper fallbacks for e.g. bottom-start: [bottom, bottom-end, top-start, top, top-end, auto].
|
|
117
|
-
return [`${sameEdge} span-${crossAxis}-${align === 'start' ? 'end' : 'start'}`, sameEdge, `${sameEdge} span-${crossAxis}-${align === 'start' ? 'start' : 'end'}`, flipKeyword, `${flipKeyword} ${flipCrossKeyword}`, `${oppositeEdge} span-${crossAxis}-${align === 'start' ? 'end' : 'start'}`, oppositeEdge, `${oppositeEdge} span-${crossAxis}-${align === 'start' ? 'start' : 'end'}`].join(', ');
|
|
118
|
-
}
|
|
119
123
|
const sameEdge = `${axis}-${edge}`;
|
|
120
124
|
const oppositeEdge = `${axis}-${flippedEdge}`;
|
|
121
125
|
const flipKeyword = axis === 'block' ? 'flip-block' : 'flip-inline';
|
|
122
|
-
|
|
126
|
+
const flipCrossKeyword = axis === 'block' ? 'flip-inline' : 'flip-block';
|
|
127
|
+
|
|
128
|
+
// Single algorithm: produce the shift list against any base alignment.
|
|
129
|
+
// For `align: 'start'` the "near" shift expands toward `end`; for
|
|
130
|
+
// `align: 'end'` it expands toward `start`; for `align: 'center'`
|
|
131
|
+
// either order is symmetric (we use end-then-start to match the
|
|
132
|
+
// historical fallback list).
|
|
133
|
+
function shifts(baseAlign, edgeKey) {
|
|
134
|
+
if (baseAlign === 'center') {
|
|
135
|
+
return [`${edgeKey} span-${crossAxis}-end`, `${edgeKey} span-${crossAxis}-start`];
|
|
136
|
+
}
|
|
137
|
+
const near = baseAlign === 'start' ? 'end' : 'start';
|
|
138
|
+
const far = baseAlign === 'start' ? 'start' : 'end';
|
|
139
|
+
return [`${edgeKey} span-${crossAxis}-${near}`, edgeKey, `${edgeKey} span-${crossAxis}-${far}`];
|
|
140
|
+
}
|
|
141
|
+
if (align !== 'center') {
|
|
142
|
+
// Aligned (start/end): try same-edge shifts, then single-axis flip,
|
|
143
|
+
// then diagonal flip for corner overflow, then opposite-edge shifts.
|
|
144
|
+
// Legacy Popper fallback ordering for e.g. bottom-start:
|
|
145
|
+
// [bottom, bottom-end, top-start, top, top-end, auto].
|
|
146
|
+
return [...shifts(align, sameEdge), flipKeyword, `${flipKeyword} ${flipCrossKeyword}`, ...shifts(align, oppositeEdge)].join(', ');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Centered: same-edge shifts, single-axis flip, opposite-edge shifts.
|
|
150
|
+
// Diagonal flip is unnecessary because centered placements expand
|
|
151
|
+
// equally in both cross-axis directions.
|
|
152
|
+
return [...shifts('center', sameEdge), flipKeyword, ...shifts('center', oppositeEdge)].join(', ');
|
|
123
153
|
}
|
|
124
154
|
|
|
125
155
|
/**
|
|
@@ -137,7 +167,7 @@ function isPopoverOpen(popover) {
|
|
|
137
167
|
|
|
138
168
|
/**
|
|
139
169
|
* Detects whether the browser supports CSS Anchor Positioning.
|
|
140
|
-
* Uses `once()` for lazy evaluation
|
|
170
|
+
* Uses `once()` for lazy evaluation: safe for SSR and avoids hydration mismatches.
|
|
141
171
|
*/
|
|
142
172
|
const supportsAnchorPositioning = once(() => {
|
|
143
173
|
if (typeof window === 'undefined' || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') {
|
|
@@ -223,34 +253,49 @@ function crossAxisShiftMargin({
|
|
|
223
253
|
}
|
|
224
254
|
|
|
225
255
|
/**
|
|
226
|
-
* Returns a referentially-stable, fully-resolved `TPlacement
|
|
227
|
-
*
|
|
228
|
-
* does not
|
|
256
|
+
* Returns a referentially-stable, fully-resolved `TPlacement`. `useMemo` is
|
|
257
|
+
* keyed on the primitive fields of the input so a fresh inline `placement`
|
|
258
|
+
* object on every render does not produce a fresh resolved object. Both
|
|
259
|
+
* `getPlacement` and the equality check are skipped when the inputs are
|
|
260
|
+
* unchanged.
|
|
229
261
|
*/
|
|
230
262
|
function useStablePlacement(placement) {
|
|
263
|
+
// Resolve to the fully-defaulted primitives BEFORE the memo so the deps
|
|
264
|
+
// array sees the same key for `{}`, `undefined` fields, and explicit
|
|
265
|
+
// defaults. Otherwise `axis: undefined` and `axis: 'block'` would be
|
|
266
|
+
// treated as different inputs and re-run the downstream effect.
|
|
231
267
|
const resolved = getPlacement({
|
|
232
268
|
placement
|
|
233
269
|
});
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
270
|
+
const axis = resolved.axis;
|
|
271
|
+
const edge = resolved.edge;
|
|
272
|
+
const align = resolved.align;
|
|
273
|
+
const gap = resolved.offset.gap;
|
|
274
|
+
const shiftValue = resolved.offset.crossAxisShift.value;
|
|
275
|
+
const shiftDirection = resolved.offset.crossAxisShift.direction;
|
|
276
|
+
// Rebuild the placement object inside the memo from the resolved primitive
|
|
277
|
+
// deps so the factory does not close over the (potentially fresh) outer
|
|
278
|
+
// `resolved` reference, and `react-hooks/exhaustive-deps` can verify the
|
|
279
|
+
// deps array.
|
|
280
|
+
return useMemo(() => ({
|
|
281
|
+
axis,
|
|
282
|
+
edge,
|
|
283
|
+
align,
|
|
284
|
+
offset: {
|
|
285
|
+
gap,
|
|
286
|
+
crossAxisShift: {
|
|
287
|
+
value: shiftValue,
|
|
288
|
+
direction: shiftDirection
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}), [axis, edge, align, gap, shiftValue, shiftDirection]);
|
|
247
292
|
}
|
|
248
293
|
|
|
249
294
|
/**
|
|
250
295
|
* Hook that positions an element relative to an anchor element using
|
|
251
296
|
* CSS Anchor Positioning (with a JS fallback for older browsers).
|
|
252
297
|
*
|
|
253
|
-
* This hook is the positioning primitive
|
|
298
|
+
* This hook is the positioning primitive. It has no knowledge of popovers,
|
|
254
299
|
* visibility, or animation. Compose it with `Popover` for anchor-positioned
|
|
255
300
|
* top-layer content.
|
|
256
301
|
*
|
|
@@ -294,6 +339,9 @@ export function useAnchorPosition({
|
|
|
294
339
|
placement: stablePlacement,
|
|
295
340
|
offset: gapCssValue
|
|
296
341
|
});
|
|
342
|
+
// Hoist crossAxisShift to a single computation; reused below to
|
|
343
|
+
// populate the active `--ds-cross-axis-shift-margin-*` custom
|
|
344
|
+
// property for the named arrow @position-try rules.
|
|
297
345
|
const crossAxisShift = crossAxisShiftMargin({
|
|
298
346
|
placement: stablePlacement,
|
|
299
347
|
crossAxisShiftCssValue,
|
|
@@ -353,11 +401,8 @@ export function useAnchorPosition({
|
|
|
353
401
|
property: '--ds-cross-axis-shift-margin-block-end',
|
|
354
402
|
value: '0px'
|
|
355
403
|
});
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
crossAxisShiftCssValue,
|
|
359
|
-
direction: stablePlacement.offset.crossAxisShift.direction
|
|
360
|
-
});
|
|
404
|
+
// `crossAxisShift` IS the active margin - reuse instead of recomputing.
|
|
405
|
+
const crossAxisShiftActive = crossAxisShift;
|
|
361
406
|
// Map margin property to its corresponding custom property name.
|
|
362
407
|
const customPropertyByMarginProperty = {
|
|
363
408
|
'margin-inline-start': '--ds-cross-axis-shift-margin-start',
|
|
@@ -382,7 +427,7 @@ export function useAnchorPosition({
|
|
|
382
427
|
* - Sometimes the `useAnchorPosition` hook and `Popover`
|
|
383
428
|
* are not in the same component.
|
|
384
429
|
* - There can be times when, even with reference counting, we
|
|
385
|
-
* could remove the anchor-name while it
|
|
430
|
+
* could remove the anchor-name while it is still being used
|
|
386
431
|
* by something else - especially with async react updates
|
|
387
432
|
* which can cause tearing
|
|
388
433
|
*
|
|
@@ -413,7 +458,6 @@ export function useAnchorPosition({
|
|
|
413
458
|
}]
|
|
414
459
|
});
|
|
415
460
|
function update() {
|
|
416
|
-
var _popover$parentElemen;
|
|
417
461
|
if (!trigger || !popover) {
|
|
418
462
|
return;
|
|
419
463
|
}
|
|
@@ -424,19 +468,19 @@ export function useAnchorPosition({
|
|
|
424
468
|
};
|
|
425
469
|
|
|
426
470
|
// Resolve consumer-supplied CSS length strings (tokens, calc, var,
|
|
427
|
-
// px, rem, etc) to pixels. The probe is mounted
|
|
428
|
-
// so
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
|
|
471
|
+
// px, rem, etc) to pixels. The probe is mounted INSIDE the popover
|
|
472
|
+
// itself so the resolved length matches the scope a token would
|
|
473
|
+
// resolve against from inside the popover content. (The popover
|
|
474
|
+
// lives in the top layer, not the trigger's DOM tree, so probing
|
|
475
|
+
// at `popover.parentElement` would resolve in a different scope
|
|
476
|
+
// than the consumer's token authoring expects.)
|
|
433
477
|
const gapPx = resolveCssLengthToPixels({
|
|
434
478
|
value: gapCssValue,
|
|
435
|
-
container:
|
|
479
|
+
container: popover
|
|
436
480
|
});
|
|
437
481
|
const crossAxisShiftPx = resolveCssLengthToPixels({
|
|
438
482
|
value: crossAxisShiftCssValue,
|
|
439
|
-
container:
|
|
483
|
+
container: popover
|
|
440
484
|
});
|
|
441
485
|
const {
|
|
442
486
|
top,
|
|
@@ -470,15 +514,12 @@ export function useAnchorPosition({
|
|
|
470
514
|
// scroll/resize is handled by the window listeners below.
|
|
471
515
|
// `ResizeObserver` is missing in some non-DOM jest environments
|
|
472
516
|
// (e.g. post-office's `node` environment). Fall back to a no-op
|
|
473
|
-
// observer there
|
|
517
|
+
// observer there. The scroll/resize listeners below still keep
|
|
474
518
|
// the popover positioned in the rare case the consumer also
|
|
475
519
|
// polyfilled `showPopover` but not `ResizeObserver`. Real
|
|
476
|
-
// browsers always have it.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
unobserve() {}
|
|
480
|
-
disconnect() {}
|
|
481
|
-
};
|
|
520
|
+
// browsers always have it. The `NoopResizeObserver` class is
|
|
521
|
+
// hoisted to module scope (see top of file) so we do not allocate
|
|
522
|
+
// a new constructor on every effect run.
|
|
482
523
|
const ResizeObserverImpl = typeof ResizeObserver !== 'undefined' ? ResizeObserver : NoopResizeObserver;
|
|
483
524
|
const resizeObserver = new ResizeObserverImpl(() => {
|
|
484
525
|
if (popover.offsetWidth > 0 && popover.offsetHeight > 0) {
|
|
@@ -501,7 +542,7 @@ export function useAnchorPosition({
|
|
|
501
542
|
// the UA-default position, then re-observe for a fresh
|
|
502
543
|
// measurement. We use `opacity: 0` rather than
|
|
503
544
|
// `visibility: hidden` because Firefox skips visibility-hidden
|
|
504
|
-
// elements during `<dialog>` initial-focus traversal
|
|
545
|
+
// elements during `<dialog>` initial-focus traversal. See
|
|
505
546
|
// `form-in-popup.spec.tsx` on `desktop-firefox`.
|
|
506
547
|
bind(popover, {
|
|
507
548
|
type: 'toggle',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect } from 'react';
|
|
2
2
|
import { bind } from 'bind-event-listener';
|
|
3
|
-
import { getFirstFocusable, getLastFocusable, getNextFocusable } from '../focus/focus';
|
|
3
|
+
import { getFirstFocusable, getLastFocusable, getNextFocusable, isNestedLayerFocused } from '../focus/focus';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Roles that require focus wrapping per WAI-ARIA APG.
|
|
@@ -14,7 +14,7 @@ import { getFirstFocusable, getLastFocusable, getNextFocusable } from '../focus/
|
|
|
14
14
|
* not Tab. Tab/Shift+Tab should move focus OUT of a menu entirely.
|
|
15
15
|
*
|
|
16
16
|
* Menu keyboard behavior (arrow keys, Home/End, type-ahead, Enter/Space)
|
|
17
|
-
* is context-dependent
|
|
17
|
+
* is context-dependent. It varies by orientation, nesting depth, item
|
|
18
18
|
* type, and control pattern (menu button vs menubar). This makes it
|
|
19
19
|
* impossible to implement generically here. The consumer component
|
|
20
20
|
* (e.g. `dropdown-menu`) owns all menu keyboard navigation.
|
|
@@ -55,6 +55,17 @@ export function useFocusWrap({
|
|
|
55
55
|
if (event.key !== 'Tab') {
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
// If focus is already in a nested layer, then
|
|
60
|
+
// we continue to let that nested layer own focus
|
|
61
|
+
if (isNestedLayerFocused({
|
|
62
|
+
container: element
|
|
63
|
+
})) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Prevent the browser giving focus to where it wants to
|
|
68
|
+
// (we will be handling it, including with wrapping)
|
|
58
69
|
event.preventDefault();
|
|
59
70
|
const direction = event.shiftKey ? 'backwards' : 'forwards';
|
|
60
71
|
|
|
@@ -70,7 +81,7 @@ export function useFocusWrap({
|
|
|
70
81
|
}
|
|
71
82
|
|
|
72
83
|
// Nothing is currently focused inside the container (or the
|
|
73
|
-
// focused element
|
|
84
|
+
// focused element is not in the focusable list). Fall back to
|
|
74
85
|
// the first or last focusable element depending on direction.
|
|
75
86
|
const fallback = direction === 'forwards' ? getFirstFocusable({
|
|
76
87
|
container: element
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect } from 'react';
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
2
|
import { getFirstFocusable } from '../focus/focus';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -19,9 +19,9 @@ import { getFirstFocusable } from '../focus/focus';
|
|
|
19
19
|
* React 19+ reflects the JSX prop as the HTML attribute automatically.
|
|
20
20
|
*/
|
|
21
21
|
function getInitialFocusTarget({
|
|
22
|
-
container
|
|
22
|
+
container,
|
|
23
|
+
role
|
|
23
24
|
}) {
|
|
24
|
-
const role = container.getAttribute('role');
|
|
25
25
|
if (role === 'dialog' || role === 'alertdialog') {
|
|
26
26
|
// Prefer element with the native HTML autofocus attribute.
|
|
27
27
|
const autofocusEl = container.querySelector('[autofocus]');
|
|
@@ -62,14 +62,15 @@ function getInitialFocusTarget({
|
|
|
62
62
|
/**
|
|
63
63
|
* Moves focus into a popover element when it opens, based on its ARIA role.
|
|
64
64
|
*
|
|
65
|
-
*
|
|
65
|
+
* Follows the WAI-ARIA APG initial-focus patterns for each role:
|
|
66
66
|
* - `dialog` / `alertdialog` → focus first focusable (or `[autofocus]`)
|
|
67
67
|
* - `menu` → focus first menu item
|
|
68
68
|
* - `listbox` → focus selected or first option
|
|
69
|
-
* - `tooltip` / no role → no focus movement
|
|
69
|
+
* - `tooltip` / no role → no focus movement (focus must remain on trigger;
|
|
70
|
+
* moving focus into the tooltip would dismiss it)
|
|
70
71
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
72
|
+
* Role is taken from the typed `role` prop - this is the consumer's contract
|
|
73
|
+
* and is not subject to drift from a directly-mutated DOM attribute.
|
|
73
74
|
*
|
|
74
75
|
* Always call this hook unconditionally. Focus is only moved when `isOpen`
|
|
75
76
|
* transitions to `true` and the role requires it.
|
|
@@ -79,6 +80,12 @@ export function useInitialFocus({
|
|
|
79
80
|
isOpen,
|
|
80
81
|
role
|
|
81
82
|
}) {
|
|
83
|
+
// Track the most recently scheduled RAF so a rapid `isOpen → false → true`
|
|
84
|
+
// transition can cancel the previous frame before its closure runs against
|
|
85
|
+
// a stale popover element. Without this, the RAF queued during the first
|
|
86
|
+
// open could fire after the second open had begun and focus the wrong
|
|
87
|
+
// instance.
|
|
88
|
+
const pendingRafRef = useRef(null);
|
|
82
89
|
useEffect(() => {
|
|
83
90
|
if (!isOpen) {
|
|
84
91
|
return;
|
|
@@ -88,6 +95,11 @@ export function useInitialFocus({
|
|
|
88
95
|
return;
|
|
89
96
|
}
|
|
90
97
|
|
|
98
|
+
// Cancel any RAF queued by the previous open cycle.
|
|
99
|
+
if (pendingRafRef.current !== null) {
|
|
100
|
+
cancelAnimationFrame(pendingRafRef.current);
|
|
101
|
+
}
|
|
102
|
+
|
|
91
103
|
// Use requestAnimationFrame to ensure the popover is fully rendered
|
|
92
104
|
// and visible in the top layer before attempting to move focus.
|
|
93
105
|
// showPopover() is called in useLayoutEffect (which runs before this
|
|
@@ -95,15 +107,24 @@ export function useInitialFocus({
|
|
|
95
107
|
// element is guaranteed to be in the top layer.
|
|
96
108
|
const rafId = requestAnimationFrame(() => {
|
|
97
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
|
+
}
|
|
98
117
|
(_getInitialFocusTarge = getInitialFocusTarget({
|
|
99
|
-
container: el
|
|
118
|
+
container: el,
|
|
119
|
+
role
|
|
100
120
|
})) === null || _getInitialFocusTarge === void 0 ? void 0 : _getInitialFocusTarge.focus();
|
|
101
121
|
});
|
|
122
|
+
pendingRafRef.current = rafId;
|
|
102
123
|
return () => {
|
|
103
124
|
cancelAnimationFrame(rafId);
|
|
125
|
+
if (pendingRafRef.current === rafId) {
|
|
126
|
+
pendingRafRef.current = null;
|
|
127
|
+
}
|
|
104
128
|
};
|
|
105
|
-
// We intentionally depend on `role` even though `getInitialFocusTarget`
|
|
106
|
-
// reads from the DOM attribute. This ensures the effect re-runs if
|
|
107
|
-
// the role prop changes while the popover is open.
|
|
108
129
|
}, [elementRef, isOpen, role]);
|
|
109
130
|
}
|