Skip to content

Commit

Permalink
fix(material/stepper): switch away from animations module
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 committed Jan 13, 2025

Verified

This commit was signed with the committer’s verified signature.
huonw Huon Wilson
1 parent a11b03b commit 73bb3e6
Showing 8 changed files with 180 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
@@ -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>();
9 changes: 4 additions & 5 deletions src/material/stepper/stepper-animations.ts
Original file line number Diff line number Diff line change
@@ -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;
@@ -43,7 +42,7 @@ export const matStepperAnimations: {
query('@*', animateChild(), {optional: true}),
]),
{
params: {'animationDuration': DEFAULT_HORIZONTAL_ANIMATION_DURATION},
params: {'animationDuration': '500ms'},
},
),
]),
@@ -63,7 +62,7 @@ export const matStepperAnimations: {
query('@*', animateChild(), {optional: true}),
]),
{
params: {'animationDuration': DEFAULT_VERTICAL_ANIMATION_DURATION},
params: {'animationDuration': '225ms'},
},
),
]),
55 changes: 27 additions & 28 deletions src/material/stepper/stepper.html
Original file line number Diff line number Diff line change
@@ -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>
@@ -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>
79 changes: 56 additions & 23 deletions src/material/stepper/stepper.scss
Original file line number Diff line number Diff line change
@@ -178,20 +178,30 @@
}

.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;
}
}

@@ -209,10 +219,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;
}
@@ -221,6 +247,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 {
@@ -252,23 +291,17 @@
.mat-vertical-stepper-content {
overflow: hidden;
outline: 0;
visibility: hidden;

// 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-stepper-animations-enabled & {
transition: visibility var(--mat-stepper-animation-duration, 0) linear;
}

.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
@@ -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,
@@ -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');
@@ -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>(
Loading

0 comments on commit 73bb3e6

Please sign in to comment.