From 9e1eeaab4891b0b663d5ffa869913a24dd605503 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Sun, 15 Dec 2024 07:38:09 +0100 Subject: [PATCH] fix(cdk/dialog): use inert to block content outside of dialog Currently we're setting `aria-hidden` on all elements outside of a dialog in order to prevent assistive technology from interacting with it. These changes switch to using the `inert` attribute when supported which resolves some long-standing issues like tabbing directly into the dialog from the address bar. --- src/cdk/dialog/dialog.ts | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/cdk/dialog/dialog.ts b/src/cdk/dialog/dialog.ts index 0c9e9f06c8e2..20bffd9d8503 100644 --- a/src/cdk/dialog/dialog.ts +++ b/src/cdk/dialog/dialog.ts @@ -46,7 +46,7 @@ export class Dialog implements OnDestroy { private _openDialogsAtThisLevel: DialogRef[] = []; private readonly _afterAllClosedAtThisLevel = new Subject(); private readonly _afterOpenedAtThisLevel = new Subject(); - private _ariaHiddenElements = new Map(); + private _inertElements = new Map(); private _scrollStrategy = inject(DIALOG_SCROLL_STRATEGY); /** Keeps track of the currently-open dialogs. */ @@ -157,7 +157,7 @@ export class Dialog implements OnDestroy { ngOnDestroy() { // Make one pass over all the dialogs that need to be untracked, but should not be closed. We // want to stop tracking the open dialog even if it hasn't been closed, because the tracking - // determines when `aria-hidden` is removed from elements outside the dialog. + // determines when `inert` is removed from elements outside the dialog. reverseForEach(this._openDialogsAtThisLevel, dialog => { // Check for `false` specifically since we want `undefined` to be interpreted as `true`. if (dialog.config.closeOnDestroy === false) { @@ -340,18 +340,27 @@ export class Dialog implements OnDestroy { if (index > -1) { (this.openDialogs as DialogRef[]).splice(index, 1); - // If all the dialogs were closed, remove/restore the `aria-hidden` + // If all the dialogs were closed, remove/restore the inert attribute // to a the siblings and emit to the `afterAllClosed` stream. if (!this.openDialogs.length) { - this._ariaHiddenElements.forEach((previousValue, element) => { - if (previousValue) { - element.setAttribute('aria-hidden', previousValue); + this._inertElements.forEach((previousValue, element) => { + const [ariaHidden, inert] = previousValue; + + // Note: this code is somewhat repetitive, but we want to use static strings inside + // the `setAttribute` calls so that we don't trip up some internal XSS checks. + if (ariaHidden) { + element.setAttribute('aria-hidden', ariaHidden); } else { element.removeAttribute('aria-hidden'); } - }); - this._ariaHiddenElements.clear(); + if (inert) { + element.setAttribute('inert', inert); + } else { + element.removeAttribute('inert'); + } + }); + this._inertElements.clear(); if (emitEvent) { this._getAfterAllClosed().next(); @@ -377,8 +386,15 @@ export class Dialog implements OnDestroy { sibling.nodeName !== 'STYLE' && !sibling.hasAttribute('aria-live') ) { - this._ariaHiddenElements.set(sibling, sibling.getAttribute('aria-hidden')); + const ariaHidden = sibling.getAttribute('aria-hidden'); + const inert = sibling.getAttribute('inert'); + + // TODO(crisbeto): ideally we'd set only either `aria-hidden` or `inert` here, but + // at the moment of writing, some internal checks don't consider `inert` elements as + // removed from the a11y tree which reveals a bunch of pre-existing breakages. + this._inertElements.set(sibling, [ariaHidden, inert]); sibling.setAttribute('aria-hidden', 'true'); + sibling.setAttribute('inert', 'true'); } } }