Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/@react-aria/overlays/src/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const OverlayContext: React.Context<{contain: boolean, setContain: React.
*/
export function Overlay(props: OverlayProps): React.ReactPortal | null {
let isSSR = useIsSSR();
let {portalContainer = isSSR ? null : document.body, isExiting} = props;
let {portalContainer = isSSR ? null : document.body} = props;
let [contain, setContain] = useState(false);
let contextValue = useMemo(() => ({contain, setContain}), [contain, setContain]);

Expand All @@ -68,7 +68,7 @@ export function Overlay(props: OverlayProps): React.ReactPortal | null {
let contents = props.children;
if (!props.disableFocusManagement) {
contents = (
<FocusScope restoreFocus contain={(props.shouldContainFocus || contain) && !isExiting}>
<FocusScope restoreFocus>
{contents}
</FocusScope>
);
Expand Down
10 changes: 9 additions & 1 deletion packages/@react-aria/overlays/src/ariaHideOutside.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ const supportsInert = typeof HTMLElement !== 'undefined' && 'inert' in HTMLEleme

interface AriaHideOutsideOptions {
root?: Element,
shouldUseInert?: boolean
shouldUseInert?: boolean,
getVisibleNodes?: (element: Element) => Element[]
}

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

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

if (getVisibleNodes) {
for (let element of getVisibleNodes(root)) {
visibleNodes.add(element);
}
}

let acceptNode = (node: Element) => {
// Skip this node and its children if it is one of the target nodes, or a live announcer.
// Also skip children of already hidden nodes, as aria-hidden is recursive. An exception is
Expand Down
120 changes: 119 additions & 1 deletion packages/@react-aria/overlays/src/useModalOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import {ariaHideOutside} from './ariaHideOutside';
import {AriaOverlayProps, useOverlay} from './useOverlay';
import {DOMAttributes, RefObject} from '@react-types/shared';
import {isElementVisible} from '../../utils/src/isElementVisible';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move to aria/utils eventually, or depending on RFC, i guess this won't matter, we'll have access

import {mergeProps} from '@react-aria/utils';
import {OverlayTriggerState} from '@react-stately/overlays';
import {useEffect} from 'react';
Expand Down Expand Up @@ -58,7 +59,7 @@ export function useModalOverlay(props: AriaModalOverlayProps, state: OverlayTrig

useEffect(() => {
if (state.isOpen && ref.current) {
return ariaHideOutside([ref.current], {shouldUseInert: true});
return hideElementsBehind(ref.current);
}
}, [state.isOpen, ref]);

Expand All @@ -67,3 +68,120 @@ export function useModalOverlay(props: AriaModalOverlayProps, state: OverlayTrig
underlayProps
};
}

function hideElementsBehind(element: Element, root = document.body) {
// TODO: automatically determine root based on parent stacking context of element?
let roots = getStackingContextRoots(root);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could be potentially really bad, calling getComputedStyle on every element on the entire page, though unlikely

I guess it only happens on mount of a dialog or something along those lines

We can't really keep a single, on page load data structure with all of them and update as the dom updates because css could change, and it'd involve mutation observers on way too many things which would cause a performance hit for other reasons

it'd be nice if we could just compare two elements traversing upwards instead, but that'd be really hard for the initial hide

let rootStackingContext = roots.find(r => r.contains(element)) || document.documentElement;
let elementZIndex = getZIndex(rootStackingContext);

return ariaHideOutside([element], {
shouldUseInert: true,
getVisibleNodes: el => {
let node: Element | null = el;
let ancestors: Element[] = [];
while (node && node !== root) {
ancestors.unshift(node);
node = node.parentElement;
}

// If an ancestor element of the added target is a stacking context root,
// use that to determine if the element should be preserved.
let stackingContext = ancestors.find(el => isStackingContext(el));
if (stackingContext) {
if (shouldPreserve(element, elementZIndex, stackingContext)) {
return [el];
}
return [];
} else {
// Otherwise, find stacking context roots within the added element, and compare with the modal element.
let roots = getStackingContextRoots(el);
let preservedElements: Element[] = [];
for (let root of roots) {
if (shouldPreserve(element, elementZIndex, root)) {
preservedElements.push(root);
}
}
return preservedElements;
}
}
});
}

function shouldPreserve(baseElement: Element, baseZIndex: number, element: Element) {
if (baseElement.contains(element)) {
return true;
}

let zIndex = getZIndex(element);
if (zIndex === baseZIndex) {
// If two elements have the same z-index, compare their document order.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this is where the check for "float" would come in

if (baseElement.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING) {
return true;
}
} else if (zIndex > baseZIndex) {
return true;
}

return false;
}

function isStackingContext(el: Element) {
let style = getComputedStyle(el);

// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Stacking_context#features_creating_stacking_contexts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably have to also account for floating elements https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Stacking_floating_elements

today I learned that getComputedStyle will return a transform matrix for any translates, include translate3d

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume a shadow root starts a new stacking context? I can't find good information on the topic, will try it if i have time

return (
el === document.documentElement ||
(style.position !== 'static' && style.zIndex !== 'auto') ||
('containerType' in style && style.containerType.includes('size')) ||
(style.zIndex !== 'auto' && isFlexOrGridItem(el)) ||
parseFloat(style.opacity) < 1 ||
('mixBlendMode' in style && style.mixBlendMode !== 'normal') ||
('transform' in style && style.transform !== 'none') ||
('webkitTransform' in style && style.webkitTransform !== 'none') ||
('scale' in style && style.scale !== 'none') ||
('rotate' in style && style.rotate !== 'none') ||
('translate' in style && style.translate !== 'none') ||
('filter' in style && style.filter !== 'none') ||
('webkitFilter' in style && style.webkitFilter !== 'none') ||
('backdropFilter' in style && style.backdropFilter !== 'none') ||
('perspective' in style && style.perspective !== 'none') ||
('clipPath' in style && style.clipPath !== 'none') ||
('mask' in style && style.mask !== 'none') ||
('maskImage' in style && style.maskImage !== 'none') ||
('maskBorder' in style && style.maskBorder !== 'none') ||
style.isolation === 'isolate' ||
/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) ||
/layout|paint|strict|content/.test(style.contain)
);
}

function getStackingContextRoots(root: Element = document.body) {
let roots: Element[] = [];

function walk(el: Element) {
if (!isElementVisible(el)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we want to use isElementVisible with the visibilityProperty: true and opacityProperty: false it currently has hard set

We might run into a problem if something is faded into the foreground where those properties start as hidden discrete and opacity 0

Instead, I think it should count both of those as visible for the purposes of these checks

What do you think?

return;
}

if (isStackingContext(el)) {
roots.push(el);
} else {
for (const child of el.children) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will this run on script and style tags? can we skip any of those?

walk(child);
}
}
}

walk(root);
return roots;
}

function getZIndex(element: Element) {
return Number(getComputedStyle(element).zIndex) || 0;
}

function isFlexOrGridItem(element: Element) {
let parent = element.parentElement;
return parent && /flex|grid/.test(getComputedStyle(parent).display);
}
14 changes: 11 additions & 3 deletions packages/@react-aria/overlays/src/useOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import {DOMAttributes, RefObject} from '@react-types/shared';
import {isElementInChildOfActiveScope} from '@react-aria/focus';
import {useEffect} from 'react';
import {useFocusWithin, useInteractOutside} from '@react-aria/interactions';
import {useFocusWithin} from '@react-aria/interactions';

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

// Handle clicking outside the overlay to close it
useInteractOutside({ref, onInteractOutside: isDismissable && isOpen ? onInteractOutside : undefined, onInteractOutsideStart});
// useInteractOutside({ref, onInteractOutside: isDismissable && isOpen ? onInteractOutside : undefined, onInteractOutsideStart});
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using clicking the underlay as interacting outside since this avoids closing when clicking on an element that's on top of it. Will need some backward compatibility logic here since I think underlayProps isn't always used.


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

Expand All @@ -156,7 +159,12 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject<Element | nul
...focusWithinProps
},
underlayProps: {
onPointerDown: onPointerDownUnderlay
onPointerDown: onPointerDownUnderlay,
onClick(e) {
if (isDismissable && isOpen && e.target === e.currentTarget) {
onInteractOutside(e.nativeEvent as PointerEvent);
}
}
}
};
}
2 changes: 1 addition & 1 deletion packages/@react-aria/toast/src/useToastRegion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
// - allows focus even outside a containing focus scope
// - doesn’t dismiss overlays when clicking on it, even though it is outside
// @ts-ignore
'data-react-aria-top-layer': true,
// 'data-react-aria-top-layer': true,
// listen to focus events separate from focuswithin because that will only fire once
// and we need to follow all focus changes
onFocus: (e) => {
Expand Down