fix(expansion-panel): implement keyboard controls (#12427)

Based on the [accessibility guidelines](https://www.w3.org/TR/wai-aria-practices-1.1/#accordion), accordions should be able to support moving focus using the keyboard. These changes implement the keyboard support and move some things around to avoid circular imports.
This commit is contained in:
Kristiyan Kostadinov
2018-08-27 18:57:18 +02:00
committed by Jeremy Elbourn
parent 040f9db3f8
commit 32e5d729fc
6 changed files with 239 additions and 37 deletions
+38
View File
@@ -0,0 +1,38 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {InjectionToken} from '@angular/core';
import {CdkAccordion} from '@angular/cdk/accordion';
/** MatAccordion's display modes. */
export type MatAccordionDisplayMode = 'default' | 'flat';
/**
* Base interface for a `MatAccordion`.
* @docs-private
*/
export interface MatAccordionBase extends CdkAccordion {
/** Whether the expansion indicator should be hidden. */
hideToggle: boolean;
/** Display mode used for all expansion panels in the accordion. */
displayMode: MatAccordionDisplayMode;
/** Handles keyboard events coming in from the panel headers. */
_handleHeaderKeydown: (event: KeyboardEvent) => void;
/** Handles focus events on the panel headers. */
_handleHeaderFocus: (header: any) => void;
}
/**
* Token used to provide a `MatAccordion` to `MatExpansionPanel`.
* Used primarily to avoid circular imports between `MatAccordion` and `MatExpansionPanel`.
*/
export const MAT_ACCORDION = new InjectionToken<MatAccordionBase>('MAT_ACCORDION');
+125 -21
View File
@@ -1,11 +1,21 @@
import {async, TestBed} from '@angular/core/testing';
import {Component, ViewChild} from '@angular/core';
import {async, TestBed, inject} from '@angular/core/testing';
import {Component, ViewChild, QueryList, ViewChildren} from '@angular/core';
import {By} from '@angular/platform-browser';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {MatExpansionModule, MatAccordion, MatExpansionPanel} from './index';
import {
MatExpansionModule,
MatAccordion,
MatExpansionPanel,
MatExpansionPanelHeader,
} from './index';
import {dispatchKeyboardEvent} from '@angular/cdk/testing';
import {DOWN_ARROW, UP_ARROW, HOME, END} from '@angular/cdk/keycodes';
import {FocusMonitor} from '@angular/cdk/a11y';
describe('MatAccordion', () => {
let focusMonitor: FocusMonitor;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
@@ -19,18 +29,25 @@ describe('MatAccordion', () => {
],
});
TestBed.compileComponents();
inject([FocusMonitor], (fm: FocusMonitor) => {
focusMonitor = fm;
})();
}));
it('should ensure only one item is expanded at a time', () => {
const fixture = TestBed.createComponent(SetOfItems);
const items = fixture.debugElement.queryAll(By.css('.mat-expansion-panel'));
fixture.detectChanges();
fixture.componentInstance.firstPanelExpanded = true;
const items = fixture.debugElement.queryAll(By.css('.mat-expansion-panel'));
const panelInstances = fixture.componentInstance.panels.toArray();
panelInstances[0].expanded = true;
fixture.detectChanges();
expect(items[0].classes['mat-expanded']).toBeTruthy();
expect(items[1].classes['mat-expanded']).toBeFalsy();
fixture.componentInstance.secondPanelExpanded = true;
panelInstances[1].expanded = true;
fixture.detectChanges();
expect(items[0].classes['mat-expanded']).toBeFalsy();
expect(items[1].classes['mat-expanded']).toBeTruthy();
@@ -38,11 +55,14 @@ describe('MatAccordion', () => {
it('should allow multiple items to be expanded simultaneously', () => {
const fixture = TestBed.createComponent(SetOfItems);
const panels = fixture.debugElement.queryAll(By.css('.mat-expansion-panel'));
fixture.componentInstance.multi = true;
fixture.componentInstance.firstPanelExpanded = true;
fixture.componentInstance.secondPanelExpanded = true;
fixture.detectChanges();
const panels = fixture.debugElement.queryAll(By.css('.mat-expansion-panel'));
const panelInstances = fixture.componentInstance.panels.toArray();
panelInstances[0].expanded = true;
panelInstances[1].expanded = true;
fixture.detectChanges();
expect(panels[0].classes['mat-expanded']).toBeTruthy();
expect(panels[1].classes['mat-expanded']).toBeTruthy();
@@ -50,10 +70,12 @@ describe('MatAccordion', () => {
it('should expand or collapse all enabled items', () => {
const fixture = TestBed.createComponent(SetOfItems);
fixture.detectChanges();
const panels = fixture.debugElement.queryAll(By.css('.mat-expansion-panel'));
fixture.componentInstance.multi = true;
fixture.componentInstance.secondPanelExpanded = true;
fixture.componentInstance.panels.toArray()[1].expanded = true;
fixture.detectChanges();
expect(panels[0].classes['mat-expanded']).toBeFalsy();
expect(panels[1].classes['mat-expanded']).toBeTruthy();
@@ -71,10 +93,12 @@ describe('MatAccordion', () => {
it('should not expand or collapse disabled items', () => {
const fixture = TestBed.createComponent(SetOfItems);
fixture.detectChanges();
const panels = fixture.debugElement.queryAll(By.css('.mat-expansion-panel'));
fixture.componentInstance.multi = true;
fixture.componentInstance.secondPanelDisabled = true;
fixture.componentInstance.panels.toArray()[1].disabled = true;
fixture.detectChanges();
fixture.componentInstance.accordion.openAll();
fixture.detectChanges();
@@ -110,27 +134,107 @@ describe('MatAccordion', () => {
expect(panel.nativeElement.querySelector('.mat-expansion-indicator'))
.toBeFalsy('Expected the expansion indicator to be removed.');
});
it('should move focus to the next header when pressing the down arrow', () => {
const fixture = TestBed.createComponent(SetOfItems);
fixture.detectChanges();
const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header'));
const headers = fixture.componentInstance.headers.toArray();
focusMonitor.focusVia(headerElements[0].nativeElement, 'keyboard');
headers.forEach(header => spyOn(header, 'focus'));
// Stop at the second-last header so focus doesn't wrap around.
for (let i = 0; i < headerElements.length - 1; i++) {
dispatchKeyboardEvent(headerElements[i].nativeElement, 'keydown', DOWN_ARROW);
fixture.detectChanges();
expect(headers[i + 1].focus).toHaveBeenCalledTimes(1);
}
});
it('should move focus to the next header when pressing the up arrow', () => {
const fixture = TestBed.createComponent(SetOfItems);
fixture.detectChanges();
const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header'));
const headers = fixture.componentInstance.headers.toArray();
focusMonitor.focusVia(headerElements[headerElements.length - 1].nativeElement, 'keyboard');
headers.forEach(header => spyOn(header, 'focus'));
// Stop before the first header
for (let i = headers.length - 1; i > 0; i--) {
dispatchKeyboardEvent(headerElements[i].nativeElement, 'keydown', UP_ARROW);
fixture.detectChanges();
expect(headers[i - 1].focus).toHaveBeenCalledTimes(1);
}
});
it('should skip disabled items when moving focus with the keyboard', () => {
const fixture = TestBed.createComponent(SetOfItems);
fixture.detectChanges();
const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header'));
const panels = fixture.componentInstance.panels.toArray();
const headers = fixture.componentInstance.headers.toArray();
focusMonitor.focusVia(headerElements[0].nativeElement, 'keyboard');
headers.forEach(header => spyOn(header, 'focus'));
panels[1].disabled = true;
fixture.detectChanges();
dispatchKeyboardEvent(headerElements[0].nativeElement, 'keydown', DOWN_ARROW);
fixture.detectChanges();
expect(headers[1].focus).not.toHaveBeenCalled();
expect(headers[2].focus).toHaveBeenCalledTimes(1);
});
it('should focus the first header when pressing the home key', () => {
const fixture = TestBed.createComponent(SetOfItems);
fixture.detectChanges();
const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header'));
const headers = fixture.componentInstance.headers.toArray();
headers.forEach(header => spyOn(header, 'focus'));
dispatchKeyboardEvent(headerElements[headerElements.length - 1].nativeElement, 'keydown', HOME);
fixture.detectChanges();
expect(headers[0].focus).toHaveBeenCalledTimes(1);
});
it('should focus the last header when pressing the end key', () => {
const fixture = TestBed.createComponent(SetOfItems);
fixture.detectChanges();
const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header'));
const headers = fixture.componentInstance.headers.toArray();
headers.forEach(header => spyOn(header, 'focus'));
dispatchKeyboardEvent(headerElements[0].nativeElement, 'keydown', END);
fixture.detectChanges();
expect(headers[headers.length - 1].focus).toHaveBeenCalledTimes(1);
});
});
@Component({template: `
<mat-accordion [multi]="multi">
<mat-expansion-panel [expanded]="firstPanelExpanded">
<mat-expansion-panel-header>Summary</mat-expansion-panel-header>
<p>Content</p>
</mat-expansion-panel>
<mat-expansion-panel [expanded]="secondPanelExpanded" [disabled]="secondPanelDisabled">
<mat-expansion-panel-header>Summary</mat-expansion-panel-header>
<mat-expansion-panel *ngFor="let i of [0, 1, 2, 3]">
<mat-expansion-panel-header>Summary {{i}}</mat-expansion-panel-header>
<p>Content</p>
</mat-expansion-panel>
</mat-accordion>`})
class SetOfItems {
@ViewChild(MatAccordion) accordion: MatAccordion;
@ViewChildren(MatExpansionPanel) panels: QueryList<MatExpansionPanel>;
@ViewChildren(MatExpansionPanelHeader) headers: QueryList<MatExpansionPanelHeader>;
multi: boolean = false;
firstPanelExpanded: boolean = false;
secondPanelExpanded: boolean = false;
secondPanelDisabled: boolean = false;
}
@Component({template: `
+40 -6
View File
@@ -6,12 +6,13 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Directive, Input} from '@angular/core';
import {Directive, Input, ContentChildren, QueryList, AfterContentInit} from '@angular/core';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {CdkAccordion} from '@angular/cdk/accordion';
/** MatAccordion's display modes. */
export type MatAccordionDisplayMode = 'default' | 'flat';
import {FocusKeyManager} from '@angular/cdk/a11y';
import {HOME, END} from '@angular/cdk/keycodes';
import {MAT_ACCORDION, MatAccordionBase, MatAccordionDisplayMode} from './accordion-base';
import {MatExpansionPanelHeader} from './expansion-panel-header';
/**
* Directive for a Material Design Accordion.
@@ -20,11 +21,20 @@ export type MatAccordionDisplayMode = 'default' | 'flat';
selector: 'mat-accordion',
exportAs: 'matAccordion',
inputs: ['multi'],
providers: [{
provide: MAT_ACCORDION,
useExisting: MatAccordion
}],
host: {
class: 'mat-accordion'
}
})
export class MatAccordion extends CdkAccordion {
export class MatAccordion extends CdkAccordion implements MatAccordionBase, AfterContentInit {
private _keyManager: FocusKeyManager<MatExpansionPanelHeader>;
@ContentChildren(MatExpansionPanelHeader, {descendants: true})
_headers: QueryList<MatExpansionPanelHeader>;
/** Whether the expansion indicator should be hidden. */
@Input()
get hideToggle(): boolean { return this._hideToggle; }
@@ -32,7 +42,7 @@ export class MatAccordion extends CdkAccordion {
private _hideToggle: boolean = false;
/**
* The display mode used for all expansion panels in the accordion. Currently two display
* Display mode used for all expansion panels in the accordion. Currently two display
* modes exist:
* default - a gutter-like spacing is placed around any expanded panel, placing the expanded
* panel at a different elevation from the rest of the accordion.
@@ -40,4 +50,28 @@ export class MatAccordion extends CdkAccordion {
* elevation.
*/
@Input() displayMode: MatAccordionDisplayMode = 'default';
ngAfterContentInit() {
this._keyManager = new FocusKeyManager(this._headers).withWrap();
}
/** Handles keyboard events coming in from the panel headers. */
_handleHeaderKeydown(event: KeyboardEvent) {
const {keyCode} = event;
const manager = this._keyManager;
if (keyCode === HOME) {
manager.setFirstItemActive();
event.preventDefault();
} else if (keyCode === END) {
manager.setLastItemActive();
event.preventDefault();
} else {
this._keyManager.onKeydown(event);
}
}
_handleHeaderFocus(header: MatExpansionPanelHeader) {
this._keyManager.updateActiveItem(header);
}
}
+31 -6
View File
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {FocusMonitor} from '@angular/cdk/a11y';
import {FocusMonitor, FocusableOption, FocusOrigin} from '@angular/cdk/a11y';
import {ENTER, SPACE} from '@angular/cdk/keycodes';
import {
ChangeDetectionStrategy,
@@ -45,7 +45,7 @@ import {MatExpansionPanel} from './expansion-panel';
'class': 'mat-expansion-panel-header',
'role': 'button',
'[attr.id]': 'panel._headerId',
'[attr.tabindex]': 'panel.disabled ? -1 : 0',
'[attr.tabindex]': 'disabled ? -1 : 0',
'[attr.aria-controls]': '_getPanelId()',
'[attr.aria-expanded]': '_isExpanded()',
'[attr.aria-disabled]': 'panel.disabled',
@@ -61,7 +61,7 @@ import {MatExpansionPanel} from './expansion-panel';
}`,
},
})
export class MatExpansionPanelHeader implements OnDestroy {
export class MatExpansionPanelHeader implements OnDestroy, FocusableOption {
private _parentChangeSubscription = Subscription.EMPTY;
constructor(
@@ -88,7 +88,11 @@ export class MatExpansionPanelHeader implements OnDestroy {
.pipe(filter(() => panel._containsFocus()))
.subscribe(() => _focusMonitor.focusVia(_element.nativeElement, 'program'));
_focusMonitor.monitor(_element);
_focusMonitor.monitor(_element).subscribe(origin => {
if (origin && panel.accordion) {
panel.accordion._handleHeaderFocus(this);
}
});
}
/** Height of the header while the panel is expanded. */
@@ -97,6 +101,14 @@ export class MatExpansionPanelHeader implements OnDestroy {
/** Height of the header while the panel is collapsed. */
@Input() collapsedHeight: string;
/**
* Whether the associated panel is disabled. Implemented as a part of `FocusableOption`.
* @docs-private
*/
get disabled() {
return this.panel.disabled;
}
/** Toggles the expanded state of the panel. */
_toggle(): void {
this.panel.toggle();
@@ -132,10 +144,23 @@ export class MatExpansionPanelHeader implements OnDestroy {
this._toggle();
break;
default:
if (this.panel.accordion) {
this.panel.accordion._handleHeaderKeydown(event);
}
return;
}
}
/**
* Focuses the panel header. Implemented as a part of `FocusableOption`.
* @param origin Origin of the action that triggered the focus.
* @docs-private
*/
focus(origin: FocusOrigin = 'program') {
this._focusMonitor.focusVia(this._element.nativeElement, origin);
}
ngOnDestroy() {
this._parentChangeSubscription.unsubscribe();
this._focusMonitor.stopMonitoring(this._element);
@@ -149,7 +174,7 @@ export class MatExpansionPanelHeader implements OnDestroy {
*/
@Directive({
selector: 'mat-panel-description',
host : {
host: {
class: 'mat-expansion-panel-header-description'
}
})
@@ -162,7 +187,7 @@ export class MatExpansionPanelDescription {}
*/
@Directive({
selector: 'mat-panel-title',
host : {
host: {
class: 'mat-expansion-panel-header-title'
}
})
+4 -4
View File
@@ -35,9 +35,9 @@ import {
import {DOCUMENT} from '@angular/common';
import {Subject} from 'rxjs';
import {filter, startWith, take} from 'rxjs/operators';
import {MatAccordion} from './accordion';
import {matExpansionAnimations} from './expansion-animations';
import {MatExpansionPanelContent} from './expansion-panel-content';
import {MAT_ACCORDION, MatAccordionBase} from './accordion-base';
// TODO(devversion): workaround for https://github.com/angular/material2/issues/12760
export const _CdkAccordionItem = CdkAccordionItem;
@@ -68,7 +68,7 @@ let uniqueId = 0;
providers: [
// Provide MatAccordion as undefined to prevent nested expansion panels from registering
// to the same accordion.
{provide: MatAccordion, useValue: undefined},
{provide: MAT_ACCORDION, useValue: undefined},
],
host: {
'class': 'mat-expansion-panel',
@@ -103,7 +103,7 @@ export class MatExpansionPanel extends CdkAccordionItem implements AfterContentI
readonly _inputChanges = new Subject<SimpleChanges>();
/** Optionally defined accordion the expansion panel belongs to. */
accordion: MatAccordion;
accordion: MatAccordionBase;
/** Content that will be rendered lazily. */
@ContentChild(MatExpansionPanelContent) _lazyContent: MatExpansionPanelContent;
@@ -117,7 +117,7 @@ export class MatExpansionPanel extends CdkAccordionItem implements AfterContentI
/** ID for the associated header element. Used for a11y labelling. */
_headerId = `mat-expansion-panel-header-${uniqueId++}`;
constructor(@Optional() @SkipSelf() accordion: MatAccordion,
constructor(@Optional() @SkipSelf() @Inject(MAT_ACCORDION) accordion: MatAccordionBase,
_changeDetectorRef: ChangeDetectorRef,
_uniqueSelectionDispatcher: UniqueSelectionDispatcher,
private _viewContainerRef: ViewContainerRef,
+1
View File
@@ -8,6 +8,7 @@
export * from './expansion-module';
export * from './accordion';
export * from './accordion-base';
export * from './expansion-panel';
export * from './expansion-panel-header';
export * from './expansion-panel-content';