Skip to content

Commit 546bb05

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 7e74b5d commit 546bb05

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
@@ -18,15 +18,19 @@ import {
1818
Component,
1919
ContentChild,
2020
Directive,
21+
ElementRef,
2122
Input,
23+
Inject,
2224
OnChanges,
2325
OnDestroy,
2426
Optional,
2527
SimpleChanges,
2628
SkipSelf,
2729
ViewContainerRef,
2830
ViewEncapsulation,
31+
ViewChild,
2932
} from '@angular/core';
33+
import {DOCUMENT} from '@angular/common';
3034
import {Subject} from 'rxjs';
3135
import {filter, startWith, take} from 'rxjs/operators';
3236
import {MatAccordion} from './accordion';
@@ -68,8 +72,13 @@ let uniqueId = 0;
6872
'[class.mat-expansion-panel-spacing]': '_hasSpacing()',
6973
}
7074
})
71-
export class MatExpansionPanel extends CdkAccordionItem
72-
implements AfterContentInit, OnChanges, OnDestroy {
75+
export class MatExpansionPanel extends CdkAccordionItem implements AfterContentInit, OnChanges,
76+
OnDestroy {
77+
78+
// @breaking-change 8.0.0 Remove `| undefined` from here
79+
// when the `_document` constructor param is required.
80+
private _document: Document | undefined;
81+
7382
/** Whether the toggle indicator should be hidden. */
7483
@Input()
7584
get hideToggle(): boolean { return this._hideToggle; }
@@ -87,6 +96,9 @@ export class MatExpansionPanel extends CdkAccordionItem
8796
/** Content that will be rendered lazily. */
8897
@ContentChild(MatExpansionPanelContent) _lazyContent: MatExpansionPanelContent;
8998

99+
/** Element containing the panel's user-provided content. */
100+
@ViewChild('body') _body: ElementRef<HTMLElement>;
101+
90102
/** Portal holding the user's content. */
91103
_portal: TemplatePortal;
92104

@@ -96,9 +108,11 @@ export class MatExpansionPanel extends CdkAccordionItem
96108
constructor(@Optional() @SkipSelf() accordion: MatAccordion,
97109
_changeDetectorRef: ChangeDetectorRef,
98110
_uniqueSelectionDispatcher: UniqueSelectionDispatcher,
99-
private _viewContainerRef: ViewContainerRef) {
111+
private _viewContainerRef: ViewContainerRef,
112+
@Inject(DOCUMENT) _document?: any) {
100113
super(accordion, _changeDetectorRef, _uniqueSelectionDispatcher);
101114
this.accordion = accordion;
115+
this._document = _document;
102116
}
103117

104118
/** Whether the expansion indicator should be hidden. */
@@ -159,6 +173,17 @@ export class MatExpansionPanel extends CdkAccordionItem
159173
classList.remove(cssClass);
160174
}
161175
}
176+
177+
/** Checks whether the expansion panel's content contains the currently-focused element. */
178+
_containsFocus(): boolean {
179+
if (this._body && this._document) {
180+
const focusedElement = this._document.activeElement;
181+
const bodyElement = this._body.nativeElement;
182+
return focusedElement === bodyElement || bodyElement.contains(focusedElement);
183+
}
184+
185+
return false;
186+
}
162187
}
163188

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