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

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 #27430.
Fixes #28644.
  • Loading branch information
crisbeto committed Feb 26, 2024
1 parent 53d3fda commit bed6ece
Show file tree
Hide file tree
Showing 3 changed files with 34 additions and 25 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
53 changes: 30 additions & 23 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 @@ -147,9 +147,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 @@ -165,24 +162,6 @@ export class MatExpansionPanel
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();
}
}
});

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

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

Expand All @@ -251,6 +229,35 @@ export class MatExpansionPanel

return false;
}

/** Called when the expansion animation has started. */
protected _animationStarted(event: AnimationEvent) {
if (!isInitialAnimation(event)) {
// 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-enabled tabbing once the animation is finished.
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
3 changes: 2 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,11 @@ 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;
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 bed6ece

Please sign in to comment.