Skip to content

Commit 2e15f54

Browse files
committed
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. (cherry picked from commit dc4fbcf)
1 parent d7cbd13 commit 2e15f54

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
@@ -238,6 +238,28 @@ describe('MDC-based MatMenu', () => {
238238
tick(500);
239239
}));
240240

241+
it('should move focus to another item if the active item is destroyed', fakeAsync(() => {
242+
const fixture = createComponent(MenuWithRepeatedItems, [], [FakeIcon]);
243+
fixture.detectChanges();
244+
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
245+
246+
triggerEl.click();
247+
fixture.detectChanges();
248+
tick(500);
249+
250+
const items = overlayContainerElement.querySelectorAll(
251+
'.mat-mdc-menu-panel .mat-mdc-menu-item',
252+
);
253+
254+
expect(document.activeElement).toBe(items[0]);
255+
256+
fixture.componentInstance.items.shift();
257+
fixture.detectChanges();
258+
tick(500);
259+
260+
expect(document.activeElement).toBe(items[1]);
261+
}));
262+
241263
it('should be able to set a custom class on the backdrop', fakeAsync(() => {
242264
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
243265

@@ -3064,3 +3086,18 @@ class StaticAriaLabelledByMenu {}
30643086
template: '<mat-menu aria-describedby="some-element"></mat-menu>',
30653087
})
30663088
class StaticAriaDescribedbyMenu {}
3089+
3090+
@Component({
3091+
template: `
3092+
<button [matMenuTriggerFor]="menu" #triggerEl>Toggle menu</button>
3093+
<mat-menu #menu="matMenu">
3094+
<button *ngFor="let item of items" mat-menu-item>{{item}}</button>
3095+
</mat-menu>
3096+
`,
3097+
})
3098+
class MenuWithRepeatedItems {
3099+
@ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
3100+
@ViewChild('triggerEl', {static: false}) triggerEl: ElementRef<HTMLElement>;
3101+
@ViewChild(MatMenu, {static: false}) menu: MatMenu;
3102+
items = ['One', 'Two', 'Three'];
3103+
}

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
@@ -238,6 +238,26 @@ describe('MatMenu', () => {
238238
tick(500);
239239
}));
240240

241+
it('should move focus to another item if the active item is destroyed', fakeAsync(() => {
242+
const fixture = createComponent(MenuWithRepeatedItems, [], [FakeIcon]);
243+
fixture.detectChanges();
244+
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
245+
246+
triggerEl.click();
247+
fixture.detectChanges();
248+
tick(500);
249+
250+
const items = overlayContainerElement.querySelectorAll('.mat-menu-panel .mat-menu-item');
251+
252+
expect(document.activeElement).toBe(items[0]);
253+
254+
fixture.componentInstance.items.shift();
255+
fixture.detectChanges();
256+
tick(500);
257+
258+
expect(document.activeElement).toBe(items[1]);
259+
}));
260+
241261
it('should be able to set a custom class on the backdrop', fakeAsync(() => {
242262
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
243263

@@ -330,6 +350,7 @@ describe('MatMenu', () => {
330350
// Add 50 items to make the menu scrollable
331351
fixture.componentInstance.extraItems = new Array(50).fill('Hello there');
332352
fixture.detectChanges();
353+
tick(50);
333354

334355
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
335356
dispatchFakeEvent(triggerEl, 'mousedown');
@@ -3046,3 +3067,18 @@ class StaticAriaLabelledByMenu {}
30463067
template: '<mat-menu aria-describedby="some-element"></mat-menu>',
30473068
})
30483069
class StaticAriaDescribedbyMenu {}
3070+
3071+
@Component({
3072+
template: `
3073+
<button [matMenuTriggerFor]="menu" #triggerEl>Toggle menu</button>
3074+
<mat-menu #menu="matMenu">
3075+
<button *ngFor="let item of items" mat-menu-item>{{item}}</button>
3076+
</mat-menu>
3077+
`,
3078+
})
3079+
class MenuWithRepeatedItems {
3080+
@ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
3081+
@ViewChild('triggerEl', {static: false}) triggerEl: ElementRef<HTMLElement>;
3082+
@ViewChild(MatMenu, {static: false}) menu: MatMenu;
3083+
items = ['One', 'Two', 'Three'];
3084+
}

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)