diff --git a/src/cdk/stepper/BUILD.bazel b/src/cdk/stepper/BUILD.bazel index 57f9698ccaa..e44edabdbfb 100644 --- a/src/cdk/stepper/BUILD.bazel +++ b/src/cdk/stepper/BUILD.bazel @@ -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", diff --git a/src/cdk/stepper/stepper.ts b/src/cdk/stepper/stepper.ts index fb26eefdc97..c184b32f6ff 100644 --- a/src/cdk/stepper/stepper.ts +++ b/src/cdk/stepper/stepper.ts @@ -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(); + /** Used for managing keyboard focus. */ + private _keyManager: FocusKeyManager; + /** The list of step components that the stepper is holding. */ @ContentChildren(CdkStep) _steps: QueryList; /** The list of step headers of the steps in the stepper. */ - _stepHeader: QueryList; + _stepHeader: QueryList; /** 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 = new EventEmitter(); - /** 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 { diff --git a/src/lib/stepper/step-header.ts b/src/lib/stepper/step-header.ts index 80aafe22d9d..1b2cec0d201 100644 --- a/src/lib/stepper/step-header.ts +++ b/src/lib/stepper/step-header.ts @@ -100,4 +100,8 @@ export class MatStepHeader implements OnDestroy { _getHostElement() { return this._element.nativeElement; } + + focus() { + this._getHostElement().focus(); + } } diff --git a/src/lib/stepper/stepper-horizontal.html b/src/lib/stepper/stepper-horizontal.html index 741fc87bede..f64f6e09c9f 100644 --- a/src/lib/stepper/stepper-horizontal.html +++ b/src/lib/stepper/stepper-horizontal.html @@ -3,7 +3,7 @@ { 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, 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, 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, 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, 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, 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): AsyncValidatorFn { diff --git a/src/lib/stepper/stepper.ts b/src/lib/stepper/stepper.ts index 20e2b37a2b6..14f18ecf372 100644 --- a/src/lib/stepper/stepper.ts +++ b/src/lib/stepper/stepper.ts @@ -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; + @ViewChildren(MatStepHeader) _stepHeader: QueryList; /** Steps that the stepper holds. */ @ContentChildren(MatStep) _steps: QueryList;