Skip to content

Commit

Permalink
fix(material/expansion): switch away from animations module
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
crisbeto committed Dec 3, 2024
1 parent f0a767c commit 01750c7
Show file tree
Hide file tree
Showing 9 changed files with 118 additions and 123 deletions.
2 changes: 2 additions & 0 deletions src/material/expansion/expansion-animations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export const EXPANSION_PANEL_ANIMATION_TIMING = '225ms cubic-bezier(0.4,0.0,0.2,
* Angular Bug: https://github.com/angular/angular/issues/18847
*
* @docs-private
* @deprecated No longer being used, to be removed.
* @breaking-change 21.0.0
*/
export const matExpansionAnimations: {
readonly indicatorRotate: AnimationTriggerMetadata;
Expand Down
2 changes: 1 addition & 1 deletion src/material/expansion/expansion-panel-header.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</span>

@if (_showToggle()) {
<span [@indicatorRotate]="_getExpandedState()" class="mat-expansion-indicator">
<span class="mat-expansion-indicator">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
Expand Down
13 changes: 12 additions & 1 deletion src/material/expansion/expansion-panel-header.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
align-items: center;
padding: 0 24px;
border-radius: inherit;
transition: height expansion-variables.$header-transition;

.mat-expansion-panel-animations-enabled & {
transition: height expansion-variables.$header-transition;
}

@include token-utils.use-tokens(
tokens-mat-expansion.$prefix, tokens-mat-expansion.get-token-slots()) {
Expand Down Expand Up @@ -141,6 +144,14 @@
// Creates the expansion indicator arrow. Done using ::after
// rather than having additional nodes in the template.
.mat-expansion-indicator {
.mat-expansion-panel-animations-enabled & {
transition: transform 225ms cubic-bezier(0.4, 0, 0.2, 1);
}

.mat-expansion-panel-header.mat-expanded & {
transform: rotate(180deg);
}

&::after {
border-style: solid;
border-width: 0 2px 2px 0;
Expand Down
5 changes: 0 additions & 5 deletions src/material/expansion/expansion-panel-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,12 @@ import {
numberAttribute,
OnDestroy,
ViewEncapsulation,
ANIMATION_MODULE_TYPE,
inject,
HostAttributeToken,
} from '@angular/core';
import {EMPTY, merge, Subscription} from 'rxjs';
import {filter} from 'rxjs/operators';
import {MatAccordionTogglePosition} from './accordion-base';
import {matExpansionAnimations} from './expansion-animations';
import {
MatExpansionPanel,
MatExpansionPanelDefaultOptions,
Expand All @@ -44,7 +42,6 @@ import {_StructuralStylesLoader} from '@angular/material/core';
templateUrl: 'expansion-panel-header.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [matExpansionAnimations.indicatorRotate],
host: {
'class': 'mat-expansion-panel-header mat-focus-indicator',
'role': 'button',
Expand All @@ -56,7 +53,6 @@ import {_StructuralStylesLoader} from '@angular/material/core';
'[class.mat-expanded]': '_isExpanded()',
'[class.mat-expansion-toggle-indicator-after]': `_getTogglePosition() === 'after'`,
'[class.mat-expansion-toggle-indicator-before]': `_getTogglePosition() === 'before'`,
'[class._mat-animation-noopable]': '_animationMode === "NoopAnimations"',
'[style.height]': '_getHeaderHeight()',
'(click)': '_toggle()',
'(keydown)': '_keydown($event)',
Expand All @@ -67,7 +63,6 @@ export class MatExpansionPanelHeader implements AfterViewInit, OnDestroy, Focusa
private _element = inject(ElementRef);
private _focusMonitor = inject(FocusMonitor);
private _changeDetectorRef = inject(ChangeDetectorRef);
_animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});

private _parentChangeSubscription = Subscription.EMPTY;

Expand Down
23 changes: 11 additions & 12 deletions src/material/expansion/expansion-panel.html
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
<ng-content select="mat-expansion-panel-header"></ng-content>
<div class="mat-expansion-panel-content"
role="region"
[@bodyExpansion]="_getExpandedState()"
(@bodyExpansion.start)="_animationStarted($event)"
(@bodyExpansion.done)="_animationDone($event)"
[attr.aria-labelledby]="_headerId"
[id]="id"
#body>
<div class="mat-expansion-panel-body">
<ng-content></ng-content>
<ng-template [cdkPortalOutlet]="_portal"></ng-template>
<div class="mat-expansion-panel-content-wrapper" [attr.inert]="expanded ? null : ''" #bodyWrapper>
<div class="mat-expansion-panel-content"
role="region"
[attr.aria-labelledby]="_headerId"
[id]="id"
#body>
<div class="mat-expansion-panel-body">
<ng-content></ng-content>
<ng-template [cdkPortalOutlet]="_portal"></ng-template>
</div>
<ng-content select="mat-action-row"></ng-content>
</div>
<ng-content select="mat-action-row"></ng-content>
</div>
65 changes: 49 additions & 16 deletions src/material/expansion/expansion-panel.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
display: block;
margin: 0;
overflow: hidden;
transition: margin 225ms variables.$fast-out-slow-in-timing-function,
elevation.private-transition-property-value();

&.mat-expansion-panel-animations-enabled {
transition: margin 225ms variables.$fast-out-slow-in-timing-function,
elevation.private-transition-property-value();
}

// Required so that the `box-shadow` works after the
// focus indicator relatively positions the header.
Expand Down Expand Up @@ -48,18 +51,58 @@
@include cdk.high-contrast {
outline: solid 1px;
}
}

&.ng-animate-disabled,
.ng-animate-disabled &,
&._mat-animation-noopable {
transition: none;
.mat-expansion-panel-content-wrapper {
// Note: we can't use `overflow: hidden` here, because it can clip content with
// ripples or box shadows. Instead we transition the `visibility` below.
// Based on https://css-tricks.com/css-grid-can-do-auto-height-transitions.
display: grid;
grid-template-rows: 0fr;
grid-template-columns: 100%;

.mat-expansion-panel-animations-enabled & {
transition: grid-template-rows 225ms cubic-bezier(0.4, 0, 0.2, 1);
}

.mat-expansion-panel.mat-expanded > & {
grid-template-rows: 1fr;
}

// All the browsers we support have support for `grid` as well, but
// given that these styles are load-bearing for the expansion panel,
// 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-expansion-panel.mat-expanded > & {
height: auto;
}
}
// stylelint-enable material/no-prefixes
}

.mat-expansion-panel-content {
display: flex;
flex-direction: column;
overflow: visible;
min-height: 0;

// The visibility here serves two purposes:
// 1. Hiding content from assistive technology.
// 2. Hiding any content that might be overflowing.
visibility: hidden;

.mat-expansion-panel-animations-enabled & {
// The duration here is slightly lower so the content
// goes away quicker than the collapse transition.
transition: visibility 190ms linear;
}

.mat-expansion-panel.mat-expanded > .mat-expansion-panel-content-wrapper > & {
visibility: visible;
}

@include token-utils.use-tokens(
tokens-mat-expansion.$prefix, tokens-mat-expansion.get-token-slots()) {
Expand All @@ -69,16 +112,6 @@
@include token-utils.create-token-slot(line-height, container-text-line-height);
@include token-utils.create-token-slot(letter-spacing, container-text-tracking);
}

// Usually the `visibility: hidden` added by the animation is enough to prevent focus from
// entering the collapsed content, but children with their own `visibility` can override it.
// In other components we set a `display: none` at the root to stop focus from reaching the
// elements, however we can't do that here, because the content can determine the width
// of an expansion panel. The most practical fallback is to use `!important` to override
// any custom visibility.
&[style*='visibility: hidden'] * {
visibility: hidden !important;
}
}

.mat-expansion-panel-body {
Expand Down
73 changes: 36 additions & 37 deletions src/material/expansion/expansion-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {AnimationEvent} from '@angular/animations';
import {CdkAccordionItem} from '@angular/cdk/accordion';
import {UniqueSelectionDispatcher} from '@angular/cdk/collections';
import {CdkPortalOutlet, TemplatePortal} from '@angular/cdk/portal';
Expand All @@ -31,12 +30,12 @@ import {
booleanAttribute,
ANIMATION_MODULE_TYPE,
inject,
NgZone,
} from '@angular/core';
import {_IdGenerator} from '@angular/cdk/a11y';
import {Subject} from 'rxjs';
import {filter, startWith, take} from 'rxjs/operators';
import {MatAccordionBase, MatAccordionTogglePosition, MAT_ACCORDION} from './accordion-base';
import {matExpansionAnimations} from './expansion-animations';
import {MAT_EXPANSION_PANEL} from './expansion-panel-base';
import {MatExpansionPanelContent} from './expansion-panel-content';

Expand Down Expand Up @@ -76,7 +75,6 @@ export const MAT_EXPANSION_PANEL_DEFAULT_OPTIONS =
templateUrl: 'expansion-panel.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [matExpansionAnimations.bodyExpansion],
providers: [
// Provide MatAccordion as undefined to prevent nested expansion panels from registering
// to the same accordion.
Expand All @@ -86,7 +84,6 @@ export const MAT_EXPANSION_PANEL_DEFAULT_OPTIONS =
host: {
'class': 'mat-expansion-panel',
'[class.mat-expanded]': 'expanded',
'[class._mat-animation-noopable]': '_animationsDisabled',
'[class.mat-expansion-panel-spacing]': '_hasSpacing()',
},
imports: [CdkPortalOutlet],
Expand All @@ -96,10 +93,11 @@ export class MatExpansionPanel
implements AfterContentInit, OnChanges, OnDestroy
{
private _viewContainerRef = inject(ViewContainerRef);
_animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});

protected _animationsDisabled: boolean;
private readonly _animationsDisabled =
inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations';
private _document = inject(DOCUMENT);
private _ngZone = inject(NgZone);
private _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);

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

/** Element wrapping the panel body. */
@ViewChild('bodyWrapper')
protected _bodyWrapper: ElementRef<HTMLElement> | undefined;

/** Portal holding the user's content. */
_portal: TemplatePortal;

Expand All @@ -156,7 +158,6 @@ export class MatExpansionPanel
);

this._expansionDispatcher = inject(UniqueSelectionDispatcher);
this._animationsDisabled = this._animationMode === 'NoopAnimations';

if (defaultOptions) {
this.hideToggle = defaultOptions.hideToggle;
Expand Down Expand Up @@ -204,6 +205,19 @@ export class MatExpansionPanel
this._portal = new TemplatePortal(this._lazyContent._template, this._viewContainerRef);
});
}

this._ngZone.runOutsideAngular(() => {
if (this._animationsDisabled) {
this.opened.subscribe(() => this.afterExpand.emit());
this.closed.subscribe(() => this.afterCollapse.emit());
} else {
setTimeout(() => {
const element = this._elementRef.nativeElement;
element.addEventListener('transitionend', this._transitionEndListener);
element.classList.add('mat-expansion-panel-animations-enabled');
}, 200);
}
});
}

ngOnChanges(changes: SimpleChanges) {
Expand All @@ -212,6 +226,10 @@ export class MatExpansionPanel

override ngOnDestroy() {
super.ngOnDestroy();
this._bodyWrapper?.nativeElement.removeEventListener(
'transitionend',
this._transitionEndListener,
);
this._inputChanges.complete();
}

Expand All @@ -226,36 +244,17 @@ export class MatExpansionPanel
return false;
}

/** Called when the expansion animation has started. */
protected _animationStarted(event: AnimationEvent) {
if (!isInitialAnimation(event) && !this._animationsDisabled && this._body) {
// Prevent the user from tabbing into the content while it's animating.
// TODO(crisbeto): maybe use `inert` to prevent focus from entering while closed as well
// instead of `visibility`? Will allow us to clean up some code but needs more testing.
this._body?.nativeElement.setAttribute('inert', '');
}
}

/** Called when the expansion animation has finished. */
protected _animationDone(event: AnimationEvent) {
if (!isInitialAnimation(event)) {
if (event.toState === 'expanded') {
this.afterExpand.emit();
} else if (event.toState === 'collapsed') {
this.afterCollapse.emit();
}

// Re-enable tabbing once the animation is finished.
if (!this._animationsDisabled && this._body) {
this._body.nativeElement.removeAttribute('inert');
}
private _transitionEndListener = ({target, propertyName}: TransitionEvent) => {
if (target === this._bodyWrapper?.nativeElement && propertyName === 'grid-template-rows') {
this._ngZone.run(() => {
if (this.expanded) {
this.afterExpand.emit();
} else {
this.afterCollapse.emit();
}
});
}
}
}

/** Checks whether an animation is the initial setup animation. */
function isInitialAnimation(event: AnimationEvent): boolean {
return event.fromState === 'void';
};
}

/**
Expand Down
Loading

0 comments on commit 01750c7

Please sign in to comment.