Skip to content

Commit 1e56636

Browse files
authoredJan 16, 2025··
fix(material/stepper): switch away from animations module (#30314)
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.
1 parent b5076f7 commit 1e56636

File tree

8 files changed

+229
-101
lines changed

8 files changed

+229
-101
lines changed
 

‎src/cdk/stepper/stepper.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ export class CdkStep implements OnChanges {
255255
export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy {
256256
private _dir = inject(Directionality, {optional: true});
257257
private _changeDetectorRef = inject(ChangeDetectorRef);
258-
private _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
258+
protected _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
259259

260260
/** Emits when the component is destroyed. */
261261
protected readonly _destroyed = new Subject<void>();

‎src/material/stepper/stepper-animations.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,11 @@ import {
1717
animateChild,
1818
} from '@angular/animations';
1919

20-
export const DEFAULT_HORIZONTAL_ANIMATION_DURATION = '500ms';
21-
export const DEFAULT_VERTICAL_ANIMATION_DURATION = '225ms';
22-
2320
/**
2421
* Animations used by the Material steppers.
2522
* @docs-private
23+
* @deprecated No longer used, will be removed.
24+
* @breaking-change 21.0.0
2625
*/
2726
export const matStepperAnimations: {
2827
readonly horizontalStepTransition: AnimationTriggerMetadata;
@@ -43,7 +42,7 @@ export const matStepperAnimations: {
4342
query('@*', animateChild(), {optional: true}),
4443
]),
4544
{
46-
params: {'animationDuration': DEFAULT_HORIZONTAL_ANIMATION_DURATION},
45+
params: {'animationDuration': '500ms'},
4746
},
4847
),
4948
]),
@@ -63,7 +62,7 @@ export const matStepperAnimations: {
6362
query('@*', animateChild(), {optional: true}),
6463
]),
6564
{
66-
params: {'animationDuration': DEFAULT_VERTICAL_ANIMATION_DURATION},
65+
params: {'animationDuration': '225ms'},
6766
},
6867
),
6968
]),

‎src/material/stepper/stepper.html

+27-28
Original file line numberDiff line numberDiff line change
@@ -12,52 +12,51 @@
1212
@case ('horizontal') {
1313
<div class="mat-horizontal-stepper-wrapper">
1414
<div class="mat-horizontal-stepper-header-container">
15-
@for (step of steps; track step; let i = $index, isLast = $last) {
15+
@for (step of steps; track step) {
1616
<ng-container
1717
[ngTemplateOutlet]="stepTemplate"
18-
[ngTemplateOutletContext]="{step: step, i: i}"></ng-container>
19-
@if (!isLast) {
18+
[ngTemplateOutletContext]="{step, i: $index}"/>
19+
@if (!$last) {
2020
<div class="mat-stepper-horizontal-line"></div>
2121
}
2222
}
2323
</div>
2424

2525
<div class="mat-horizontal-content-container">
26-
@for (step of steps; track step; let i = $index) {
27-
<div class="mat-horizontal-stepper-content" role="tabpanel"
28-
[@horizontalStepTransition]="{
29-
'value': _getAnimationDirection(i),
30-
'params': {'animationDuration': _getAnimationDuration()}
31-
}"
32-
(@horizontalStepTransition.done)="_animationDone.next($event)"
33-
[id]="_getStepContentId(i)"
34-
[attr.aria-labelledby]="_getStepLabelId(i)"
35-
[class.mat-horizontal-stepper-content-inactive]="selectedIndex !== i">
36-
<ng-container [ngTemplateOutlet]="step.content"></ng-container>
26+
@for (step of steps; track step) {
27+
<div
28+
#animatedContainer
29+
class="mat-horizontal-stepper-content"
30+
role="tabpanel"
31+
[id]="_getStepContentId($index)"
32+
[attr.aria-labelledby]="_getStepLabelId($index)"
33+
[class]="'mat-horizontal-stepper-content-' + _getAnimationDirection($index)"
34+
[attr.inert]="selectedIndex === $index ? null : ''">
35+
<ng-container [ngTemplateOutlet]="step.content"/>
3736
</div>
3837
}
3938
</div>
4039
</div>
4140
}
4241

4342
@case ('vertical') {
44-
@for (step of steps; track step; let i = $index, isLast = $last) {
43+
@for (step of steps; track step) {
4544
<div class="mat-step">
4645
<ng-container
4746
[ngTemplateOutlet]="stepTemplate"
48-
[ngTemplateOutletContext]="{step: step, i: i}"></ng-container>
49-
<div class="mat-vertical-content-container" [class.mat-stepper-vertical-line]="!isLast">
50-
<div class="mat-vertical-stepper-content" role="tabpanel"
51-
[@verticalStepTransition]="{
52-
'value': _getAnimationDirection(i),
53-
'params': {'animationDuration': _getAnimationDuration()}
54-
}"
55-
(@verticalStepTransition.done)="_animationDone.next($event)"
56-
[id]="_getStepContentId(i)"
57-
[attr.aria-labelledby]="_getStepLabelId(i)"
58-
[class.mat-vertical-stepper-content-inactive]="selectedIndex !== i">
47+
[ngTemplateOutletContext]="{step, i: $index}"/>
48+
<div
49+
#animatedContainer
50+
class="mat-vertical-content-container"
51+
[class.mat-stepper-vertical-line]="!$last"
52+
[class.mat-vertical-content-container-active]="selectedIndex === $index"
53+
[attr.inert]="selectedIndex === $index ? null : ''">
54+
<div class="mat-vertical-stepper-content"
55+
role="tabpanel"
56+
[id]="_getStepContentId($index)"
57+
[attr.aria-labelledby]="_getStepLabelId($index)">
5958
<div class="mat-vertical-content">
60-
<ng-container [ngTemplateOutlet]="step.content"></ng-container>
59+
<ng-container [ngTemplateOutlet]="step.content"/>
6160
</div>
6261
</div>
6362
</div>
@@ -91,5 +90,5 @@
9190
[errorMessage]="step.errorMessage"
9291
[iconOverrides]="_iconOverrides"
9392
[disableRipple]="disableRipple || !_stepIsNavigable(i, step)"
94-
[color]="step.color || color"></mat-step-header>
93+
[color]="step.color || color"/>
9594
</ng-template>

‎src/material/stepper/stepper.scss

+60-23
Original file line numberDiff line numberDiff line change
@@ -178,20 +178,34 @@
178178
}
179179

180180
.mat-horizontal-stepper-content {
181+
visibility: hidden;
182+
overflow: hidden;
181183
outline: 0;
184+
height: 0;
182185

183-
&.mat-horizontal-stepper-content-inactive {
184-
height: 0;
185-
overflow: hidden;
186+
.mat-stepper-animations-enabled & {
187+
transition: transform var(--mat-stepper-animation-duration, 0) cubic-bezier(0.35, 0, 0.25, 1);
188+
}
189+
190+
&.mat-horizontal-stepper-content-previous {
191+
transform: translate3d(-100%, 0, 0);
192+
}
193+
194+
&.mat-horizontal-stepper-content-next {
195+
transform: translate3d(100%, 0, 0);
186196
}
187197

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

@@ -209,10 +223,26 @@
209223
}
210224

211225
.mat-vertical-content-container {
226+
display: grid;
227+
grid-template-rows: 0fr;
228+
grid-template-columns: 100%;
212229
margin-left: stepper-variables.$vertical-stepper-content-margin;
213230
border: 0;
214231
position: relative;
215232

233+
.mat-stepper-animations-enabled & {
234+
transition: grid-template-rows var(--mat-stepper-animation-duration, 0)
235+
cubic-bezier(0.4, 0, 0.2, 1);
236+
}
237+
238+
&.mat-vertical-content-container-active {
239+
grid-template-rows: 1fr;
240+
}
241+
242+
.mat-step:last-child & {
243+
border: none;
244+
}
245+
216246
@include cdk.high-contrast {
217247
outline: solid 1px;
218248
}
@@ -221,6 +251,19 @@
221251
margin-left: 0;
222252
margin-right: stepper-variables.$vertical-stepper-content-margin;
223253
}
254+
255+
256+
// All the browsers we support have support for `grid` as well, but given that these styles are
257+
// load-bearing for the stepper, we have a fallback to height which doesn't animate, just in case.
258+
// stylelint-disable material/no-prefixes
259+
@supports not (grid-template-rows: 0fr) {
260+
height: 0;
261+
262+
&.mat-vertical-content-container-active {
263+
height: auto;
264+
}
265+
}
266+
// stylelint-enable material/no-prefixes
224267
}
225268

226269
.mat-stepper-vertical-line::before {
@@ -252,23 +295,17 @@
252295
.mat-vertical-stepper-content {
253296
overflow: hidden;
254297
outline: 0;
298+
visibility: hidden;
299+
300+
.mat-stepper-animations-enabled & {
301+
transition: visibility var(--mat-stepper-animation-duration, 0) linear;
302+
}
255303

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

266309
.mat-vertical-content {
267310
padding: 0 stepper-variables.$side-gap stepper-variables.$side-gap stepper-variables.$side-gap;
268311
}
269-
270-
.mat-step:last-child {
271-
.mat-vertical-content-container {
272-
border: none;
273-
}
274-
}

‎src/material/stepper/stepper.spec.ts

+3-8
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
inject,
3535
signal,
3636
} from '@angular/core';
37-
import {ComponentFixture, TestBed, fakeAsync, flush} from '@angular/core/testing';
37+
import {ComponentFixture, TestBed} from '@angular/core/testing';
3838
import {
3939
AbstractControl,
4040
AsyncValidatorFn,
@@ -364,7 +364,7 @@ describe('MatStepper', () => {
364364
expect(stepperComponent._getIndicatorType(0)).toBe('done');
365365
});
366366

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

377-
expect(selectionChangeSpy).toHaveBeenCalledTimes(1);
378-
expect(animationDoneSpy).not.toHaveBeenCalled();
379-
380-
flush();
381-
382377
expect(selectionChangeSpy).toHaveBeenCalledTimes(1);
383378
expect(animationDoneSpy).toHaveBeenCalledTimes(1);
384379

385380
selectionChangeSubscription.unsubscribe();
386381
animationDoneSubscription.unsubscribe();
387-
}));
382+
});
388383

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

0 commit comments

Comments
 (0)
Please sign in to comment.