From 933fde4b64bed2a4033d92d91486fc8458243dc4 Mon Sep 17 00:00:00 2001 From: Zach Arend Date: Fri, 23 Jun 2023 19:27:21 +0000 Subject: [PATCH] fix(material/sidenav): only trap focus when backdrop is enabled Correct when Sidenav enabled focus trapping. When backdrop is show, trap focus. Do no trap focus when backdrop is not shown. Existing behavior is that Sidenav traps focus whenever it is not in side mode. This causes the end user to not be able to interact with the sidenav content when the mode is push/over, backdrop is disabled and using ConfigurableFocusTrapFactory (#26572). With this commit applied, Sidenav always traps focus when backdrop is shown. Sidenav never traps focus when backdrop is not shown, regardless of what mode the sidenav is in, focus trapping will respect if the backdrop is shown or not shown. Fix this issue by correcting boolean logic for detecting if backdrop is enabled and using that logic to determine when to trap focus. Add an example that injects ConfigurableFocusTrapFactory. Fix #26572 --- .../material/sidenav/index.ts | 2 +- ...idenav-configurable-focus-trap-example.css | 14 ++++++++ ...denav-configurable-focus-trap-example.html | 36 +++++++++++++++++++ ...sidenav-configurable-focus-trap-example.ts | 31 ++++++++++++++++ src/material/sidenav/drawer.spec.ts | 33 ++++++++++++++++- src/material/sidenav/drawer.ts | 19 +++++----- 6 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 src/components-examples/material/sidenav/sidenav-configurable-focus-trap/sidenav-configurable-focus-trap-example.css create mode 100644 src/components-examples/material/sidenav/sidenav-configurable-focus-trap/sidenav-configurable-focus-trap-example.html create mode 100644 src/components-examples/material/sidenav/sidenav-configurable-focus-trap/sidenav-configurable-focus-trap-example.ts diff --git a/src/components-examples/material/sidenav/index.ts b/src/components-examples/material/sidenav/index.ts index 86e5e276dc64..a726006091c5 100644 --- a/src/components-examples/material/sidenav/index.ts +++ b/src/components-examples/material/sidenav/index.ts @@ -3,7 +3,7 @@ export {SidenavBackdropExample} from './sidenav-backdrop/sidenav-backdrop-exampl export {SidenavDisableCloseExample} from './sidenav-disable-close/sidenav-disable-close-example'; export {SidenavDrawerOverviewExample} from './sidenav-drawer-overview/sidenav-drawer-overview-example'; export {SidenavFixedExample} from './sidenav-fixed/sidenav-fixed-example'; -export {SidenavModeExample} from './sidenav-mode/sidenav-mode-example'; +export {SidenavConfigurableFocusTrapExample} from './sidenav-configurable-focus-trap/sidenav-configurable-focus-trap-example'; export {SidenavOpenCloseExample} from './sidenav-open-close/sidenav-open-close-example'; export {SidenavOverviewExample} from './sidenav-overview/sidenav-overview-example'; export {SidenavPositionExample} from './sidenav-position/sidenav-position-example'; diff --git a/src/components-examples/material/sidenav/sidenav-configurable-focus-trap/sidenav-configurable-focus-trap-example.css b/src/components-examples/material/sidenav/sidenav-configurable-focus-trap/sidenav-configurable-focus-trap-example.css new file mode 100644 index 000000000000..cd425d420f9c --- /dev/null +++ b/src/components-examples/material/sidenav/sidenav-configurable-focus-trap/sidenav-configurable-focus-trap-example.css @@ -0,0 +1,14 @@ +.example-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; +} + +.example-radio-group { + display: block; + border: 1px solid #555; + margin: 20px; + padding: 10px; +} diff --git a/src/components-examples/material/sidenav/sidenav-configurable-focus-trap/sidenav-configurable-focus-trap-example.html b/src/components-examples/material/sidenav/sidenav-configurable-focus-trap/sidenav-configurable-focus-trap-example.html new file mode 100644 index 000000000000..e9542d34307c --- /dev/null +++ b/src/components-examples/material/sidenav/sidenav-configurable-focus-trap/sidenav-configurable-focus-trap-example.html @@ -0,0 +1,36 @@ + + +

+

+ +

+
+ + +

+

+ + + Over + Side + Push + + + + Default + true + false + + + + Start + End + +

+

+ +

+
+
+ +
Please open on Stackblitz to see result
diff --git a/src/components-examples/material/sidenav/sidenav-configurable-focus-trap/sidenav-configurable-focus-trap-example.ts b/src/components-examples/material/sidenav/sidenav-configurable-focus-trap/sidenav-configurable-focus-trap-example.ts new file mode 100644 index 000000000000..f397fcd2d04d --- /dev/null +++ b/src/components-examples/material/sidenav/sidenav-configurable-focus-trap/sidenav-configurable-focus-trap-example.ts @@ -0,0 +1,31 @@ +import {Component} from '@angular/core'; +import {NgIf} from '@angular/common'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {MatDrawerMode, MatSidenavModule} from '@angular/material/sidenav'; +import {MatRadioModule} from '@angular/material/radio'; +import {MatButtonModule} from '@angular/material/button'; +import {ConfigurableFocusTrapFactory, FocusTrapFactory} from '@angular/cdk/a11y'; + +/** @title Sidenav using injected ConfigurableFocusTrap */ +@Component({ + selector: 'sidenav-configurable-focus-trap-example', + templateUrl: 'sidenav-configurable-focus-trap-example.html', + styleUrls: ['sidenav-configurable-focus-trap-example.css'], + standalone: true, + imports: [ + NgIf, + MatSidenavModule, + MatButtonModule, + MatRadioModule, + FormsModule, + ReactiveFormsModule, + ], + providers: [{provide: FocusTrapFactory, useClass: ConfigurableFocusTrapFactory}], +}) +export class SidenavConfigurableFocusTrapExample { + mode = new FormControl('over' as MatDrawerMode); + hasBackdrop = new FormControl(null as null | boolean); + position = new FormControl('start' as 'start' | 'end'); + + shouldRun = /(^|.)(stackblitz|webcontainer).(io|com)$/.test(window.location.host); +} diff --git a/src/material/sidenav/drawer.spec.ts b/src/material/sidenav/drawer.spec.ts index 8ec61d14b97d..3758567e0f39 100644 --- a/src/material/sidenav/drawer.spec.ts +++ b/src/material/sidenav/drawer.spec.ts @@ -567,6 +567,19 @@ describe('MatDrawer', () => { expect(document.activeElement).toBe(firstFocusableElement); })); + it('should trap focus when opened in "side" mode if backdrop is explicitly enabled', fakeAsync(() => { + testComponent.mode = 'push'; + testComponent.hasBackdrop = true; + fixture.detectChanges(); + lastFocusableElement.focus(); + + drawer.open(); + fixture.detectChanges(); + tick(); + + expect(document.activeElement).toBe(firstFocusableElement); + })); + it('should not auto-focus by default when opened in "side" mode', fakeAsync(() => { testComponent.mode = 'side'; fixture.detectChanges(); @@ -596,6 +609,23 @@ describe('MatDrawer', () => { }), ); + it( + 'should auto-focus to first tabbable element when opened in "push" mode' + + 'when backdrop is enabled explicitly', + fakeAsync(() => { + testComponent.mode = 'push'; + testComponent.hasBackdrop = true; + fixture.detectChanges(); + lastFocusableElement.focus(); + + drawer.open(); + fixture.detectChanges(); + tick(); + + expect(document.activeElement).toBe(firstFocusableElement); + }), + ); + it('should focus the drawer if there are no focusable elements', fakeAsync(() => { fixture.destroy(); @@ -1229,7 +1259,7 @@ class DrawerDynamicPosition { // Note: we use inputs here, because they're guaranteed // to be focusable across all platforms. template: ` - + @@ -1238,6 +1268,7 @@ class DrawerDynamicPosition { }) class DrawerWithFocusableElements { mode: string = 'over'; + hasBackdrop: boolean | null = null; } @Component({ diff --git a/src/material/sidenav/drawer.ts b/src/material/sidenav/drawer.ts index 342124513a0d..15642f295636 100644 --- a/src/material/sidenav/drawer.ts +++ b/src/material/sidenav/drawer.ts @@ -595,8 +595,9 @@ export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy /** Updates the enabled state of the focus trap. */ private _updateFocusTrapState() { if (this._focusTrap) { - // The focus trap is only enabled when the drawer is open in any mode other than side. - this._focusTrap.enabled = this.opened && this.mode !== 'side'; + // Trap focus only if the backdrop is enabled. Otherwise, allow end user to interact with the + // sidenav content. + this._focusTrap.enabled = !!this._container?.hasBackdrop; } } @@ -698,7 +699,9 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy @Input() get hasBackdrop(): boolean { if (this._backdropOverride == null) { - return !this._start || this._start.mode !== 'side' || !this._end || this._end.mode !== 'side'; + const startHasBackdrop = !!this._start && this._start.mode !== 'side'; + const endHasBackdrop = !!this._end && this._end.mode !== 'side'; + return startHasBackdrop || endHasBackdrop; } return this._backdropOverride; @@ -1004,21 +1007,17 @@ export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy _closeModalDrawersViaBackdrop() { // Close all open drawers where closing is not disabled and the mode is not `side`. [this._start, this._end] - .filter(drawer => drawer && !drawer.disableClose && this._canHaveBackdrop(drawer)) + .filter(drawer => drawer && !drawer.disableClose && this.hasBackdrop) .forEach(drawer => drawer!._closeViaBackdropClick()); } _isShowingBackdrop(): boolean { return ( - (this._isDrawerOpen(this._start) && this._canHaveBackdrop(this._start)) || - (this._isDrawerOpen(this._end) && this._canHaveBackdrop(this._end)) + (this._isDrawerOpen(this._start) && this.hasBackdrop) || + (this._isDrawerOpen(this._end) && this.hasBackdrop) ); } - private _canHaveBackdrop(drawer: MatDrawer): boolean { - return drawer.mode !== 'side' || !!this._backdropOverride; - } - private _isDrawerOpen(drawer: MatDrawer | null): drawer is MatDrawer { return drawer != null && drawer.opened; }