Skip to content

Commit 8a7ca7f

Browse files
crisbetojelbourn
authored andcommitted
fix(stepper): focus lost if focus is inside stepper while changing step (#12761)
Fixes the user's focus being returned to the body, if they switch the step while focus is inside the stepper. The issue comes from the fact that when the step is collapsed, it becomes hidden which blurs the focused element.
1 parent 8b69db0 commit 8a7ca7f

File tree

3 files changed

+55
-4
lines changed

3 files changed

+55
-4
lines changed

src/cdk/stepper/stepper.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
ContentChildren,
2020
Directive,
2121
EventEmitter,
22+
ElementRef,
2223
forwardRef,
2324
Inject,
2425
Input,
@@ -31,6 +32,7 @@ import {
3132
ViewChild,
3233
ViewEncapsulation,
3334
} from '@angular/core';
35+
import {DOCUMENT} from '@angular/common';
3436
import {AbstractControl} from '@angular/forms';
3537
import {CdkStepLabel} from './step-label';
3638
import {Observable, Subject, of as obaservableOf} from 'rxjs';
@@ -164,6 +166,12 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
164166
/** Used for managing keyboard focus. */
165167
private _keyManager: FocusKeyManager<FocusableOption>;
166168

169+
/**
170+
* @breaking-change 8.0.0 Remove `| undefined` once the `_document`
171+
* constructor param is required.
172+
*/
173+
private _document: Document | undefined;
174+
167175
/** The list of step components that the stepper is holding. */
168176
@ContentChildren(CdkStep) _steps: QueryList<CdkStep>;
169177

@@ -218,8 +226,12 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
218226

219227
constructor(
220228
@Optional() private _dir: Directionality,
221-
private _changeDetectorRef: ChangeDetectorRef) {
229+
private _changeDetectorRef: ChangeDetectorRef,
230+
// @breaking-change 8.0.0 `_elementRef` and `_document` parameters to become required.
231+
private _elementRef?: ElementRef<HTMLElement>,
232+
@Inject(DOCUMENT) _document?: any) {
222233
this._groupId = nextId++;
234+
this._document = _document;
223235
}
224236

225237
ngAfterViewInit() {
@@ -305,7 +317,14 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
305317
selectedStep: stepsArray[newIndex],
306318
previouslySelectedStep: stepsArray[this._selectedIndex],
307319
});
308-
this._keyManager.updateActiveItemIndex(newIndex);
320+
321+
// If focus is inside the stepper, move it to the next header, otherwise it may become
322+
// lost when the active step content is hidden. We can't be more granular with the check
323+
// (e.g. checking whether focus is inside the active step), because we don't have a
324+
// reference to the elements that are rendering out the content.
325+
this._containsFocus() ? this._keyManager.setActiveItem(newIndex) :
326+
this._keyManager.updateActiveItemIndex(newIndex);
327+
309328
this._selectedIndex = newIndex;
310329
this._stateChanged();
311330
}
@@ -348,4 +367,15 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
348367
private _layoutDirection(): Direction {
349368
return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr';
350369
}
370+
371+
/** Checks whether the stepper contains the focused element. */
372+
private _containsFocus(): boolean {
373+
if (!this._document || !this._elementRef) {
374+
return false;
375+
}
376+
377+
const stepperElement = this._elementRef.nativeElement;
378+
const focusedElement = this._document.activeElement;
379+
return stepperElement === focusedElement || stepperElement.contains(focusedElement);
380+
}
351381
}

src/lib/stepper/stepper.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,20 @@ describe('MatStepper', () => {
259259
expect(stepHeaderEl.focus).not.toHaveBeenCalled();
260260
});
261261

262+
it('should focus next step header if focus is inside the stepper', () => {
263+
let stepperComponent = fixture.debugElement.query(By.directive(MatStepper)).componentInstance;
264+
let stepHeaderEl = fixture.debugElement.queryAll(By.css('mat-step-header'))[1].nativeElement;
265+
let nextButtonNativeEl = fixture.debugElement
266+
.queryAll(By.directive(MatStepperNext))[0].nativeElement;
267+
spyOn(stepHeaderEl, 'focus');
268+
nextButtonNativeEl.focus();
269+
nextButtonNativeEl.click();
270+
fixture.detectChanges();
271+
272+
expect(stepperComponent.selectedIndex).toBe(1);
273+
expect(stepHeaderEl.focus).toHaveBeenCalled();
274+
});
275+
262276
it('should only be able to return to a previous step if it is editable', () => {
263277
let stepperComponent = fixture.debugElement.query(By.directive(MatStepper)).componentInstance;
264278

src/lib/stepper/stepper.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
ContentChild,
1818
ContentChildren,
1919
Directive,
20+
ElementRef,
2021
EventEmitter,
2122
forwardRef,
2223
Inject,
@@ -29,6 +30,7 @@ import {
2930
ViewEncapsulation,
3031
} from '@angular/core';
3132
import {FormControl, FormGroupDirective, NgForm} from '@angular/forms';
33+
import {DOCUMENT} from '@angular/common';
3234
import {ErrorStateMatcher} from '@angular/material/core';
3335
import {MatStepHeader} from './step-header';
3436
import {MatStepLabel} from './step-label';
@@ -147,8 +149,13 @@ export class MatHorizontalStepper extends MatStepper { }
147149
changeDetection: ChangeDetectionStrategy.OnPush,
148150
})
149151
export class MatVerticalStepper extends MatStepper {
150-
constructor(@Optional() dir: Directionality, changeDetectorRef: ChangeDetectorRef) {
151-
super(dir, changeDetectorRef);
152+
constructor(
153+
@Optional() dir: Directionality,
154+
changeDetectorRef: ChangeDetectorRef,
155+
// @breaking-change 8.0.0 `elementRef` and `_document` parameters to become required.
156+
elementRef?: ElementRef<HTMLElement>,
157+
@Inject(DOCUMENT) _document?: any) {
158+
super(dir, changeDetectorRef, elementRef, _document);
152159
this._orientation = 'vertical';
153160
}
154161
}

0 commit comments

Comments
 (0)