Skip to content

Commit 56349cc

Browse files
committed
fix(material/menu): focus lost if active item is removed
Fixes the user's focus being lost if the active item is destroyed while a menu is open. Fixes #14028.
1 parent d011cae commit 56349cc

File tree

5 files changed

+99
-3
lines changed

5 files changed

+99
-3
lines changed

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

+37
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,28 @@ describe('MDC-based MatMenu', () => {
259259
tick(500);
260260
}));
261261

262+
it('should move focus to another item if the active item is destroyed', fakeAsync(() => {
263+
const fixture = createComponent(MenuWithRepeatedItems, [], [FakeIcon]);
264+
fixture.detectChanges();
265+
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
266+
267+
triggerEl.click();
268+
fixture.detectChanges();
269+
tick(500);
270+
271+
const items = overlayContainerElement.querySelectorAll(
272+
'.mat-mdc-menu-panel .mat-mdc-menu-item',
273+
);
274+
275+
expect(document.activeElement).toBe(items[0]);
276+
277+
fixture.componentInstance.items.shift();
278+
fixture.detectChanges();
279+
tick(500);
280+
281+
expect(document.activeElement).toBe(items[1]);
282+
}));
283+
262284
it('should be able to set a custom class on the backdrop', fakeAsync(() => {
263285
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
264286

@@ -3071,3 +3093,18 @@ class StaticAriaLabelledByMenu {}
30713093
template: '<mat-menu aria-describedby="some-element"></mat-menu>',
30723094
})
30733095
class StaticAriaDescribedbyMenu {}
3096+
3097+
@Component({
3098+
template: `
3099+
<button [matMenuTriggerFor]="menu" #triggerEl>Toggle menu</button>
3100+
<mat-menu #menu="matMenu">
3101+
<button *ngFor="let item of items" mat-menu-item>{{item}}</button>
3102+
</mat-menu>
3103+
`,
3104+
})
3105+
class MenuWithRepeatedItems {
3106+
@ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
3107+
@ViewChild('triggerEl', {static: false}) triggerEl: ElementRef<HTMLElement>;
3108+
@ViewChild(MatMenu, {static: false}) menu: MatMenu;
3109+
items = ['One', 'Two', 'Three'];
3110+
}

src/material/menu/menu-item.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,7 @@ export class MatMenuItem
7676
_triggersSubmenu: boolean = false;
7777

7878
/**
79-
* @deprecated `document` parameter to be removed, `changeDetectorRef` and
80-
* `focusMonitor` to become required.
79+
* @deprecated `_document`, `changeDetectorRef` and `focusMonitor` to become required.
8180
* @breaking-change 12.0.0
8281
*/
8382
constructor(
@@ -90,7 +89,7 @@ export class MatMenuItem
9089

9190
constructor(
9291
private _elementRef: ElementRef<HTMLElement>,
93-
@Inject(DOCUMENT) _document?: any,
92+
@Inject(DOCUMENT) private _document?: any,
9493
private _focusMonitor?: FocusMonitor,
9594
@Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel<MatMenuItem>,
9695
private _changeDetectorRef?: ChangeDetectorRef,
@@ -176,4 +175,8 @@ export class MatMenuItem
176175
this._highlighted = isHighlighted;
177176
this._changeDetectorRef?.markForCheck();
178177
}
178+
179+
_hasFocus(): boolean {
180+
return this._document && this._document.activeElement === this._getHostElement();
181+
}
179182
}

src/material/menu/menu.spec.ts

+36
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,26 @@ describe('MatMenu', () => {
259259
tick(500);
260260
}));
261261

262+
it('should move focus to another item if the active item is destroyed', fakeAsync(() => {
263+
const fixture = createComponent(MenuWithRepeatedItems, [], [FakeIcon]);
264+
fixture.detectChanges();
265+
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
266+
267+
triggerEl.click();
268+
fixture.detectChanges();
269+
tick(500);
270+
271+
const items = overlayContainerElement.querySelectorAll('.mat-menu-panel .mat-menu-item');
272+
273+
expect(document.activeElement).toBe(items[0]);
274+
275+
fixture.componentInstance.items.shift();
276+
fixture.detectChanges();
277+
tick(500);
278+
279+
expect(document.activeElement).toBe(items[1]);
280+
}));
281+
262282
it('should be able to set a custom class on the backdrop', fakeAsync(() => {
263283
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
264284

@@ -351,6 +371,7 @@ describe('MatMenu', () => {
351371
// Add 50 items to make the menu scrollable
352372
fixture.componentInstance.extraItems = new Array(50).fill('Hello there');
353373
fixture.detectChanges();
374+
tick(50);
354375

355376
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
356377
dispatchFakeEvent(triggerEl, 'mousedown');
@@ -3053,3 +3074,18 @@ class StaticAriaLabelledByMenu {}
30533074
template: '<mat-menu aria-describedby="some-element"></mat-menu>',
30543075
})
30553076
class StaticAriaDescribedbyMenu {}
3077+
3078+
@Component({
3079+
template: `
3080+
<button [matMenuTriggerFor]="menu" #triggerEl>Toggle menu</button>
3081+
<mat-menu #menu="matMenu">
3082+
<button *ngFor="let item of items" mat-menu-item>{{item}}</button>
3083+
</mat-menu>
3084+
`,
3085+
})
3086+
class MenuWithRepeatedItems {
3087+
@ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
3088+
@ViewChild('triggerEl', {static: false}) triggerEl: ElementRef<HTMLElement>;
3089+
@ViewChild(MatMenu, {static: false}) menu: MatMenu;
3090+
items = ['One', 'Two', 'Three'];
3091+
}

src/material/menu/menu.ts

+18
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,24 @@ export class _MatMenuBase
298298
switchMap(items => merge(...items.map((item: MatMenuItem) => item._focused))),
299299
)
300300
.subscribe(focusedItem => this._keyManager.updateActiveItem(focusedItem as MatMenuItem));
301+
302+
this._directDescendantItems.changes.subscribe((itemsList: QueryList<MatMenuItem>) => {
303+
// Move focus to another item, if the active item is removed from the list.
304+
// We need to debounce the callback, because multiple items might be removed
305+
// in quick succession.
306+
const manager = this._keyManager;
307+
308+
if (this._panelAnimationState === 'enter' && manager.activeItem?._hasFocus()) {
309+
const items = itemsList.toArray();
310+
const index = Math.max(0, Math.min(items.length - 1, manager.activeItemIndex || 0));
311+
312+
if (items[index] && !items[index].disabled) {
313+
manager.setActiveItem(index);
314+
} else {
315+
manager.setNextItemActive();
316+
}
317+
}
318+
});
301319
}
302320

303321
ngOnDestroy() {

tools/public_api_guard/material/menu.md

+2
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ export class MatMenuItem extends _MatMenuItemBase implements FocusableOption, Ca
203203
getLabel(): string;
204204
_getTabIndex(): string;
205205
_handleMouseEnter(): void;
206+
// (undocumented)
207+
_hasFocus(): boolean;
206208
_highlighted: boolean;
207209
readonly _hovered: Subject<MatMenuItem>;
208210
// (undocumented)

0 commit comments

Comments
 (0)