Skip to content

Commit 32e5d72

Browse files
crisbetojelbourn
authored andcommitted
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.
1 parent 040f9db commit 32e5d72

File tree

6 files changed

+237
-35
lines changed

6 files changed

+237
-35
lines changed

src/lib/expansion/accordion-base.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {InjectionToken} from '@angular/core';
10+
import {CdkAccordion} from '@angular/cdk/accordion';
11+
12+
/** MatAccordion's display modes. */
13+
export type MatAccordionDisplayMode = 'default' | 'flat';
14+
15+
/**
16+
* Base interface for a `MatAccordion`.
17+
* @docs-private
18+
*/
19+
export interface MatAccordionBase extends CdkAccordion {
20+
/** Whether the expansion indicator should be hidden. */
21+
hideToggle: boolean;
22+
23+
/** Display mode used for all expansion panels in the accordion. */
24+
displayMode: MatAccordionDisplayMode;
25+
26+
/** Handles keyboard events coming in from the panel headers. */
27+
_handleHeaderKeydown: (event: KeyboardEvent) => void;
28+
29+
/** Handles focus events on the panel headers. */
30+
_handleHeaderFocus: (header: any) => void;
31+
}
32+
33+
34+
/**
35+
* Token used to provide a `MatAccordion` to `MatExpansionPanel`.
36+
* Used primarily to avoid circular imports between `MatAccordion` and `MatExpansionPanel`.
37+
*/
38+
export const MAT_ACCORDION = new InjectionToken<MatAccordionBase>('MAT_ACCORDION');

src/lib/expansion/accordion.spec.ts

Lines changed: 123 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
1-
import {async, TestBed} from '@angular/core/testing';
2-
import {Component, ViewChild} from '@angular/core';
1+
import {async, TestBed, inject} from '@angular/core/testing';
2+
import {Component, ViewChild, QueryList, ViewChildren} from '@angular/core';
33
import {By} from '@angular/platform-browser';
44
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
5-
import {MatExpansionModule, MatAccordion, MatExpansionPanel} from './index';
5+
import {
6+
MatExpansionModule,
7+
MatAccordion,
8+
MatExpansionPanel,
9+
MatExpansionPanelHeader,
10+
} from './index';
11+
import {dispatchKeyboardEvent} from '@angular/cdk/testing';
12+
import {DOWN_ARROW, UP_ARROW, HOME, END} from '@angular/cdk/keycodes';
13+
import {FocusMonitor} from '@angular/cdk/a11y';
614

715

816
describe('MatAccordion', () => {
17+
let focusMonitor: FocusMonitor;
18+
919
beforeEach(async(() => {
1020
TestBed.configureTestingModule({
1121
imports: [
@@ -19,41 +29,53 @@ describe('MatAccordion', () => {
1929
],
2030
});
2131
TestBed.compileComponents();
32+
33+
inject([FocusMonitor], (fm: FocusMonitor) => {
34+
focusMonitor = fm;
35+
})();
2236
}));
2337

2438
it('should ensure only one item is expanded at a time', () => {
2539
const fixture = TestBed.createComponent(SetOfItems);
40+
fixture.detectChanges();
41+
2642
const items = fixture.debugElement.queryAll(By.css('.mat-expansion-panel'));
43+
const panelInstances = fixture.componentInstance.panels.toArray();
2744

28-
fixture.componentInstance.firstPanelExpanded = true;
45+
panelInstances[0].expanded = true;
2946
fixture.detectChanges();
3047
expect(items[0].classes['mat-expanded']).toBeTruthy();
3148
expect(items[1].classes['mat-expanded']).toBeFalsy();
3249

33-
fixture.componentInstance.secondPanelExpanded = true;
50+
panelInstances[1].expanded = true;
3451
fixture.detectChanges();
3552
expect(items[0].classes['mat-expanded']).toBeFalsy();
3653
expect(items[1].classes['mat-expanded']).toBeTruthy();
3754
});
3855

3956
it('should allow multiple items to be expanded simultaneously', () => {
4057
const fixture = TestBed.createComponent(SetOfItems);
58+
fixture.componentInstance.multi = true;
59+
fixture.detectChanges();
60+
4161
const panels = fixture.debugElement.queryAll(By.css('.mat-expansion-panel'));
62+
const panelInstances = fixture.componentInstance.panels.toArray();
4263

43-
fixture.componentInstance.multi = true;
44-
fixture.componentInstance.firstPanelExpanded = true;
45-
fixture.componentInstance.secondPanelExpanded = true;
64+
panelInstances[0].expanded = true;
65+
panelInstances[1].expanded = true;
4666
fixture.detectChanges();
4767
expect(panels[0].classes['mat-expanded']).toBeTruthy();
4868
expect(panels[1].classes['mat-expanded']).toBeTruthy();
4969
});
5070

5171
it('should expand or collapse all enabled items', () => {
5272
const fixture = TestBed.createComponent(SetOfItems);
73+
fixture.detectChanges();
74+
5375
const panels = fixture.debugElement.queryAll(By.css('.mat-expansion-panel'));
5476

5577
fixture.componentInstance.multi = true;
56-
fixture.componentInstance.secondPanelExpanded = true;
78+
fixture.componentInstance.panels.toArray()[1].expanded = true;
5779
fixture.detectChanges();
5880
expect(panels[0].classes['mat-expanded']).toBeFalsy();
5981
expect(panels[1].classes['mat-expanded']).toBeTruthy();
@@ -71,10 +93,12 @@ describe('MatAccordion', () => {
7193

7294
it('should not expand or collapse disabled items', () => {
7395
const fixture = TestBed.createComponent(SetOfItems);
96+
fixture.detectChanges();
97+
7498
const panels = fixture.debugElement.queryAll(By.css('.mat-expansion-panel'));
7599

76100
fixture.componentInstance.multi = true;
77-
fixture.componentInstance.secondPanelDisabled = true;
101+
fixture.componentInstance.panels.toArray()[1].disabled = true;
78102
fixture.detectChanges();
79103
fixture.componentInstance.accordion.openAll();
80104
fixture.detectChanges();
@@ -110,27 +134,107 @@ describe('MatAccordion', () => {
110134
expect(panel.nativeElement.querySelector('.mat-expansion-indicator'))
111135
.toBeFalsy('Expected the expansion indicator to be removed.');
112136
});
137+
138+
it('should move focus to the next header when pressing the down arrow', () => {
139+
const fixture = TestBed.createComponent(SetOfItems);
140+
fixture.detectChanges();
141+
142+
const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header'));
143+
const headers = fixture.componentInstance.headers.toArray();
144+
145+
focusMonitor.focusVia(headerElements[0].nativeElement, 'keyboard');
146+
headers.forEach(header => spyOn(header, 'focus'));
147+
148+
// Stop at the second-last header so focus doesn't wrap around.
149+
for (let i = 0; i < headerElements.length - 1; i++) {
150+
dispatchKeyboardEvent(headerElements[i].nativeElement, 'keydown', DOWN_ARROW);
151+
fixture.detectChanges();
152+
expect(headers[i + 1].focus).toHaveBeenCalledTimes(1);
153+
}
154+
});
155+
156+
it('should move focus to the next header when pressing the up arrow', () => {
157+
const fixture = TestBed.createComponent(SetOfItems);
158+
fixture.detectChanges();
159+
160+
const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header'));
161+
const headers = fixture.componentInstance.headers.toArray();
162+
163+
focusMonitor.focusVia(headerElements[headerElements.length - 1].nativeElement, 'keyboard');
164+
headers.forEach(header => spyOn(header, 'focus'));
165+
166+
// Stop before the first header
167+
for (let i = headers.length - 1; i > 0; i--) {
168+
dispatchKeyboardEvent(headerElements[i].nativeElement, 'keydown', UP_ARROW);
169+
fixture.detectChanges();
170+
expect(headers[i - 1].focus).toHaveBeenCalledTimes(1);
171+
}
172+
});
173+
174+
it('should skip disabled items when moving focus with the keyboard', () => {
175+
const fixture = TestBed.createComponent(SetOfItems);
176+
fixture.detectChanges();
177+
178+
const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header'));
179+
const panels = fixture.componentInstance.panels.toArray();
180+
const headers = fixture.componentInstance.headers.toArray();
181+
182+
focusMonitor.focusVia(headerElements[0].nativeElement, 'keyboard');
183+
headers.forEach(header => spyOn(header, 'focus'));
184+
panels[1].disabled = true;
185+
fixture.detectChanges();
186+
187+
dispatchKeyboardEvent(headerElements[0].nativeElement, 'keydown', DOWN_ARROW);
188+
fixture.detectChanges();
189+
190+
expect(headers[1].focus).not.toHaveBeenCalled();
191+
expect(headers[2].focus).toHaveBeenCalledTimes(1);
192+
});
193+
194+
it('should focus the first header when pressing the home key', () => {
195+
const fixture = TestBed.createComponent(SetOfItems);
196+
fixture.detectChanges();
197+
198+
const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header'));
199+
const headers = fixture.componentInstance.headers.toArray();
200+
201+
headers.forEach(header => spyOn(header, 'focus'));
202+
dispatchKeyboardEvent(headerElements[headerElements.length - 1].nativeElement, 'keydown', HOME);
203+
fixture.detectChanges();
204+
205+
expect(headers[0].focus).toHaveBeenCalledTimes(1);
206+
});
207+
208+
it('should focus the last header when pressing the end key', () => {
209+
const fixture = TestBed.createComponent(SetOfItems);
210+
fixture.detectChanges();
211+
212+
const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header'));
213+
const headers = fixture.componentInstance.headers.toArray();
214+
215+
headers.forEach(header => spyOn(header, 'focus'));
216+
dispatchKeyboardEvent(headerElements[0].nativeElement, 'keydown', END);
217+
fixture.detectChanges();
218+
219+
expect(headers[headers.length - 1].focus).toHaveBeenCalledTimes(1);
220+
});
221+
113222
});
114223

115224

116225
@Component({template: `
117226
<mat-accordion [multi]="multi">
118-
<mat-expansion-panel [expanded]="firstPanelExpanded">
119-
<mat-expansion-panel-header>Summary</mat-expansion-panel-header>
120-
<p>Content</p>
121-
</mat-expansion-panel>
122-
<mat-expansion-panel [expanded]="secondPanelExpanded" [disabled]="secondPanelDisabled">
123-
<mat-expansion-panel-header>Summary</mat-expansion-panel-header>
227+
<mat-expansion-panel *ngFor="let i of [0, 1, 2, 3]">
228+
<mat-expansion-panel-header>Summary {{i}}</mat-expansion-panel-header>
124229
<p>Content</p>
125230
</mat-expansion-panel>
126231
</mat-accordion>`})
127232
class SetOfItems {
128233
@ViewChild(MatAccordion) accordion: MatAccordion;
234+
@ViewChildren(MatExpansionPanel) panels: QueryList<MatExpansionPanel>;
235+
@ViewChildren(MatExpansionPanelHeader) headers: QueryList<MatExpansionPanelHeader>;
129236

130237
multi: boolean = false;
131-
firstPanelExpanded: boolean = false;
132-
secondPanelExpanded: boolean = false;
133-
secondPanelDisabled: boolean = false;
134238
}
135239

136240
@Component({template: `

src/lib/expansion/accordion.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Directive, Input} from '@angular/core';
9+
import {Directive, Input, ContentChildren, QueryList, AfterContentInit} from '@angular/core';
1010
import {coerceBooleanProperty} from '@angular/cdk/coercion';
1111
import {CdkAccordion} from '@angular/cdk/accordion';
12-
13-
/** MatAccordion's display modes. */
14-
export type MatAccordionDisplayMode = 'default' | 'flat';
12+
import {FocusKeyManager} from '@angular/cdk/a11y';
13+
import {HOME, END} from '@angular/cdk/keycodes';
14+
import {MAT_ACCORDION, MatAccordionBase, MatAccordionDisplayMode} from './accordion-base';
15+
import {MatExpansionPanelHeader} from './expansion-panel-header';
1516

1617
/**
1718
* Directive for a Material Design Accordion.
@@ -20,24 +21,57 @@ export type MatAccordionDisplayMode = 'default' | 'flat';
2021
selector: 'mat-accordion',
2122
exportAs: 'matAccordion',
2223
inputs: ['multi'],
24+
providers: [{
25+
provide: MAT_ACCORDION,
26+
useExisting: MatAccordion
27+
}],
2328
host: {
2429
class: 'mat-accordion'
2530
}
2631
})
27-
export class MatAccordion extends CdkAccordion {
32+
export class MatAccordion extends CdkAccordion implements MatAccordionBase, AfterContentInit {
33+
private _keyManager: FocusKeyManager<MatExpansionPanelHeader>;
34+
35+
@ContentChildren(MatExpansionPanelHeader, {descendants: true})
36+
_headers: QueryList<MatExpansionPanelHeader>;
37+
2838
/** Whether the expansion indicator should be hidden. */
2939
@Input()
3040
get hideToggle(): boolean { return this._hideToggle; }
3141
set hideToggle(show: boolean) { this._hideToggle = coerceBooleanProperty(show); }
3242
private _hideToggle: boolean = false;
3343

3444
/**
35-
* The display mode used for all expansion panels in the accordion. Currently two display
45+
* Display mode used for all expansion panels in the accordion. Currently two display
3646
* modes exist:
3747
* default - a gutter-like spacing is placed around any expanded panel, placing the expanded
3848
* panel at a different elevation from the rest of the accordion.
3949
* flat - no spacing is placed around expanded panels, showing all panels at the same
4050
* elevation.
4151
*/
4252
@Input() displayMode: MatAccordionDisplayMode = 'default';
53+
54+
ngAfterContentInit() {
55+
this._keyManager = new FocusKeyManager(this._headers).withWrap();
56+
}
57+
58+
/** Handles keyboard events coming in from the panel headers. */
59+
_handleHeaderKeydown(event: KeyboardEvent) {
60+
const {keyCode} = event;
61+
const manager = this._keyManager;
62+
63+
if (keyCode === HOME) {
64+
manager.setFirstItemActive();
65+
event.preventDefault();
66+
} else if (keyCode === END) {
67+
manager.setLastItemActive();
68+
event.preventDefault();
69+
} else {
70+
this._keyManager.onKeydown(event);
71+
}
72+
}
73+
74+
_handleHeaderFocus(header: MatExpansionPanelHeader) {
75+
this._keyManager.updateActiveItem(header);
76+
}
4377
}

0 commit comments

Comments
 (0)