Skip to content

Commit 612d98b

Browse files
committed
fix(menu): set appropriate origin when restoring focus
Sets the correct focus origin depending on the way a menu has been opened. Fixes #9292.
1 parent af44b9d commit 612d98b

File tree

6 files changed

+88
-17
lines changed

6 files changed

+88
-17
lines changed

src/cdk/a11y/focus-monitor.spec.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import {TAB} from '@angular/cdk/keycodes';
2-
import {dispatchFakeEvent, dispatchKeyboardEvent, dispatchMouseEvent} from '@angular/cdk/testing';
2+
import {
3+
dispatchFakeEvent,
4+
dispatchKeyboardEvent,
5+
dispatchMouseEvent,
6+
patchElementFocus,
7+
} from '@angular/cdk/testing';
38
import {Component} from '@angular/core';
49
import {ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing';
510
import {By} from '@angular/platform-browser';
@@ -399,14 +404,3 @@ class ComplexComponentWithMonitorElementFocus {}
399404
template: `<div tabindex="0" cdkMonitorSubtreeFocus><button></button></div>`
400405
})
401406
class ComplexComponentWithMonitorSubtreeFocus {}
402-
403-
404-
/**
405-
* Patches an elements focus and blur methods to emit events consistently and predictably.
406-
* This is necessary, because some browsers, like IE11, will call the focus handlers asynchronously,
407-
* while others won't fire them at all if the browser window is not focused.
408-
*/
409-
function patchElementFocus(element: HTMLElement) {
410-
element.focus = () => dispatchFakeEvent(element, 'focus');
411-
element.blur = () => dispatchFakeEvent(element, 'blur');
412-
}

src/cdk/testing/element-focus.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {dispatchFakeEvent} from './dispatch-events';
10+
11+
/**
12+
* Patches an elements focus and blur methods to emit events consistently and predictably.
13+
* This is necessary, because some browsers, like IE11, will call the focus handlers asynchronously,
14+
* while others won't fire them at all if the browser window is not focused.
15+
*/
16+
export function patchElementFocus(element: HTMLElement) {
17+
element.focus = () => dispatchFakeEvent(element, 'focus');
18+
element.blur = () => dispatchFakeEvent(element, 'blur');
19+
}

src/cdk/testing/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export * from './type-in-element';
1212
export * from './wrapped-error-message';
1313
export * from './fake-viewport-ruler';
1414
export * from './mock-ng-zone';
15+
export * from './element-focus';

src/lib/menu/menu-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {NgModule} from '@angular/core';
1010
import {CommonModule} from '@angular/common';
1111
import {MatCommonModule} from '@angular/material/core';
1212
import {OverlayModule} from '@angular/cdk/overlay';
13+
import {A11yModule} from '@angular/cdk/a11y';
1314
import {MatMenu, MAT_MENU_DEFAULT_OPTIONS} from './menu-directive';
1415
import {MatMenuItem} from './menu-item';
1516
import {MatMenuTrigger, MAT_MENU_SCROLL_STRATEGY_PROVIDER} from './menu-trigger';
@@ -20,6 +21,7 @@ import {MatRippleModule} from '@angular/material/core';
2021
imports: [
2122
OverlayModule,
2223
CommonModule,
24+
A11yModule,
2325
MatRippleModule,
2426
MatCommonModule,
2527
],

src/lib/menu/menu-trigger.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {throwMatMenuMissingError} from './menu-errors';
4343
import {MatMenuItem} from './menu-item';
4444
import {MatMenuPanel} from './menu-panel';
4545
import {MenuPositionX, MenuPositionY} from './menu-positions';
46+
import {FocusMonitor, FocusOrigin} from '@angular/cdk/a11y';
4647

4748
/** Injection token that determines the scroll handling while the menu is open. */
4849
export const MAT_MENU_SCROLL_STRATEGY =
@@ -130,7 +131,9 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
130131
@Inject(MAT_MENU_SCROLL_STRATEGY) private _scrollStrategy,
131132
@Optional() private _parentMenu: MatMenu,
132133
@Optional() @Self() private _menuItemInstance: MatMenuItem,
133-
@Optional() private _dir: Directionality) {
134+
@Optional() private _dir: Directionality,
135+
// TODO(crisbeto): make the _focusMonitor required when doing breaking changes.
136+
private _focusMonitor?: FocusMonitor) {
134137

135138
if (_menuItemInstance) {
136139
_menuItemInstance._triggersSubmenu = this.triggersSubmenu();
@@ -207,9 +210,16 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
207210
this.menu.close.emit();
208211
}
209212

210-
/** Focuses the menu trigger. */
211-
focus() {
212-
this._element.nativeElement.focus();
213+
/**
214+
* Focuses the menu trigger.
215+
* @param origin Source of the menu trigger's focus.
216+
*/
217+
focus(origin: FocusOrigin = 'program') {
218+
if (this._focusMonitor) {
219+
this._focusMonitor.focusVia(this._element.nativeElement, origin);
220+
} else {
221+
this._element.nativeElement.focus();
222+
}
213223
}
214224

215225
/** Closes the menu and does the necessary cleanup. */
@@ -274,8 +284,12 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
274284
// We should reset focus if the user is navigating using a keyboard or
275285
// if we have a top-level trigger which might cause focus to be lost
276286
// when clicking on the backdrop.
277-
if (!this._openedByMouse || !this.triggersSubmenu()) {
287+
if (!this._openedByMouse) {
288+
// Note that the focus style will show up both for `program` and
289+
// `keyboard` so we don't have to specify which one it is.
278290
this.focus();
291+
} else if (!this.triggersSubmenu()) {
292+
this.focus('mouse');
279293
}
280294

281295
this._openedByMouse = false;

src/lib/menu/menu.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,11 @@ import {
3434
createKeyboardEvent,
3535
createMouseEvent,
3636
dispatchFakeEvent,
37+
patchElementFocus,
3738
} from '@angular/cdk/testing';
3839
import {Subject} from 'rxjs/Subject';
3940
import {ScrollDispatcher} from '@angular/cdk/scrolling';
41+
import {FocusMonitor} from '@angular/cdk/a11y';
4042

4143

4244
describe('MatMenu', () => {
@@ -142,6 +144,45 @@ describe('MatMenu', () => {
142144
expect(document.activeElement).toBe(triggerEl);
143145
}));
144146

147+
it('should set the proper focus origin when restoring focus after opening by keyboard',
148+
fakeAsync(inject([FocusMonitor], (focusMonitor: FocusMonitor) => {
149+
const fixture = TestBed.createComponent(SimpleMenu);
150+
fixture.detectChanges();
151+
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
152+
153+
patchElementFocus(triggerEl);
154+
focusMonitor.monitor(triggerEl, false);
155+
triggerEl.click(); // A click without a mousedown before it is considered a keyboard open.
156+
fixture.detectChanges();
157+
fixture.componentInstance.trigger.closeMenu();
158+
fixture.detectChanges();
159+
tick(500);
160+
fixture.detectChanges();
161+
162+
expect(triggerEl.classList).toContain('cdk-program-focused');
163+
focusMonitor.stopMonitoring(triggerEl);
164+
})));
165+
166+
it('should set the proper focus origin when restoring focus after opening by mouse',
167+
fakeAsync(inject([FocusMonitor], (focusMonitor: FocusMonitor) => {
168+
const fixture = TestBed.createComponent(SimpleMenu);
169+
fixture.detectChanges();
170+
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
171+
172+
dispatchFakeEvent(triggerEl, 'mousedown');
173+
triggerEl.click();
174+
fixture.detectChanges();
175+
patchElementFocus(triggerEl);
176+
focusMonitor.monitor(triggerEl, false);
177+
fixture.componentInstance.trigger.closeMenu();
178+
fixture.detectChanges();
179+
tick(500);
180+
fixture.detectChanges();
181+
182+
expect(triggerEl.classList).toContain('cdk-mouse-focused');
183+
focusMonitor.stopMonitoring(triggerEl);
184+
})));
185+
145186
it('should close the menu when pressing ESCAPE', fakeAsync(() => {
146187
const fixture = TestBed.createComponent(SimpleMenu);
147188
fixture.detectChanges();

0 commit comments

Comments
 (0)