Skip to content

Commit 0c11393

Browse files
committed
fix(material/expansion): switch away from animations module
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.
1 parent f0a767c commit 0c11393

File tree

9 files changed

+83
-101
lines changed

9 files changed

+83
-101
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: 19 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,28 @@
4851
@include cdk.high-contrast {
4952
outline: solid 1px;
5053
}
54+
}
55+
56+
.mat-expansion-panel-content-wrapper {
57+
// Based on https://css-tricks.com/css-grid-can-do-auto-height-transitions.
58+
display: grid;
59+
grid-template-rows: 0fr;
60+
overflow: hidden;
5161

52-
&.ng-animate-disabled,
53-
.ng-animate-disabled &,
54-
&._mat-animation-noopable {
55-
transition: none;
62+
.mat-expansion-panel-animations-enabled & {
63+
transition: grid-template-rows 225ms cubic-bezier(0.4, 0, 0.2, 1);
64+
}
65+
66+
.mat-expansion-panel.mat-expanded > & {
67+
grid-template-rows: 1fr;
5668
}
5769
}
5870

5971
.mat-expansion-panel-content {
6072
display: flex;
6173
flex-direction: column;
6274
overflow: visible;
75+
min-height: 0;
6376

6477
@include token-utils.use-tokens(
6578
tokens-mat-expansion.$prefix, tokens-mat-expansion.get-token-slots()) {
@@ -69,16 +82,6 @@
6982
@include token-utils.create-token-slot(line-height, container-text-line-height);
7083
@include token-utils.create-token-slot(letter-spacing, container-text-tracking);
7184
}
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-
}
8285
}
8386

8487
.mat-expansion-panel-body {

src/material/expansion/expansion-panel.ts

Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ import {
3131
booleanAttribute,
3232
ANIMATION_MODULE_TYPE,
3333
inject,
34+
NgZone,
3435
} from '@angular/core';
3536
import {_IdGenerator} from '@angular/cdk/a11y';
3637
import {Subject} from 'rxjs';
3738
import {filter, startWith, take} from 'rxjs/operators';
3839
import {MatAccordionBase, MatAccordionTogglePosition, MAT_ACCORDION} from './accordion-base';
39-
import {matExpansionAnimations} from './expansion-animations';
4040
import {MAT_EXPANSION_PANEL} from './expansion-panel-base';
4141
import {MatExpansionPanelContent} from './expansion-panel-content';
4242

@@ -76,7 +76,6 @@ export const MAT_EXPANSION_PANEL_DEFAULT_OPTIONS =
7676
templateUrl: 'expansion-panel.html',
7777
encapsulation: ViewEncapsulation.None,
7878
changeDetection: ChangeDetectionStrategy.OnPush,
79-
animations: [matExpansionAnimations.bodyExpansion],
8079
providers: [
8180
// Provide MatAccordion as undefined to prevent nested expansion panels from registering
8281
// to the same accordion.
@@ -86,7 +85,6 @@ export const MAT_EXPANSION_PANEL_DEFAULT_OPTIONS =
8685
host: {
8786
'class': 'mat-expansion-panel',
8887
'[class.mat-expanded]': 'expanded',
89-
'[class._mat-animation-noopable]': '_animationsDisabled',
9088
'[class.mat-expansion-panel-spacing]': '_hasSpacing()',
9189
},
9290
imports: [CdkPortalOutlet],
@@ -96,10 +94,11 @@ export class MatExpansionPanel
9694
implements AfterContentInit, OnChanges, OnDestroy
9795
{
9896
private _viewContainerRef = inject(ViewContainerRef);
99-
_animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});
100-
101-
protected _animationsDisabled: boolean;
97+
private readonly _animationsDisabled =
98+
inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations';
10299
private _document = inject(DOCUMENT);
100+
private _ngZone = inject(NgZone);
101+
private _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
103102

104103
/** Whether the toggle indicator should be hidden. */
105104
@Input({transform: booleanAttribute})
@@ -139,6 +138,10 @@ export class MatExpansionPanel
139138
/** Element containing the panel's user-provided content. */
140139
@ViewChild('body') _body: ElementRef<HTMLElement>;
141140

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

@@ -156,7 +159,6 @@ export class MatExpansionPanel
156159
);
157160

158161
this._expansionDispatcher = inject(UniqueSelectionDispatcher);
159-
this._animationsDisabled = this._animationMode === 'NoopAnimations';
160162

161163
if (defaultOptions) {
162164
this.hideToggle = defaultOptions.hideToggle;
@@ -204,6 +206,19 @@ export class MatExpansionPanel
204206
this._portal = new TemplatePortal(this._lazyContent._template, this._viewContainerRef);
205207
});
206208
}
209+
210+
this._ngZone.runOutsideAngular(() => {
211+
if (this._animationsDisabled) {
212+
this.opened.subscribe(() => this.afterExpand.emit());
213+
this.closed.subscribe(() => this.afterCollapse.emit());
214+
} else {
215+
setTimeout(() => {
216+
const element = this._elementRef.nativeElement;
217+
element.addEventListener('transitionend', this._transitionEndListener);
218+
element.classList.add('mat-expansion-panel-animations-enabled');
219+
}, 200);
220+
}
221+
});
207222
}
208223

209224
ngOnChanges(changes: SimpleChanges) {
@@ -212,6 +227,10 @@ export class MatExpansionPanel
212227

213228
override ngOnDestroy() {
214229
super.ngOnDestroy();
230+
this._bodyWrapper?.nativeElement.removeEventListener(
231+
'transitionend',
232+
this._transitionEndListener,
233+
);
215234
this._inputChanges.complete();
216235
}
217236

@@ -226,36 +245,17 @@ export class MatExpansionPanel
226245
return false;
227246
}
228247

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-
}
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-
// Re-enable tabbing once the animation is finished.
249-
if (!this._animationsDisabled && this._body) {
250-
this._body.nativeElement.removeAttribute('inert');
251-
}
248+
private _transitionEndListener = ({target, propertyName}: TransitionEvent) => {
249+
if (target === this._bodyWrapper?.nativeElement && propertyName === 'grid-template-rows') {
250+
this._ngZone.run(() => {
251+
if (this.expanded) {
252+
this.afterExpand.emit();
253+
} else {
254+
this.afterCollapse.emit();
255+
}
256+
});
252257
}
253-
}
254-
}
255-
256-
/** Checks whether an animation is the initial setup animation. */
257-
function isInitialAnimation(event: AnimationEvent): boolean {
258-
return event.fromState === 'void';
258+
};
259259
}
260260

261261
/**

src/material/expansion/expansion.spec.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -347,26 +347,6 @@ describe('MatExpansionPanel', () => {
347347
expect(content.classList).toContain('mat-content-hide-toggle');
348348
});
349349

350-
it('should update the indicator rotation when the expanded state is toggled programmatically', fakeAsync(() => {
351-
const fixture = TestBed.createComponent(PanelWithContent);
352-
353-
fixture.detectChanges();
354-
tick(250);
355-
356-
const arrow = fixture.debugElement.query(By.css('.mat-expansion-indicator'))!.nativeElement;
357-
358-
expect(arrow.style.transform).withContext('Expected no rotation.').toBe('rotate(0deg)');
359-
360-
fixture.componentInstance.expanded = true;
361-
fixture.changeDetectorRef.markForCheck();
362-
fixture.detectChanges();
363-
tick(250);
364-
365-
expect(arrow.style.transform)
366-
.withContext('Expected 180 degree rotation.')
367-
.toBe('rotate(180deg)');
368-
}));
369-
370350
it('should make sure accordion item runs ngOnDestroy when expansion panel is destroyed', () => {
371351
const fixture = TestBed.createComponent(PanelWithContentInNgIf);
372352
fixture.detectChanges();

tools/public_api_guard/material/expansion.md

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
import { AfterContentInit } from '@angular/core';
88
import { AfterViewInit } from '@angular/core';
9-
import { AnimationEvent as AnimationEvent_2 } from '@angular/animations';
109
import { AnimationTriggerMetadata } from '@angular/animations';
1110
import { CdkAccordion } from '@angular/cdk/accordion';
1211
import { CdkAccordionItem } from '@angular/cdk/accordion';
@@ -75,7 +74,7 @@ export type MatAccordionDisplayMode = 'default' | 'flat';
7574
// @public
7675
export type MatAccordionTogglePosition = 'before' | 'after';
7776

78-
// @public
77+
// @public @deprecated
7978
export const matExpansionAnimations: {
8079
readonly indicatorRotate: AnimationTriggerMetadata;
8180
readonly bodyExpansion: AnimationTriggerMetadata;
@@ -97,13 +96,8 @@ export class MatExpansionPanel extends CdkAccordionItem implements AfterContentI
9796
accordion: MatAccordionBase;
9897
readonly afterCollapse: EventEmitter<void>;
9998
readonly afterExpand: EventEmitter<void>;
100-
protected _animationDone(event: AnimationEvent_2): void;
101-
// (undocumented)
102-
_animationMode: "NoopAnimations" | "BrowserAnimations" | null;
103-
// (undocumented)
104-
protected _animationsDisabled: boolean;
105-
protected _animationStarted(event: AnimationEvent_2): void;
10699
_body: ElementRef<HTMLElement>;
100+
protected _bodyWrapper: ElementRef<HTMLElement> | undefined;
107101
close(): void;
108102
_containsFocus(): boolean;
109103
_getExpandedState(): MatExpansionPanelState;
@@ -171,8 +165,6 @@ export class MatExpansionPanelDescription {
171165
// @public
172166
export class MatExpansionPanelHeader implements AfterViewInit, OnDestroy, FocusableOption {
173167
constructor(...args: unknown[]);
174-
// (undocumented)
175-
_animationMode: "NoopAnimations" | "BrowserAnimations" | null;
176168
collapsedHeight: string;
177169
get disabled(): boolean;
178170
expandedHeight: string;

0 commit comments

Comments
 (0)