mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(sidenav): focus capturing (#1695)
* Focus capturing for sidenav. Captures focus when sidenav is open in "over" or "push" mode, but not when opened in "side" mode. * lint fixes. * address comments * addressed comments * s/active/!disabled * fix tests * fix lint * fix visibility issue
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
<div tabindex="0" (focus)="focusLastTabbableElement()"></div>
|
||||
<div *ngIf="!disabled" tabindex="0" (focus)="focusLastTabbableElement()"></div>
|
||||
<div #trappedContent><ng-content></ng-content></div>
|
||||
<div tabindex="0" (focus)="focusFirstTabbableElement()"></div>
|
||||
<div *ngIf="!disabled" tabindex="0" (focus)="focusFirstTabbableElement()"></div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Component, ViewEncapsulation, ViewChild, ElementRef} from '@angular/core';
|
||||
import {Component, ViewEncapsulation, ViewChild, ElementRef, Input, NgZone} from '@angular/core';
|
||||
import {InteractivityChecker} from './interactivity-checker';
|
||||
import {coerceBooleanProperty} from '../coersion/boolean-property';
|
||||
|
||||
|
||||
/**
|
||||
@@ -19,7 +20,33 @@ import {InteractivityChecker} from './interactivity-checker';
|
||||
export class FocusTrap {
|
||||
@ViewChild('trappedContent') trappedContent: ElementRef;
|
||||
|
||||
constructor(private _checker: InteractivityChecker) { }
|
||||
/** Whether the focus trap is active. */
|
||||
@Input()
|
||||
get disabled(): boolean { return this._disabled; }
|
||||
set disabled(val: boolean) { this._disabled = coerceBooleanProperty(val); }
|
||||
private _disabled: boolean = false;
|
||||
|
||||
constructor(private _checker: InteractivityChecker, private _ngZone: NgZone) { }
|
||||
|
||||
/**
|
||||
* Waits for microtask queue to empty, then focuses the first tabbable element within the focus
|
||||
* trap region.
|
||||
*/
|
||||
focusFirstTabbableElementWhenReady() {
|
||||
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
|
||||
this.focusFirstTabbableElement();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for microtask queue to empty, then focuses the last tabbable element within the focus
|
||||
* trap region.
|
||||
*/
|
||||
focusLastTabbableElementWhenReady() {
|
||||
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
|
||||
this.focusLastTabbableElement();
|
||||
});
|
||||
}
|
||||
|
||||
/** Focuses the first tabbable element within the focus trap region. */
|
||||
focusFirstTabbableElement() {
|
||||
|
||||
@@ -2,12 +2,13 @@ import {NgModule, ModuleWithProviders} from '@angular/core';
|
||||
import {FocusTrap} from './focus-trap';
|
||||
import {MdLiveAnnouncer} from './live-announcer';
|
||||
import {InteractivityChecker} from './interactivity-checker';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {PlatformModule} from '../platform/platform';
|
||||
|
||||
export const A11Y_PROVIDERS = [MdLiveAnnouncer, InteractivityChecker];
|
||||
|
||||
@NgModule({
|
||||
imports: [PlatformModule],
|
||||
imports: [CommonModule, PlatformModule],
|
||||
declarations: [FocusTrap],
|
||||
exports: [FocusTrap],
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ import {fakeAsync, async, tick, ComponentFixture, TestBed} from '@angular/core/t
|
||||
import {Component} from '@angular/core';
|
||||
import {By} from '@angular/platform-browser';
|
||||
import {MdSidenav, MdSidenavModule, MdSidenavToggleResult} from './sidenav';
|
||||
import {A11yModule} from '../core/a11y/index';
|
||||
import {PlatformModule} from '../core/platform/platform';
|
||||
|
||||
|
||||
function endSidenavTransition(fixture: ComponentFixture<any>) {
|
||||
@@ -15,10 +17,9 @@ function endSidenavTransition(fixture: ComponentFixture<any>) {
|
||||
|
||||
|
||||
describe('MdSidenav', () => {
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [MdSidenavModule.forRoot()],
|
||||
imports: [MdSidenavModule.forRoot(), A11yModule.forRoot(), PlatformModule.forRoot()],
|
||||
declarations: [
|
||||
BasicTestApp,
|
||||
SidenavLayoutTwoSidenavTestApp,
|
||||
@@ -26,6 +27,7 @@ describe('MdSidenav', () => {
|
||||
SidenavSetToOpenedFalse,
|
||||
SidenavSetToOpenedTrue,
|
||||
SidenavDynamicAlign,
|
||||
SidenavWitFocusableElements,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -236,7 +238,6 @@ describe('MdSidenav', () => {
|
||||
});
|
||||
|
||||
describe('attributes', () => {
|
||||
|
||||
it('should correctly parse opened="false"', () => {
|
||||
let fixture = TestBed.createComponent(SidenavSetToOpenedFalse);
|
||||
fixture.detectChanges();
|
||||
@@ -290,6 +291,55 @@ describe('MdSidenav', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('focus trapping behavior', () => {
|
||||
let fixture: ComponentFixture<SidenavWitFocusableElements>;
|
||||
let testComponent: SidenavWitFocusableElements;
|
||||
let sidenav: MdSidenav;
|
||||
let firstFocusableElement: HTMLElement;
|
||||
let lastFocusableElement: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SidenavWitFocusableElements);
|
||||
testComponent = fixture.debugElement.componentInstance;
|
||||
sidenav = fixture.debugElement.query(By.directive(MdSidenav)).componentInstance;
|
||||
firstFocusableElement = fixture.debugElement.query(By.css('.link1')).nativeElement;
|
||||
lastFocusableElement = fixture.debugElement.query(By.css('.link1')).nativeElement;
|
||||
lastFocusableElement.focus();
|
||||
});
|
||||
|
||||
it('should trap focus when opened in "over" mode', fakeAsync(() => {
|
||||
testComponent.mode = 'over';
|
||||
lastFocusableElement.focus();
|
||||
|
||||
sidenav.open();
|
||||
endSidenavTransition(fixture);
|
||||
tick();
|
||||
|
||||
expect(document.activeElement).toBe(firstFocusableElement);
|
||||
}));
|
||||
|
||||
it('should trap focus when opened in "push" mode', fakeAsync(() => {
|
||||
testComponent.mode = 'push';
|
||||
lastFocusableElement.focus();
|
||||
|
||||
sidenav.open();
|
||||
endSidenavTransition(fixture);
|
||||
tick();
|
||||
|
||||
expect(document.activeElement).toBe(firstFocusableElement);
|
||||
}));
|
||||
|
||||
it('should not trap focus when opened in "side" mode', fakeAsync(() => {
|
||||
testComponent.mode = 'side';
|
||||
lastFocusableElement.focus();
|
||||
|
||||
sidenav.open();
|
||||
endSidenavTransition(fixture);
|
||||
tick();
|
||||
|
||||
expect(document.activeElement).toBe(lastFocusableElement);
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -381,3 +431,16 @@ class SidenavDynamicAlign {
|
||||
sidenav1Align = 'start';
|
||||
sidenav2Align = 'end';
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<md-sidenav-layout>
|
||||
<md-sidenav align="start" [mode]="mode">
|
||||
<a class="link1" href="#">link1</a>
|
||||
</md-sidenav>
|
||||
<a class="link2" href="#">link2</a>
|
||||
</md-sidenav-layout>`,
|
||||
})
|
||||
class SidenavWitFocusableElements {
|
||||
mode: string = 'over';
|
||||
}
|
||||
|
||||
+17
-11
@@ -13,17 +13,12 @@ import {
|
||||
EventEmitter,
|
||||
Renderer,
|
||||
ViewEncapsulation,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {Dir, MdError, coerceBooleanProperty, DefaultStyleCompatibilityModeModule} from '../core';
|
||||
|
||||
|
||||
/** Exception thrown when two MdSidenav are matching the same side. */
|
||||
export class MdDuplicatedSidenavError extends MdError {
|
||||
constructor(align: string) {
|
||||
super(`A sidenav was already declared for 'align="${align}"'`);
|
||||
}
|
||||
}
|
||||
import {Dir, coerceBooleanProperty, DefaultStyleCompatibilityModeModule} from '../core';
|
||||
import {A11yModule} from '../core/a11y/index';
|
||||
import {FocusTrap} from '../core/a11y/focus-trap';
|
||||
|
||||
|
||||
/** Sidenav toggle promise result. */
|
||||
@@ -42,7 +37,7 @@ export class MdSidenavToggleResult {
|
||||
@Component({
|
||||
moduleId: module.id,
|
||||
selector: 'md-sidenav, mat-sidenav',
|
||||
template: '<ng-content></ng-content>',
|
||||
template: '<focus-trap [disabled]="isFocusTrapDisabled"><ng-content></ng-content></focus-trap>',
|
||||
host: {
|
||||
'(transitionend)': '_onTransitionEnd($event)',
|
||||
// must prevent the browser from aligning text based on value
|
||||
@@ -61,6 +56,8 @@ export class MdSidenavToggleResult {
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class MdSidenav implements AfterContentInit {
|
||||
@ViewChild(FocusTrap) _focusTrap: FocusTrap;
|
||||
|
||||
/** Alignment of the sidenav (direction neutral); whether 'start' or 'end'. */
|
||||
private _align: 'start' | 'end' = 'start';
|
||||
|
||||
@@ -122,6 +119,11 @@ export class MdSidenav implements AfterContentInit {
|
||||
*/
|
||||
private _resolveToggleAnimationPromise: (animationFinished: boolean) => void = null;
|
||||
|
||||
get isFocusTrapDisabled() {
|
||||
// The focus trap is only enabled when the sidenav is open in any mode other than side.
|
||||
return !this.opened || this.mode == 'side';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param _elementRef The DOM element reference. Used for transition and width calculation.
|
||||
* If not available we do not hook on transitions.
|
||||
@@ -186,6 +188,10 @@ export class MdSidenav implements AfterContentInit {
|
||||
this.onCloseStart.emit();
|
||||
}
|
||||
|
||||
if (!this.isFocusTrapDisabled) {
|
||||
this._focusTrap.focusFirstTabbableElementWhenReady();
|
||||
}
|
||||
|
||||
if (this._toggleAnimationPromise) {
|
||||
this._resolveToggleAnimationPromise(false);
|
||||
}
|
||||
@@ -456,7 +462,7 @@ export class MdSidenavLayout implements AfterContentInit {
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, DefaultStyleCompatibilityModeModule],
|
||||
imports: [CommonModule, DefaultStyleCompatibilityModeModule, A11yModule],
|
||||
exports: [MdSidenavLayout, MdSidenav, DefaultStyleCompatibilityModeModule],
|
||||
declarations: [MdSidenavLayout, MdSidenav],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user