Skip to content

Commit 5e2a5af

Browse files
committed
fix(expansion-panel): focus lost if focused element is inside closing panel
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 b9651df commit 5e2a5af

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
@@ -71,14 +71,19 @@ export class MatExpansionPanelHeader implements OnDestroy {
7171
private _changeDetectorRef: ChangeDetectorRef) {
7272

7373
// Since the toggle state depends on an @Input on the panel, we
74-
// need to subscribe and trigger change detection manually.
74+
// need to subscribe and trigger change detection manually.
7575
this._parentChangeSubscription = merge(
7676
panel.opened,
7777
panel.closed,
7878
panel._inputChanges.pipe(filter(changes => !!(changes.hideToggle || changes.disabled)))
7979
)
8080
.subscribe(() => this._changeDetectorRef.markForCheck());
8181

82+
// Avoids focus being lost if the panel contained the focused element and was closed.
83+
panel.closed
84+
.pipe(filter(() => panel._containsFocus()))
85+
.subscribe(() => _focusMonitor.focusVia(_element.nativeElement, 'program'));
86+
8287
_focusMonitor.monitor(_element.nativeElement);
8388
}
8489

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';
@@ -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 { return this._hideToggle; }
@@ -95,6 +104,9 @@ export class MatExpansionPanel extends CdkAccordionItem
95104
/** Content that will be rendered lazily. */
96105
@ContentChild(MatExpansionPanelContent) _lazyContent: MatExpansionPanelContent;
97106

107+
/** Element containing the panel's user-provided content. */
108+
@ViewChild('body') _body: ElementRef<HTMLElement>;
109+
98110
/** Portal holding the user's content. */
99111
_portal: TemplatePortal;
100112

@@ -104,9 +116,11 @@ export class MatExpansionPanel extends CdkAccordionItem
104116
constructor(@Optional() @SkipSelf() accordion: MatAccordion,
105117
_changeDetectorRef: ChangeDetectorRef,
106118
_uniqueSelectionDispatcher: UniqueSelectionDispatcher,
107-
private _viewContainerRef: ViewContainerRef) {
119+
private _viewContainerRef: ViewContainerRef,
120+
@Inject(DOCUMENT) _document?: any) {
108121
super(accordion, _changeDetectorRef, _uniqueSelectionDispatcher);
109122
this.accordion = accordion;
123+
this._document = _document;
110124
}
111125

112126
/** Whether the expansion indicator should be hidden. */
@@ -175,6 +189,17 @@ export class MatExpansionPanel extends CdkAccordionItem
175189
this.afterCollapse.emit();
176190
}
177191
}
192+
193+
/** Checks whether the expansion panel's content contains the currently-focused element. */
194+
_containsFocus(): boolean {
195+
if (this._body && this._document) {
196+
const focusedElement = this._document.activeElement;
197+
const bodyElement = this._body.nativeElement;
198+
return focusedElement === bodyElement || bodyElement.contains(focusedElement);
199+
}
200+
201+
return false;
202+
}
178203
}
179204

180205
@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)