Skip to content

Commit 3bb9bd4

Browse files
crisbetoandrewseguin
authored andcommitted
fix(material/stepper): incorrect navigation order when steps are added later on (#23541)
The Material stepper's template is set up such that we have a single `ng-template` at the bottom declaring the header which is then rendered out with an `ngTemplateOutlet` inside an `ngFor`. We do this in order to reduce duplication, because a lot of properties and attributes have to be set on `mat-step-header`. Then problem with this approach is that because the header is outside the `ngFor`, it may not appear in the correct order in the `ngFor` if the list changes. This results in incorrect navigation order when using the next/previous buttons or the keyboard. These changes add some logic that will sort the headers based on their position in the DOM in order to work around the issue. Alternatively, we could fix this by inlining the `mat-step-header` template, but that'll result in some duplicated code that is somewhat error-prone if we ever need to add more attributes. Fixes #23539. (cherry picked from commit 741a57e)
1 parent b6de384 commit 3bb9bd4

File tree

2 files changed

+49
-1
lines changed

2 files changed

+49
-1
lines changed

src/cdk/stepper/stepper.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,9 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy {
272272
/** The list of step headers of the steps in the stepper. */
273273
@ContentChildren(CdkStepHeader, {descendants: true}) _stepHeader: QueryList<CdkStepHeader>;
274274

275+
/** List of step headers sorted based on their DOM order. */
276+
private _sortedHeaders = new QueryList<CdkStepHeader>();
277+
275278
/** Whether the validity of previous steps should be checked or not. */
276279
@Input()
277280
get linear(): boolean {
@@ -362,10 +365,31 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy {
362365
}
363366

364367
ngAfterViewInit() {
368+
// If the step headers are defined outside of the `ngFor` that renders the steps, like in the
369+
// Material stepper, they won't appear in the `QueryList` in the same order as they're
370+
// rendered in the DOM which will lead to incorrect keyboard navigation. We need to sort
371+
// them manually to ensure that they're correct. Alternatively, we can change the Material
372+
// template to inline the headers in the `ngFor`, but that'll result in a lot of
373+
// code duplciation. See #23539.
374+
this._stepHeader.changes
375+
.pipe(startWith(this._stepHeader), takeUntil(this._destroyed))
376+
.subscribe((headers: QueryList<CdkStepHeader>) => {
377+
this._sortedHeaders.reset(headers.toArray().sort((a, b) => {
378+
const documentPosition =
379+
a._elementRef.nativeElement.compareDocumentPosition(b._elementRef.nativeElement);
380+
381+
// `compareDocumentPosition` returns a bitmask so we have to use a bitwise operator.
382+
// https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
383+
// tslint:disable-next-line:no-bitwise
384+
return documentPosition & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
385+
}));
386+
this._sortedHeaders.notifyOnChanges();
387+
});
388+
365389
// Note that while the step headers are content children by default, any components that
366390
// extend this one might have them as view children. We initialize the keyboard handling in
367391
// AfterViewInit so we're guaranteed for both view and content children to be defined.
368-
this._keyManager = new FocusKeyManager<FocusableOption>(this._stepHeader)
392+
this._keyManager = new FocusKeyManager<FocusableOption>(this._sortedHeaders)
369393
.withWrap()
370394
.withHomeAndEnd()
371395
.withVerticalOrientation(this._orientation === 'vertical');
@@ -393,6 +417,7 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy {
393417

394418
ngOnDestroy() {
395419
this.steps.destroy();
420+
this._sortedHeaders.destroy();
396421
this._destroyed.next();
397422
this._destroyed.complete();
398423
}

src/material/stepper/stepper.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,16 @@ describe('MatStepper', () => {
10191019
assertArrowKeyInteractionInRtl(fixture, stepHeaders);
10201020
});
10211021

1022+
it('should maintain the correct navigation order when a step is added later on', () => {
1023+
const fixture = createComponent(HorizontalStepperWithDelayedStep);
1024+
fixture.detectChanges();
1025+
fixture.componentInstance.renderSecondStep = true;
1026+
fixture.detectChanges();
1027+
1028+
const stepHeaders = fixture.debugElement.queryAll(By.css('.mat-horizontal-stepper-header'));
1029+
assertCorrectKeyboardInteraction(fixture, stepHeaders, 'horizontal');
1030+
});
1031+
10221032
it('should reverse arrow key focus when switching into RTL after init', () => {
10231033
const fixture = createComponent(SimpleMatHorizontalStepperApp);
10241034
fixture.detectChanges();
@@ -2032,3 +2042,16 @@ class StepperWithStaticOutOfBoundsIndex {
20322042
class StepperWithLazyContent {
20332043
selectedIndex = 0;
20342044
}
2045+
2046+
@Component({
2047+
template: `
2048+
<mat-stepper>
2049+
<mat-step label="Step 1">Content 1</mat-step>
2050+
<mat-step label="Step 2" *ngIf="renderSecondStep">Content 2</mat-step>
2051+
<mat-step label="Step 3">Content 3</mat-step>
2052+
</mat-stepper>
2053+
`
2054+
})
2055+
class HorizontalStepperWithDelayedStep {
2056+
renderSecondStep = false;
2057+
}

0 commit comments

Comments
 (0)