diff --git a/src/cdk/a11y/BUILD.bazel b/src/cdk/a11y/BUILD.bazel index 77703e0290ea..a72b3203e31e 100644 --- a/src/cdk/a11y/BUILD.bazel +++ b/src/cdk/a11y/BUILD.bazel @@ -52,6 +52,7 @@ ng_test_library( "//src/cdk/platform", "//src/cdk/portal", "//src/cdk/testing/private", + "@npm//@angular/common", "@npm//@angular/platform-browser", "@npm//rxjs", ], diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts index d712a5b780cd..dbe4cd935e09 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts @@ -7,6 +7,7 @@ import { createMouseEvent, dispatchEvent, } from '@angular/cdk/testing/private'; +import {DOCUMENT} from '@angular/common'; import {Component, NgZone} from '@angular/core'; import {ComponentFixture, fakeAsync, flush, inject, TestBed, tick} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; @@ -25,13 +26,39 @@ describe('FocusMonitor', () => { let buttonElement: HTMLElement; let focusMonitor: FocusMonitor; let changeHandler: (origin: FocusOrigin) => void; + let fakeActiveElement: HTMLElement | null; beforeEach(() => { + fakeActiveElement = null; + TestBed.configureTestingModule({ imports: [A11yModule], - declarations: [ - PlainButton, - ], + declarations: [PlainButton], + providers: [{ + provide: DOCUMENT, + useFactory: () => { + // We have to stub out the `document` in order to be able to fake `activeElement`. + const fakeDocument = {body: document.body}; + + [ + 'createElement', + 'dispatchEvent', + 'querySelectorAll', + 'addEventListener', + 'removeEventListener' + ].forEach(method => { + (fakeDocument as any)[method] = function() { + return (document as any)[method].apply(document, arguments); + }; + }); + + Object.defineProperty(fakeDocument, 'activeElement', { + get: () => fakeActiveElement || document.activeElement + }); + + return fakeDocument; + } + }] }).compileComponents(); }); @@ -294,6 +321,37 @@ describe('FocusMonitor', () => { expect(parent.classList).toContain('cdk-mouse-focused'); })); + it('focusVia should change the focus origin when called on the focused node', fakeAsync(() => { + spyOn(buttonElement, 'focus').and.callThrough(); + focusMonitor.focusVia(buttonElement, 'keyboard'); + flush(); + fakeActiveElement = buttonElement; + + expect(buttonElement.classList.length) + .toBe(2, 'button should have exactly 2 focus classes'); + expect(buttonElement.classList.contains('cdk-focused')) + .toBe(true, 'button should have cdk-focused class'); + expect(buttonElement.classList.contains('cdk-keyboard-focused')) + .toBe(true, 'button should have cdk-keyboard-focused class'); + expect(changeHandler).toHaveBeenCalledTimes(1); + expect(changeHandler).toHaveBeenCalledWith('keyboard'); + expect(buttonElement.focus).toHaveBeenCalledTimes(1); + + focusMonitor.focusVia(buttonElement, 'mouse'); + flush(); + fakeActiveElement = buttonElement; + + expect(buttonElement.classList.length) + .toBe(2, 'button should have exactly 2 focus classes'); + expect(buttonElement.classList.contains('cdk-focused')) + .toBe(true, 'button should have cdk-focused class'); + expect(buttonElement.classList.contains('cdk-mouse-focused')) + .toBe(true, 'button should have cdk-mouse-focused class'); + expect(changeHandler).toHaveBeenCalledTimes(2); + expect(changeHandler).toHaveBeenCalledWith('mouse'); + expect(buttonElement.focus).toHaveBeenCalledTimes(1); + })); + }); describe('FocusMonitor with "eventual" detection', () => { diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index b1e7fdfe0d2f..ac43d02178f6 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -308,13 +308,20 @@ export class FocusMonitor implements OnDestroy { options?: FocusOptions): void { const nativeElement = coerceElement(element); + const focusedElement = this._getDocument().activeElement; - this._setOriginForCurrentEventQueue(origin); + // If the element is focused already, calling `focus` again won't trigger the event listener + // which means that the focus classes won't be updated. If that's the case, update the classes + // directly without waiting for an event. + if (nativeElement === focusedElement && this._elementInfo.has(nativeElement)) { + this._originChanged(nativeElement, origin, this._elementInfo.get(nativeElement)!); + } else { + this._setOriginForCurrentEventQueue(origin); - // `focus` isn't available on the server - if (typeof nativeElement.focus === 'function') { - // Cast the element to `any`, because the TS typings don't have the `options` parameter yet. - (nativeElement as any).focus(options); + // `focus` isn't available on the server + if (typeof nativeElement.focus === 'function') { + nativeElement.focus(options); + } } } @@ -438,10 +445,7 @@ export class FocusMonitor implements OnDestroy { return; } - const origin = this._getFocusOrigin(event); - this._setClasses(element, origin); - this._emitOrigin(elementInfo.subject, origin); - this._lastFocusOrigin = origin; + this._originChanged(element, this._getFocusOrigin(event), elementInfo); } /** @@ -541,6 +545,14 @@ export class FocusMonitor implements OnDestroy { clearTimeout(this._originTimeoutId); } } + + /** Updates all the state on an element once its focus origin has changed. */ + private _originChanged(element: HTMLElement, origin: FocusOrigin, + elementInfo: MonitoredElementInfo) { + this._setClasses(element, origin); + this._emitOrigin(elementInfo.subject, origin); + this._lastFocusOrigin = origin; + } } /** Gets the target of an event, accounting for Shadow DOM. */ diff --git a/src/material-experimental/mdc-menu/menu.spec.ts b/src/material-experimental/mdc-menu/menu.spec.ts index 4ae735f258d0..8f2455dc0e92 100644 --- a/src/material-experimental/mdc-menu/menu.spec.ts +++ b/src/material-experimental/mdc-menu/menu.spec.ts @@ -804,6 +804,9 @@ describe('MDC-based MatMenu', () => { fixture.componentInstance.trigger.openMenu(); fixture.detectChanges(); + // Reset the automatic focus when the menu is opened. + (document.activeElement as HTMLElement)?.blur(); + const panel = overlayContainerElement.querySelector('.mat-mdc-menu-panel')!; const items = Array.from(panel.querySelectorAll('.mat-mdc-menu-item')) as HTMLElement[]; items.forEach(patchElementFocus); diff --git a/src/material/menu/menu.spec.ts b/src/material/menu/menu.spec.ts index 283e722ba2fa..c3fda4702ec6 100644 --- a/src/material/menu/menu.spec.ts +++ b/src/material/menu/menu.spec.ts @@ -802,6 +802,9 @@ describe('MatMenu', () => { fixture.componentInstance.trigger.openMenu(); fixture.detectChanges(); + // Reset the automatic focus when the menu is opened. + (document.activeElement as HTMLElement)?.blur(); + const panel = overlayContainerElement.querySelector('.mat-menu-panel')!; const items = Array.from(panel.querySelectorAll('.mat-menu-item')) as HTMLElement[]; items.forEach(patchElementFocus);