mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
tinayuangao
parent
bb1803dcc0
commit
ccbab877c3
@@ -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
@@ -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 {
|
||||
|
||||
@@ -100,4 +100,8 @@ export class MatStepHeader implements OnDestroy {
|
||||
_getHostElement() {
|
||||
return this._element.nativeElement;
|
||||
}
|
||||
|
||||
focus() {
|
||||
this._getHostElement().focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user