Skip to content

Commit 65fcff4

Browse files
committed
fix(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 2adf629 commit 65fcff4

File tree

2 files changed

+62
-3
lines changed

2 files changed

+62
-3
lines changed

src/material/menu/menu.spec.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,26 @@ describe('MatMenu', () => {
180180
expect(document.activeElement).not.toBe(triggerEl);
181181
}));
182182

183+
it('should move focus to another item if the active item is destroyed', fakeAsync(() => {
184+
const fixture = createComponent(MenuWithRepeatedItems, [], [FakeIcon]);
185+
fixture.detectChanges();
186+
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
187+
188+
triggerEl.click();
189+
fixture.detectChanges();
190+
tick(500);
191+
192+
const items = overlayContainerElement.querySelectorAll('.mat-menu-panel .mat-menu-item');
193+
194+
expect(document.activeElement).toBe(items[0]);
195+
196+
fixture.componentInstance.items.shift();
197+
fixture.detectChanges();
198+
tick(500);
199+
200+
expect(document.activeElement).toBe(items[1]);
201+
}));
202+
183203
it('should be able to set a custom class on the backdrop', fakeAsync(() => {
184204
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
185205

@@ -237,6 +257,7 @@ describe('MatMenu', () => {
237257
// Add 50 items to make the menu scrollable
238258
fixture.componentInstance.extraItems = new Array(50).fill('Hello there');
239259
fixture.detectChanges();
260+
tick(50);
240261

241262
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
242263
dispatchFakeEvent(triggerEl, 'mousedown');
@@ -2299,7 +2320,6 @@ class DynamicPanelMenu {
22992320
@ViewChild('two', {static: false}) secondMenu: MatMenu;
23002321
}
23012322

2302-
23032323
@Component({
23042324
template: `
23052325
<button [matMenuTriggerFor]="menu">Toggle menu</button>
@@ -2313,3 +2333,19 @@ class DynamicPanelMenu {
23132333
class MenuWithCheckboxItems {
23142334
@ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
23152335
}
2336+
2337+
2338+
@Component({
2339+
template: `
2340+
<button [matMenuTriggerFor]="menu" #triggerEl>Toggle menu</button>
2341+
<mat-menu #menu="matMenu">
2342+
<button *ngFor="let item of items" mat-menu-item>{{item}}</button>
2343+
</mat-menu>
2344+
`
2345+
})
2346+
class MenuWithRepeatedItems {
2347+
@ViewChild(MatMenuTrigger) trigger: MatMenuTrigger;
2348+
@ViewChild('triggerEl') triggerEl: ElementRef<HTMLElement>;
2349+
@ViewChild(MatMenu) menu: MatMenu;
2350+
items = ['One', 'Two', 'Three'];
2351+
}

src/material/menu/menu.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
OnInit,
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, debounceTime} from 'rxjs/operators';
4444
import {matMenuAnimations} from './menu-animations';
4545
import {MatMenuContent} from './menu-content';
4646
import {MenuPositionX, MenuPositionY} from './menu-positions';
@@ -244,11 +244,34 @@ export class _MatMenuBase implements AfterContentInit, MatMenuPanel<MatMenuItem>
244244
ngAfterContentInit() {
245245
this._keyManager = new FocusKeyManager<MatMenuItem>(this._items).withWrap().withTypeAhead();
246246
this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.closed.emit('tab'));
247+
248+
// TODO(crisbeto): the `debounce` here should be removed since it's something
249+
// that people have to flush in their tests. It'll be possible once we switch back
250+
// to using a QueryList in #11720.
251+
this._ngZone.runOutsideAngular(() => {
252+
// Move focus to another item, if the active item is removed from the list.
253+
// We need to debounce the callback, because multiple items might be removed
254+
// in quick succession.
255+
this._itemChanges.pipe(debounceTime(50)).subscribe(items => {
256+
const manager = this._keyManager;
257+
258+
if (manager.activeItem && items.indexOf(manager.activeItem) === -1) {
259+
const index = Math.max(0, Math.min(items.length - 1, manager.activeItemIndex || 0));
260+
261+
if (items[index] && !items[index].disabled) {
262+
manager.setActiveItem(index);
263+
} else {
264+
manager.setNextItemActive();
265+
}
266+
}
267+
});
268+
});
247269
}
248270

249271
ngOnDestroy() {
250272
this._tabSubscription.unsubscribe();
251273
this.closed.complete();
274+
this._itemChanges.complete();
252275
}
253276

254277
/** Stream that emits whenever the hovered menu item changes. */
@@ -359,7 +382,7 @@ export class _MatMenuBase implements AfterContentInit, MatMenuPanel<MatMenuItem>
359382
removeItem(item: MatMenuItem) {
360383
const index = this._items.indexOf(item);
361384

362-
if (this._items.indexOf(item) > -1) {
385+
if (index > -1) {
363386
this._items.splice(index, 1);
364387
this._itemChanges.next(this._items);
365388
}

0 commit comments

Comments
 (0)