Skip to content

Conversation

devongovett
Copy link
Member

@devongovett devongovett commented Aug 29, 2025

RSP Component Milestones (view)

Related issues:

Currently, our dialogs mark all elements outside them as inert. This makes it difficult to use them with third party components that use portals, such as external dialogs, menus, browser extensions, embedded widgets like cookie consents, toasts, support chats, etc. Even before we switched to inert from aria-hidden, FocusScope would steal focus back to the dialog and screen readers could not access these elements. We have data-react-aria-top-layer as an opt out, but you must manually apply it which can be difficult.

This PR is an attempt to make this work automatically. Instead of marking all elements outside dialogs as inert (with a few exceptions), it compares their z-indexes with the modal. Any elements that are visually above the modal get preserved. This works by traversing down from the document body to find the root stacking contexts, and then comparing these with the root stacking context of the modal itself. When a new element such as a toast comes in after the dialog opens the same process occurs.

Since we use inert, we don't need FocusScope to contain focus for the most part. This will also mean the browser will handle tabbing, which should fix some issues with shadow DOM and native elements. Some exceptions to handle still:

  1. Inert does not prevent tabbing out of a dialog into the browser chrome like our current dialog does. Accessibility experts are divided on that issue. The native element does not prevent this, but the ARIA practices guide says it should. See Modal dialog: consider allowing Tab/Shift+Tab to escape to browser chrome w3c/aria-practices#1772
  2. Inert does not prevent tabbing out of a dialog within an iframe to the parent page. This seems like a major issue especially for unified shell.

Perhaps we could do a more limited focus trap just within the window/iframe instead of only within the dialog. That would allow tabbing to other elements on top of the dialog, but not out of the window.

To do

  • Fix tests (JSDOM doesn't support inert so this will be interesting)
  • Do the same for popovers
  • Handle above cases

@devongovett devongovett force-pushed the modal-inert-stacking branch from 96a2fbe to 564cc88 Compare August 29, 2025 19:11
@@ -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.

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

@@ -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


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


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 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?

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants