Skip to content

Commit

Permalink
fix(material/sidenav): only trap focus when backdrop is enabled
Browse files Browse the repository at this point in the history
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 (angular#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 angular#26572
  • Loading branch information
zarend committed Jun 23, 2023
1 parent 0610fd9 commit 933fde4
Show file tree
Hide file tree
Showing 6 changed files with 123 additions and 12 deletions.
2 changes: 1 addition & 1 deletion src/components-examples/material/sidenav/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<mat-sidenav-container class="example-container" *ngIf="shouldRun" [hasBackdrop]="hasBackdrop.value">
<mat-sidenav #sidenav [mode]="mode.value!" [position]="position.value!">
<p><button mat-button (click)="sidenav.toggle()">Toggle</button></p>
<p>
<label>Test input for drawer<input/></label>
</p>
</mat-sidenav>

<mat-sidenav-content>
<p><button mat-button (click)="sidenav.toggle()">Toggle</button></p>
<p>
<mat-radio-group class="example-radio-group" [formControl]="mode">
<label>Mode:</label>
<mat-radio-button value="over">Over</mat-radio-button>
<mat-radio-button value="side">Side</mat-radio-button>
<mat-radio-button value="push">Push</mat-radio-button>
</mat-radio-group>
<mat-radio-group class="example-radio-group" [formControl]="hasBackdrop">
<label>Has Backdrop:</label>
<mat-radio-button [value]="null">Default</mat-radio-button>
<mat-radio-button [value]="true">true</mat-radio-button>
<mat-radio-button [value]="false">false</mat-radio-button>
</mat-radio-group>
<mat-radio-group class="example-radio-group" [formControl]="position">
<label>Position:</label>
<mat-radio-button value="start">Start</mat-radio-button>
<mat-radio-button value="end">End</mat-radio-button>
</mat-radio-group>
</p>
<p>
<label>Test input for drawer content<input/></label>
</p>
</mat-sidenav-content>
</mat-sidenav-container>

<div *ngIf="!shouldRun">Please open on Stackblitz to see result</div>
Original file line number Diff line number Diff line change
@@ -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);
}
33 changes: 32 additions & 1 deletion src/material/sidenav/drawer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -1229,7 +1259,7 @@ class DrawerDynamicPosition {
// Note: we use inputs here, because they're guaranteed
// to be focusable across all platforms.
template: `
<mat-drawer-container>
<mat-drawer-container [hasBackdrop]="hasBackdrop">
<mat-drawer position="start" [mode]="mode">
<input type="text" class="input1"/>
</mat-drawer>
Expand All @@ -1238,6 +1268,7 @@ class DrawerDynamicPosition {
})
class DrawerWithFocusableElements {
mode: string = 'over';
hasBackdrop: boolean | null = null;
}

@Component({
Expand Down
19 changes: 9 additions & 10 deletions src/material/sidenav/drawer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down

0 comments on commit 933fde4

Please sign in to comment.