refactor(stepper): use common keyboard focus handling (#10074)

Switches the stepper to use the `FocusKeyManager`, instead of re-implementing all of the logic itself.
This commit is contained in:
Kristiyan Kostadinov
2018-03-02 23:39:47 +02:00
committed by tinayuangao
parent bb1803dcc0
commit ccbab877c3
7 changed files with 57 additions and 78 deletions
+1
View File
@@ -6,6 +6,7 @@ ng_module(
srcs = glob(["**/*.ts"], exclude=["**/*.spec.ts"]),
module_name = "@angular/cdk/stepper",
deps = [
"//src/cdk/a11y",
"//src/cdk/bidi",
"//src/cdk/coercion",
"//src/cdk/keycodes",
+35 -58
View File
@@ -7,13 +7,13 @@
*/
import {
AfterViewInit,
ContentChildren,
EventEmitter,
Input,
Output,
QueryList,
Directive,
ElementRef,
Component,
ContentChild,
ViewChild,
@@ -28,10 +28,6 @@ import {
OnDestroy
} from '@angular/core';
import {
LEFT_ARROW,
RIGHT_ARROW,
DOWN_ARROW,
UP_ARROW,
ENTER,
SPACE,
HOME,
@@ -42,6 +38,7 @@ import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {AbstractControl} from '@angular/forms';
import {Direction, Directionality} from '@angular/cdk/bidi';
import {Subject} from 'rxjs/Subject';
import {FocusKeyManager, FocusableOption} from '@angular/cdk/a11y';
/** Used to generate unique ID for each stepper component. */
let nextId = 0;
@@ -156,15 +153,18 @@ export class CdkStep implements OnChanges {
selector: '[cdkStepper]',
exportAs: 'cdkStepper',
})
export class CdkStepper implements OnDestroy {
export class CdkStepper implements AfterViewInit, OnDestroy {
/** Emits when the component is destroyed. */
protected _destroyed = new Subject<void>();
/** Used for managing keyboard focus. */
private _keyManager: FocusKeyManager<FocusableOption>;
/** The list of step components that the stepper is holding. */
@ContentChildren(CdkStep) _steps: QueryList<CdkStep>;
/** The list of step headers of the steps in the stepper. */
_stepHeader: QueryList<ElementRef>;
_stepHeader: QueryList<FocusableOption>;
/** Whether the validity of previous steps should be checked or not. */
@Input()
@@ -182,16 +182,15 @@ export class CdkStepper implements OnDestroy {
throw Error('cdkStepper: Cannot assign out-of-bounds value to `selectedIndex`.');
}
if (this._anyControlsInvalidOrPending(index) || index < this._selectedIndex &&
!this._steps.toArray()[index].editable) {
// remove focus from clicked step header if the step is not able to be selected
this._stepHeader.toArray()[index].nativeElement.blur();
} else if (this._selectedIndex != index) {
if (this._selectedIndex != index &&
!this._anyControlsInvalidOrPending(index) &&
(index >= this._selectedIndex || this._steps.toArray()[index].editable)) {
this._emitStepperSelectionEvent(index);
this._focusIndex = this._selectedIndex;
this._keyManager.updateActiveItemIndex(this._selectedIndex);
}
} else {
this._selectedIndex = this._focusIndex = index;
this._selectedIndex = index;
}
}
private _selectedIndex = 0;
@@ -207,9 +206,6 @@ export class CdkStepper implements OnDestroy {
@Output() selectionChange: EventEmitter<StepperSelectionEvent>
= new EventEmitter<StepperSelectionEvent>();
/** The index of the step that the focus can be set. */
_focusIndex: number = 0;
/** Used to track unique ID for each stepper component. */
_groupId: number;
@@ -221,6 +217,15 @@ export class CdkStepper implements OnDestroy {
this._groupId = nextId++;
}
ngAfterViewInit() {
this._keyManager = new FocusKeyManager(this._stepHeader)
.withWrap()
.withHorizontalOrientation(this._layoutDirection())
.withVerticalOrientation(this._orientation === 'vertical');
this._keyManager.updateActiveItemIndex(this._selectedIndex);
}
ngOnDestroy() {
this._destroyed.next();
this._destroyed.complete();
@@ -279,6 +284,11 @@ export class CdkStepper implements OnDestroy {
}
}
/** Returns the index of the currently-focused step header. */
_getFocusIndex() {
return this._keyManager ? this._keyManager.activeItemIndex : this._selectedIndex;
}
private _emitStepperSelectionEvent(newIndex: number): void {
const stepsArray = this._steps.toArray();
this.selectionChange.emit({
@@ -294,51 +304,18 @@ export class CdkStepper implements OnDestroy {
_onKeydown(event: KeyboardEvent) {
const keyCode = event.keyCode;
// Note that the left/right arrows work both in vertical and horizontal mode.
if (keyCode === RIGHT_ARROW) {
this._layoutDirection() === 'rtl' ? this._focusPreviousStep() : this._focusNextStep();
if (this._keyManager.activeItemIndex != null && (keyCode === SPACE || keyCode === ENTER)) {
this.selectedIndex = this._keyManager.activeItemIndex;
event.preventDefault();
}
if (keyCode === LEFT_ARROW) {
this._layoutDirection() === 'rtl' ? this._focusNextStep() : this._focusPreviousStep();
} else if (keyCode === HOME) {
this._keyManager.setFirstItemActive();
event.preventDefault();
}
// Note that the up/down arrows only work in vertical mode.
// See: https://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel
if (this._orientation === 'vertical' && (keyCode === UP_ARROW || keyCode === DOWN_ARROW)) {
keyCode === UP_ARROW ? this._focusPreviousStep() : this._focusNextStep();
} else if (keyCode === END) {
this._keyManager.setLastItemActive();
event.preventDefault();
} else {
this._keyManager.onKeydown(event);
}
if (keyCode === SPACE || keyCode === ENTER) {
this.selectedIndex = this._focusIndex;
event.preventDefault();
}
if (keyCode === HOME) {
this._focusStep(0);
event.preventDefault();
}
if (keyCode === END) {
this._focusStep(this._steps.length - 1);
event.preventDefault();
}
}
private _focusNextStep() {
this._focusStep((this._focusIndex + 1) % this._steps.length);
}
private _focusPreviousStep() {
this._focusStep((this._focusIndex + this._steps.length - 1) % this._steps.length);
}
private _focusStep(index: number) {
this._focusIndex = index;
this._stepHeader.toArray()[this._focusIndex].nativeElement.focus();
}
private _anyControlsInvalidOrPending(index: number): boolean {
+4
View File
@@ -100,4 +100,8 @@ export class MatStepHeader implements OnDestroy {
_getHostElement() {
return this._element.nativeElement;
}
focus() {
this._getHostElement().focus();
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
<mat-step-header class="mat-horizontal-stepper-header"
(click)="step.select()"
(keydown)="_onKeydown($event)"
[tabIndex]="_focusIndex === i ? 0 : -1"
[tabIndex]="_getFocusIndex() === i ? 0 : -1"
[id]="_getStepLabelId(i)"
[attr.aria-controls]="_getStepContentId(i)"
[attr.aria-selected]="selectedIndex == i"
+1 -1
View File
@@ -2,7 +2,7 @@
<mat-step-header class="mat-vertical-stepper-header"
(click)="step.select()"
(keydown)="_onKeydown($event)"
[tabIndex]="_focusIndex == i ? 0 : -1"
[tabIndex]="_getFocusIndex() == i ? 0 : -1"
[id]="_getStepLabelId(i)"
[attr.aria-controls]="_getStepContentId(i)"
[attr.aria-selected]="selectedIndex === i"
+14 -16
View File
@@ -485,14 +485,12 @@ describe('MatStepper', () => {
expect(stepperComponent.selectedIndex).toBe(2);
});
it('should not focus step header upon click if it is not able to be selected', () => {
it('should be able to focus step header upon click if it is unable to be selected', () => {
let stepHeaderEl = fixture.debugElement.queryAll(By.css('mat-step-header'))[1].nativeElement;
spyOn(stepHeaderEl, 'blur');
stepHeaderEl.click();
fixture.detectChanges();
expect(stepHeaderEl.blur).toHaveBeenCalled();
expect(stepHeaderEl.getAttribute('tabindex')).toBe('-1');
});
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>,
let nextKey = orientation === 'vertical' ? DOWN_ARROW : RIGHT_ARROW;
let prevKey = orientation === 'vertical' ? UP_ARROW : LEFT_ARROW;
expect(stepperComponent._focusIndex).toBe(0);
expect(stepperComponent._getFocusIndex()).toBe(0);
expect(stepperComponent.selectedIndex).toBe(0);
let stepHeaderEl = stepHeaders[0].nativeElement;
dispatchKeyboardEvent(stepHeaderEl, 'keydown', nextKey);
fixture.detectChanges();
expect(stepperComponent._focusIndex)
expect(stepperComponent._getFocusIndex())
.toBe(1, 'Expected index of focused step to increase by 1 after pressing the next key.');
expect(stepperComponent.selectedIndex)
.toBe(0, 'Expected index of selected step to remain unchanged after pressing the next key.');
@@ -770,7 +768,7 @@ function assertCorrectKeyboardInteraction(fixture: ComponentFixture<any>,
dispatchKeyboardEvent(stepHeaderEl, 'keydown', ENTER);
fixture.detectChanges();
expect(stepperComponent._focusIndex)
expect(stepperComponent._getFocusIndex())
.toBe(1, 'Expected index of focused step to remain unchanged after ENTER event.');
expect(stepperComponent.selectedIndex)
.toBe(1,
@@ -780,19 +778,19 @@ function assertCorrectKeyboardInteraction(fixture: ComponentFixture<any>,
dispatchKeyboardEvent(stepHeaderEl, 'keydown', prevKey);
fixture.detectChanges();
expect(stepperComponent._focusIndex)
expect(stepperComponent._getFocusIndex())
.toBe(0, 'Expected index of focused step to decrease by 1 after pressing the previous key.');
expect(stepperComponent.selectedIndex).toBe(1,
'Expected index of selected step to remain unchanged after pressing the previous key.');
// When the focus is on the last step and right arrow key is pressed, the focus should cycle
// through to the first step.
stepperComponent._focusIndex = 2;
stepperComponent._keyManager.updateActiveItemIndex(2);
stepHeaderEl = stepHeaders[2].nativeElement;
dispatchKeyboardEvent(stepHeaderEl, 'keydown', nextKey);
fixture.detectChanges();
expect(stepperComponent._focusIndex).toBe(0,
expect(stepperComponent._getFocusIndex()).toBe(0,
'Expected index of focused step to cycle through to index 0 after pressing the next key.');
expect(stepperComponent.selectedIndex)
.toBe(1, 'Expected index of selected step to remain unchanged after pressing the next key.');
@@ -801,19 +799,19 @@ function assertCorrectKeyboardInteraction(fixture: ComponentFixture<any>,
dispatchKeyboardEvent(stepHeaderEl, 'keydown', SPACE);
fixture.detectChanges();
expect(stepperComponent._focusIndex)
expect(stepperComponent._getFocusIndex())
.toBe(0, 'Expected index of focused to remain unchanged after SPACE event.');
expect(stepperComponent.selectedIndex)
.toBe(0,
'Expected index of selected step to change to index of focused step after SPACE event.');
const endEvent = dispatchKeyboardEvent(stepHeaderEl, 'keydown', END);
expect(stepperComponent._focusIndex)
expect(stepperComponent._getFocusIndex())
.toBe(stepHeaders.length - 1, 'Expected last step to be focused when pressing END.');
expect(endEvent.defaultPrevented).toBe(true, 'Expected default END action to be prevented.');
const homeEvent = dispatchKeyboardEvent(stepHeaderEl, 'keydown', HOME);
expect(stepperComponent._focusIndex)
expect(stepperComponent._getFocusIndex())
.toBe(0, 'Expected first step to be focused when pressing HOME.');
expect(homeEvent.defaultPrevented).toBe(true, 'Expected default HOME action to be prevented.');
}
@@ -823,19 +821,19 @@ function assertArrowKeyInteractionInRtl(fixture: ComponentFixture<any>,
stepHeaders: DebugElement[]) {
let stepperComponent = fixture.debugElement.query(By.directive(MatStepper)).componentInstance;
expect(stepperComponent._focusIndex).toBe(0);
expect(stepperComponent._getFocusIndex()).toBe(0);
let stepHeaderEl = stepHeaders[0].nativeElement;
dispatchKeyboardEvent(stepHeaderEl, 'keydown', LEFT_ARROW);
fixture.detectChanges();
expect(stepperComponent._focusIndex).toBe(1);
expect(stepperComponent._getFocusIndex()).toBe(1);
stepHeaderEl = stepHeaders[1].nativeElement;
dispatchKeyboardEvent(stepHeaderEl, 'keydown', RIGHT_ARROW);
fixture.detectChanges();
expect(stepperComponent._focusIndex).toBe(0);
expect(stepperComponent._getFocusIndex()).toBe(0);
}
function asyncValidator(minLength: number, validationTrigger: Observable<any>): AsyncValidatorFn {
+1 -2
View File
@@ -14,7 +14,6 @@ import {
ContentChild,
ContentChildren,
Directive,
ElementRef,
forwardRef,
Inject,
QueryList,
@@ -76,7 +75,7 @@ export class MatStep extends CdkStep implements ErrorStateMatcher {
})
export class MatStepper extends CdkStepper implements AfterContentInit {
/** The list of step headers of the steps in the stepper. */
@ViewChildren(MatStepHeader, {read: ElementRef}) _stepHeader: QueryList<ElementRef>;
@ViewChildren(MatStepHeader) _stepHeader: QueryList<MatStepHeader>;
/** Steps that the stepper holds. */
@ContentChildren(MatStep) _steps: QueryList<MatStep>;