diff --git a/src/material-experimental/mdc-menu/menu.spec.ts b/src/material-experimental/mdc-menu/menu.spec.ts index 705d06dd2f2f..66a3fcef8dd8 100644 --- a/src/material-experimental/mdc-menu/menu.spec.ts +++ b/src/material-experimental/mdc-menu/menu.spec.ts @@ -817,6 +817,37 @@ describe('MDC-based MatMenu', () => { flush(); })); + it('should sync the focus order when an item is focused programmatically', fakeAsync(() => { + const fixture = createComponent(SimpleMenuWithRepeater); + + // Add some more items to work with. + for (let i = 0; i < 5; i++) { + fixture.componentInstance.items.push({label: `Extra ${i}`, disabled: false}); + } + + fixture.detectChanges(); + fixture.componentInstance.trigger.openMenu(); + fixture.detectChanges(); + tick(500); + + const menuPanel = document.querySelector('.mat-mdc-menu-panel')!; + const items = menuPanel.querySelectorAll('.mat-mdc-menu-panel [mat-menu-item]'); + + expect(document.activeElement).toBe(items[0], 'Expected first item to be focused on open'); + + fixture.componentInstance.itemInstances.toArray()[3].focus(); + fixture.detectChanges(); + + expect(document.activeElement).toBe(items[3], 'Expected fourth item to be focused'); + + dispatchKeyboardEvent(menuPanel, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + tick(); + + expect(document.activeElement).toBe(items[4], 'Expected fifth item to be focused'); + flush(); + })); + it('should focus the menu panel if all items are disabled', fakeAsync(() => { const fixture = createComponent(SimpleMenuWithRepeater, [], [FakeIcon]); fixture.componentInstance.items.forEach(item => item.disabled = true); @@ -2454,6 +2485,7 @@ class MenuWithCheckboxItems { class SimpleMenuWithRepeater { @ViewChild(MatMenuTrigger) trigger: MatMenuTrigger; @ViewChild(MatMenu) menu: MatMenu; + @ViewChildren(MatMenuItem) itemInstances: QueryList; items = [{label: 'Pizza', disabled: false}, {label: 'Pasta', disabled: false}]; } diff --git a/src/material/menu/menu-item.ts b/src/material/menu/menu-item.ts index 16dedaddcf2f..60a6f86be4c6 100644 --- a/src/material/menu/menu-item.ts +++ b/src/material/menu/menu-item.ts @@ -66,6 +66,9 @@ export class MatMenuItem extends _MatMenuItemMixinBase /** Stream that emits when the menu item is hovered. */ readonly _hovered: Subject = new Subject(); + /** Stream that emits when the menu item is focused. */ + readonly _focused = new Subject(); + /** Whether the menu item is highlighted. */ _highlighted: boolean = false; @@ -102,6 +105,8 @@ export class MatMenuItem extends _MatMenuItemMixinBase } else { this._getHostElement().focus(options); } + + this._focused.next(this); } ngOnDestroy() { @@ -114,6 +119,7 @@ export class MatMenuItem extends _MatMenuItemMixinBase } this._hovered.complete(); + this._focused.complete(); } /** Used to set the `tabindex`. */ diff --git a/src/material/menu/menu.spec.ts b/src/material/menu/menu.spec.ts index 5745781abc2b..b7cd743fd401 100644 --- a/src/material/menu/menu.spec.ts +++ b/src/material/menu/menu.spec.ts @@ -943,6 +943,37 @@ describe('MatMenu', () => { flush(); })); + it('should sync the focus order when an item is focused programmatically', fakeAsync(() => { + const fixture = createComponent(SimpleMenuWithRepeater); + + // Add some more items to work with. + for (let i = 0; i < 5; i++) { + fixture.componentInstance.items.push({label: `Extra ${i}`, disabled: false}); + } + + fixture.detectChanges(); + fixture.componentInstance.trigger.openMenu(); + fixture.detectChanges(); + tick(500); + + const menuPanel = document.querySelector('.mat-menu-panel')!; + const items = menuPanel.querySelectorAll('.mat-menu-panel [mat-menu-item]'); + + expect(document.activeElement).toBe(items[0], 'Expected first item to be focused on open'); + + fixture.componentInstance.itemInstances.toArray()[3].focus(); + fixture.detectChanges(); + + expect(document.activeElement).toBe(items[3], 'Expected fourth item to be focused'); + + dispatchKeyboardEvent(menuPanel, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + tick(); + + expect(document.activeElement).toBe(items[4], 'Expected fifth item to be focused'); + flush(); + })); + it('should open submenus when the menu is inside an OnPush component', fakeAsync(() => { const fixture = createComponent(LazyMenuWithOnPush); fixture.detectChanges(); @@ -2442,6 +2473,7 @@ class MenuWithCheckboxItems { class SimpleMenuWithRepeater { @ViewChild(MatMenuTrigger) trigger: MatMenuTrigger; @ViewChild(MatMenu) menu: MatMenu; + @ViewChildren(MatMenuItem) itemInstances: QueryList; items = [{label: 'Pizza', disabled: false}, {label: 'Pasta', disabled: false}]; } diff --git a/src/material/menu/menu.ts b/src/material/menu/menu.ts index 1800ef1b1df4..ec48f2a2d2e1 100644 --- a/src/material/menu/menu.ts +++ b/src/material/menu/menu.ts @@ -251,6 +251,14 @@ export class _MatMenuBase implements AfterContentInit, MatMenuPanel this._updateDirectDescendants(); this._keyManager = new FocusKeyManager(this._directDescendantItems).withWrap().withTypeAhead(); this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.closed.emit('tab')); + + // If a user manually (programatically) focuses a menu item, we need to reflect that focus + // change back to the key manager. Note that we don't need to unsubscribe here because _focused + // is internal and we know that it gets completed on destroy. + this._directDescendantItems.changes.pipe( + startWith(this._directDescendantItems), + switchMap(items => merge(...items.map((item: MatMenuItem) => item._focused))) + ).subscribe(focusedItem => this._keyManager.updateActiveItem(focusedItem)); } ngOnDestroy() { diff --git a/tools/public_api_guard/material/menu.d.ts b/tools/public_api_guard/material/menu.d.ts index 266ac09254cf..b3e4a75fb345 100644 --- a/tools/public_api_guard/material/menu.d.ts +++ b/tools/public_api_guard/material/menu.d.ts @@ -91,6 +91,7 @@ export interface MatMenuDefaultOptions { } export declare class MatMenuItem extends _MatMenuItemMixinBase implements FocusableOption, CanDisable, CanDisableRipple, OnDestroy { + readonly _focused: Subject; _highlighted: boolean; readonly _hovered: Subject; _parentMenu?: MatMenuPanel | undefined;