From 564cc8803bc41da45515caab1f0c2895cd427321 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 29 Aug 2025 14:34:56 -0400 Subject: [PATCH] wip: Use stacking contexts to determine non-inert elements outside modals --- packages/@react-aria/overlays/src/Overlay.tsx | 4 +- .../overlays/src/ariaHideOutside.ts | 10 +- .../overlays/src/useModalOverlay.ts | 120 +++++++++++++++++- .../@react-aria/overlays/src/useOverlay.ts | 14 +- .../@react-aria/toast/src/useToastRegion.ts | 2 +- 5 files changed, 142 insertions(+), 8 deletions(-) diff --git a/packages/@react-aria/overlays/src/Overlay.tsx b/packages/@react-aria/overlays/src/Overlay.tsx index 0c1a1365880..8f87952f99c 100644 --- a/packages/@react-aria/overlays/src/Overlay.tsx +++ b/packages/@react-aria/overlays/src/Overlay.tsx @@ -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]); @@ -68,7 +68,7 @@ export function Overlay(props: OverlayProps): React.ReactPortal | null { let contents = props.children; if (!props.disableFocusManagement) { contents = ( - + {contents} ); diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 753c2a926a3..081f5f4cb0f 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -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 @@ -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(targets); let hiddenNodes = new Set(); @@ -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 diff --git a/packages/@react-aria/overlays/src/useModalOverlay.ts b/packages/@react-aria/overlays/src/useModalOverlay.ts index c0fbb70a0b1..1eb036a5975 100644 --- a/packages/@react-aria/overlays/src/useModalOverlay.ts +++ b/packages/@react-aria/overlays/src/useModalOverlay.ts @@ -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'; import {mergeProps} from '@react-aria/utils'; import {OverlayTriggerState} from '@react-stately/overlays'; import {useEffect} from 'react'; @@ -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]); @@ -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); + 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. + 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 + 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)) { + return; + } + + if (isStackingContext(el)) { + roots.push(el); + } else { + for (const child of el.children) { + 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); +} diff --git a/packages/@react-aria/overlays/src/useOverlay.ts b/packages/@react-aria/overlays/src/useOverlay.ts index 8fdcfa39410..17abe9175ee 100644 --- a/packages/@react-aria/overlays/src/useOverlay.ts +++ b/packages/@react-aria/overlays/src/useOverlay.ts @@ -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. */ @@ -119,7 +119,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject(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) => {