Skip to content

Commit 444cee8

Browse files
crisbetojelbourn
authored andcommitted
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.
1 parent 517ea57 commit 444cee8

File tree

4 files changed

+66
-6
lines changed

4 files changed

+66
-6
lines changed

src/lib/expansion/expansion-panel-header.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,14 @@ import {matExpansionAnimations} from './expansion-animations';
4848
host: {
4949
'class': 'mat-expansion-panel-header',
5050
'role': 'button',
51+
'[attr.id]': 'panel._headerId',
5152
'[attr.tabindex]': 'panel.disabled ? -1 : 0',
5253
'[attr.aria-controls]': '_getPanelId()',
5354
'[attr.aria-expanded]': '_isExpanded()',
5455
'[attr.aria-disabled]': 'panel.disabled',
5556
'[class.mat-expanded]': '_isExpanded()',
5657
'(click)': '_toggle()',
57-
'(keyup)': '_keyup($event)',
58+
'(keydown)': '_keydown($event)',
5859
'[@expansionHeight]': `{
5960
value: _getExpandedState(),
6061
params: {
@@ -118,8 +119,8 @@ export class MatExpansionPanelHeader implements OnDestroy {
118119
return !this.panel.hideToggle && !this.panel.disabled;
119120
}
120121

121-
/** Handle keyup event calling to toggle() if appropriate. */
122-
_keyup(event: KeyboardEvent) {
122+
/** Handle keydown event calling to toggle() if appropriate. */
123+
_keydown(event: KeyboardEvent) {
123124
switch (event.keyCode) {
124125
// Toggle for space and enter keys.
125126
case SPACE:

src/lib/expansion/expansion-panel.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
<ng-content select="mat-expansion-panel-header"></ng-content>
22
<div class="mat-expansion-panel-content"
3+
role="region"
34
[class.mat-expanded]="expanded"
45
[@bodyExpansion]="_getExpandedState()"
5-
[id]="id">
6+
[id]="id"
7+
[attr.aria-labelledby]="_headerId">
68
<div class="mat-expansion-panel-body">
79
<ng-content></ng-content>
810
<ng-template [cdkPortalOutlet]="_portal"></ng-template>

src/lib/expansion/expansion-panel.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export const _MatExpansionPanelMixinBase = mixinDisabled(MatExpansionPanelBase);
5757
/** MatExpansionPanel's states. */
5858
export type MatExpansionPanelState = 'expanded' | 'collapsed';
5959

60+
/** Counter for generating unique element ids. */
61+
let uniqueId = 0;
62+
6063
/**
6164
* <mat-expansion-panel> component.
6265
*
@@ -111,6 +114,9 @@ export class MatExpansionPanel extends _MatExpansionPanelMixinBase
111114
/** Portal holding the user's content. */
112115
_portal: TemplatePortal;
113116

117+
/** ID for the associated header element. Used for a11y labelling. */
118+
_headerId = `mat-expansion-panel-header-${uniqueId++}`;
119+
114120
constructor(@Optional() @Host() accordion: MatAccordion,
115121
_changeDetectorRef: ChangeDetectorRef,
116122
_uniqueSelectionDispatcher: UniqueSelectionDispatcher,

src/lib/expansion/expansion.spec.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {Component, ViewChild} from '@angular/core';
33
import {By} from '@angular/platform-browser';
44
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
55
import {MatExpansionModule, MatExpansionPanel} from './index';
6+
import {SPACE, ENTER} from '@angular/cdk/keycodes';
7+
import {dispatchKeyboardEvent} from '@angular/cdk/testing';
68

79

810
describe('MatExpansionPanel', () => {
@@ -71,7 +73,7 @@ describe('MatExpansionPanel', () => {
7173
expect(fixture.componentInstance.closeCallback).toHaveBeenCalled();
7274
});
7375

74-
it('creates a unique panel id for each panel', () => {
76+
it('should create a unique panel id for each panel', () => {
7577
const fixtureOne = TestBed.createComponent(PanelWithContent);
7678
const headerElOne = fixtureOne.nativeElement.querySelector('.mat-expansion-panel-header');
7779
const fixtureTwo = TestBed.createComponent(PanelWithContent);
@@ -84,9 +86,58 @@ describe('MatExpansionPanel', () => {
8486
expect(panelIdOne).not.toBe(panelIdTwo);
8587
});
8688

87-
it('should not be able to focus content while closed', fakeAsync(() => {
89+
it('should set `aria-labelledby` of the content to the header id', () => {
90+
const fixture = TestBed.createComponent(PanelWithContent);
91+
const headerEl = fixture.nativeElement.querySelector('.mat-expansion-panel-header');
92+
const contentEl = fixture.nativeElement.querySelector('.mat-expansion-panel-content');
93+
94+
fixture.detectChanges();
95+
96+
const headerId = headerEl.getAttribute('id');
97+
const contentLabel = contentEl.getAttribute('aria-labelledby');
98+
99+
expect(headerId).toBeTruthy();
100+
expect(contentLabel).toBeTruthy();
101+
expect(headerId).toBe(contentLabel);
102+
});
103+
104+
it('should set the proper role on the content element', () => {
105+
const fixture = TestBed.createComponent(PanelWithContent);
106+
const contentEl = fixture.nativeElement.querySelector('.mat-expansion-panel-content');
107+
108+
expect(contentEl.getAttribute('role')).toBe('region');
109+
});
110+
111+
it('should toggle the panel when pressing SPACE on the header', () => {
112+
const fixture = TestBed.createComponent(PanelWithContent);
113+
const headerEl = fixture.nativeElement.querySelector('.mat-expansion-panel-header');
114+
115+
spyOn(fixture.componentInstance.panel, 'toggle');
116+
117+
const event = dispatchKeyboardEvent(headerEl, 'keydown', SPACE);
118+
119+
fixture.detectChanges();
120+
121+
expect(fixture.componentInstance.panel.toggle).toHaveBeenCalled();
122+
expect(event.defaultPrevented).toBe(true);
123+
});
124+
125+
it('should toggle the panel when pressing ENTER on the header', () => {
88126
const fixture = TestBed.createComponent(PanelWithContent);
127+
const headerEl = fixture.nativeElement.querySelector('.mat-expansion-panel-header');
128+
129+
spyOn(fixture.componentInstance.panel, 'toggle');
89130

131+
const event = dispatchKeyboardEvent(headerEl, 'keydown', ENTER);
132+
133+
fixture.detectChanges();
134+
135+
expect(fixture.componentInstance.panel.toggle).toHaveBeenCalled();
136+
expect(event.defaultPrevented).toBe(true);
137+
});
138+
139+
it('should not be able to focus content while closed', fakeAsync(() => {
140+
const fixture = TestBed.createComponent(PanelWithContent);
90141
fixture.componentInstance.expanded = true;
91142
fixture.detectChanges();
92143
tick(250);

0 commit comments

Comments
 (0)