Skip to content

Commit fcbf76c

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 740a2ee commit fcbf76c

File tree

2 files changed

+62
-3
lines changed

2 files changed

+62
-3
lines changed

src/lib/menu/menu-directive.ts

+25-2
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 {throwMatMenuInvalidPositionX, throwMatMenuInvalidPositionY} from './menu-errors';
@@ -259,11 +259,34 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
259259
ngAfterContentInit() {
260260
this._keyManager = new FocusKeyManager<MatMenuItem>(this._items).withWrap().withTypeAhead();
261261
this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.closed.emit('tab'));
262+
263+
// TODO(crisbeto): the `debounce` here should be removed since it's something
264+
// that people have to flush in their tests. It'll be possible once we switch back
265+
// to using a QueryList in #11720.
266+
this._ngZone.runOutsideAngular(() => {
267+
// Move focus to another item, if the active item is removed from the list.
268+
// We need to debounce the callback, because multiple items might be removed
269+
// in quick succession.
270+
this._itemChanges.pipe(debounceTime(50)).subscribe(items => {
271+
const manager = this._keyManager;
272+
273+
if (manager.activeItem && items.indexOf(manager.activeItem) === -1) {
274+
const index = Math.max(0, Math.min(items.length - 1, manager.activeItemIndex || 0));
275+
276+
if (items[index] && !items[index].disabled) {
277+
manager.setActiveItem(index);
278+
} else {
279+
manager.setNextItemActive();
280+
}
281+
}
282+
});
283+
});
262284
}
263285

264286
ngOnDestroy() {
265287
this._tabSubscription.unsubscribe();
266288
this.closed.complete();
289+
this._itemChanges.complete();
267290
}
268291

269292
/** Stream that emits whenever the hovered menu item changes. */
@@ -374,7 +397,7 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
374397
removeItem(item: MatMenuItem) {
375398
const index = this._items.indexOf(item);
376399

377-
if (this._items.indexOf(item) > -1) {
400+
if (index > -1) {
378401
this._items.splice(index, 1);
379402
this._itemChanges.next(this._items);
380403
}

src/lib/menu/menu.spec.ts

+37-1
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') 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) 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+
}

0 commit comments

Comments
 (0)