fix(expansion-panel): improved accessibility labelling and keyboard default action not being prevented (#9174)

Adds the following improvements to the expansion accessibility, based on the [accessible accordion guidelines](https://www.w3.org/TR/wai-aria-practices-1.1/examples/accordion/accordion.html):
* Adds the `region` role to the content element.
* Adds `aria-labelledby` pointing to the header.
* Fixes the page being scrolled down when a panel is opened using space. The issue was that we were preventing the default action during `keydown` which is too late in the lifecycle.

Also adds a couple of extra tests for the keyboard controls.
This commit is contained in:
Kristiyan Kostadinov
2018-01-23 17:08:40 +01:00
committed by Jeremy Elbourn
parent e09c0a694d
commit e62afdf06e
4 changed files with 66 additions and 6 deletions
+4 -3
View File
@@ -46,13 +46,14 @@ import {matExpansionAnimations} from './expansion-animations';
host: {
'class': 'mat-expansion-panel-header',
'role': 'button',
'[attr.id]': 'panel._headerId',
'[attr.tabindex]': 'panel.disabled ? -1 : 0',
'[attr.aria-controls]': '_getPanelId()',
'[attr.aria-expanded]': '_isExpanded()',
'[attr.aria-disabled]': 'panel.disabled',
'[class.mat-expanded]': '_isExpanded()',
'(click)': '_toggle()',
'(keyup)': '_keyup($event)',
'(keydown)': '_keydown($event)',
'[@expansionHeight]': `{
value: _getExpandedState(),
params: {
@@ -116,8 +117,8 @@ export class MatExpansionPanelHeader implements OnDestroy {
return !this.panel.hideToggle && !this.panel.disabled;
}
/** Handle keyup event calling to toggle() if appropriate. */
_keyup(event: KeyboardEvent) {
/** Handle keydown event calling to toggle() if appropriate. */
_keydown(event: KeyboardEvent) {
switch (event.keyCode) {
// Toggle for space and enter keys.
case SPACE:
+3 -1
View File
@@ -1,8 +1,10 @@
<ng-content select="mat-expansion-panel-header"></ng-content>
<div class="mat-expansion-panel-content"
role="region"
[class.mat-expanded]="expanded"
[@bodyExpansion]="_getExpandedState()"
[id]="id">
[id]="id"
[attr.aria-labelledby]="_headerId">
<div class="mat-expansion-panel-body">
<ng-content></ng-content>
<ng-template [cdkPortalOutlet]="_portal"></ng-template>
+6
View File
@@ -57,6 +57,9 @@ export const _MatExpansionPanelMixinBase = mixinDisabled(MatExpansionPanelBase);
/** MatExpansionPanel's states. */
export type MatExpansionPanelState = 'expanded' | 'collapsed';
/** Counter for generating unique element ids. */
let uniqueId = 0;
/**
* <mat-expansion-panel>
*
@@ -107,6 +110,9 @@ export class MatExpansionPanel extends _MatExpansionPanelMixinBase
/** Portal holding the user's content. */
_portal: TemplatePortal;
/** ID for the associated header element. Used for a11y labelling. */
_headerId = `mat-expansion-panel-header-${uniqueId++}`;
constructor(@Optional() @Host() accordion: MatAccordion,
_changeDetectorRef: ChangeDetectorRef,
_uniqueSelectionDispatcher: UniqueSelectionDispatcher,
+53 -2
View File
@@ -3,6 +3,8 @@ import {Component, ViewChild} from '@angular/core';
import {By} from '@angular/platform-browser';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {MatExpansionModule, MatExpansionPanel} from './index';
import {SPACE, ENTER} from '@angular/cdk/keycodes';
import {dispatchKeyboardEvent} from '@angular/cdk/testing';
describe('MatExpansionPanel', () => {
@@ -72,7 +74,7 @@ describe('MatExpansionPanel', () => {
expect(fixture.componentInstance.closeCallback).toHaveBeenCalled();
});
it('creates a unique panel id for each panel', () => {
it('should create a unique panel id for each panel', () => {
const fixtureOne = TestBed.createComponent(PanelWithContent);
const headerElOne = fixtureOne.nativeElement.querySelector('.mat-expansion-panel-header');
const fixtureTwo = TestBed.createComponent(PanelWithContent);
@@ -85,9 +87,58 @@ describe('MatExpansionPanel', () => {
expect(panelIdOne).not.toBe(panelIdTwo);
});
it('should set `aria-labelledby` of the content to the header id', () => {
const fixture = TestBed.createComponent(PanelWithContent);
const headerEl = fixture.nativeElement.querySelector('.mat-expansion-panel-header');
const contentEl = fixture.nativeElement.querySelector('.mat-expansion-panel-content');
fixture.detectChanges();
const headerId = headerEl.getAttribute('id');
const contentLabel = contentEl.getAttribute('aria-labelledby');
expect(headerId).toBeTruthy();
expect(contentLabel).toBeTruthy();
expect(headerId).toBe(contentLabel);
});
it('should set the proper role on the content element', () => {
const fixture = TestBed.createComponent(PanelWithContent);
const contentEl = fixture.nativeElement.querySelector('.mat-expansion-panel-content');
expect(contentEl.getAttribute('role')).toBe('region');
});
it('should toggle the panel when pressing SPACE on the header', () => {
const fixture = TestBed.createComponent(PanelWithContent);
const headerEl = fixture.nativeElement.querySelector('.mat-expansion-panel-header');
spyOn(fixture.componentInstance.panel, 'toggle');
const event = dispatchKeyboardEvent(headerEl, 'keydown', SPACE);
fixture.detectChanges();
expect(fixture.componentInstance.panel.toggle).toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
});
it('should toggle the panel when pressing ENTER on the header', () => {
const fixture = TestBed.createComponent(PanelWithContent);
const headerEl = fixture.nativeElement.querySelector('.mat-expansion-panel-header');
spyOn(fixture.componentInstance.panel, 'toggle');
const event = dispatchKeyboardEvent(headerEl, 'keydown', ENTER);
fixture.detectChanges();
expect(fixture.componentInstance.panel.toggle).toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
});
it('should not be able to focus content while closed', fakeAsync(() => {
const fixture = TestBed.createComponent(PanelWithContent);
fixture.componentInstance.expanded = true;
fixture.detectChanges();
tick(250);