diff --git a/src/cdk/stepper/stepper.ts b/src/cdk/stepper/stepper.ts index 70164d14e1f3..7f555569dae5 100644 --- a/src/cdk/stepper/stepper.ts +++ b/src/cdk/stepper/stepper.ts @@ -272,6 +272,9 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { /** The list of step headers of the steps in the stepper. */ @ContentChildren(CdkStepHeader, {descendants: true}) _stepHeader: QueryList; + /** List of step headers sorted based on their DOM order. */ + private _sortedHeaders = new QueryList(); + /** Whether the validity of previous steps should be checked or not. */ @Input() get linear(): boolean { @@ -362,10 +365,31 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { } ngAfterViewInit() { + // If the step headers are defined outside of the `ngFor` that renders the steps, like in the + // Material stepper, they won't appear in the `QueryList` in the same order as they're + // rendered in the DOM which will lead to incorrect keyboard navigation. We need to sort + // them manually to ensure that they're correct. Alternatively, we can change the Material + // template to inline the headers in the `ngFor`, but that'll result in a lot of + // code duplciation. See #23539. + this._stepHeader.changes + .pipe(startWith(this._stepHeader), takeUntil(this._destroyed)) + .subscribe((headers: QueryList) => { + this._sortedHeaders.reset(headers.toArray().sort((a, b) => { + const documentPosition = + a._elementRef.nativeElement.compareDocumentPosition(b._elementRef.nativeElement); + + // `compareDocumentPosition` returns a bitmask so we have to use a bitwise operator. + // https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition + // tslint:disable-next-line:no-bitwise + return documentPosition & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; + })); + this._sortedHeaders.notifyOnChanges(); + }); + // Note that while the step headers are content children by default, any components that // extend this one might have them as view children. We initialize the keyboard handling in // AfterViewInit so we're guaranteed for both view and content children to be defined. - this._keyManager = new FocusKeyManager(this._stepHeader) + this._keyManager = new FocusKeyManager(this._sortedHeaders) .withWrap() .withHomeAndEnd() .withVerticalOrientation(this._orientation === 'vertical'); @@ -393,6 +417,7 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { ngOnDestroy() { this.steps.destroy(); + this._sortedHeaders.destroy(); this._destroyed.next(); this._destroyed.complete(); } diff --git a/src/material/stepper/stepper.spec.ts b/src/material/stepper/stepper.spec.ts index b7e79c36fc75..b04082e1699f 100644 --- a/src/material/stepper/stepper.spec.ts +++ b/src/material/stepper/stepper.spec.ts @@ -1019,6 +1019,16 @@ describe('MatStepper', () => { assertArrowKeyInteractionInRtl(fixture, stepHeaders); }); + it('should maintain the correct navigation order when a step is added later on', () => { + const fixture = createComponent(HorizontalStepperWithDelayedStep); + fixture.detectChanges(); + fixture.componentInstance.renderSecondStep = true; + fixture.detectChanges(); + + const stepHeaders = fixture.debugElement.queryAll(By.css('.mat-horizontal-stepper-header')); + assertCorrectKeyboardInteraction(fixture, stepHeaders, 'horizontal'); + }); + it('should reverse arrow key focus when switching into RTL after init', () => { const fixture = createComponent(SimpleMatHorizontalStepperApp); fixture.detectChanges(); @@ -2032,3 +2042,16 @@ class StepperWithStaticOutOfBoundsIndex { class StepperWithLazyContent { selectedIndex = 0; } + +@Component({ + template: ` + + Content 1 + Content 2 + Content 3 + + ` +}) +class HorizontalStepperWithDelayedStep { + renderSecondStep = false; +}