Skip to content

Commit 7b64c45

Browse files
committed
fix(material/expansion): switch away from animations module (#30119)
Reworks the expansion panel to animate purely with CSS, rather than going through the `@angular/animations` module. This simplifies the setup and allows us to resolve several long-standing bug reports. Fixes #27946. Fixes #22715. Fixes #21434. Fixes #20517. Fixes #17177. Fixes #16534. Fixes #16503. Fixes #14952. Fixes #14759. Fixes #14075. Fixes #11765. (cherry picked from commit aafa151)
1 parent 8f2b8f3 commit 7b64c45

File tree

9 files changed

+124
-121
lines changed

9 files changed

+124
-121
lines changed

src/material/expansion/expansion-animations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export const EXPANSION_PANEL_ANIMATION_TIMING = '225ms cubic-bezier(0.4,0.0,0.2,
3939
* Angular Bug: https://github.com/angular/angular/issues/18847
4040
*
4141
* @docs-private
42+
* @deprecated No longer being used, to be removed.
43+
* @breaking-change 21.0.0
4244
*/
4345
export const matExpansionAnimations: {
4446
readonly indicatorRotate: AnimationTriggerMetadata;

src/material/expansion/expansion-panel-header.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
</span>
66

77
@if (_showToggle()) {
8-
<span [@indicatorRotate]="_getExpandedState()" class="mat-expansion-indicator">
8+
<span class="mat-expansion-indicator">
99
<svg
1010
xmlns="http://www.w3.org/2000/svg"
1111
viewBox="0 -960 960 960"

src/material/expansion/expansion-panel-header.scss

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
align-items: center;
1010
padding: 0 24px;
1111
border-radius: inherit;
12-
transition: height expansion-variables.$header-transition;
12+
13+
.mat-expansion-panel-animations-enabled & {
14+
transition: height expansion-variables.$header-transition;
15+
}
1316

1417
@include token-utils.use-tokens(
1518
tokens-mat-expansion.$prefix, tokens-mat-expansion.get-token-slots()) {
@@ -141,6 +144,14 @@
141144
// Creates the expansion indicator arrow. Done using ::after
142145
// rather than having additional nodes in the template.
143146
.mat-expansion-indicator {
147+
.mat-expansion-panel-animations-enabled & {
148+
transition: transform 225ms cubic-bezier(0.4, 0, 0.2, 1);
149+
}
150+
151+
.mat-expansion-panel-header.mat-expanded & {
152+
transform: rotate(180deg);
153+
}
154+
144155
&::after {
145156
border-style: solid;
146157
border-width: 0 2px 2px 0;

src/material/expansion/expansion-panel-header.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,12 @@ import {
1919
numberAttribute,
2020
OnDestroy,
2121
ViewEncapsulation,
22-
ANIMATION_MODULE_TYPE,
2322
inject,
2423
HostAttributeToken,
2524
} from '@angular/core';
2625
import {EMPTY, merge, Subscription} from 'rxjs';
2726
import {filter} from 'rxjs/operators';
2827
import {MatAccordionTogglePosition} from './accordion-base';
29-
import {matExpansionAnimations} from './expansion-animations';
3028
import {
3129
MatExpansionPanel,
3230
MatExpansionPanelDefaultOptions,
@@ -44,7 +42,6 @@ import {_StructuralStylesLoader} from '@angular/material/core';
4442
templateUrl: 'expansion-panel-header.html',
4543
encapsulation: ViewEncapsulation.None,
4644
changeDetection: ChangeDetectionStrategy.OnPush,
47-
animations: [matExpansionAnimations.indicatorRotate],
4845
host: {
4946
'class': 'mat-expansion-panel-header mat-focus-indicator',
5047
'role': 'button',
@@ -56,7 +53,6 @@ import {_StructuralStylesLoader} from '@angular/material/core';
5653
'[class.mat-expanded]': '_isExpanded()',
5754
'[class.mat-expansion-toggle-indicator-after]': `_getTogglePosition() === 'after'`,
5855
'[class.mat-expansion-toggle-indicator-before]': `_getTogglePosition() === 'before'`,
59-
'[class._mat-animation-noopable]': '_animationMode === "NoopAnimations"',
6056
'[style.height]': '_getHeaderHeight()',
6157
'(click)': '_toggle()',
6258
'(keydown)': '_keydown($event)',
@@ -67,7 +63,6 @@ export class MatExpansionPanelHeader implements AfterViewInit, OnDestroy, Focusa
6763
private _element = inject(ElementRef);
6864
private _focusMonitor = inject(FocusMonitor);
6965
private _changeDetectorRef = inject(ChangeDetectorRef);
70-
_animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});
7166

7267
private _parentChangeSubscription = Subscription.EMPTY;
7368

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
<ng-content select="mat-expansion-panel-header"></ng-content>
2-
<div class="mat-expansion-panel-content"
3-
role="region"
4-
[@bodyExpansion]="_getExpandedState()"
5-
(@bodyExpansion.start)="_animationStarted($event)"
6-
(@bodyExpansion.done)="_animationDone($event)"
7-
[attr.aria-labelledby]="_headerId"
8-
[id]="id"
9-
#body>
10-
<div class="mat-expansion-panel-body">
11-
<ng-content></ng-content>
12-
<ng-template [cdkPortalOutlet]="_portal"></ng-template>
2+
<div class="mat-expansion-panel-content-wrapper" [attr.inert]="expanded ? null : ''" #bodyWrapper>
3+
<div class="mat-expansion-panel-content"
4+
role="region"
5+
[attr.aria-labelledby]="_headerId"
6+
[id]="id"
7+
#body>
8+
<div class="mat-expansion-panel-body">
9+
<ng-content></ng-content>
10+
<ng-template [cdkPortalOutlet]="_portal"></ng-template>
11+
</div>
12+
<ng-content select="mat-action-row"></ng-content>
1313
</div>
14-
<ng-content select="mat-action-row"></ng-content>
1514
</div>

src/material/expansion/expansion-panel.scss

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
display: block;
1111
margin: 0;
1212
overflow: hidden;
13-
transition: margin 225ms variables.$fast-out-slow-in-timing-function,
14-
elevation.private-transition-property-value();
13+
14+
&.mat-expansion-panel-animations-enabled {
15+
transition: margin 225ms variables.$fast-out-slow-in-timing-function,
16+
elevation.private-transition-property-value();
17+
}
1518

1619
// Required so that the `box-shadow` works after the
1720
// focus indicator relatively positions the header.
@@ -48,18 +51,58 @@
4851
@include cdk.high-contrast {
4952
outline: solid 1px;
5053
}
54+
}
5155

52-
&.ng-animate-disabled,
53-
.ng-animate-disabled &,
54-
&._mat-animation-noopable {
55-
transition: none;
56+
.mat-expansion-panel-content-wrapper {
57+
// Note: we can't use `overflow: hidden` here, because it can clip content with
58+
// ripples or box shadows. Instead we transition the `visibility` below.
59+
// Based on https://css-tricks.com/css-grid-can-do-auto-height-transitions.
60+
display: grid;
61+
grid-template-rows: 0fr;
62+
grid-template-columns: 100%;
63+
64+
.mat-expansion-panel-animations-enabled & {
65+
transition: grid-template-rows 225ms cubic-bezier(0.4, 0, 0.2, 1);
5666
}
67+
68+
.mat-expansion-panel.mat-expanded > & {
69+
grid-template-rows: 1fr;
70+
}
71+
72+
// All the browsers we support have support for `grid` as well, but
73+
// given that these styles are load-bearing for the expansion panel,
74+
// we have a fallback to `height` which doesn't animate, just in case.
75+
// stylelint-disable material/no-prefixes
76+
@supports not (grid-template-rows: 0fr) {
77+
height: 0;
78+
79+
.mat-expansion-panel.mat-expanded > & {
80+
height: auto;
81+
}
82+
}
83+
// stylelint-enable material/no-prefixes
5784
}
5885

5986
.mat-expansion-panel-content {
6087
display: flex;
6188
flex-direction: column;
6289
overflow: visible;
90+
min-height: 0;
91+
92+
// The visibility here serves two purposes:
93+
// 1. Hiding content from assistive technology.
94+
// 2. Hiding any content that might be overflowing.
95+
visibility: hidden;
96+
97+
.mat-expansion-panel-animations-enabled & {
98+
// The duration here is slightly lower so the content
99+
// goes away quicker than the collapse transition.
100+
transition: visibility 190ms linear;
101+
}
102+
103+
.mat-expansion-panel.mat-expanded > .mat-expansion-panel-content-wrapper > & {
104+
visibility: visible;
105+
}
63106

64107
@include token-utils.use-tokens(
65108
tokens-mat-expansion.$prefix, tokens-mat-expansion.get-token-slots()) {
@@ -69,16 +112,6 @@
69112
@include token-utils.create-token-slot(line-height, container-text-line-height);
70113
@include token-utils.create-token-slot(letter-spacing, container-text-tracking);
71114
}
72-
73-
// Usually the `visibility: hidden` added by the animation is enough to prevent focus from
74-
// entering the collapsed content, but children with their own `visibility` can override it.
75-
// In other components we set a `display: none` at the root to stop focus from reaching the
76-
// elements, however we can't do that here, because the content can determine the width
77-
// of an expansion panel. The most practical fallback is to use `!important` to override
78-
// any custom visibility.
79-
&[style*='visibility: hidden'] * {
80-
visibility: hidden !important;
81-
}
82115
}
83116

84117
.mat-expansion-panel-body {

src/material/expansion/expansion-panel.ts

Lines changed: 40 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {AnimationEvent} from '@angular/animations';
109
import {CdkAccordionItem} from '@angular/cdk/accordion';
1110
import {UniqueSelectionDispatcher} from '@angular/cdk/collections';
1211
import {CdkPortalOutlet, TemplatePortal} from '@angular/cdk/portal';
@@ -31,12 +30,12 @@ import {
3130
booleanAttribute,
3231
ANIMATION_MODULE_TYPE,
3332
inject,
33+
NgZone,
3434
} from '@angular/core';
3535
import {_IdGenerator} from '@angular/cdk/a11y';
3636
import {Subject} from 'rxjs';
3737
import {filter, startWith, take} from 'rxjs/operators';
3838
import {MatAccordionBase, MatAccordionTogglePosition, MAT_ACCORDION} from './accordion-base';
39-
import {matExpansionAnimations} from './expansion-animations';
4039
import {MAT_EXPANSION_PANEL} from './expansion-panel-base';
4140
import {MatExpansionPanelContent} from './expansion-panel-content';
4241

@@ -76,7 +75,6 @@ export const MAT_EXPANSION_PANEL_DEFAULT_OPTIONS =
7675
templateUrl: 'expansion-panel.html',
7776
encapsulation: ViewEncapsulation.None,
7877
changeDetection: ChangeDetectionStrategy.OnPush,
79-
animations: [matExpansionAnimations.bodyExpansion],
8078
providers: [
8179
// Provide MatAccordion as undefined to prevent nested expansion panels from registering
8280
// to the same accordion.
@@ -86,7 +84,6 @@ export const MAT_EXPANSION_PANEL_DEFAULT_OPTIONS =
8684
host: {
8785
'class': 'mat-expansion-panel',
8886
'[class.mat-expanded]': 'expanded',
89-
'[class._mat-animation-noopable]': '_animationsDisabled',
9087
'[class.mat-expansion-panel-spacing]': '_hasSpacing()',
9188
},
9289
imports: [CdkPortalOutlet],
@@ -96,10 +93,11 @@ export class MatExpansionPanel
9693
implements AfterContentInit, OnChanges, OnDestroy
9794
{
9895
private _viewContainerRef = inject(ViewContainerRef);
99-
_animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});
100-
101-
protected _animationsDisabled: boolean;
96+
private readonly _animationsDisabled =
97+
inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations';
10298
private _document = inject(DOCUMENT);
99+
private _ngZone = inject(NgZone);
100+
private _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
103101

104102
/** Whether the toggle indicator should be hidden. */
105103
@Input({transform: booleanAttribute})
@@ -139,6 +137,10 @@ export class MatExpansionPanel
139137
/** Element containing the panel's user-provided content. */
140138
@ViewChild('body') _body: ElementRef<HTMLElement>;
141139

140+
/** Element wrapping the panel body. */
141+
@ViewChild('bodyWrapper')
142+
protected _bodyWrapper: ElementRef<HTMLElement> | undefined;
143+
142144
/** Portal holding the user's content. */
143145
_portal: TemplatePortal;
144146

@@ -156,7 +158,6 @@ export class MatExpansionPanel
156158
);
157159

158160
this._expansionDispatcher = inject(UniqueSelectionDispatcher);
159-
this._animationsDisabled = this._animationMode === 'NoopAnimations';
160161

161162
if (defaultOptions) {
162163
this.hideToggle = defaultOptions.hideToggle;
@@ -204,6 +205,8 @@ export class MatExpansionPanel
204205
this._portal = new TemplatePortal(this._lazyContent._template, this._viewContainerRef);
205206
});
206207
}
208+
209+
this._setupAnimationEvents();
207210
}
208211

209212
ngOnChanges(changes: SimpleChanges) {
@@ -212,6 +215,10 @@ export class MatExpansionPanel
212215

213216
override ngOnDestroy() {
214217
super.ngOnDestroy();
218+
this._bodyWrapper?.nativeElement.removeEventListener(
219+
'transitionend',
220+
this._transitionEndListener,
221+
);
215222
this._inputChanges.complete();
216223
}
217224

@@ -226,38 +233,36 @@ export class MatExpansionPanel
226233
return false;
227234
}
228235

229-
/** Called when the expansion animation has started. */
230-
protected _animationStarted(event: AnimationEvent) {
231-
if (!isInitialAnimation(event) && !this._animationsDisabled && this._body) {
232-
// Prevent the user from tabbing into the content while it's animating.
233-
// TODO(crisbeto): maybe use `inert` to prevent focus from entering while closed as well
234-
// instead of `visibility`? Will allow us to clean up some code but needs more testing.
235-
this._body?.nativeElement.setAttribute('inert', '');
236+
private _transitionEndListener = ({target, propertyName}: TransitionEvent) => {
237+
if (target === this._bodyWrapper?.nativeElement && propertyName === 'grid-template-rows') {
238+
this._ngZone.run(() => {
239+
if (this.expanded) {
240+
this.afterExpand.emit();
241+
} else {
242+
this.afterCollapse.emit();
243+
}
244+
});
236245
}
237-
}
238-
239-
/** Called when the expansion animation has finished. */
240-
protected _animationDone(event: AnimationEvent) {
241-
if (!isInitialAnimation(event)) {
242-
if (event.toState === 'expanded') {
243-
this.afterExpand.emit();
244-
} else if (event.toState === 'collapsed') {
245-
this.afterCollapse.emit();
246+
};
247+
248+
protected _setupAnimationEvents() {
249+
// This method is defined separately, because we need to
250+
// disable this logic in some internal components.
251+
this._ngZone.runOutsideAngular(() => {
252+
if (this._animationsDisabled) {
253+
this.opened.subscribe(() => this._ngZone.run(() => this.afterExpand.emit()));
254+
this.closed.subscribe(() => this._ngZone.run(() => this.afterCollapse.emit()));
255+
} else {
256+
setTimeout(() => {
257+
const element = this._elementRef.nativeElement;
258+
element.addEventListener('transitionend', this._transitionEndListener);
259+
element.classList.add('mat-expansion-panel-animations-enabled');
260+
}, 200);
246261
}
247-
248-
// Re-enable tabbing once the animation is finished.
249-
if (!this._animationsDisabled && this._body) {
250-
this._body.nativeElement.removeAttribute('inert');
251-
}
252-
}
262+
});
253263
}
254264
}
255265

256-
/** Checks whether an animation is the initial setup animation. */
257-
function isInitialAnimation(event: AnimationEvent): boolean {
258-
return event.fromState === 'void';
259-
}
260-
261266
/**
262267
* Actions of a `<mat-expansion-panel>`.
263268
*/

0 commit comments

Comments
 (0)