Skip to content

Commit ccbab87

Browse files
crisbetotinayuangao
authored andcommitted
refactor(stepper): use common keyboard focus handling (#10074)
Switches the stepper to use the `FocusKeyManager`, instead of re-implementing all of the logic itself.
1 parent bb1803d commit ccbab87

File tree

7 files changed

+57
-78
lines changed

7 files changed

+57
-78
lines changed

src/cdk/stepper/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ ng_module(
66
srcs = glob(["**/*.ts"], exclude=["**/*.spec.ts"]),
77
module_name = "@angular/cdk/stepper",
88
deps = [
9+
"//src/cdk/a11y",
910
"//src/cdk/bidi",
1011
"//src/cdk/coercion",
1112
"//src/cdk/keycodes",

src/cdk/stepper/stepper.ts

Lines changed: 35 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77
*/
88

99
import {
10+
AfterViewInit,
1011
ContentChildren,
1112
EventEmitter,
1213
Input,
1314
Output,
1415
QueryList,
1516
Directive,
16-
ElementRef,
1717
Component,
1818
ContentChild,
1919
ViewChild,
@@ -28,10 +28,6 @@ import {
2828
OnDestroy
2929
} from '@angular/core';
3030
import {
31-
LEFT_ARROW,
32-
RIGHT_ARROW,
33-
DOWN_ARROW,
34-
UP_ARROW,
3531
ENTER,
3632
SPACE,
3733
HOME,
@@ -42,6 +38,7 @@ import {coerceBooleanProperty} from '@angular/cdk/coercion';
4238
import {AbstractControl} from '@angular/forms';
4339
import {Direction, Directionality} from '@angular/cdk/bidi';
4440
import {Subject} from 'rxjs/Subject';
41+
import {FocusKeyManager, FocusableOption} from '@angular/cdk/a11y';
4542

4643
/** Used to generate unique ID for each stepper component. */
4744
let nextId = 0;
@@ -156,15 +153,18 @@ export class CdkStep implements OnChanges {
156153
selector: '[cdkStepper]',
157154
exportAs: 'cdkStepper',
158155
})
159-
export class CdkStepper implements OnDestroy {
156+
export class CdkStepper implements AfterViewInit, OnDestroy {
160157
/** Emits when the component is destroyed. */
161158
protected _destroyed = new Subject<void>();
162159

160+
/** Used for managing keyboard focus. */
161+
private _keyManager: FocusKeyManager<FocusableOption>;
162+
163163
/** The list of step components that the stepper is holding. */
164164
@ContentChildren(CdkStep) _steps: QueryList<CdkStep>;
165165

166166
/** The list of step headers of the steps in the stepper. */
167-
_stepHeader: QueryList<ElementRef>;
167+
_stepHeader: QueryList<FocusableOption>;
168168

169169
/** Whether the validity of previous steps should be checked or not. */
170170
@Input()
@@ -182,16 +182,15 @@ export class CdkStepper implements OnDestroy {
182182
throw Error('cdkStepper: Cannot assign out-of-bounds value to `selectedIndex`.');
183183
}
184184

185-
if (this._anyControlsInvalidOrPending(index) || index < this._selectedIndex &&
186-
!this._steps.toArray()[index].editable) {
187-
// remove focus from clicked step header if the step is not able to be selected
188-
this._stepHeader.toArray()[index].nativeElement.blur();
189-
} else if (this._selectedIndex != index) {
185+
if (this._selectedIndex != index &&
186+
!this._anyControlsInvalidOrPending(index) &&
187+
(index >= this._selectedIndex || this._steps.toArray()[index].editable)) {
188+
190189
this._emitStepperSelectionEvent(index);
191-
this._focusIndex = this._selectedIndex;
190+
this._keyManager.updateActiveItemIndex(this._selectedIndex);
192191
}
193192
} else {
194-
this._selectedIndex = this._focusIndex = index;
193+
this._selectedIndex = index;
195194
}
196195
}
197196
private _selectedIndex = 0;
@@ -207,9 +206,6 @@ export class CdkStepper implements OnDestroy {
207206
@Output() selectionChange: EventEmitter<StepperSelectionEvent>
208207
= new EventEmitter<StepperSelectionEvent>();
209208

210-
/** The index of the step that the focus can be set. */
211-
_focusIndex: number = 0;
212-
213209
/** Used to track unique ID for each stepper component. */
214210
_groupId: number;
215211

@@ -221,6 +217,15 @@ export class CdkStepper implements OnDestroy {
221217
this._groupId = nextId++;
222218
}
223219

220+
ngAfterViewInit() {
221+
this._keyManager = new FocusKeyManager(this._stepHeader)
222+
.withWrap()
223+
.withHorizontalOrientation(this._layoutDirection())
224+
.withVerticalOrientation(this._orientation === 'vertical');
225+
226+
this._keyManager.updateActiveItemIndex(this._selectedIndex);
227+
}
228+
224229
ngOnDestroy() {
225230
this._destroyed.next();
226231
this._destroyed.complete();
@@ -279,6 +284,11 @@ export class CdkStepper implements OnDestroy {
279284
}
280285
}
281286

287+
/** Returns the index of the currently-focused step header. */
288+
_getFocusIndex() {
289+
return this._keyManager ? this._keyManager.activeItemIndex : this._selectedIndex;
290+
}
291+
282292
private _emitStepperSelectionEvent(newIndex: number): void {
283293
const stepsArray = this._steps.toArray();
284294
this.selectionChange.emit({
@@ -294,53 +304,20 @@ export class CdkStepper implements OnDestroy {
294304
_onKeydown(event: KeyboardEvent) {
295305
const keyCode = event.keyCode;
296306

297-
// Note that the left/right arrows work both in vertical and horizontal mode.
298-
if (keyCode === RIGHT_ARROW) {
299-
this._layoutDirection() === 'rtl' ? this._focusPreviousStep() : this._focusNextStep();
300-
event.preventDefault();
301-
}
302-
303-
if (keyCode === LEFT_ARROW) {
304-
this._layoutDirection() === 'rtl' ? this._focusNextStep() : this._focusPreviousStep();
305-
event.preventDefault();
306-
}
307-
308-
// Note that the up/down arrows only work in vertical mode.
309-
// See: https://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel
310-
if (this._orientation === 'vertical' && (keyCode === UP_ARROW || keyCode === DOWN_ARROW)) {
311-
keyCode === UP_ARROW ? this._focusPreviousStep() : this._focusNextStep();
307+
if (this._keyManager.activeItemIndex != null && (keyCode === SPACE || keyCode === ENTER)) {
308+
this.selectedIndex = this._keyManager.activeItemIndex;
312309
event.preventDefault();
313-
}
314-
315-
if (keyCode === SPACE || keyCode === ENTER) {
316-
this.selectedIndex = this._focusIndex;
310+
} else if (keyCode === HOME) {
311+
this._keyManager.setFirstItemActive();
317312
event.preventDefault();
318-
}
319-
320-
if (keyCode === HOME) {
321-
this._focusStep(0);
322-
event.preventDefault();
323-
}
324-
325-
if (keyCode === END) {
326-
this._focusStep(this._steps.length - 1);
313+
} else if (keyCode === END) {
314+
this._keyManager.setLastItemActive();
327315
event.preventDefault();
316+
} else {
317+
this._keyManager.onKeydown(event);
328318
}
329319
}
330320

331-
private _focusNextStep() {
332-
this._focusStep((this._focusIndex + 1) % this._steps.length);
333-
}
334-
335-
private _focusPreviousStep() {
336-
this._focusStep((this._focusIndex + this._steps.length - 1) % this._steps.length);
337-
}
338-
339-
private _focusStep(index: number) {
340-
this._focusIndex = index;
341-
this._stepHeader.toArray()[this._focusIndex].nativeElement.focus();
342-
}
343-
344321
private _anyControlsInvalidOrPending(index: number): boolean {
345322
const steps = this._steps.toArray();
346323

src/lib/stepper/step-header.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,8 @@ export class MatStepHeader implements OnDestroy {
100100
_getHostElement() {
101101
return this._element.nativeElement;
102102
}
103+
104+
focus() {
105+
this._getHostElement().focus();
106+
}
103107
}

src/lib/stepper/stepper-horizontal.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<mat-step-header class="mat-horizontal-stepper-header"
44
(click)="step.select()"
55
(keydown)="_onKeydown($event)"
6-
[tabIndex]="_focusIndex === i ? 0 : -1"
6+
[tabIndex]="_getFocusIndex() === i ? 0 : -1"
77
[id]="_getStepLabelId(i)"
88
[attr.aria-controls]="_getStepContentId(i)"
99
[attr.aria-selected]="selectedIndex == i"

src/lib/stepper/stepper-vertical.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<mat-step-header class="mat-vertical-stepper-header"
33
(click)="step.select()"
44
(keydown)="_onKeydown($event)"
5-
[tabIndex]="_focusIndex == i ? 0 : -1"
5+
[tabIndex]="_getFocusIndex() == i ? 0 : -1"
66
[id]="_getStepLabelId(i)"
77
[attr.aria-controls]="_getStepContentId(i)"
88
[attr.aria-selected]="selectedIndex === i"

src/lib/stepper/stepper.spec.ts

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -485,14 +485,12 @@ describe('MatStepper', () => {
485485
expect(stepperComponent.selectedIndex).toBe(2);
486486
});
487487

488-
it('should not focus step header upon click if it is not able to be selected', () => {
488+
it('should be able to focus step header upon click if it is unable to be selected', () => {
489489
let stepHeaderEl = fixture.debugElement.queryAll(By.css('mat-step-header'))[1].nativeElement;
490490

491-
spyOn(stepHeaderEl, 'blur');
492-
stepHeaderEl.click();
493491
fixture.detectChanges();
494492

495-
expect(stepHeaderEl.blur).toHaveBeenCalled();
493+
expect(stepHeaderEl.getAttribute('tabindex')).toBe('-1');
496494
});
497495

498496
it('should be able to move to next step even when invalid if current step is optional', () => {
@@ -754,14 +752,14 @@ function assertCorrectKeyboardInteraction(fixture: ComponentFixture<any>,
754752
let nextKey = orientation === 'vertical' ? DOWN_ARROW : RIGHT_ARROW;
755753
let prevKey = orientation === 'vertical' ? UP_ARROW : LEFT_ARROW;
756754

757-
expect(stepperComponent._focusIndex).toBe(0);
755+
expect(stepperComponent._getFocusIndex()).toBe(0);
758756
expect(stepperComponent.selectedIndex).toBe(0);
759757

760758
let stepHeaderEl = stepHeaders[0].nativeElement;
761759
dispatchKeyboardEvent(stepHeaderEl, 'keydown', nextKey);
762760
fixture.detectChanges();
763761

764-
expect(stepperComponent._focusIndex)
762+
expect(stepperComponent._getFocusIndex())
765763
.toBe(1, 'Expected index of focused step to increase by 1 after pressing the next key.');
766764
expect(stepperComponent.selectedIndex)
767765
.toBe(0, 'Expected index of selected step to remain unchanged after pressing the next key.');
@@ -770,7 +768,7 @@ function assertCorrectKeyboardInteraction(fixture: ComponentFixture<any>,
770768
dispatchKeyboardEvent(stepHeaderEl, 'keydown', ENTER);
771769
fixture.detectChanges();
772770

773-
expect(stepperComponent._focusIndex)
771+
expect(stepperComponent._getFocusIndex())
774772
.toBe(1, 'Expected index of focused step to remain unchanged after ENTER event.');
775773
expect(stepperComponent.selectedIndex)
776774
.toBe(1,
@@ -780,19 +778,19 @@ function assertCorrectKeyboardInteraction(fixture: ComponentFixture<any>,
780778
dispatchKeyboardEvent(stepHeaderEl, 'keydown', prevKey);
781779
fixture.detectChanges();
782780

783-
expect(stepperComponent._focusIndex)
781+
expect(stepperComponent._getFocusIndex())
784782
.toBe(0, 'Expected index of focused step to decrease by 1 after pressing the previous key.');
785783
expect(stepperComponent.selectedIndex).toBe(1,
786784
'Expected index of selected step to remain unchanged after pressing the previous key.');
787785

788786
// When the focus is on the last step and right arrow key is pressed, the focus should cycle
789787
// through to the first step.
790-
stepperComponent._focusIndex = 2;
788+
stepperComponent._keyManager.updateActiveItemIndex(2);
791789
stepHeaderEl = stepHeaders[2].nativeElement;
792790
dispatchKeyboardEvent(stepHeaderEl, 'keydown', nextKey);
793791
fixture.detectChanges();
794792

795-
expect(stepperComponent._focusIndex).toBe(0,
793+
expect(stepperComponent._getFocusIndex()).toBe(0,
796794
'Expected index of focused step to cycle through to index 0 after pressing the next key.');
797795
expect(stepperComponent.selectedIndex)
798796
.toBe(1, 'Expected index of selected step to remain unchanged after pressing the next key.');
@@ -801,19 +799,19 @@ function assertCorrectKeyboardInteraction(fixture: ComponentFixture<any>,
801799
dispatchKeyboardEvent(stepHeaderEl, 'keydown', SPACE);
802800
fixture.detectChanges();
803801

804-
expect(stepperComponent._focusIndex)
802+
expect(stepperComponent._getFocusIndex())
805803
.toBe(0, 'Expected index of focused to remain unchanged after SPACE event.');
806804
expect(stepperComponent.selectedIndex)
807805
.toBe(0,
808806
'Expected index of selected step to change to index of focused step after SPACE event.');
809807

810808
const endEvent = dispatchKeyboardEvent(stepHeaderEl, 'keydown', END);
811-
expect(stepperComponent._focusIndex)
809+
expect(stepperComponent._getFocusIndex())
812810
.toBe(stepHeaders.length - 1, 'Expected last step to be focused when pressing END.');
813811
expect(endEvent.defaultPrevented).toBe(true, 'Expected default END action to be prevented.');
814812

815813
const homeEvent = dispatchKeyboardEvent(stepHeaderEl, 'keydown', HOME);
816-
expect(stepperComponent._focusIndex)
814+
expect(stepperComponent._getFocusIndex())
817815
.toBe(0, 'Expected first step to be focused when pressing HOME.');
818816
expect(homeEvent.defaultPrevented).toBe(true, 'Expected default HOME action to be prevented.');
819817
}
@@ -823,19 +821,19 @@ function assertArrowKeyInteractionInRtl(fixture: ComponentFixture<any>,
823821
stepHeaders: DebugElement[]) {
824822
let stepperComponent = fixture.debugElement.query(By.directive(MatStepper)).componentInstance;
825823

826-
expect(stepperComponent._focusIndex).toBe(0);
824+
expect(stepperComponent._getFocusIndex()).toBe(0);
827825

828826
let stepHeaderEl = stepHeaders[0].nativeElement;
829827
dispatchKeyboardEvent(stepHeaderEl, 'keydown', LEFT_ARROW);
830828
fixture.detectChanges();
831829

832-
expect(stepperComponent._focusIndex).toBe(1);
830+
expect(stepperComponent._getFocusIndex()).toBe(1);
833831

834832
stepHeaderEl = stepHeaders[1].nativeElement;
835833
dispatchKeyboardEvent(stepHeaderEl, 'keydown', RIGHT_ARROW);
836834
fixture.detectChanges();
837835

838-
expect(stepperComponent._focusIndex).toBe(0);
836+
expect(stepperComponent._getFocusIndex()).toBe(0);
839837
}
840838

841839
function asyncValidator(minLength: number, validationTrigger: Observable<any>): AsyncValidatorFn {

src/lib/stepper/stepper.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
ContentChild,
1515
ContentChildren,
1616
Directive,
17-
ElementRef,
1817
forwardRef,
1918
Inject,
2019
QueryList,
@@ -76,7 +75,7 @@ export class MatStep extends CdkStep implements ErrorStateMatcher {
7675
})
7776
export class MatStepper extends CdkStepper implements AfterContentInit {
7877
/** The list of step headers of the steps in the stepper. */
79-
@ViewChildren(MatStepHeader, {read: ElementRef}) _stepHeader: QueryList<ElementRef>;
78+
@ViewChildren(MatStepHeader) _stepHeader: QueryList<MatStepHeader>;
8079

8180
/** Steps that the stepper holds. */
8281
@ContentChildren(MatStep) _steps: QueryList<MatStep>;

0 commit comments

Comments
 (0)