Skip to content

Commit 96a2fbe

Browse files
committed
wip: Use stacking contexts to determine non-inert elements outside modals
1 parent 6a86b5c commit 96a2fbe

File tree

5 files changed

+142
-8
lines changed

5 files changed

+142
-8
lines changed

packages/@react-aria/overlays/src/Overlay.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export const OverlayContext: React.Context<{contain: boolean, setContain: React.
5252
*/
5353
export function Overlay(props: OverlayProps): React.ReactPortal | null {
5454
let isSSR = useIsSSR();
55-
let {portalContainer = isSSR ? null : document.body, isExiting} = props;
55+
let {portalContainer = isSSR ? null : document.body} = props;
5656
let [contain, setContain] = useState(false);
5757
let contextValue = useMemo(() => ({contain, setContain}), [contain, setContain]);
5858

@@ -68,7 +68,7 @@ export function Overlay(props: OverlayProps): React.ReactPortal | null {
6868
let contents = props.children;
6969
if (!props.disableFocusManagement) {
7070
contents = (
71-
<FocusScope restoreFocus contain={(props.shouldContainFocus || contain) && !isExiting}>
71+
<FocusScope restoreFocus>
7272
{contents}
7373
</FocusScope>
7474
);

packages/@react-aria/overlays/src/ariaHideOutside.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ const supportsInert = typeof HTMLElement !== 'undefined' && 'inert' in HTMLEleme
1515

1616
interface AriaHideOutsideOptions {
1717
root?: Element,
18-
shouldUseInert?: boolean
18+
shouldUseInert?: boolean,
19+
getVisibleNodes?: (element: Element) => Element[]
1920
}
2021

2122
// Keeps a ref count of all hidden elements. Added to when hiding an element, and
@@ -42,6 +43,7 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt
4243
let opts = options instanceof windowObj.Element ? {root: options} : options;
4344
let root = opts?.root ?? document.body;
4445
let shouldUseInert = opts?.shouldUseInert && supportsInert;
46+
let getVisibleNodes = opts?.getVisibleNodes;
4547
let visibleNodes = new Set<Element>(targets);
4648
let hiddenNodes = new Set<Element>();
4749

@@ -70,6 +72,12 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt
7072
visibleNodes.add(element);
7173
}
7274

75+
if (getVisibleNodes) {
76+
for (let element of getVisibleNodes(root)) {
77+
visibleNodes.add(element);
78+
}
79+
}
80+
7381
let acceptNode = (node: Element) => {
7482
// Skip this node and its children if it is one of the target nodes, or a live announcer.
7583
// Also skip children of already hidden nodes, as aria-hidden is recursive. An exception is

packages/@react-aria/overlays/src/useModalOverlay.ts

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import {ariaHideOutside} from './ariaHideOutside';
1414
import {AriaOverlayProps, useOverlay} from './useOverlay';
1515
import {DOMAttributes, RefObject} from '@react-types/shared';
16+
import {isElementVisible} from '../../utils/src/isElementVisible';
1617
import {mergeProps} from '@react-aria/utils';
1718
import {OverlayTriggerState} from '@react-stately/overlays';
1819
import {useEffect} from 'react';
@@ -58,7 +59,7 @@ export function useModalOverlay(props: AriaModalOverlayProps, state: OverlayTrig
5859

5960
useEffect(() => {
6061
if (state.isOpen && ref.current) {
61-
return ariaHideOutside([ref.current], {shouldUseInert: true});
62+
return hideElementsBehind(ref.current);
6263
}
6364
}, [state.isOpen, ref]);
6465

@@ -67,3 +68,120 @@ export function useModalOverlay(props: AriaModalOverlayProps, state: OverlayTrig
6768
underlayProps
6869
};
6970
}
71+
72+
function hideElementsBehind(element: Element, root = document.body) {
73+
// TODO: automatically determine root based on parent stacking context of element?
74+
let roots = getStackingContextRoots(root);
75+
let rootStackingContext = roots.find(r => r.contains(element)) || document.documentElement;
76+
let elementZIndex = getZIndex(rootStackingContext);
77+
78+
return ariaHideOutside([element], {
79+
shouldUseInert: true,
80+
getVisibleNodes: el => {
81+
let node: Element | null = el;
82+
let ancestors: Element[] = [];
83+
while (node && node !== root) {
84+
ancestors.unshift(node);
85+
node = node.parentElement;
86+
}
87+
88+
// If an ancestor element of the added target is a stacking context root,
89+
// use that to determine if the element should be preserved.
90+
let stackingContext = ancestors.find(el => isStackingContext(el));
91+
if (stackingContext) {
92+
if (shouldPreserve(element, elementZIndex, stackingContext)) {
93+
return [el];
94+
}
95+
return [];
96+
} else {
97+
// Otherwise, find stacking context roots within the added element, and compare with the modal element.
98+
let roots = getStackingContextRoots(el);
99+
let preservedElements: Element[] = [];
100+
for (let root of roots) {
101+
if (shouldPreserve(element, elementZIndex, root)) {
102+
preservedElements.push(root);
103+
}
104+
}
105+
return preservedElements;
106+
}
107+
}
108+
});
109+
}
110+
111+
function shouldPreserve(baseElement: Element, baseZIndex: number, element: Element) {
112+
if (baseElement.contains(element)) {
113+
return true;
114+
}
115+
116+
let zIndex = getZIndex(element);
117+
if (zIndex === baseZIndex) {
118+
// If two elements have the same z-index, compare their document order.
119+
if (element.compareDocumentPosition(baseElement) & Node.DOCUMENT_POSITION_FOLLOWING) {
120+
return true;
121+
}
122+
} else if (zIndex > baseZIndex) {
123+
return true;
124+
}
125+
126+
return false;
127+
}
128+
129+
function isStackingContext(el: Element) {
130+
let style = getComputedStyle(el);
131+
132+
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Stacking_context#features_creating_stacking_contexts
133+
return (
134+
el === document.documentElement ||
135+
(style.position !== 'static' && style.zIndex !== 'auto') ||
136+
('containerType' in style && style.containerType.includes('size')) ||
137+
(style.zIndex !== 'auto' && isFlexOrGridItem(el)) ||
138+
parseFloat(style.opacity) < 1 ||
139+
('mixBlendMode' in style && style.mixBlendMode !== 'normal') ||
140+
('transform' in style && style.transform !== 'none') ||
141+
('webkitTransform' in style && style.webkitTransform !== 'none') ||
142+
('scale' in style && style.scale !== 'none') ||
143+
('rotate' in style && style.rotate !== 'none') ||
144+
('translate' in style && style.translate !== 'none') ||
145+
('filter' in style && style.filter !== 'none') ||
146+
('webkitFilter' in style && style.webkitFilter !== 'none') ||
147+
('backdropFilter' in style && style.backdropFilter !== 'none') ||
148+
('perspective' in style && style.perspective !== 'none') ||
149+
('clipPath' in style && style.clipPath !== 'none') ||
150+
('mask' in style && style.mask !== 'none') ||
151+
('maskImage' in style && style.maskImage !== 'none') ||
152+
('maskBorder' in style && style.maskBorder !== 'none') ||
153+
style.isolation === 'isolate' ||
154+
/position|z-index|opacity|mix-blend-mode|transform|webkit-transform|scale|rotate|translate|filter|webkit-filter|backdrop-filter|perspective|clip-path|mask|mask-image|mask-border|isolation/.test(style.willChange) ||
155+
/layout|paint|strict|content/.test(style.contain)
156+
);
157+
}
158+
159+
function getStackingContextRoots(root: Element = document.body) {
160+
let roots: Element[] = [];
161+
162+
function walk(el: Element) {
163+
if (!isElementVisible(el)) {
164+
return;
165+
}
166+
167+
if (isStackingContext(el)) {
168+
roots.push(el);
169+
} else {
170+
for (const child of el.children) {
171+
walk(child);
172+
}
173+
}
174+
}
175+
176+
walk(root);
177+
return roots;
178+
}
179+
180+
function getZIndex(element: Element) {
181+
return Number(getComputedStyle(element).zIndex) || 0;
182+
}
183+
184+
function isFlexOrGridItem(element: Element) {
185+
let parent = element.parentElement;
186+
return parent && /flex|grid/.test(getComputedStyle(parent).display);
187+
}

packages/@react-aria/overlays/src/useOverlay.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import {DOMAttributes, RefObject} from '@react-types/shared';
1414
import {isElementInChildOfActiveScope} from '@react-aria/focus';
1515
import {useEffect} from 'react';
16-
import {useFocusWithin, useInteractOutside} from '@react-aria/interactions';
16+
import {useFocusWithin} from '@react-aria/interactions';
1717

1818
export interface AriaOverlayProps {
1919
/** Whether the overlay is currently open. */
@@ -119,7 +119,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject<Element | nul
119119
};
120120

121121
// Handle clicking outside the overlay to close it
122-
useInteractOutside({ref, onInteractOutside: isDismissable && isOpen ? onInteractOutside : undefined, onInteractOutsideStart});
122+
// useInteractOutside({ref, onInteractOutside: isDismissable && isOpen ? onInteractOutside : undefined, onInteractOutsideStart});
123123

124124
let {focusWithinProps} = useFocusWithin({
125125
isDisabled: !shouldCloseOnBlur,
@@ -147,6 +147,9 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject<Element | nul
147147
// fixes a firefox issue that starts text selection https://bugzilla.mozilla.org/show_bug.cgi?id=1675846
148148
if (e.target === e.currentTarget) {
149149
e.preventDefault();
150+
if (isDismissable) {
151+
onInteractOutsideStart(e);
152+
}
150153
}
151154
};
152155

@@ -156,7 +159,12 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject<Element | nul
156159
...focusWithinProps
157160
},
158161
underlayProps: {
159-
onPointerDown: onPointerDownUnderlay
162+
onPointerDown: onPointerDownUnderlay,
163+
onClick(e) {
164+
if (isDismissable && isOpen && e.target === e.currentTarget) {
165+
onInteractOutside(e.nativeEvent as PointerEvent);
166+
}
167+
}
160168
}
161169
};
162170
}

packages/@react-aria/toast/src/useToastRegion.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
186186
// - allows focus even outside a containing focus scope
187187
// - doesn’t dismiss overlays when clicking on it, even though it is outside
188188
// @ts-ignore
189-
'data-react-aria-top-layer': true,
189+
// 'data-react-aria-top-layer': true,
190190
// listen to focus events separate from focuswithin because that will only fire once
191191
// and we need to follow all focus changes
192192
onFocus: (e) => {

0 commit comments

Comments
 (0)