Skip to content

Commit

Permalink
fix(material/stepper): incorrect navigation order when steps are adde…
Browse files Browse the repository at this point in the history
…d 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)
  • Loading branch information
crisbeto authored and andrewseguin committed Sep 10, 2021
1 parent b6de384 commit 3bb9bd4
Show file tree
Hide file tree
Showing 2 changed files with 49 additions and 1 deletion.
27 changes: 26 additions & 1 deletion src/cdk/stepper/stepper.ts
Expand Up @@ -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<CdkStepHeader>;

/** List of step headers sorted based on their DOM order. */
private _sortedHeaders = new QueryList<CdkStepHeader>();

/** Whether the validity of previous steps should be checked or not. */
@Input()
get linear(): boolean {
Expand Down Expand Up @@ -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<CdkStepHeader>) => {
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<FocusableOption>(this._stepHeader)
this._keyManager = new FocusKeyManager<FocusableOption>(this._sortedHeaders)
.withWrap()
.withHomeAndEnd()
.withVerticalOrientation(this._orientation === 'vertical');
Expand Down Expand Up @@ -393,6 +417,7 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy {

ngOnDestroy() {
this.steps.destroy();
this._sortedHeaders.destroy();
this._destroyed.next();
this._destroyed.complete();
}
Expand Down
23 changes: 23 additions & 0 deletions src/material/stepper/stepper.spec.ts
Expand Up @@ -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();
Expand Down Expand Up @@ -2032,3 +2042,16 @@ class StepperWithStaticOutOfBoundsIndex {
class StepperWithLazyContent {
selectedIndex = 0;
}

@Component({
template: `
<mat-stepper>
<mat-step label="Step 1">Content 1</mat-step>
<mat-step label="Step 2" *ngIf="renderSecondStep">Content 2</mat-step>
<mat-step label="Step 3">Content 3</mat-step>
</mat-stepper>
`
})
class HorizontalStepperWithDelayedStep {
renderSecondStep = false;
}

0 comments on commit 3bb9bd4

Please sign in to comment.