Skip to content

Commit 930fe9b

Browse files
committed
fix(material/sidenav): mark content as inert while open
Even though we trap focus within the sidenav, it's still possible for users' focus to escape and go into the content that's hidden behind it. These changes mark the content as `inert` while a sidenav is open. Fixes angular#32805.
1 parent 2edcb4c commit 930fe9b

3 files changed

Lines changed: 82 additions & 21 deletions

File tree

goldens/material/sidenav/index.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ export class MatDrawerContent extends CdkScrollable implements AfterContentInit
125125
ngAfterContentInit(): void;
126126
protected _shouldBeHidden(): boolean;
127127
// (undocumented)
128+
_updateInert(): void;
129+
// (undocumented)
128130
static ɵcmp: i0.ɵɵComponentDeclaration<MatDrawerContent, "mat-drawer-content", never, {}, {}, never, ["*"], true, never>;
129131
// (undocumented)
130132
static ɵfac: i0.ɵɵFactoryDeclaration<MatDrawerContent, never>;

src/material/sidenav/drawer.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,40 @@ describe('MatDrawer', () => {
717717
expect(anchors.length).toBeGreaterThan(0);
718718
expect(anchors.every(anchor => !anchor.hasAttribute('tabindex'))).toBe(true);
719719
});
720+
721+
it('should mark the content as `inert` in `over` mode', async () => {
722+
testComponent.mode = 'over';
723+
fixture.changeDetectorRef.markForCheck();
724+
fixture.detectChanges();
725+
lastFocusableElement.focus();
726+
727+
const content = fixture.nativeElement.querySelector('.mat-drawer-content');
728+
expect(content.hasAttribute('inert')).toBe(false);
729+
730+
drawer.open();
731+
fixture.detectChanges();
732+
await wait(100);
733+
fixture.detectChanges();
734+
735+
expect(content.getAttribute('inert')).toBe('true');
736+
});
737+
738+
it('should not mark the content as `inert` in `side` mode', async () => {
739+
testComponent.mode = 'side';
740+
fixture.changeDetectorRef.markForCheck();
741+
fixture.detectChanges();
742+
lastFocusableElement.focus();
743+
744+
const content = fixture.nativeElement.querySelector('.mat-drawer-content');
745+
expect(content.hasAttribute('inert')).toBe(false);
746+
747+
drawer.open();
748+
fixture.detectChanges();
749+
await wait(100);
750+
fixture.detectChanges();
751+
752+
expect(content.hasAttribute('inert')).toBe(false);
753+
});
720754
});
721755

722756
it('should mark the drawer content as scrollable', () => {

src/material/sidenav/drawer.ts

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,29 @@ export const MAT_DRAWER_CONTAINER = new InjectionToken<MatDrawerContainer>('MAT_
9999
export class MatDrawerContent extends CdkScrollable implements AfterContentInit {
100100
private _platform = inject(Platform);
101101
private _changeDetectorRef = inject(ChangeDetectorRef);
102+
private _element = inject<ElementRef<HTMLElement>>(ElementRef);
103+
private _isInert = false;
102104
_container = inject(MatDrawerContainer);
103105

104106
ngAfterContentInit() {
105-
this._container._contentMarginChanges.subscribe(() => {
106-
this._changeDetectorRef.markForCheck();
107-
});
107+
this._container._contentMarginChanges.subscribe(() => this._changeDetectorRef.markForCheck());
108+
}
109+
110+
_updateInert() {
111+
const newValue = this._container._isShowingBackdrop();
112+
113+
if (newValue !== this._isInert) {
114+
const element = this._element.nativeElement;
115+
this._isInert = newValue;
116+
117+
// This can be called right before we attempt to move focus. Set the value
118+
// directly, instead of waiting on change detection, because the timing is tight.
119+
if (newValue) {
120+
element.setAttribute('inert', 'true');
121+
} else {
122+
element.removeAttribute('inert');
123+
}
124+
}
108125
}
109126

110127
/** Determines whether the content element should be hidden from the user. */
@@ -360,11 +377,18 @@ export class MatDrawer implements AfterViewInit, OnDestroy {
360377
}
361378

362379
/**
363-
* Focuses the provided element. If the element is not focusable, it will add a tabIndex
364-
* attribute to forcefully focus it. The attribute is removed after focus is moved.
365-
* @param element The element to focus.
380+
* Focuses the first element that matches the given selector within the focus trap.
381+
* @param selector The CSS selector for the element to set focus to.
366382
*/
367-
private _forceFocus(element: HTMLElement, options?: FocusOptions) {
383+
private _focusByCssSelector(selector: string, options?: FocusOptions) {
384+
const element = this._elementRef.nativeElement.querySelector(selector) as HTMLElement | null;
385+
386+
if (!element) {
387+
return;
388+
}
389+
390+
// If the element isn't focusable, force focus to it by
391+
// setting a tabindex, focusing it and then clear it.
368392
if (!this._interactivityChecker.isFocusable(element)) {
369393
element.tabIndex = -1;
370394
// The tabindex attribute should be removed to avoid navigating to that element again
@@ -379,20 +403,8 @@ export class MatDrawer implements AfterViewInit, OnDestroy {
379403
const cleanupMousedown = this._renderer.listen(element, 'mousedown', callback);
380404
});
381405
}
382-
element.focus(options);
383-
}
384406

385-
/**
386-
* Focuses the first element that matches the given selector within the focus trap.
387-
* @param selector The CSS selector for the element to set focus to.
388-
*/
389-
private _focusByCssSelector(selector: string, options?: FocusOptions) {
390-
let elementToFocus = this._elementRef.nativeElement.querySelector(
391-
selector,
392-
) as HTMLElement | null;
393-
if (elementToFocus) {
394-
this._forceFocus(elementToFocus, options);
395-
}
407+
element.focus(options);
396408
}
397409

398410
/**
@@ -421,24 +433,38 @@ export class MatDrawer implements AfterViewInit, OnDestroy {
421433
if (!hasMovedFocus && typeof element.focus === 'function') {
422434
element.focus();
423435
}
436+
437+
// When capturing focus, we need to delay making the
438+
// container inert until focus has actually been moved.
439+
this._notifyContentFocus();
424440
},
425441
{injector: this._injector},
426442
);
427443
break;
428444
case 'first-heading':
429445
this._focusByCssSelector('h1, h2, h3, h4, h5, h6, [role="heading"]');
446+
this._notifyContentFocus();
430447
break;
431448
default:
432449
this._focusByCssSelector(this.autoFocus!);
450+
this._notifyContentFocus();
433451
break;
434452
}
435453
}
436454

455+
private _notifyContentFocus() {
456+
(this._container?._content || this._container?._userContent)?._updateInert();
457+
}
458+
437459
/**
438460
* Restores focus to the element that was originally focused when the drawer opened.
439461
* If no element was focused at that time, the focus will be restored to the drawer.
440462
*/
441463
private _restoreFocus(focusOrigin: Exclude<FocusOrigin, null>) {
464+
// When restoring focus, we need remove `inert` as early as possible,
465+
// because the element needs to become focusable before we can focus it.
466+
this._notifyContentFocus();
467+
442468
if (this.autoFocus === 'dialog') {
443469
return;
444470
}
@@ -923,7 +949,6 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
923949
* is properly hidden.
924950
*/
925951
private _watchDrawerToggle(drawer: MatDrawer): void {
926-
//
927952
drawer._animationStarted.pipe(takeUntil(this._drawers.changes)).subscribe(() => {
928953
this.updateContentMargins();
929954
this._changeDetectorRef.markForCheck();

0 commit comments

Comments
 (0)