Skip to content

Commit 36ac000

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 141afcf commit 36ac000

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
@@ -31,7 +31,7 @@ import {
3131
OnInit,
3232
} from '@angular/core';
3333
import {merge, Observable, Subject, Subscription} from 'rxjs';
34-
import {startWith, switchMap, take} from 'rxjs/operators';
34+
import {startWith, switchMap, take, debounceTime} from 'rxjs/operators';
3535
import {matMenuAnimations} from './menu-animations';
3636
import {MatMenuContent} from './menu-content';
3737
import {throwMatMenuInvalidPositionX, throwMatMenuInvalidPositionY} from './menu-errors';
@@ -240,11 +240,34 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
240240
ngAfterContentInit() {
241241
this._keyManager = new FocusKeyManager<MatMenuItem>(this._items).withWrap().withTypeAhead();
242242
this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.closed.emit('tab'));
243+
244+
// TODO(crisbeto): the `debounce` here should be removed since it's something
245+
// that people have to flush in their tests. It'll be possible once we switch back
246+
// to using a QueryList in #11720.
247+
this._ngZone.runOutsideAngular(() => {
248+
// Move focus to another item, if the active item is removed from the list.
249+
// We need to debounce the callback, because multiple items might be removed
250+
// in quick succession.
251+
this._itemChanges.pipe(debounceTime(50)).subscribe(items => {
252+
const manager = this._keyManager;
253+
254+
if (manager.activeItem && items.indexOf(manager.activeItem) === -1) {
255+
const index = Math.max(0, Math.min(items.length - 1, manager.activeItemIndex || 0));
256+
257+
if (items[index] && !items[index].disabled) {
258+
manager.setActiveItem(index);
259+
} else {
260+
manager.setNextItemActive();
261+
}
262+
}
263+
});
264+
});
243265
}
244266

245267
ngOnDestroy() {
246268
this._tabSubscription.unsubscribe();
247269
this.closed.complete();
270+
this._itemChanges.complete();
248271
}
249272

250273
/** Stream that emits whenever the hovered menu item changes. */
@@ -347,7 +370,7 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
347370
removeItem(item: MatMenuItem) {
348371
const index = this._items.indexOf(item);
349372

350-
if (this._items.indexOf(item) > -1) {
373+
if (index > -1) {
351374
this._items.splice(index, 1);
352375
this._itemChanges.next(this._items);
353376
}

src/lib/menu/menu.spec.ts

+37-1
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,26 @@ describe('MatMenu', () => {
132132
expect(document.activeElement).toBe(triggerEl);
133133
}));
134134

135+
it('should move focus to another item if the active item is destroyed', fakeAsync(() => {
136+
const fixture = createComponent(MenuWithRepeatedItems, [], [FakeIcon]);
137+
fixture.detectChanges();
138+
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
139+
140+
triggerEl.click();
141+
fixture.detectChanges();
142+
tick(500);
143+
144+
const items = overlayContainerElement.querySelectorAll('.mat-menu-panel .mat-menu-item');
145+
146+
expect(document.activeElement).toBe(items[0]);
147+
148+
fixture.componentInstance.items.shift();
149+
fixture.detectChanges();
150+
tick(500);
151+
152+
expect(document.activeElement).toBe(items[1]);
153+
}));
154+
135155
it('should be able to set a custom class on the backdrop', fakeAsync(() => {
136156
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
137157

@@ -189,6 +209,7 @@ describe('MatMenu', () => {
189209
// Add 50 items to make the menu scrollable
190210
fixture.componentInstance.extraItems = new Array(50).fill('Hello there');
191211
fixture.detectChanges();
212+
tick(50);
192213

193214
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
194215
dispatchFakeEvent(triggerEl, 'mousedown');
@@ -2125,7 +2146,6 @@ class DynamicPanelMenu {
21252146
@ViewChild('two') secondMenu: MatMenu;
21262147
}
21272148

2128-
21292149
@Component({
21302150
template: `
21312151
<button [matMenuTriggerFor]="menu">Toggle menu</button>
@@ -2139,3 +2159,19 @@ class DynamicPanelMenu {
21392159
class MenuWithCheckboxItems {
21402160
@ViewChild(MatMenuTrigger) trigger: MatMenuTrigger;
21412161
}
2162+
2163+
2164+
@Component({
2165+
template: `
2166+
<button [matMenuTriggerFor]="menu" #triggerEl>Toggle menu</button>
2167+
<mat-menu #menu="matMenu">
2168+
<button *ngFor="let item of items" mat-menu-item>{{item}}</button>
2169+
</mat-menu>
2170+
`
2171+
})
2172+
class MenuWithRepeatedItems {
2173+
@ViewChild(MatMenuTrigger) trigger: MatMenuTrigger;
2174+
@ViewChild('triggerEl') triggerEl: ElementRef<HTMLElement>;
2175+
@ViewChild(MatMenu) menu: MatMenu;
2176+
items = ['One', 'Two', 'Three'];
2177+
}

0 commit comments

Comments
 (0)