Skip to content

Commit

Permalink
fix(expansion-panel): implement keyboard controls (#12427)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
crisbeto authored and jelbourn committed Aug 29, 2018
1 parent 92f53ce commit 04d5955
Show file tree
Hide file tree
Showing 6 changed files with 237 additions and 35 deletions.
38 changes: 38 additions & 0 deletions src/lib/expansion/accordion-base.ts
Original file line number Diff line number Diff line change
@@ -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');
142 changes: 123 additions & 19 deletions src/lib/expansion/accordion.spec.ts
Original file line number Diff line number Diff line change
@@ -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: [
Expand All @@ -19,41 +29,53 @@ 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);
fixture.detectChanges();

const items = fixture.debugElement.queryAll(By.css('.mat-expansion-panel'));
const panelInstances = fixture.componentInstance.panels.toArray();

fixture.componentInstance.firstPanelExpanded = true;
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();
});

it('should allow multiple items to be expanded simultaneously', () => {
const fixture = TestBed.createComponent(SetOfItems);
fixture.componentInstance.multi = true;
fixture.detectChanges();

const panels = fixture.debugElement.queryAll(By.css('.mat-expansion-panel'));
const panelInstances = fixture.componentInstance.panels.toArray();

fixture.componentInstance.multi = true;
fixture.componentInstance.firstPanelExpanded = true;
fixture.componentInstance.secondPanelExpanded = true;
panelInstances[0].expanded = true;
panelInstances[1].expanded = true;
fixture.detectChanges();
expect(panels[0].classes['mat-expanded']).toBeTruthy();
expect(panels[1].classes['mat-expanded']).toBeTruthy();
});

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();
Expand All @@ -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();
Expand Down Expand Up @@ -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: `
Expand Down
46 changes: 40 additions & 6 deletions src/lib/expansion/accordion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -20,24 +21,57 @@ 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; }
set hideToggle(show: boolean) { this._hideToggle = coerceBooleanProperty(show); }
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.
* flat - no spacing is placed around expanded panels, showing all panels at the same
* 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);
}
}
Loading

0 comments on commit 04d5955

Please sign in to comment.