From 65fcff41d17477ecdc9a354f56566824054540fc Mon Sep 17 00:00:00 2001 From: crisbeto Date: Thu, 8 Nov 2018 11:12:45 +0000 Subject: [PATCH] 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. --- src/material/menu/menu.spec.ts | 38 +++++++++++++++++++++++++++++++++- src/material/menu/menu.ts | 27 ++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/material/menu/menu.spec.ts b/src/material/menu/menu.spec.ts index dc6d7f78b8c3..2a6627b19508 100644 --- a/src/material/menu/menu.spec.ts +++ b/src/material/menu/menu.spec.ts @@ -180,6 +180,26 @@ describe('MatMenu', () => { expect(document.activeElement).not.toBe(triggerEl); })); + it('should move focus to another item if the active item is destroyed', fakeAsync(() => { + const fixture = createComponent(MenuWithRepeatedItems, [], [FakeIcon]); + fixture.detectChanges(); + const triggerEl = fixture.componentInstance.triggerEl.nativeElement; + + triggerEl.click(); + fixture.detectChanges(); + tick(500); + + const items = overlayContainerElement.querySelectorAll('.mat-menu-panel .mat-menu-item'); + + expect(document.activeElement).toBe(items[0]); + + fixture.componentInstance.items.shift(); + fixture.detectChanges(); + tick(500); + + expect(document.activeElement).toBe(items[1]); + })); + it('should be able to set a custom class on the backdrop', fakeAsync(() => { const fixture = createComponent(SimpleMenu, [], [FakeIcon]); @@ -237,6 +257,7 @@ describe('MatMenu', () => { // Add 50 items to make the menu scrollable fixture.componentInstance.extraItems = new Array(50).fill('Hello there'); fixture.detectChanges(); + tick(50); const triggerEl = fixture.componentInstance.triggerEl.nativeElement; dispatchFakeEvent(triggerEl, 'mousedown'); @@ -2299,7 +2320,6 @@ class DynamicPanelMenu { @ViewChild('two', {static: false}) secondMenu: MatMenu; } - @Component({ template: ` @@ -2313,3 +2333,19 @@ class DynamicPanelMenu { class MenuWithCheckboxItems { @ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger; } + + +@Component({ + template: ` + + + + + ` +}) +class MenuWithRepeatedItems { + @ViewChild(MatMenuTrigger) trigger: MatMenuTrigger; + @ViewChild('triggerEl') triggerEl: ElementRef; + @ViewChild(MatMenu) menu: MatMenu; + items = ['One', 'Two', 'Three']; +} diff --git a/src/material/menu/menu.ts b/src/material/menu/menu.ts index 1710c8663798..fbb9f1c55b45 100644 --- a/src/material/menu/menu.ts +++ b/src/material/menu/menu.ts @@ -40,7 +40,7 @@ import { OnInit, } from '@angular/core'; import {merge, Observable, Subject, Subscription} from 'rxjs'; -import {startWith, switchMap, take} from 'rxjs/operators'; +import {startWith, switchMap, take, debounceTime} from 'rxjs/operators'; import {matMenuAnimations} from './menu-animations'; import {MatMenuContent} from './menu-content'; import {MenuPositionX, MenuPositionY} from './menu-positions'; @@ -244,11 +244,34 @@ export class _MatMenuBase implements AfterContentInit, MatMenuPanel ngAfterContentInit() { this._keyManager = new FocusKeyManager(this._items).withWrap().withTypeAhead(); this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.closed.emit('tab')); + + // TODO(crisbeto): the `debounce` here should be removed since it's something + // that people have to flush in their tests. It'll be possible once we switch back + // to using a QueryList in #11720. + this._ngZone.runOutsideAngular(() => { + // Move focus to another item, if the active item is removed from the list. + // We need to debounce the callback, because multiple items might be removed + // in quick succession. + this._itemChanges.pipe(debounceTime(50)).subscribe(items => { + const manager = this._keyManager; + + if (manager.activeItem && items.indexOf(manager.activeItem) === -1) { + const index = Math.max(0, Math.min(items.length - 1, manager.activeItemIndex || 0)); + + if (items[index] && !items[index].disabled) { + manager.setActiveItem(index); + } else { + manager.setNextItemActive(); + } + } + }); + }); } ngOnDestroy() { this._tabSubscription.unsubscribe(); this.closed.complete(); + this._itemChanges.complete(); } /** Stream that emits whenever the hovered menu item changes. */ @@ -359,7 +382,7 @@ export class _MatMenuBase implements AfterContentInit, MatMenuPanel removeItem(item: MatMenuItem) { const index = this._items.indexOf(item); - if (this._items.indexOf(item) > -1) { + if (index > -1) { this._items.splice(index, 1); this._itemChanges.next(this._items); }