Skip to content

fix(material/expansion): prevent focus from entering the panel while it's animating #28646

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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