Skip to content

Commit 1fcfe74

Browse files
committed
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.
1 parent 15288a9 commit 1fcfe74

File tree

4 files changed

+53
-9
lines changed

4 files changed

+53
-9
lines changed

src/cdk/dialog/dialog.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {of as observableOf, Observable, Subject, defer} from 'rxjs';
2323
import {DialogRef} from './dialog-ref';
2424
import {DialogConfig} from './dialog-config';
2525
import {Directionality} from '@angular/cdk/bidi';
26+
import {_supportsInert} from '@angular/cdk/platform';
2627
import {
2728
ComponentType,
2829
Overlay,
@@ -44,7 +45,7 @@ export class Dialog implements OnDestroy {
4445
private _openDialogsAtThisLevel: DialogRef<any, any>[] = [];
4546
private readonly _afterAllClosedAtThisLevel = new Subject<void>();
4647
private readonly _afterOpenedAtThisLevel = new Subject<DialogRef>();
47-
private _ariaHiddenElements = new Map<Element, string | null>();
48+
private _inertElements = new Map<Element, [ariaHidden: string | null, inert: string | null]>();
4849
private _scrollStrategy: () => ScrollStrategy;
4950

5051
/** Keeps track of the currently-open dialogs. */
@@ -162,7 +163,7 @@ export class Dialog implements OnDestroy {
162163
ngOnDestroy() {
163164
// Make one pass over all the dialogs that need to be untracked, but should not be closed. We
164165
// want to stop tracking the open dialog even if it hasn't been closed, because the tracking
165-
// determines when `aria-hidden` is removed from elements outside the dialog.
166+
// determines when `inert` is removed from elements outside the dialog.
166167
reverseForEach(this._openDialogsAtThisLevel, dialog => {
167168
// Check for `false` specifically since we want `undefined` to be interpreted as `true`.
168169
if (dialog.config.closeOnDestroy === false) {
@@ -350,18 +351,29 @@ export class Dialog implements OnDestroy {
350351
if (index > -1) {
351352
(this.openDialogs as DialogRef<R, C>[]).splice(index, 1);
352353

353-
// If all the dialogs were closed, remove/restore the `aria-hidden`
354+
// If all the dialogs were closed, remove/restore the inert attribute
354355
// to a the siblings and emit to the `afterAllClosed` stream.
355356
if (!this.openDialogs.length) {
356-
this._ariaHiddenElements.forEach((previousValue, element) => {
357-
if (previousValue) {
358-
element.setAttribute('aria-hidden', previousValue);
357+
this._inertElements.forEach((previousValue, element) => {
358+
const [ariaHidden, inert] = previousValue;
359+
360+
// Note: this code is somewhat repetitive, but we want to use static strings inside
361+
// the `setAttribute` calls so that we don't trip up some internal XSS checks.
362+
if (ariaHidden) {
363+
element.setAttribute('aria-hidden', ariaHidden);
359364
} else {
360365
element.removeAttribute('aria-hidden');
361366
}
362-
});
363367

364-
this._ariaHiddenElements.clear();
368+
if (_supportsInert()) {
369+
if (inert) {
370+
element.setAttribute('inert', inert);
371+
} else {
372+
element.removeAttribute('inert');
373+
}
374+
}
375+
});
376+
this._inertElements.clear();
365377

366378
if (emitEvent) {
367379
this._getAfterAllClosed().next();
@@ -387,8 +399,17 @@ export class Dialog implements OnDestroy {
387399
sibling.nodeName !== 'STYLE' &&
388400
!sibling.hasAttribute('aria-live')
389401
) {
390-
this._ariaHiddenElements.set(sibling, sibling.getAttribute('aria-hidden'));
402+
const ariaHidden = sibling.getAttribute('aria-hidden');
403+
const inert = _supportsInert() ? sibling.getAttribute('inert') : null;
404+
405+
// TODO(crisbeto): ideally we'd set only either `aria-hidden` or `inert` here, but
406+
// at the moment of writing, some internal checks don't consider `inert` elements as
407+
// removed from the a11y tree which reveals a bunch of pre-existing breakages.
408+
this._inertElements.set(sibling, [ariaHidden, inert]);
391409
sibling.setAttribute('aria-hidden', 'true');
410+
if (_supportsInert()) {
411+
sibling.setAttribute('inert', 'true');
412+
}
392413
}
393414
}
394415
}

src/cdk/platform/features/inert.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
let supportsInert: boolean | undefined;
10+
11+
/** Returns whether the browser supports the `inert` attribute. */
12+
export function _supportsInert(): boolean {
13+
if (typeof supportsInert === 'boolean') {
14+
return supportsInert;
15+
}
16+
17+
supportsInert = typeof document !== 'undefined' && !!document.body && 'inert' in document.body;
18+
return supportsInert;
19+
}

src/cdk/platform/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export * from './features/passive-listeners';
1313
export * from './features/scrolling';
1414
export * from './features/shadow-dom';
1515
export * from './features/test-environment';
16+
export * from './features/inert';

tools/public_api_guard/cdk/platform.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ export const enum RtlScrollAxisType {
6262
NORMAL = 0
6363
}
6464

65+
// @public
66+
export function _supportsInert(): boolean;
67+
6568
// @public
6669
export function supportsPassiveEventListeners(): boolean;
6770

0 commit comments

Comments
 (0)