Skip to content

Commit dc4fbcf

Browse files
authored
fix(material/menu): focus lost if active item is removed (#14039)
Fixes the user's focus being lost if the active item is destroyed while a menu is open. Fixes #14028.
1 parent 4429352 commit dc4fbcf

File tree

5 files changed

+99
-3
lines changed

5 files changed

+99
-3
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

@@ -3091,3 +3113,18 @@ class StaticAriaLabelledByMenu {}
30913113
template: '<mat-menu aria-describedby="some-element"></mat-menu>',
30923114
})
30933115
class StaticAriaDescribedbyMenu {}
3116+
3117+
@Component({
3118+
template: `
3119+
<button [matMenuTriggerFor]="menu" #triggerEl>Toggle menu</button>
3120+
<mat-menu #menu="matMenu">
3121+
<button *ngFor="let item of items" mat-menu-item>{{item}}</button>
3122+
</mat-menu>
3123+
`,
3124+
})
3125+
class MenuWithRepeatedItems {
3126+
@ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
3127+
@ViewChild('triggerEl', {static: false}) triggerEl: ElementRef<HTMLElement>;
3128+
@ViewChild(MatMenu, {static: false}) menu: MatMenu;
3129+
items = ['One', 'Two', 'Three'];
3130+
}

src/material/menu/menu-item.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,7 @@ export class MatMenuItem
7676
_triggersSubmenu: boolean = false;
7777

7878
/**
79-
* @deprecated `document` parameter to be removed, `changeDetectorRef` and
80-
* `focusMonitor` to become required.
79+
* @deprecated `_document`, `changeDetectorRef` and `focusMonitor` to become required.
8180
* @breaking-change 12.0.0
8281
*/
8382
constructor(
@@ -90,7 +89,7 @@ export class MatMenuItem
9089

9190
constructor(
9291
private _elementRef: ElementRef<HTMLElement>,
93-
@Inject(DOCUMENT) _document?: any,
92+
@Inject(DOCUMENT) private _document?: any,
9493
private _focusMonitor?: FocusMonitor,
9594
@Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel<MatMenuItem>,
9695
private _changeDetectorRef?: ChangeDetectorRef,
@@ -176,4 +175,8 @@ export class MatMenuItem
176175
this._highlighted = isHighlighted;
177176
this._changeDetectorRef?.markForCheck();
178177
}
178+
179+
_hasFocus(): boolean {
180+
return this._document && this._document.activeElement === this._getHostElement();
181+
}
179182
}

src/material/menu/menu.spec.ts

+36
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,26 @@ describe('MatMenu', () => {
261261
tick(500);
262262
}));
263263

264+
it('should move focus to another item if the active item is destroyed', fakeAsync(() => {
265+
const fixture = createComponent(MenuWithRepeatedItems, [], [FakeIcon]);
266+
fixture.detectChanges();
267+
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
268+
269+
triggerEl.click();
270+
fixture.detectChanges();
271+
tick(500);
272+
273+
const items = overlayContainerElement.querySelectorAll('.mat-menu-panel .mat-menu-item');
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+
264284
it('should be able to set a custom class on the backdrop', fakeAsync(() => {
265285
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
266286

@@ -353,6 +373,7 @@ describe('MatMenu', () => {
353373
// Add 50 items to make the menu scrollable
354374
fixture.componentInstance.extraItems = new Array(50).fill('Hello there');
355375
fixture.detectChanges();
376+
tick(50);
356377

357378
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
358379
dispatchFakeEvent(triggerEl, 'mousedown');
@@ -3075,3 +3096,18 @@ class StaticAriaLabelledByMenu {}
30753096
template: '<mat-menu aria-describedby="some-element"></mat-menu>',
30763097
})
30773098
class StaticAriaDescribedbyMenu {}
3099+
3100+
@Component({
3101+
template: `
3102+
<button [matMenuTriggerFor]="menu" #triggerEl>Toggle menu</button>
3103+
<mat-menu #menu="matMenu">
3104+
<button *ngFor="let item of items" mat-menu-item>{{item}}</button>
3105+
</mat-menu>
3106+
`,
3107+
})
3108+
class MenuWithRepeatedItems {
3109+
@ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
3110+
@ViewChild('triggerEl', {static: false}) triggerEl: ElementRef<HTMLElement>;
3111+
@ViewChild(MatMenu, {static: false}) menu: MatMenu;
3112+
items = ['One', 'Two', 'Three'];
3113+
}

src/material/menu/menu.ts

+18
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,24 @@ export class _MatMenuBase
298298
switchMap(items => merge(...items.map((item: MatMenuItem) => item._focused))),
299299
)
300300
.subscribe(focusedItem => this._keyManager.updateActiveItem(focusedItem as MatMenuItem));
301+
302+
this._directDescendantItems.changes.subscribe((itemsList: QueryList<MatMenuItem>) => {
303+
// Move focus to another item, if the active item is removed from the list.
304+
// We need to debounce the callback, because multiple items might be removed
305+
// in quick succession.
306+
const manager = this._keyManager;
307+
308+
if (this._panelAnimationState === 'enter' && manager.activeItem?._hasFocus()) {
309+
const items = itemsList.toArray();
310+
const index = Math.max(0, Math.min(items.length - 1, manager.activeItemIndex || 0));
311+
312+
if (items[index] && !items[index].disabled) {
313+
manager.setActiveItem(index);
314+
} else {
315+
manager.setNextItemActive();
316+
}
317+
}
318+
});
301319
}
302320

303321
ngOnDestroy() {

tools/public_api_guard/material/menu.md

+2
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ export class MatMenuItem extends _MatMenuItemBase implements FocusableOption, Ca
203203
getLabel(): string;
204204
_getTabIndex(): string;
205205
_handleMouseEnter(): void;
206+
// (undocumented)
207+
_hasFocus(): boolean;
206208
_highlighted: boolean;
207209
readonly _hovered: Subject<MatMenuItem>;
208210
// (undocumented)

0 commit comments

Comments
 (0)