Skip to content

Commit 99a350d

Browse files
committed
fix(stepper): use up/down arrows for navigating vertical stepper
Currently both vertical and horizontal steppers use the left/right arrows to move focus steps. Based on the a11y guidelines (https://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel) the vertical stepper should use the up/down arrows instead.
1 parent b488b39 commit 99a350d

File tree

3 files changed

+157
-114
lines changed

3 files changed

+157
-114
lines changed

src/cdk/stepper/stepper.ts

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
OnChanges,
2828
OnDestroy
2929
} from '@angular/core';
30-
import {LEFT_ARROW, RIGHT_ARROW, ENTER, SPACE} from '@angular/cdk/keycodes';
30+
import {LEFT_ARROW, RIGHT_ARROW, DOWN_ARROW, UP_ARROW, ENTER, SPACE} from '@angular/cdk/keycodes';
3131
import {CdkStepLabel} from './step-label';
3232
import {coerceBooleanProperty} from '@angular/cdk/coercion';
3333
import {AbstractControl} from '@angular/forms';
@@ -43,6 +43,9 @@ let nextId = 0;
4343
*/
4444
export type StepContentPositionState = 'previous' | 'current' | 'next';
4545

46+
/** Possible orientation of a stepper. */
47+
export type StepperOrientation = 'horizontal' | 'vertical';
48+
4649
/** Change event emitted on selection changes. */
4750
export class StepperSelectionEvent {
4851
/** Index of the step now selected. */
@@ -182,6 +185,8 @@ export class CdkStepper implements OnDestroy {
182185
/** Used to track unique ID for each stepper component. */
183186
_groupId: number;
184187

188+
protected _orientation: StepperOrientation = 'horizontal';
189+
185190
constructor(
186191
@Optional() private _dir: Directionality,
187192
private _changeDetectorRef: ChangeDetectorRef) {
@@ -252,30 +257,29 @@ export class CdkStepper implements OnDestroy {
252257
}
253258

254259
_onKeydown(event: KeyboardEvent) {
255-
switch (event.keyCode) {
256-
case RIGHT_ARROW:
257-
if (this._layoutDirection() === 'rtl') {
258-
this._focusPreviousStep();
259-
} else {
260-
this._focusNextStep();
261-
}
262-
break;
263-
case LEFT_ARROW:
264-
if (this._layoutDirection() === 'rtl') {
265-
this._focusNextStep();
266-
} else {
267-
this._focusPreviousStep();
268-
}
269-
break;
270-
case SPACE:
271-
case ENTER:
272-
this.selectedIndex = this._focusIndex;
273-
break;
274-
default:
275-
// Return to avoid calling preventDefault on keys that are not explicitly handled.
276-
return;
260+
const keyCode = event.keyCode;
261+
262+
if (this._orientation === 'horizontal') {
263+
if (keyCode === RIGHT_ARROW) {
264+
this._layoutDirection() === 'rtl' ? this._focusPreviousStep() : this._focusNextStep();
265+
event.preventDefault();
266+
}
267+
268+
if (keyCode === LEFT_ARROW) {
269+
this._layoutDirection() === 'rtl' ? this._focusNextStep() : this._focusPreviousStep();
270+
event.preventDefault();
271+
}
272+
}
273+
274+
if (this._orientation === 'vertical' && (keyCode === UP_ARROW || keyCode === DOWN_ARROW)) {
275+
keyCode === UP_ARROW ? this._focusPreviousStep() : this._focusNextStep();
276+
event.preventDefault();
277+
}
278+
279+
if (keyCode === SPACE || keyCode === ENTER) {
280+
this.selectedIndex = this._focusIndex;
281+
event.preventDefault();
277282
}
278-
event.preventDefault();
279283
}
280284

281285
private _focusNextStep() {

src/lib/stepper/stepper.spec.ts

Lines changed: 120 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {Directionality} from '@angular/cdk/bidi';
2-
import {ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes';
2+
import {ENTER, LEFT_ARROW, RIGHT_ARROW, UP_ARROW, DOWN_ARROW, SPACE} from '@angular/cdk/keycodes';
33
import {dispatchKeyboardEvent} from '@angular/cdk/testing';
44
import {Component, DebugElement} from '@angular/core';
55
import {async, ComponentFixture, TestBed, inject} from '@angular/core/testing';
@@ -89,7 +89,58 @@ describe('MatHorizontalStepper', () => {
8989

9090
it('should support keyboard events to move and select focus', () => {
9191
let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-horizontal-stepper-header'));
92-
assertCorrectKeyboardInteraction(fixture, stepHeaders);
92+
let stepperComponent = fixture.debugElement.query(By.directive(MatStepper)).componentInstance;
93+
let stepHeaderEl = stepHeaders[0].nativeElement;
94+
95+
expect(stepperComponent._focusIndex).toBe(0);
96+
expect(stepperComponent.selectedIndex).toBe(0);
97+
98+
dispatchKeyboardEvent(stepHeaderEl, 'keydown', RIGHT_ARROW);
99+
fixture.detectChanges();
100+
101+
expect(stepperComponent._focusIndex)
102+
.toBe(1, 'Expected index of focused step to increase by 1 after RIGHT_ARROW event.');
103+
expect(stepperComponent.selectedIndex)
104+
.toBe(0, 'Expected index of selected step to remain unchanged after RIGHT_ARROW event.');
105+
106+
stepHeaderEl = stepHeaders[1].nativeElement;
107+
dispatchKeyboardEvent(stepHeaderEl, 'keydown', ENTER);
108+
fixture.detectChanges();
109+
110+
expect(stepperComponent._focusIndex)
111+
.toBe(1, 'Expected index of focused step to remain unchanged after ENTER event.');
112+
expect(stepperComponent.selectedIndex).toBe(1,
113+
'Expected index of selected step to change to index of focused step after ENTER event.');
114+
115+
stepHeaderEl = stepHeaders[1].nativeElement;
116+
dispatchKeyboardEvent(stepHeaderEl, 'keydown', LEFT_ARROW);
117+
fixture.detectChanges();
118+
119+
expect(stepperComponent._focusIndex)
120+
.toBe(0, 'Expected index of focused step to decrease by 1 after LEFT_ARROW event.');
121+
expect(stepperComponent.selectedIndex)
122+
.toBe(1, 'Expected index of selected step to remain unchanged after LEFT_ARROW event.');
123+
124+
// When the focus is on the last step and right arrow key is pressed, the focus should cycle
125+
// through to the first step.
126+
stepperComponent._focusIndex = 2;
127+
stepHeaderEl = stepHeaders[2].nativeElement;
128+
dispatchKeyboardEvent(stepHeaderEl, 'keydown', RIGHT_ARROW);
129+
fixture.detectChanges();
130+
131+
expect(stepperComponent._focusIndex).toBe(0,
132+
'Expected index of focused step to cycle through to index 0 after RIGHT_ARROW event.');
133+
expect(stepperComponent.selectedIndex)
134+
.toBe(1, 'Expected index of selected step to remain unchanged after RIGHT_ARROW event.');
135+
136+
stepHeaderEl = stepHeaders[0].nativeElement;
137+
dispatchKeyboardEvent(stepHeaderEl, 'keydown', SPACE);
138+
fixture.detectChanges();
139+
140+
expect(stepperComponent._focusIndex)
141+
.toBe(0, 'Expected index of focused to remain unchanged after SPACE event.');
142+
expect(stepperComponent.selectedIndex)
143+
.toBe(0, 'Expected index of selected step to change after SPACE event.');
93144
});
94145

95146
it('should not set focus on header of selected step if header is not clicked', () => {
@@ -135,7 +186,21 @@ describe('MatHorizontalStepper', () => {
135186

136187
it('should reverse arrow key focus in RTL mode', () => {
137188
let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-horizontal-stepper-header'));
138-
assertArrowKeyInteractionInRtl(fixture, stepHeaders);
189+
let stepperComponent = fixture.debugElement.query(By.directive(MatStepper)).componentInstance;
190+
let stepHeaderEl = stepHeaders[0].nativeElement;
191+
192+
expect(stepperComponent._focusIndex).toBe(0);
193+
194+
dispatchKeyboardEvent(stepHeaderEl, 'keydown', LEFT_ARROW);
195+
fixture.detectChanges();
196+
197+
expect(stepperComponent._focusIndex).toBe(1);
198+
199+
stepHeaderEl = stepHeaders[1].nativeElement;
200+
dispatchKeyboardEvent(stepHeaderEl, 'keydown', RIGHT_ARROW);
201+
fixture.detectChanges();
202+
203+
expect(stepperComponent._focusIndex).toBe(0);
139204
});
140205

141206
it('should reverse animation in RTL mode', () => {
@@ -273,7 +338,58 @@ describe('MatVerticalStepper', () => {
273338

274339
it('should support keyboard events to move and select focus', () => {
275340
let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-vertical-stepper-header'));
276-
assertCorrectKeyboardInteraction(fixture, stepHeaders);
341+
let stepperComponent = fixture.debugElement.query(By.directive(MatStepper)).componentInstance;
342+
let stepHeaderEl = stepHeaders[0].nativeElement;
343+
344+
expect(stepperComponent._focusIndex).toBe(0);
345+
expect(stepperComponent.selectedIndex).toBe(0);
346+
347+
dispatchKeyboardEvent(stepHeaderEl, 'keydown', DOWN_ARROW);
348+
fixture.detectChanges();
349+
350+
expect(stepperComponent._focusIndex)
351+
.toBe(1, 'Expected index of focused step to increase by 1 after DOWN_ARROW event.');
352+
expect(stepperComponent.selectedIndex)
353+
.toBe(0, 'Expected index of selected step to remain unchanged after DOWN_ARROW event.');
354+
355+
stepHeaderEl = stepHeaders[1].nativeElement;
356+
dispatchKeyboardEvent(stepHeaderEl, 'keydown', ENTER);
357+
fixture.detectChanges();
358+
359+
expect(stepperComponent._focusIndex)
360+
.toBe(1, 'Expected index of focused step to remain unchanged after ENTER event.');
361+
expect(stepperComponent.selectedIndex).toBe(1,
362+
'Expected index of selected step to change to index of focused step after ENTER event.');
363+
364+
stepHeaderEl = stepHeaders[1].nativeElement;
365+
dispatchKeyboardEvent(stepHeaderEl, 'keydown', UP_ARROW);
366+
fixture.detectChanges();
367+
368+
expect(stepperComponent._focusIndex)
369+
.toBe(0, 'Expected index of focused step to decrease by 1 after UP_ARROW event.');
370+
expect(stepperComponent.selectedIndex)
371+
.toBe(1, 'Expected index of selected step to remain unchanged after UP_ARROW event.');
372+
373+
// When the focus is on the last step and right arrow key is pressed, the focus should cycle
374+
// through to the first step.
375+
stepperComponent._focusIndex = 2;
376+
stepHeaderEl = stepHeaders[2].nativeElement;
377+
dispatchKeyboardEvent(stepHeaderEl, 'keydown', DOWN_ARROW);
378+
fixture.detectChanges();
379+
380+
expect(stepperComponent._focusIndex).toBe(0,
381+
'Expected index of focused step to cycle through to index 0 after DOWN_ARROW event.');
382+
expect(stepperComponent.selectedIndex)
383+
.toBe(1, 'Expected index of selected step to remain unchanged after DOWN_ARROW event.');
384+
385+
stepHeaderEl = stepHeaders[0].nativeElement;
386+
dispatchKeyboardEvent(stepHeaderEl, 'keydown', SPACE);
387+
fixture.detectChanges();
388+
389+
expect(stepperComponent._focusIndex)
390+
.toBe(0, 'Expected index of focused to remain unchanged after SPACE event.');
391+
expect(stepperComponent.selectedIndex)
392+
.toBe(0, 'Expected index of selected step to change after SPACE event.');
277393
});
278394

279395
it('should not set focus on header of selected step if header is not clicked', () => {
@@ -302,11 +418,6 @@ describe('MatVerticalStepper', () => {
302418
fixture.detectChanges();
303419
});
304420

305-
it('should reverse arrow key focus in RTL mode', () => {
306-
let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-vertical-stepper-header'));
307-
assertArrowKeyInteractionInRtl(fixture, stepHeaders);
308-
});
309-
310421
it('should reverse animation in RTL mode', () => {
311422
assertCorrectStepAnimationDirection(fixture, 'rtl');
312423
});
@@ -514,66 +625,6 @@ function assertCorrectStepAnimationDirection(fixture: ComponentFixture<any>, rtl
514625
expect(stepperComponent._getAnimationDirection(2)).toBe('current');
515626
}
516627

517-
/** Asserts that keyboard interaction works correctly. */
518-
function assertCorrectKeyboardInteraction(fixture: ComponentFixture<any>,
519-
stepHeaders: DebugElement[]) {
520-
let stepperComponent = fixture.debugElement.query(By.directive(MatStepper)).componentInstance;
521-
522-
expect(stepperComponent._focusIndex).toBe(0);
523-
expect(stepperComponent.selectedIndex).toBe(0);
524-
525-
let stepHeaderEl = stepHeaders[0].nativeElement;
526-
dispatchKeyboardEvent(stepHeaderEl, 'keydown', RIGHT_ARROW);
527-
fixture.detectChanges();
528-
529-
expect(stepperComponent._focusIndex)
530-
.toBe(1, 'Expected index of focused step to increase by 1 after RIGHT_ARROW event.');
531-
expect(stepperComponent.selectedIndex)
532-
.toBe(0, 'Expected index of selected step to remain unchanged after RIGHT_ARROW event.');
533-
534-
stepHeaderEl = stepHeaders[1].nativeElement;
535-
dispatchKeyboardEvent(stepHeaderEl, 'keydown', ENTER);
536-
fixture.detectChanges();
537-
538-
expect(stepperComponent._focusIndex)
539-
.toBe(1, 'Expected index of focused step to remain unchanged after ENTER event.');
540-
expect(stepperComponent.selectedIndex)
541-
.toBe(1,
542-
'Expected index of selected step to change to index of focused step after ENTER event.');
543-
544-
stepHeaderEl = stepHeaders[1].nativeElement;
545-
dispatchKeyboardEvent(stepHeaderEl, 'keydown', LEFT_ARROW);
546-
fixture.detectChanges();
547-
548-
expect(stepperComponent._focusIndex)
549-
.toBe(0, 'Expected index of focused step to decrease by 1 after LEFT_ARROW event.');
550-
expect(stepperComponent.selectedIndex)
551-
.toBe(1, 'Expected index of selected step to remain unchanged after LEFT_ARROW event.');
552-
553-
// When the focus is on the last step and right arrow key is pressed, the focus should cycle
554-
// through to the first step.
555-
stepperComponent._focusIndex = 2;
556-
stepHeaderEl = stepHeaders[2].nativeElement;
557-
dispatchKeyboardEvent(stepHeaderEl, 'keydown', RIGHT_ARROW);
558-
fixture.detectChanges();
559-
560-
expect(stepperComponent._focusIndex)
561-
.toBe(0,
562-
'Expected index of focused step to cycle through to index 0 after RIGHT_ARROW event.');
563-
expect(stepperComponent.selectedIndex)
564-
.toBe(1, 'Expected index of selected step to remain unchanged after RIGHT_ARROW event.');
565-
566-
stepHeaderEl = stepHeaders[0].nativeElement;
567-
dispatchKeyboardEvent(stepHeaderEl, 'keydown', SPACE);
568-
fixture.detectChanges();
569-
570-
expect(stepperComponent._focusIndex)
571-
.toBe(0, 'Expected index of focused to remain unchanged after SPACE event.');
572-
expect(stepperComponent.selectedIndex)
573-
.toBe(0,
574-
'Expected index of selected step to change to index of focused step after SPACE event.');
575-
}
576-
577628
/** Asserts that step selection change using stepper buttons does not focus step header. */
578629
function assertStepHeaderFocusNotCalled(fixture: ComponentFixture<any>) {
579630
let stepperComponent = fixture.debugElement.query(By.directive(MatStepper)).componentInstance;
@@ -588,26 +639,6 @@ function assertStepHeaderFocusNotCalled(fixture: ComponentFixture<any>) {
588639
expect(stepHeaderEl.focus).not.toHaveBeenCalled();
589640
}
590641

591-
/** Asserts that arrow key direction works correctly in RTL mode. */
592-
function assertArrowKeyInteractionInRtl(fixture: ComponentFixture<any>,
593-
stepHeaders: DebugElement[]) {
594-
let stepperComponent = fixture.debugElement.query(By.directive(MatStepper)).componentInstance;
595-
596-
expect(stepperComponent._focusIndex).toBe(0);
597-
598-
let stepHeaderEl = stepHeaders[0].nativeElement;
599-
dispatchKeyboardEvent(stepHeaderEl, 'keydown', LEFT_ARROW);
600-
fixture.detectChanges();
601-
602-
expect(stepperComponent._focusIndex).toBe(1);
603-
604-
stepHeaderEl = stepHeaders[1].nativeElement;
605-
dispatchKeyboardEvent(stepHeaderEl, 'keydown', RIGHT_ARROW);
606-
fixture.detectChanges();
607-
608-
expect(stepperComponent._focusIndex).toBe(0);
609-
}
610-
611642
/**
612643
* Asserts that linear stepper does not allow step selection change if current step is not valid.
613644
*/

src/lib/stepper/stepper.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {animate, state, style, transition, trigger} from '@angular/animations';
1010
import {CdkStep, CdkStepper} from '@angular/cdk/stepper';
11+
import {Directionality} from '@angular/cdk/bidi';
1112
import {
1213
AfterContentInit,
1314
Component,
@@ -21,7 +22,9 @@ import {
2122
SkipSelf,
2223
ViewChildren,
2324
ViewEncapsulation,
25+
ChangeDetectorRef,
2426
ChangeDetectionStrategy,
27+
Optional,
2528
} from '@angular/core';
2629
import {FormControl, FormGroupDirective, NgForm} from '@angular/forms';
2730
import {ErrorStateMatcher} from '@angular/material/core';
@@ -129,4 +132,9 @@ export class MatHorizontalStepper extends MatStepper { }
129132
preserveWhitespaces: false,
130133
changeDetection: ChangeDetectionStrategy.OnPush,
131134
})
132-
export class MatVerticalStepper extends MatStepper { }
135+
export class MatVerticalStepper extends MatStepper {
136+
constructor(@Optional() dir: Directionality, changeDetectorRef: ChangeDetectorRef) {
137+
super(dir, changeDetectorRef);
138+
this._orientation = 'vertical';
139+
}
140+
}

0 commit comments

Comments
 (0)