Skip to content

Commit

Permalink
fix(material/expansion): prevent focus from entering the panel while …
Browse files Browse the repository at this point in the history
…it's animating (angular#28646)

Currently the expansion panel prevents focus from entering it using `visibility: hidden`, but that only works when it's closed. This means that if the user tabs into it while it's animating, they may scroll the content make the component look broken.

These changes resolve the issue by setting `inert` on the panel content while it's animating. Also cleans up an old workaround for IE.

Fixes angular#27430.
Fixes angular#28644.
  • Loading branch information
crisbeto committed Feb 26, 2024
1 parent ef68e32 commit 24eaa2e
Show file tree
Hide file tree
Showing 3 changed files with 41 additions and 26 deletions.
3 changes: 2 additions & 1 deletion src/material/expansion/expansion-panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
<div class="mat-expansion-panel-content"
role="region"
[@bodyExpansion]="_getExpandedState()"
(@bodyExpansion.done)="_bodyAnimationDone.next($event)"
(@bodyExpansion.start)="_animationStarted($event)"
(@bodyExpansion.done)="_animationDone($event)"
[attr.aria-labelledby]="_headerId"
[id]="id"
#body>
Expand Down
59 changes: 35 additions & 24 deletions src/material/expansion/expansion-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
ANIMATION_MODULE_TYPE,
} from '@angular/core';
import {Subject} from 'rxjs';
import {distinctUntilChanged, filter, startWith, take} from 'rxjs/operators';
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';
Expand Down Expand Up @@ -91,7 +91,7 @@ export const MAT_EXPANSION_PANEL_DEFAULT_OPTIONS =
host: {
'class': 'mat-expansion-panel',
'[class.mat-expanded]': 'expanded',
'[class._mat-animation-noopable]': '_animationMode === "NoopAnimations"',
'[class._mat-animation-noopable]': '_animationsDisabled',
'[class.mat-expansion-panel-spacing]': '_hasSpacing()',
},
standalone: true,
Expand All @@ -101,6 +101,7 @@ export class MatExpansionPanel
extends CdkAccordionItem
implements AfterContentInit, OnChanges, OnDestroy
{
protected _animationsDisabled: boolean;
private _document: Document;

/** Whether the toggle indicator should be hidden. */
Expand Down Expand Up @@ -147,9 +148,6 @@ export class MatExpansionPanel
/** ID for the associated header element. Used for a11y labelling. */
_headerId = `mat-expansion-panel-header-${uniqueId++}`;

/** Stream of body animation done events. */
readonly _bodyAnimationDone = new Subject<AnimationEvent>();

constructor(
@Optional() @SkipSelf() @Inject(MAT_ACCORDION) accordion: MatAccordionBase,
_changeDetectorRef: ChangeDetectorRef,
Expand All @@ -164,24 +162,7 @@ export class MatExpansionPanel
super(accordion, _changeDetectorRef, _uniqueSelectionDispatcher);
this.accordion = accordion;
this._document = _document;

// We need a Subject with distinctUntilChanged, because the `done` event
// fires twice on some browsers. See https://github.com/angular/angular/issues/24084
this._bodyAnimationDone
.pipe(
distinctUntilChanged((x, y) => {
return x.fromState === y.fromState && x.toState === y.toState;
}),
)
.subscribe(event => {
if (event.fromState !== 'void') {
if (event.toState === 'expanded') {
this.afterExpand.emit();
} else if (event.toState === 'collapsed') {
this.afterCollapse.emit();
}
}
});
this._animationsDisabled = _animationMode === 'NoopAnimations';

if (defaultOptions) {
this.hideToggle = defaultOptions.hideToggle;
Expand Down Expand Up @@ -237,7 +218,6 @@ export class MatExpansionPanel

override ngOnDestroy() {
super.ngOnDestroy();
this._bodyAnimationDone.complete();
this._inputChanges.complete();
}

Expand All @@ -251,6 +231,37 @@ 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');
}
}
}
}

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

/**
Expand Down
5 changes: 4 additions & 1 deletion tools/public_api_guard/material/expansion.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,13 @@ export class MatExpansionPanel extends CdkAccordionItem implements AfterContentI
accordion: MatAccordionBase;
readonly afterCollapse: EventEmitter<void>;
readonly afterExpand: EventEmitter<void>;
protected _animationDone(event: AnimationEvent_2): void;
// (undocumented)
_animationMode: string;
// (undocumented)
protected _animationsDisabled: boolean;
protected _animationStarted(event: AnimationEvent_2): void;
_body: ElementRef<HTMLElement>;
readonly _bodyAnimationDone: Subject<AnimationEvent_2>;
close(): void;
_containsFocus(): boolean;
_getExpandedState(): MatExpansionPanelState;
Expand Down

0 comments on commit 24eaa2e

Please sign in to comment.