Skip to content

Commit 70474dc

Browse files
committed
fix(sidenav): restore focus if drawer is closed through backdrop click
Currently we do not restore focus properly when a drawer is closed through backdrop click. This is because we only restore focus when an element inside the drawer is focused before closing. This is not the case when the backdrop is clicked, as the focus is usually redirected to the `document.body` element. Fixes #17749.
1 parent 72c38f7 commit 70474dc

File tree

3 files changed

+76
-20
lines changed

3 files changed

+76
-20
lines changed

src/material/sidenav/drawer.spec.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,36 @@ describe('MatDrawer', () => {
284284
expect(testComponent.closeStartCount).toBe(0);
285285
}));
286286

287-
it('should restore focus on close if focus is inside drawer', fakeAsync(() => {
287+
it('should restore focus on close if backdrop has been clicked', fakeAsync(() => {
288+
const fixture = TestBed.createComponent(BasicTestApp);
289+
fixture.detectChanges();
290+
291+
const drawer = fixture.debugElement.query(By.directive(MatDrawer))!.componentInstance;
292+
const openButton = fixture.componentInstance.openButton.nativeElement;
293+
294+
openButton.focus();
295+
drawer.open();
296+
fixture.detectChanges();
297+
flush();
298+
299+
const backdrop = fixture.nativeElement.querySelector('.mat-drawer-backdrop');
300+
expect(backdrop).toBeTruthy();
301+
302+
// Ensure the element that has been focused on drawer open is blurred. This simulates
303+
// the behavior where clicks on the backdrop blur the active element.
304+
if (document.activeElement !== null && document.activeElement instanceof HTMLElement) {
305+
document.activeElement.blur();
306+
}
307+
308+
backdrop.click();
309+
fixture.detectChanges();
310+
flush();
311+
312+
expect(document.activeElement)
313+
.toBe(openButton, 'Expected focus to be restored to the open button on close.');
314+
}));
315+
316+
it('should restore focus on close if focus is on drawer', fakeAsync(() => {
288317
let fixture = TestBed.createComponent(BasicTestApp);
289318

290319
fixture.detectChanges();

src/material/sidenav/drawer.ts

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
283283
}
284284

285285
this._takeFocus();
286-
} else {
286+
} else if (this._isFocusWithinDrawer()) {
287287
this._restoreFocus();
288288
}
289289
});
@@ -339,29 +339,31 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
339339
}
340340

341341
/**
342-
* If focus is currently inside the drawer, restores it to where it was before the drawer
343-
* opened.
342+
* Restores focus to the element that was originally focused when the drawer opened.
343+
* If no element was focused at that time, the focus will be restored to the drawer.
344344
*/
345345
private _restoreFocus() {
346346
if (!this.autoFocus) {
347347
return;
348348
}
349349

350-
const activeEl = this._doc && this._doc.activeElement;
351-
352-
if (activeEl && this._elementRef.nativeElement.contains(activeEl)) {
353-
// Note that we don't check via `instanceof HTMLElement` so that we can cover SVGs as well.
354-
if (this._elementFocusedBeforeDrawerWasOpened) {
355-
this._focusMonitor.focusVia(this._elementFocusedBeforeDrawerWasOpened, this._openedVia);
356-
} else {
357-
this._elementRef.nativeElement.blur();
358-
}
350+
// Note that we don't check via `instanceof HTMLElement` so that we can cover SVGs as well.
351+
if (this._elementFocusedBeforeDrawerWasOpened) {
352+
this._focusMonitor.focusVia(this._elementFocusedBeforeDrawerWasOpened, this._openedVia);
353+
} else {
354+
this._elementRef.nativeElement.blur();
359355
}
360356

361357
this._elementFocusedBeforeDrawerWasOpened = null;
362358
this._openedVia = null;
363359
}
364360

361+
/** Whether focus is currently within the drawer. */
362+
private _isFocusWithinDrawer(): boolean {
363+
const activeEl = this._doc?.activeElement;
364+
return !!activeEl && this._elementRef.nativeElement.contains(activeEl);
365+
}
366+
365367
ngAfterContentInit() {
366368
this._focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement);
367369
this._updateFocusTrapState();
@@ -403,23 +405,47 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
403405
return this.toggle(false);
404406
}
405407

408+
/** Closes the drawer with context that the backdrop was clicked. */
409+
_closeViaBackdropClick(): Promise<MatDrawerToggleResult> {
410+
// If the drawer is closed upon a backdrop click, we always want to restore focus. We
411+
// don't need to check whether focus is currently in the drawer, as clicking on the
412+
// backdrop causes blurring of the active element.
413+
return this._setOpen(/* isOpen */ false, /* restoreFocus */ true);
414+
}
415+
406416
/**
407417
* Toggle this drawer.
408418
* @param isOpen Whether the drawer should be open.
409419
* @param openedVia Whether the drawer was opened by a key press, mouse click or programmatically.
410420
* Used for focus management after the sidenav is closed.
411421
*/
412-
toggle(isOpen: boolean = !this.opened, openedVia: FocusOrigin = 'program'):
413-
Promise<MatDrawerToggleResult> {
422+
toggle(isOpen: boolean = !this.opened, openedVia?: FocusOrigin)
423+
: Promise<MatDrawerToggleResult> {
424+
// If the focus is currently inside the drawer content and we are closing the drawer,
425+
// restore the focus to the initially focused element (when the drawer opened).
426+
return this._setOpen(
427+
isOpen, /* restoreFocus */ !isOpen && this._isFocusWithinDrawer(), openedVia);
428+
}
414429

430+
/**
431+
* Toggles the opened state of the drawer.
432+
* @param isOpen Whether the drawer should open or close.
433+
* @param restoreFocus Whether focus should be restored on close.
434+
* @param openedVia Focus origin that can be optionally set when opening a drawer. The
435+
* origin will be used later when focus is restored on drawer close.
436+
*/
437+
private _setOpen(isOpen: boolean, restoreFocus: boolean, openedVia: FocusOrigin = 'program')
438+
: Promise<MatDrawerToggleResult> {
415439
this._opened = isOpen;
416440

417441
if (isOpen) {
418442
this._animationState = this._enableAnimations ? 'open' : 'open-instant';
419443
this._openedVia = openedVia;
420444
} else {
421445
this._animationState = 'void';
422-
this._restoreFocus();
446+
if (restoreFocus) {
447+
this._restoreFocus();
448+
}
423449
}
424450

425451
this._updateFocusTrapState();
@@ -818,14 +844,14 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy
818844

819845
_onBackdropClicked() {
820846
this.backdropClick.emit();
821-
this._closeModalDrawer();
847+
this._closeModalDrawersViaBackdrop();
822848
}
823849

824-
_closeModalDrawer() {
850+
_closeModalDrawersViaBackdrop() {
825851
// Close all open drawers where closing is not disabled and the mode is not `side`.
826852
[this._start, this._end]
827853
.filter(drawer => drawer && !drawer.disableClose && this._canHaveBackdrop(drawer))
828-
.forEach(drawer => drawer!.close());
854+
.forEach(drawer => drawer!._closeViaBackdropClick());
829855
}
830856

831857
_isShowingBackdrop(): boolean {

tools/public_api_guard/material/sidenav.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export declare class MatDrawer implements AfterContentInit, AfterContentChecked,
2929
_container?: MatDrawerContainer | undefined);
3030
_animationDoneListener(event: AnimationEvent): void;
3131
_animationStartListener(event: AnimationEvent): void;
32+
_closeViaBackdropClick(): Promise<MatDrawerToggleResult>;
3233
close(): Promise<MatDrawerToggleResult>;
3334
ngAfterContentChecked(): void;
3435
ngAfterContentInit(): void;
@@ -69,7 +70,7 @@ export declare class MatDrawerContainer implements AfterContentInit, DoCheck, On
6970
get scrollable(): CdkScrollable;
7071
get start(): MatDrawer | null;
7172
constructor(_dir: Directionality, _element: ElementRef<HTMLElement>, _ngZone: NgZone, _changeDetectorRef: ChangeDetectorRef, viewportRuler: ViewportRuler, defaultAutosize?: boolean, _animationMode?: string | undefined);
72-
_closeModalDrawer(): void;
73+
_closeModalDrawersViaBackdrop(): void;
7374
_isShowingBackdrop(): boolean;
7475
_onBackdropClicked(): void;
7576
close(): void;

0 commit comments

Comments
 (0)