Skip to content

Commit

Permalink
fix(material/stepper): switch away from animations module (#30314)
Browse files Browse the repository at this point in the history
Reworks the stepper so it uses CSS directly to animate, instead of going through the animations module. This both simplifies the setup and allows us to avoid the issues that come with the animations module.
  • Loading branch information
crisbeto authored Jan 16, 2025
1 parent b5076f7 commit 1e56636
Show file tree
Hide file tree
Showing 8 changed files with 229 additions and 101 deletions.
2 changes: 1 addition & 1 deletion src/cdk/stepper/stepper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ export class CdkStep implements OnChanges {
export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy {
private _dir = inject(Directionality, {optional: true});
private _changeDetectorRef = inject(ChangeDetectorRef);
private _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
protected _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);

/** Emits when the component is destroyed. */
protected readonly _destroyed = new Subject<void>();
Expand Down
9 changes: 4 additions & 5 deletions src/material/stepper/stepper-animations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,11 @@ import {
animateChild,
} from '@angular/animations';

export const DEFAULT_HORIZONTAL_ANIMATION_DURATION = '500ms';
export const DEFAULT_VERTICAL_ANIMATION_DURATION = '225ms';

/**
* Animations used by the Material steppers.
* @docs-private
* @deprecated No longer used, will be removed.
* @breaking-change 21.0.0
*/
export const matStepperAnimations: {
readonly horizontalStepTransition: AnimationTriggerMetadata;
Expand All @@ -43,7 +42,7 @@ export const matStepperAnimations: {
query('@*', animateChild(), {optional: true}),
]),
{
params: {'animationDuration': DEFAULT_HORIZONTAL_ANIMATION_DURATION},
params: {'animationDuration': '500ms'},
},
),
]),
Expand All @@ -63,7 +62,7 @@ export const matStepperAnimations: {
query('@*', animateChild(), {optional: true}),
]),
{
params: {'animationDuration': DEFAULT_VERTICAL_ANIMATION_DURATION},
params: {'animationDuration': '225ms'},
},
),
]),
Expand Down
55 changes: 27 additions & 28 deletions src/material/stepper/stepper.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,52 +12,51 @@
@case ('horizontal') {
<div class="mat-horizontal-stepper-wrapper">
<div class="mat-horizontal-stepper-header-container">
@for (step of steps; track step; let i = $index, isLast = $last) {
@for (step of steps; track step) {
<ng-container
[ngTemplateOutlet]="stepTemplate"
[ngTemplateOutletContext]="{step: step, i: i}"></ng-container>
@if (!isLast) {
[ngTemplateOutletContext]="{step, i: $index}"/>
@if (!$last) {
<div class="mat-stepper-horizontal-line"></div>
}
}
</div>

<div class="mat-horizontal-content-container">
@for (step of steps; track step; let i = $index) {
<div class="mat-horizontal-stepper-content" role="tabpanel"
[@horizontalStepTransition]="{
'value': _getAnimationDirection(i),
'params': {'animationDuration': _getAnimationDuration()}
}"
(@horizontalStepTransition.done)="_animationDone.next($event)"
[id]="_getStepContentId(i)"
[attr.aria-labelledby]="_getStepLabelId(i)"
[class.mat-horizontal-stepper-content-inactive]="selectedIndex !== i">
<ng-container [ngTemplateOutlet]="step.content"></ng-container>
@for (step of steps; track step) {
<div
#animatedContainer
class="mat-horizontal-stepper-content"
role="tabpanel"
[id]="_getStepContentId($index)"
[attr.aria-labelledby]="_getStepLabelId($index)"
[class]="'mat-horizontal-stepper-content-' + _getAnimationDirection($index)"
[attr.inert]="selectedIndex === $index ? null : ''">
<ng-container [ngTemplateOutlet]="step.content"/>
</div>
}
</div>
</div>
}

@case ('vertical') {
@for (step of steps; track step; let i = $index, isLast = $last) {
@for (step of steps; track step) {
<div class="mat-step">
<ng-container
[ngTemplateOutlet]="stepTemplate"
[ngTemplateOutletContext]="{step: step, i: i}"></ng-container>
<div class="mat-vertical-content-container" [class.mat-stepper-vertical-line]="!isLast">
<div class="mat-vertical-stepper-content" role="tabpanel"
[@verticalStepTransition]="{
'value': _getAnimationDirection(i),
'params': {'animationDuration': _getAnimationDuration()}
}"
(@verticalStepTransition.done)="_animationDone.next($event)"
[id]="_getStepContentId(i)"
[attr.aria-labelledby]="_getStepLabelId(i)"
[class.mat-vertical-stepper-content-inactive]="selectedIndex !== i">
[ngTemplateOutletContext]="{step, i: $index}"/>
<div
#animatedContainer
class="mat-vertical-content-container"
[class.mat-stepper-vertical-line]="!$last"
[class.mat-vertical-content-container-active]="selectedIndex === $index"
[attr.inert]="selectedIndex === $index ? null : ''">
<div class="mat-vertical-stepper-content"
role="tabpanel"
[id]="_getStepContentId($index)"
[attr.aria-labelledby]="_getStepLabelId($index)">
<div class="mat-vertical-content">
<ng-container [ngTemplateOutlet]="step.content"></ng-container>
<ng-container [ngTemplateOutlet]="step.content"/>
</div>
</div>
</div>
Expand Down Expand Up @@ -91,5 +90,5 @@
[errorMessage]="step.errorMessage"
[iconOverrides]="_iconOverrides"
[disableRipple]="disableRipple || !_stepIsNavigable(i, step)"
[color]="step.color || color"></mat-step-header>
[color]="step.color || color"/>
</ng-template>
83 changes: 60 additions & 23 deletions src/material/stepper/stepper.scss
Original file line number Diff line number Diff line change
Expand Up @@ -178,20 +178,34 @@
}

.mat-horizontal-stepper-content {
visibility: hidden;
overflow: hidden;
outline: 0;
height: 0;

&.mat-horizontal-stepper-content-inactive {
height: 0;
overflow: hidden;
.mat-stepper-animations-enabled & {
transition: transform var(--mat-stepper-animation-duration, 0) cubic-bezier(0.35, 0, 0.25, 1);
}

&.mat-horizontal-stepper-content-previous {
transform: translate3d(-100%, 0, 0);
}

&.mat-horizontal-stepper-content-next {
transform: translate3d(100%, 0, 0);
}

// Used to avoid an issue where when the stepper is nested inside a component that
// changes the `visibility` as a part of an Angular animation, the stepper's content
// stays hidden (see #25925). The value has to be `!important` to override the incorrect
// `visibility` from the animations package. This can also be solved using `visibility: visible`
// on `.mat-horizontal-stepper-content`, but it can allow tabbing into hidden content.
&:not(.mat-horizontal-stepper-content-inactive) {
visibility: inherit !important;
&.mat-horizontal-stepper-content-current {
// TODO(crisbeto): the height and visibility switches are a bit jarring, but that's how the
// animation was set up when we still used the Animations module. We should be able to make
// it a bit smoother.
visibility: visible;
transform: none;
height: auto;
}

.mat-stepper-horizontal:not(.mat-stepper-animating) &.mat-horizontal-stepper-content-current {
overflow: visible;
}
}

Expand All @@ -209,10 +223,26 @@
}

.mat-vertical-content-container {
display: grid;
grid-template-rows: 0fr;
grid-template-columns: 100%;
margin-left: stepper-variables.$vertical-stepper-content-margin;
border: 0;
position: relative;

.mat-stepper-animations-enabled & {
transition: grid-template-rows var(--mat-stepper-animation-duration, 0)
cubic-bezier(0.4, 0, 0.2, 1);
}

&.mat-vertical-content-container-active {
grid-template-rows: 1fr;
}

.mat-step:last-child & {
border: none;
}

@include cdk.high-contrast {
outline: solid 1px;
}
Expand All @@ -221,6 +251,19 @@
margin-left: 0;
margin-right: stepper-variables.$vertical-stepper-content-margin;
}


// All the browsers we support have support for `grid` as well, but given that these styles are
// load-bearing for the stepper, we have a fallback to height which doesn't animate, just in case.
// stylelint-disable material/no-prefixes
@supports not (grid-template-rows: 0fr) {
height: 0;

&.mat-vertical-content-container-active {
height: auto;
}
}
// stylelint-enable material/no-prefixes
}

.mat-stepper-vertical-line::before {
Expand Down Expand Up @@ -252,23 +295,17 @@
.mat-vertical-stepper-content {
overflow: hidden;
outline: 0;
visibility: hidden;

.mat-stepper-animations-enabled & {
transition: visibility var(--mat-stepper-animation-duration, 0) linear;
}

// Used to avoid an issue where when the stepper is nested inside a component that
// changes the `visibility` as a part of an Angular animation, the stepper's content
// stays hidden (see #25925). The value has to be `!important` to override the incorrect
// `visibility` from the animations package. This can also be solved using `visibility: visible`
// on `.mat-vertical-stepper-content`, but it can allow tabbing into hidden content.
&:not(.mat-vertical-stepper-content-inactive) {
visibility: inherit !important;
.mat-vertical-content-container-active > & {
visibility: visible;
}
}

.mat-vertical-content {
padding: 0 stepper-variables.$side-gap stepper-variables.$side-gap stepper-variables.$side-gap;
}

.mat-step:last-child {
.mat-vertical-content-container {
border: none;
}
}
11 changes: 3 additions & 8 deletions src/material/stepper/stepper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
inject,
signal,
} from '@angular/core';
import {ComponentFixture, TestBed, fakeAsync, flush} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {
AbstractControl,
AsyncValidatorFn,
Expand Down Expand Up @@ -364,7 +364,7 @@ describe('MatStepper', () => {
expect(stepperComponent._getIndicatorType(0)).toBe('done');
});

it('should emit an event when the enter animation is done', fakeAsync(() => {
it('should emit an event when the enter animation is done', () => {
const stepper = fixture.debugElement.query(By.directive(MatStepper))!.componentInstance;
const selectionChangeSpy = jasmine.createSpy('selectionChange spy');
const animationDoneSpy = jasmine.createSpy('animationDone spy');
Expand All @@ -374,17 +374,12 @@ describe('MatStepper', () => {
stepper.selectedIndex = 1;
fixture.detectChanges();

expect(selectionChangeSpy).toHaveBeenCalledTimes(1);
expect(animationDoneSpy).not.toHaveBeenCalled();

flush();

expect(selectionChangeSpy).toHaveBeenCalledTimes(1);
expect(animationDoneSpy).toHaveBeenCalledTimes(1);

selectionChangeSubscription.unsubscribe();
animationDoneSubscription.unsubscribe();
}));
});

it('should set the correct aria-posinset and aria-setsize', () => {
const headers = Array.from<HTMLElement>(
Expand Down
Loading

0 comments on commit 1e56636

Please sign in to comment.