Skip to content

Commit 21cae48

Browse files
authored
fix(material/menu): nested menu trigger not restoring focus (#21531)
Fixes that navigating back from a nested menu using the keyboard doesn't restore focus to the previous trigger, breaking the interaction since it ends up going back to the `body`. This used to work, but seems to have regressed at some point.
1 parent 192efb8 commit 21cae48

File tree

5 files changed

+66
-37
lines changed

5 files changed

+66
-37
lines changed

src/material-experimental/mdc-menu/menu.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1883,6 +1883,24 @@ describe('MDC-based MatMenu', () => {
18831883
.toBe(true, 'Expected focus to be back inside the root menu');
18841884
});
18851885

1886+
it('should restore focus to a nested trigger when navgating via the keyboard', fakeAsync(() => {
1887+
compileTestComponent();
1888+
instance.rootTriggerEl.nativeElement.click();
1889+
fixture.detectChanges();
1890+
1891+
const levelOneTrigger = overlay.querySelector('#level-one-trigger')! as HTMLElement;
1892+
dispatchKeyboardEvent(levelOneTrigger, 'keydown', RIGHT_ARROW);
1893+
fixture.detectChanges();
1894+
1895+
const spy = spyOn(levelOneTrigger, 'focus').and.callThrough();
1896+
dispatchKeyboardEvent(
1897+
overlay.querySelectorAll('.mat-mdc-menu-panel')[1], 'keydown', LEFT_ARROW);
1898+
fixture.detectChanges();
1899+
tick(500);
1900+
1901+
expect(spy).toHaveBeenCalled();
1902+
}));
1903+
18861904
it('should position the sub-menu to the right edge of the trigger in ltr', () => {
18871905
compileTestComponent();
18881906
instance.rootTriggerEl.nativeElement.style.position = 'fixed';

src/material/menu/menu-trigger.ts

Lines changed: 21 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
import {normalizePassiveListenerOptions} from '@angular/cdk/platform';
3737
import {asapScheduler, merge, of as observableOf, Subscription} from 'rxjs';
3838
import {delay, filter, take, takeUntil} from 'rxjs/operators';
39-
import {_MatMenuBase} from './menu';
39+
import {MenuCloseReason, _MatMenuBase} from './menu';
4040
import {throwMatMenuMissingError, throwMatMenuRecursiveError} from './menu-errors';
4141
import {MatMenuItem} from './menu-item';
4242
import {MatMenuPanel, MAT_MENU_PANEL} from './menu-panel';
@@ -103,7 +103,7 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
103103

104104
// Tracking input type is necessary so it's possible to only auto-focus
105105
// the first item of the list when the menu is opened via the keyboard
106-
_openedBy: Exclude<FocusOrigin, 'program'> = null;
106+
_openedBy: Exclude<FocusOrigin, 'program' | null> | undefined = undefined;
107107

108108
/**
109109
* @deprecated
@@ -131,15 +131,14 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
131131
throwMatMenuRecursiveError();
132132
}
133133

134-
this._menuCloseSubscription = menu.close.subscribe(
135-
(reason: 'click' | 'tab' | 'keydown' | undefined) => {
136-
this._destroyMenu();
134+
this._menuCloseSubscription = menu.close.subscribe((reason: MenuCloseReason) => {
135+
this._destroyMenu(reason);
137136

138-
// If a click closed the menu, we should close the entire chain of nested menus.
139-
if ((reason === 'click' || reason === 'tab') && this._parentMaterialMenu) {
140-
this._parentMaterialMenu.closed.emit(reason);
141-
}
142-
});
137+
// If a click closed the menu, we should close the entire chain of nested menus.
138+
if ((reason === 'click' || reason === 'tab') && this._parentMaterialMenu) {
139+
this._parentMaterialMenu.closed.emit(reason);
140+
}
141+
});
143142
}
144143
}
145144
private _menu: MatMenuPanel;
@@ -284,15 +283,24 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
284283
}
285284

286285
/** Closes the menu and does the necessary cleanup. */
287-
private _destroyMenu() {
286+
private _destroyMenu(reason: MenuCloseReason) {
288287
if (!this._overlayRef || !this.menuOpen) {
289288
return;
290289
}
291290

292291
const menu = this.menu;
293292
this._closingActionsSubscription.unsubscribe();
294293
this._overlayRef.detach();
295-
this._restoreFocus();
294+
295+
// Always restore focus if the user is navigating using the keyboard or the menu was opened
296+
// programmatically. We don't restore for non-root triggers, because it can prevent focus
297+
// from making it back to the root trigger when closing a long chain of menus by clicking
298+
// on the backdrop.
299+
if (this.restoreFocus && (reason === 'keydown' || !this._openedBy || !this.triggersSubmenu())) {
300+
this.focus(this._openedBy);
301+
}
302+
303+
this._openedBy = undefined;
296304

297305
if (menu instanceof _MatMenuBase) {
298306
menu._resetAnimation();
@@ -350,24 +358,6 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
350358
}
351359
}
352360

353-
/** Restores focus to the element that was focused before the menu was open. */
354-
private _restoreFocus() {
355-
// We should reset focus if the user is navigating using a keyboard or
356-
// if we have a top-level trigger which might cause focus to be lost
357-
// when clicking on the backdrop.
358-
if (this.restoreFocus) {
359-
if (!this._openedBy) {
360-
// Note that the focus style will show up both for `program` and
361-
// `keyboard` so we don't have to specify which one it is.
362-
this.focus();
363-
} else if (!this.triggersSubmenu()) {
364-
this.focus(this._openedBy);
365-
}
366-
}
367-
368-
this._openedBy = null;
369-
}
370-
371361
// set state rather than toggle to support triggers sharing a menu
372362
private _setIsMenuOpen(isOpen: boolean): void {
373363
this._menuOpen = isOpen;
@@ -506,7 +496,7 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
506496
if (!isFakeMousedownFromScreenReader(event)) {
507497
// Since right or middle button clicks won't trigger the `click` event,
508498
// we shouldn't consider the menu as opened by mouse in those cases.
509-
this._openedBy = event.button === 0 ? 'mouse' : null;
499+
this._openedBy = event.button === 0 ? 'mouse' : undefined;
510500

511501
// Since clicking on the trigger won't close the menu if it opens a sub-menu,
512502
// we should prevent focus from moving onto it via click to avoid the

src/material/menu/menu.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1894,6 +1894,23 @@ describe('MatMenu', () => {
18941894
.toBe(true, 'Expected focus to be back inside the root menu');
18951895
});
18961896

1897+
it('should restore focus to a nested trigger when navgating via the keyboard', fakeAsync(() => {
1898+
compileTestComponent();
1899+
instance.rootTriggerEl.nativeElement.click();
1900+
fixture.detectChanges();
1901+
1902+
const levelOneTrigger = overlay.querySelector('#level-one-trigger')! as HTMLElement;
1903+
dispatchKeyboardEvent(levelOneTrigger, 'keydown', RIGHT_ARROW);
1904+
fixture.detectChanges();
1905+
1906+
const spy = spyOn(levelOneTrigger, 'focus').and.callThrough();
1907+
dispatchKeyboardEvent(overlay.querySelectorAll('.mat-menu-panel')[1], 'keydown', LEFT_ARROW);
1908+
fixture.detectChanges();
1909+
tick(500);
1910+
1911+
expect(spy).toHaveBeenCalled();
1912+
}));
1913+
18971914
it('should position the sub-menu to the right edge of the trigger in ltr', () => {
18981915
compileTestComponent();
18991916
instance.rootTriggerEl.nativeElement.style.position = 'fixed';

src/material/menu/menu.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ const MAT_MENU_BASE_ELEVATION = 4;
9393

9494
let menuPanelUid = 0;
9595

96+
97+
/** Reason why the menu was closed. */
98+
export type MenuCloseReason = void | 'click' | 'keydown' | 'tab';
99+
100+
96101
/** Base class with all of the `MatMenu` functionality. */
97102
@Directive()
98103
export class _MatMenuBase implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnInit,
@@ -239,15 +244,14 @@ export class _MatMenuBase implements AfterContentInit, MatMenuPanel<MatMenuItem>
239244
set classList(classes: string) { this.panelClass = classes; }
240245

241246
/** Event emitted when the menu is closed. */
242-
@Output() readonly closed: EventEmitter<void | 'click' | 'keydown' | 'tab'> =
243-
new EventEmitter<void | 'click' | 'keydown' | 'tab'>();
247+
@Output() readonly closed: EventEmitter<MenuCloseReason> = new EventEmitter<MenuCloseReason>();
244248

245249
/**
246250
* Event emitted when the menu is closed.
247251
* @deprecated Switch to `closed` instead
248252
* @breaking-change 8.0.0
249253
*/
250-
@Output() close: EventEmitter<void | 'click' | 'keydown' | 'tab'> = this.closed;
254+
@Output() close: EventEmitter<MenuCloseReason> = this.closed;
251255

252256
readonly panelId = `mat-menu-panel-${menuPanelUid++}`;
253257

tools/public_api_guard/material/menu.d.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ export declare class _MatMenuBase implements AfterContentInit, MatMenuPanel<MatM
1212
backdropClass: string;
1313
get classList(): string;
1414
set classList(classes: string);
15-
close: EventEmitter<void | 'click' | 'keydown' | 'tab'>;
16-
readonly closed: EventEmitter<void | 'click' | 'keydown' | 'tab'>;
15+
close: EventEmitter<MenuCloseReason>;
16+
readonly closed: EventEmitter<MenuCloseReason>;
1717
direction: Direction;
1818
get hasBackdrop(): boolean | undefined;
1919
set hasBackdrop(value: boolean | undefined);
@@ -149,7 +149,7 @@ export interface MatMenuPanel<T = any> {
149149
export declare class MatMenuTrigger implements AfterContentInit, OnDestroy {
150150
get _deprecatedMatMenuTriggerFor(): MatMenuPanel;
151151
set _deprecatedMatMenuTriggerFor(v: MatMenuPanel);
152-
_openedBy: Exclude<FocusOrigin, 'program'>;
152+
_openedBy: Exclude<FocusOrigin, 'program' | null> | undefined;
153153
get dir(): Direction;
154154
get menu(): MatMenuPanel;
155155
set menu(menu: MatMenuPanel);

0 commit comments

Comments
 (0)