Skip to content

Commit 4f12641

Browse files
committed
fix(expansion-panel): implement keyboard controls
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 877de56 commit 4f12641

File tree

6 files changed

+238
-36
lines changed

6 files changed

+238
-36
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: [
@@ -18,41 +28,53 @@ describe('MatAccordion', () => {
1828
],
1929
});
2030
TestBed.compileComponents();
31+
32+
inject([FocusMonitor], (fm: FocusMonitor) => {
33+
focusMonitor = fm;
34+
})();
2135
}));
2236

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

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

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

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

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

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

5476
fixture.componentInstance.multi = true;
55-
fixture.componentInstance.secondPanelExpanded = true;
77+
fixture.componentInstance.panels.toArray()[1].expanded = true;
5678
fixture.detectChanges();
5779
expect(panels[0].classes['mat-expanded']).toBeFalsy();
5880
expect(panels[1].classes['mat-expanded']).toBeTruthy();
@@ -70,10 +92,12 @@ describe('MatAccordion', () => {
7092

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

7599
fixture.componentInstance.multi = true;
76-
fixture.componentInstance.secondPanelDisabled = true;
100+
fixture.componentInstance.panels.toArray()[1].disabled = true;
77101
fixture.detectChanges();
78102
fixture.componentInstance.accordion.openAll();
79103
fixture.detectChanges();
@@ -93,27 +117,107 @@ describe('MatAccordion', () => {
93117

94118
expect(innerPanel.accordion).not.toBe(outerPanel.accordion);
95119
});
120+
121+
it('should move focus to the next header when pressing the down arrow', () => {
122+
const fixture = TestBed.createComponent(SetOfItems);
123+
fixture.detectChanges();
124+
125+
const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header'));
126+
const headers = fixture.componentInstance.headers.toArray();
127+
128+
focusMonitor.focusVia(headerElements[0].nativeElement, 'keyboard');
129+
headers.forEach(header => spyOn(header, 'focus'));
130+
131+
// Stop at the second-last header so focus doesn't wrap around.
132+
for (let i = 0; i < headerElements.length - 1; i++) {
133+
dispatchKeyboardEvent(headerElements[i].nativeElement, 'keydown', DOWN_ARROW);
134+
fixture.detectChanges();
135+
expect(headers[i + 1].focus).toHaveBeenCalledTimes(1);
136+
}
137+
});
138+
139+
it('should move focus to the next header when pressing the up arrow', () => {
140+
const fixture = TestBed.createComponent(SetOfItems);
141+
fixture.detectChanges();
142+
143+
const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header'));
144+
const headers = fixture.componentInstance.headers.toArray();
145+
146+
focusMonitor.focusVia(headerElements[headerElements.length - 1].nativeElement, 'keyboard');
147+
headers.forEach(header => spyOn(header, 'focus'));
148+
149+
// Stop before the first header
150+
for (let i = headers.length - 1; i > 0; i--) {
151+
dispatchKeyboardEvent(headerElements[i].nativeElement, 'keydown', UP_ARROW);
152+
fixture.detectChanges();
153+
expect(headers[i - 1].focus).toHaveBeenCalledTimes(1);
154+
}
155+
});
156+
157+
it('should skip disabled items when moving focus with the keyboard', () => {
158+
const fixture = TestBed.createComponent(SetOfItems);
159+
fixture.detectChanges();
160+
161+
const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header'));
162+
const panels = fixture.componentInstance.panels.toArray();
163+
const headers = fixture.componentInstance.headers.toArray();
164+
165+
focusMonitor.focusVia(headerElements[0].nativeElement, 'keyboard');
166+
headers.forEach(header => spyOn(header, 'focus'));
167+
panels[1].disabled = true;
168+
fixture.detectChanges();
169+
170+
dispatchKeyboardEvent(headerElements[0].nativeElement, 'keydown', DOWN_ARROW);
171+
fixture.detectChanges();
172+
173+
expect(headers[1].focus).not.toHaveBeenCalled();
174+
expect(headers[2].focus).toHaveBeenCalledTimes(1);
175+
});
176+
177+
it('should focus the first header when pressing the home key', () => {
178+
const fixture = TestBed.createComponent(SetOfItems);
179+
fixture.detectChanges();
180+
181+
const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header'));
182+
const headers = fixture.componentInstance.headers.toArray();
183+
184+
headers.forEach(header => spyOn(header, 'focus'));
185+
dispatchKeyboardEvent(headerElements[headerElements.length - 1].nativeElement, 'keydown', HOME);
186+
fixture.detectChanges();
187+
188+
expect(headers[0].focus).toHaveBeenCalledTimes(1);
189+
});
190+
191+
it('should focus the last header when pressing the end key', () => {
192+
const fixture = TestBed.createComponent(SetOfItems);
193+
fixture.detectChanges();
194+
195+
const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header'));
196+
const headers = fixture.componentInstance.headers.toArray();
197+
198+
headers.forEach(header => spyOn(header, 'focus'));
199+
dispatchKeyboardEvent(headerElements[0].nativeElement, 'keydown', END);
200+
fixture.detectChanges();
201+
202+
expect(headers[headers.length - 1].focus).toHaveBeenCalledTimes(1);
203+
});
204+
96205
});
97206

98207

99208
@Component({template: `
100209
<mat-accordion [multi]="multi">
101-
<mat-expansion-panel [expanded]="firstPanelExpanded">
102-
<mat-expansion-panel-header>Summary</mat-expansion-panel-header>
103-
<p>Content</p>
104-
</mat-expansion-panel>
105-
<mat-expansion-panel [expanded]="secondPanelExpanded" [disabled]="secondPanelDisabled">
106-
<mat-expansion-panel-header>Summary</mat-expansion-panel-header>
210+
<mat-expansion-panel *ngFor="let i of [0, 1, 2, 3]">
211+
<mat-expansion-panel-header>Summary {{i}}</mat-expansion-panel-header>
107212
<p>Content</p>
108213
</mat-expansion-panel>
109214
</mat-accordion>`})
110215
class SetOfItems {
111216
@ViewChild(MatAccordion) accordion: MatAccordion;
217+
@ViewChildren(MatExpansionPanel) panels: QueryList<MatExpansionPanel>;
218+
@ViewChildren(MatExpansionPanelHeader) headers: QueryList<MatExpansionPanelHeader>;
112219

113220
multi: boolean = false;
114-
firstPanelExpanded: boolean = false;
115-
secondPanelExpanded: boolean = false;
116-
secondPanelDisabled: boolean = false;
117221
}
118222

119223
@Component({template: `

src/lib/expansion/accordion.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,37 +6,71 @@
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.
1819
*/
1920
@Directive({
2021
selector: 'mat-accordion',
2122
exportAs: 'matAccordion',
23+
providers: [{
24+
provide: MAT_ACCORDION,
25+
useExisting: MatAccordion
26+
}],
2227
host: {
2328
class: 'mat-accordion'
2429
}
2530
})
26-
export class MatAccordion extends CdkAccordion {
31+
export class MatAccordion extends CdkAccordion implements MatAccordionBase, AfterContentInit {
32+
private _keyManager: FocusKeyManager<MatExpansionPanelHeader>;
33+
34+
@ContentChildren(MatExpansionPanelHeader, {descendants: true})
35+
_headers: QueryList<MatExpansionPanelHeader>;
36+
2737
/** Whether the expansion indicator should be hidden. */
2838
@Input()
2939
get hideToggle(): boolean { return this._hideToggle; }
3040
set hideToggle(show: boolean) { this._hideToggle = coerceBooleanProperty(show); }
3141
private _hideToggle: boolean = false;
3242

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

0 commit comments

Comments
 (0)