Skip to content

Commit 3596e9d

Browse files
crisbetojelbourn
authored andcommitted
fix(expansion-panel): focus lost if focused element is inside closing panel (#12692)
Currently when an expansion panel is closed, we make the content non-focusable using `visibility: hidden`, but that means that if the focused element was inside the panel, focus will be returned back to the body. These changes add a listener that will restore focus to the panel header, if the focused element is inside the panel when it is closed.
1 parent 81e0542 commit 3596e9d

File tree

3 files changed

+53
-4
lines changed

3 files changed

+53
-4
lines changed

src/lib/expansion/expansion-panel-header.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,12 @@ export class MatExpansionPanelHeader implements OnDestroy {
8383
)
8484
.subscribe(() => this._changeDetectorRef.markForCheck());
8585

86-
_focusMonitor.monitor(_element.nativeElement);
86+
// Avoids focus being lost if the panel contained the focused element and was closed.
87+
panel.closed
88+
.pipe(filter(() => panel._containsFocus()))
89+
.subscribe(() => _focusMonitor.focusVia(_element.nativeElement, 'program'));
90+
91+
_focusMonitor.monitor(_element.nativeElement);
8792
}
8893

8994
/** Height of the header while the panel is expanded. */

src/lib/expansion/expansion-panel.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,23 @@ import {CdkAccordionItem} from '@angular/cdk/accordion';
1111
import {coerceBooleanProperty} from '@angular/cdk/coercion';
1212
import {UniqueSelectionDispatcher} from '@angular/cdk/collections';
1313
import {TemplatePortal} from '@angular/cdk/portal';
14+
import {DOCUMENT} from '@angular/common';
1415
import {
1516
AfterContentInit,
1617
ChangeDetectionStrategy,
1718
ChangeDetectorRef,
1819
Component,
1920
ContentChild,
2021
Directive,
22+
ElementRef,
23+
Inject,
2124
Input,
2225
OnChanges,
2326
OnDestroy,
2427
Optional,
2528
SimpleChanges,
2629
SkipSelf,
30+
ViewChild,
2731
ViewContainerRef,
2832
ViewEncapsulation,
2933
} from '@angular/core';
@@ -70,8 +74,13 @@ let uniqueId = 0;
7074
'[class.mat-expansion-panel-spacing]': '_hasSpacing()',
7175
}
7276
})
73-
export class MatExpansionPanel extends _CdkAccordionItem
74-
implements AfterContentInit, OnChanges, OnDestroy {
77+
export class MatExpansionPanel extends CdkAccordionItem implements AfterContentInit, OnChanges,
78+
OnDestroy {
79+
80+
// @breaking-change 8.0.0 Remove `| undefined` from here
81+
// when the `_document` constructor param is required.
82+
private _document: Document | undefined;
83+
7584
/** Whether the toggle indicator should be hidden. */
7685
@Input()
7786
get hideToggle(): boolean {
@@ -91,6 +100,9 @@ export class MatExpansionPanel extends _CdkAccordionItem
91100
/** Content that will be rendered lazily. */
92101
@ContentChild(MatExpansionPanelContent) _lazyContent: MatExpansionPanelContent;
93102

103+
/** Element containing the panel's user-provided content. */
104+
@ViewChild('body') _body: ElementRef<HTMLElement>;
105+
94106
/** Portal holding the user's content. */
95107
_portal: TemplatePortal;
96108

@@ -100,9 +112,11 @@ export class MatExpansionPanel extends _CdkAccordionItem
100112
constructor(@Optional() @SkipSelf() accordion: MatAccordion,
101113
_changeDetectorRef: ChangeDetectorRef,
102114
_uniqueSelectionDispatcher: UniqueSelectionDispatcher,
103-
private _viewContainerRef: ViewContainerRef) {
115+
private _viewContainerRef: ViewContainerRef,
116+
@Inject(DOCUMENT) _document?: any) {
104117
super(accordion, _changeDetectorRef, _uniqueSelectionDispatcher);
105118
this.accordion = accordion;
119+
this._document = _document;
106120
}
107121

108122
/** Determines whether the expansion panel should have spacing between it and its siblings. */
@@ -158,6 +172,17 @@ export class MatExpansionPanel extends _CdkAccordionItem
158172
classList.remove(cssClass);
159173
}
160174
}
175+
176+
/** Checks whether the expansion panel's content contains the currently-focused element. */
177+
_containsFocus(): boolean {
178+
if (this._body && this._document) {
179+
const focusedElement = this._document.activeElement;
180+
const bodyElement = this._body.nativeElement;
181+
return focusedElement === bodyElement || bodyElement.contains(focusedElement);
182+
}
183+
184+
return false;
185+
}
161186
}
162187

163188
@Directive({

src/lib/expansion/expansion.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,25 @@ describe('MatExpansionPanel', () => {
160160
expect(document.activeElement).not.toBe(button, 'Expected button to no longer be focusable.');
161161
}));
162162

163+
it('should restore focus to header if focused element is inside panel on close', fakeAsync(() => {
164+
const fixture = TestBed.createComponent(PanelWithContent);
165+
fixture.componentInstance.expanded = true;
166+
fixture.detectChanges();
167+
tick(250);
168+
169+
const button = fixture.debugElement.query(By.css('button')).nativeElement;
170+
const header = fixture.debugElement.query(By.css('mat-expansion-panel-header')).nativeElement;
171+
172+
button.focus();
173+
expect(document.activeElement).toBe(button, 'Expected button to start off focusable.');
174+
175+
fixture.componentInstance.expanded = false;
176+
fixture.detectChanges();
177+
tick(250);
178+
179+
expect(document.activeElement).toBe(header, 'Expected header to be focused.');
180+
}));
181+
163182
it('should not override the panel margin if it is not inside an accordion', fakeAsync(() => {
164183
let fixture = TestBed.createComponent(PanelWithCustomMargin);
165184
fixture.detectChanges();

0 commit comments

Comments
 (0)