Skip to content

Commit a9d4d0a

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 ff3d342 commit a9d4d0a

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
@@ -21,6 +21,7 @@ import {of as observableOf, Observable, Subject, defer} from 'rxjs';
2121
import {DialogRef} from './dialog-ref';
2222
import {DialogConfig} from './dialog-config';
2323
import {Directionality} from '@angular/cdk/bidi';
24+
import {_supportsInert} from '@angular/cdk/platform';
2425
import {
2526
ComponentType,
2627
Overlay,
@@ -47,7 +48,7 @@ export class Dialog implements OnDestroy {
4748
private _openDialogsAtThisLevel: DialogRef<any, any>[] = [];
4849
private readonly _afterAllClosedAtThisLevel = new Subject<void>();
4950
private readonly _afterOpenedAtThisLevel = new Subject<DialogRef>();
50-
private _ariaHiddenElements = new Map<Element, string | null>();
51+
private _inertElements = new Map<Element, [ariaHidden: string | null, inert: string | null]>();
5152
private _scrollStrategy = inject(DIALOG_SCROLL_STRATEGY);
5253

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

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

361-
this._ariaHiddenElements.clear();
365+
if (_supportsInert()) {
366+
if (inert) {
367+
element.setAttribute('inert', inert);
368+
} else {
369+
element.removeAttribute('inert');
370+
}
371+
}
372+
});
373+
this._inertElements.clear();
362374

363375
if (emitEvent) {
364376
this._getAfterAllClosed().next();
@@ -384,8 +396,17 @@ export class Dialog implements OnDestroy {
384396
sibling.nodeName !== 'STYLE' &&
385397
!sibling.hasAttribute('aria-live')
386398
) {
387-
this._ariaHiddenElements.set(sibling, sibling.getAttribute('aria-hidden'));
399+
const ariaHidden = sibling.getAttribute('aria-hidden');
400+
const inert = _supportsInert() ? sibling.getAttribute('inert') : null;
401+
402+
// TODO(crisbeto): ideally we'd set only either `aria-hidden` or `inert` here, but
403+
// at the moment of writing, some internal checks don't consider `inert` elements as
404+
// removed from the a11y tree which reveals a bunch of pre-existing breakages.
405+
this._inertElements.set(sibling, [ariaHidden, inert]);
388406
sibling.setAttribute('aria-hidden', 'true');
407+
if (_supportsInert()) {
408+
sibling.setAttribute('inert', 'true');
409+
}
389410
}
390411
}
391412
}

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