diff --git a/src/cdk/a11y/focus-monitor.spec.ts b/src/cdk/a11y/focus-monitor.spec.ts index 7ae9dc08330b..98cfd1a9302f 100644 --- a/src/cdk/a11y/focus-monitor.spec.ts +++ b/src/cdk/a11y/focus-monitor.spec.ts @@ -1,5 +1,10 @@ import {TAB} from '@angular/cdk/keycodes'; -import {dispatchFakeEvent, dispatchKeyboardEvent, dispatchMouseEvent} from '@angular/cdk/testing'; +import { + dispatchFakeEvent, + dispatchKeyboardEvent, + dispatchMouseEvent, + patchElementFocus, +} from '@angular/cdk/testing'; import {Component} from '@angular/core'; import {ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; @@ -413,14 +418,3 @@ class ComplexComponentWithMonitorElementFocus {} template: `
` }) class ComplexComponentWithMonitorSubtreeFocus {} - - -/** - * Patches an elements focus and blur methods to emit events consistently and predictably. - * This is necessary, because some browsers, like IE11, will call the focus handlers asynchronously, - * while others won't fire them at all if the browser window is not focused. - */ -function patchElementFocus(element: HTMLElement) { - element.focus = () => dispatchFakeEvent(element, 'focus'); - element.blur = () => dispatchFakeEvent(element, 'blur'); -} diff --git a/src/cdk/testing/element-focus.ts b/src/cdk/testing/element-focus.ts new file mode 100644 index 000000000000..280166dfe709 --- /dev/null +++ b/src/cdk/testing/element-focus.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {dispatchFakeEvent} from './dispatch-events'; + +/** + * Patches an elements focus and blur methods to emit events consistently and predictably. + * This is necessary, because some browsers, like IE11, will call the focus handlers asynchronously, + * while others won't fire them at all if the browser window is not focused. + */ +export function patchElementFocus(element: HTMLElement) { + element.focus = () => dispatchFakeEvent(element, 'focus'); + element.blur = () => dispatchFakeEvent(element, 'blur'); +} diff --git a/src/cdk/testing/public-api.ts b/src/cdk/testing/public-api.ts index 752df0b0d44a..819791050ca7 100644 --- a/src/cdk/testing/public-api.ts +++ b/src/cdk/testing/public-api.ts @@ -11,3 +11,4 @@ export * from './event-objects'; export * from './type-in-element'; export * from './wrapped-error-message'; export * from './mock-ng-zone'; +export * from './element-focus'; diff --git a/src/lib/menu/menu-module.ts b/src/lib/menu/menu-module.ts index f44a8af8226f..843ffb8299a5 100644 --- a/src/lib/menu/menu-module.ts +++ b/src/lib/menu/menu-module.ts @@ -10,6 +10,7 @@ import {NgModule} from '@angular/core'; import {CommonModule} from '@angular/common'; import {MatCommonModule} from '@angular/material/core'; import {OverlayModule} from '@angular/cdk/overlay'; +import {A11yModule} from '@angular/cdk/a11y'; import {MatMenu, MAT_MENU_DEFAULT_OPTIONS} from './menu-directive'; import {MatMenuItem} from './menu-item'; import {MatMenuTrigger, MAT_MENU_SCROLL_STRATEGY_PROVIDER} from './menu-trigger'; @@ -21,6 +22,7 @@ import {A11yModule} from '@angular/cdk/a11y'; imports: [ OverlayModule, CommonModule, + A11yModule, MatRippleModule, MatCommonModule, A11yModule, diff --git a/src/lib/menu/menu-trigger.ts b/src/lib/menu/menu-trigger.ts index bc8ba4840476..de3063f5723a 100644 --- a/src/lib/menu/menu-trigger.ts +++ b/src/lib/menu/menu-trigger.ts @@ -43,6 +43,7 @@ import {throwMatMenuMissingError} from './menu-errors'; import {MatMenuItem} from './menu-item'; import {MatMenuPanel} from './menu-panel'; import {MenuPositionX, MenuPositionY} from './menu-positions'; +import {FocusMonitor, FocusOrigin} from '@angular/cdk/a11y'; /** Injection token that determines the scroll handling while the menu is open. */ export const MAT_MENU_SCROLL_STRATEGY = @@ -130,7 +131,9 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy { @Inject(MAT_MENU_SCROLL_STRATEGY) private _scrollStrategy, @Optional() private _parentMenu: MatMenu, @Optional() @Self() private _menuItemInstance: MatMenuItem, - @Optional() private _dir: Directionality) { + @Optional() private _dir: Directionality, + // TODO(crisbeto): make the _focusMonitor required when doing breaking changes. + private _focusMonitor?: FocusMonitor) { if (_menuItemInstance) { _menuItemInstance._triggersSubmenu = this.triggersSubmenu(); @@ -207,9 +210,16 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy { this.menu.close.emit(); } - /** Focuses the menu trigger. */ - focus() { - this._element.nativeElement.focus(); + /** + * Focuses the menu trigger. + * @param origin Source of the menu trigger's focus. + */ + focus(origin: FocusOrigin = 'program') { + if (this._focusMonitor) { + this._focusMonitor.focusVia(this._element.nativeElement, origin); + } else { + this._element.nativeElement.focus(); + } } /** Closes the menu and does the necessary cleanup. */ @@ -262,8 +272,12 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy { // We should reset focus if the user is navigating using a keyboard or // if we have a top-level trigger which might cause focus to be lost // when clicking on the backdrop. - if (!this._openedByMouse || !this.triggersSubmenu()) { + if (!this._openedByMouse) { + // Note that the focus style will show up both for `program` and + // `keyboard` so we don't have to specify which one it is. this.focus(); + } else if (!this.triggersSubmenu()) { + this.focus('mouse'); } this._openedByMouse = false; diff --git a/src/lib/menu/menu.spec.ts b/src/lib/menu/menu.spec.ts index 31159c30c377..f3547aee108d 100644 --- a/src/lib/menu/menu.spec.ts +++ b/src/lib/menu/menu.spec.ts @@ -34,9 +34,11 @@ import { createKeyboardEvent, createMouseEvent, dispatchFakeEvent, + patchElementFocus, } from '@angular/cdk/testing'; import {Subject} from 'rxjs/Subject'; import {ScrollDispatcher} from '@angular/cdk/scrolling'; +import {FocusMonitor} from '@angular/cdk/a11y'; describe('MatMenu', () => { @@ -142,6 +144,45 @@ describe('MatMenu', () => { expect(document.activeElement).toBe(triggerEl); })); + it('should set the proper focus origin when restoring focus after opening by keyboard', + fakeAsync(inject([FocusMonitor], (focusMonitor: FocusMonitor) => { + const fixture = TestBed.createComponent(SimpleMenu); + fixture.detectChanges(); + const triggerEl = fixture.componentInstance.triggerEl.nativeElement; + + patchElementFocus(triggerEl); + focusMonitor.monitor(triggerEl, false); + triggerEl.click(); // A click without a mousedown before it is considered a keyboard open. + fixture.detectChanges(); + fixture.componentInstance.trigger.closeMenu(); + fixture.detectChanges(); + tick(500); + fixture.detectChanges(); + + expect(triggerEl.classList).toContain('cdk-program-focused'); + focusMonitor.stopMonitoring(triggerEl); + }))); + + it('should set the proper focus origin when restoring focus after opening by mouse', + fakeAsync(inject([FocusMonitor], (focusMonitor: FocusMonitor) => { + const fixture = TestBed.createComponent(SimpleMenu); + fixture.detectChanges(); + const triggerEl = fixture.componentInstance.triggerEl.nativeElement; + + dispatchFakeEvent(triggerEl, 'mousedown'); + triggerEl.click(); + fixture.detectChanges(); + patchElementFocus(triggerEl); + focusMonitor.monitor(triggerEl, false); + fixture.componentInstance.trigger.closeMenu(); + fixture.detectChanges(); + tick(500); + fixture.detectChanges(); + + expect(triggerEl.classList).toContain('cdk-mouse-focused'); + focusMonitor.stopMonitoring(triggerEl); + }))); + it('should close the menu when pressing ESCAPE', fakeAsync(() => { const fixture = TestBed.createComponent(SimpleMenu); fixture.detectChanges();