Skip to content

Commit 912e46f

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 912e46f

File tree

3 files changed

+92
-1
lines changed

3 files changed

+92
-1
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.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

+19-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
ChangeDetectorRef,
4141
} from '@angular/core';
4242
import {merge, Observable, Subject, Subscription} from 'rxjs';
43-
import {startWith, switchMap, take} from 'rxjs/operators';
43+
import {startWith, switchMap, take, tap} from 'rxjs/operators';
4444
import {matMenuAnimations} from './menu-animations';
4545
import {MAT_MENU_CONTENT, MatMenuContent} from './menu-content';
4646
import {MenuPositionX, MenuPositionY} from './menu-positions';
@@ -295,6 +295,24 @@ export class _MatMenuBase
295295
this._directDescendantItems.changes
296296
.pipe(
297297
startWith(this._directDescendantItems),
298+
tap((itemsList: QueryList<MatMenuItem>) => {
299+
// Move focus to another item, if the active item is removed from the list.
300+
// We need to debounce the callback, because multiple items might be removed
301+
// in quick succession.
302+
303+
const manager = this._keyManager;
304+
const items = itemsList.toArray();
305+
306+
if (manager.activeItem && items.indexOf(manager.activeItem) === -1) {
307+
const index = Math.max(0, Math.min(items.length - 1, manager.activeItemIndex || 0));
308+
309+
if (items[index] && !items[index].disabled) {
310+
manager.setActiveItem(index);
311+
} else {
312+
manager.setNextItemActive();
313+
}
314+
}
315+
}),
298316
switchMap(items => merge(...items.map((item: MatMenuItem) => item._focused))),
299317
)
300318
.subscribe(focusedItem => this._keyManager.updateActiveItem(focusedItem as MatMenuItem));

0 commit comments

Comments
 (0)