Skip to content

Commit baf6419

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 b919a48 commit baf6419

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);
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);
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
@@ -19,7 +19,9 @@ import {
1919
ContentChild,
2020
Directive,
2121
EventEmitter,
22+
ElementRef,
2223
Input,
24+
Inject,
2325
OnChanges,
2426
OnDestroy,
2527
Optional,
@@ -28,7 +30,9 @@ import {
2830
SkipSelf,
2931
ViewContainerRef,
3032
ViewEncapsulation,
33+
ViewChild,
3134
} from '@angular/core';
35+
import {DOCUMENT} from '@angular/common';
3236
import {Subject} from 'rxjs';
3337
import {filter, startWith, take} from 'rxjs/operators';
3438
import {MatAccordion} from './accordion';
@@ -72,8 +76,13 @@ let uniqueId = 0;
7276
'[class.mat-expansion-panel-spacing]': '_hasSpacing()',
7377
}
7478
})
75-
export class MatExpansionPanel extends _CdkAccordionItem
76-
implements AfterContentInit, OnChanges, OnDestroy {
79+
export class MatExpansionPanel extends CdkAccordionItem implements AfterContentInit, OnChanges,
80+
OnDestroy {
81+
82+
// @breaking-change 8.0.0 Remove `| undefined` from here
83+
// when the `_document` constructor param is required.
84+
private _document: Document | undefined;
85+
7786
/** Whether the toggle indicator should be hidden. */
7887
@Input()
7988
get hideToggle(): boolean {
@@ -99,6 +108,9 @@ export class MatExpansionPanel extends _CdkAccordionItem
99108
/** Content that will be rendered lazily. */
100109
@ContentChild(MatExpansionPanelContent) _lazyContent: MatExpansionPanelContent;
101110

111+
/** Element containing the panel's user-provided content. */
112+
@ViewChild('body') _body: ElementRef<HTMLElement>;
113+
102114
/** Portal holding the user's content. */
103115
_portal: TemplatePortal;
104116

@@ -108,9 +120,11 @@ export class MatExpansionPanel extends _CdkAccordionItem
108120
constructor(@Optional() @SkipSelf() accordion: MatAccordion,
109121
_changeDetectorRef: ChangeDetectorRef,
110122
_uniqueSelectionDispatcher: UniqueSelectionDispatcher,
111-
private _viewContainerRef: ViewContainerRef) {
123+
private _viewContainerRef: ViewContainerRef,
124+
@Inject(DOCUMENT) _document?: any) {
112125
super(accordion, _changeDetectorRef, _uniqueSelectionDispatcher);
113126
this.accordion = accordion;
127+
this._document = _document;
114128
}
115129

116130
/** Determines whether the expansion panel should have spacing between it and its siblings. */
@@ -174,6 +188,17 @@ export class MatExpansionPanel extends _CdkAccordionItem
174188
this.afterCollapse.emit();
175189
}
176190
}
191+
192+
/** Checks whether the expansion panel's content contains the currently-focused element. */
193+
_containsFocus(): boolean {
194+
if (this._body && this._document) {
195+
const focusedElement = this._document.activeElement;
196+
const bodyElement = this._body.nativeElement;
197+
return focusedElement === bodyElement || bodyElement.contains(focusedElement);
198+
}
199+
200+
return false;
201+
}
177202
}
178203

179204
@Directive({

src/lib/expansion/expansion.spec.ts

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

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

0 commit comments

Comments
 (0)