mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(FocusOriginMonitor): support monitoring subtree focus as well as element (#3113)
* focus origin monitor improvements * addressed comment + tests * addressed comments and replaced Observable w/ EventEmitter * simplify fat arrow * rename the files now that the directive name has changed
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<button #b class="demo-button" cdkFocusClasses>focus me!</button>
|
||||
<button #b class="demo-focusable" cdkMonitorElementFocus>focus me!</button>
|
||||
<button (click)="b.focus()">focus programmatically</button>
|
||||
|
||||
<button (click)="fom.focusVia(b, renderer, 'mouse')">focusVia: mouse</button>
|
||||
@@ -7,3 +7,15 @@
|
||||
<button (click)="fom.focusVia(b, renderer, 'program')">focusVia: program</button>
|
||||
|
||||
<div>Active classes: {{b.classList}}</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div class="demo-focusable" tabindex="0" cdkMonitorElementFocus>
|
||||
<p>div with element focus monitored</p>
|
||||
<button>1</button><button>2</button>
|
||||
</div>
|
||||
|
||||
<div class="demo-focusable" tabindex="0" cdkMonitorSubtreeFocus>
|
||||
<p>div with subtree focus monitored</p>
|
||||
<button>1</button><button>2</button>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
.demo-button.cdk-focused {
|
||||
.demo-focusable.cdk-focused {
|
||||
border: 2px solid red;
|
||||
}
|
||||
|
||||
.demo-button.cdk-mouse-focused {
|
||||
.demo-focusable.cdk-mouse-focused {
|
||||
background: green;
|
||||
}
|
||||
|
||||
.demo-button.cdk-keyboard-focused {
|
||||
.demo-focusable.cdk-keyboard-focused {
|
||||
background: yellow;
|
||||
}
|
||||
|
||||
.demo-button.cdk-program-focused {
|
||||
.demo-focusable.cdk-program-focused {
|
||||
background: blue;
|
||||
}
|
||||
|
||||
.demo-button.cdk-touch-focused {
|
||||
.demo-focusable.cdk-touch-focused {
|
||||
background: purple;
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import {Directive, Injectable, Optional, SkipSelf, Renderer, ElementRef} from '@angular/core';
|
||||
import {Observable} from 'rxjs/Observable';
|
||||
import {Subject} from 'rxjs/Subject';
|
||||
|
||||
|
||||
// This is the value used by AngularJS Material. Through trial and error (on iPhone 6S) they found
|
||||
// that a value of around 650ms seems appropriate.
|
||||
export const TOUCH_BUFFER_MS = 650;
|
||||
|
||||
|
||||
export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program';
|
||||
|
||||
|
||||
/** Monitors mouse and keyboard events to determine the cause of focus events. */
|
||||
@Injectable()
|
||||
export class FocusOriginMonitor {
|
||||
/** The focus origin that the next focus event is a result of. */
|
||||
private _origin: FocusOrigin = null;
|
||||
|
||||
/** The FocusOrigin of the last focus event tracked by the FocusOriginMonitor. */
|
||||
private _lastFocusOrigin: FocusOrigin;
|
||||
|
||||
/** Whether the window has just been focused. */
|
||||
private _windowFocused = false;
|
||||
|
||||
/** The target of the last touch event. */
|
||||
private _lastTouchTarget: EventTarget;
|
||||
|
||||
/** The timeout id of the touch timeout, used to cancel timeout later. */
|
||||
private _touchTimeout: number;
|
||||
|
||||
constructor() {
|
||||
// Note: we listen to events in the capture phase so we can detect them even if the user stops
|
||||
// propagation.
|
||||
|
||||
// On keydown record the origin and clear any touch event that may be in progress.
|
||||
document.addEventListener('keydown', () => {
|
||||
this._lastTouchTarget = null;
|
||||
this._setOriginForCurrentEventQueue('keyboard');
|
||||
}, true);
|
||||
|
||||
// On mousedown record the origin only if there is not touch target, since a mousedown can
|
||||
// happen as a result of a touch event.
|
||||
document.addEventListener('mousedown', () => {
|
||||
if (!this._lastTouchTarget) {
|
||||
this._setOriginForCurrentEventQueue('mouse');
|
||||
}
|
||||
}, true);
|
||||
|
||||
// When the touchstart event fires the focus event is not yet in the event queue. This means we
|
||||
// can't rely on the trick used above (setting timeout of 0ms). Instead we wait 650ms to see if
|
||||
// a focus happens.
|
||||
document.addEventListener('touchstart', (event: Event) => {
|
||||
if (this._touchTimeout != null) {
|
||||
clearTimeout(this._touchTimeout);
|
||||
}
|
||||
this._lastTouchTarget = event.target;
|
||||
this._touchTimeout = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS);
|
||||
}, true);
|
||||
|
||||
// Make a note of when the window regains focus, so we can restore the origin info for the
|
||||
// focused element.
|
||||
window.addEventListener('focus', () => {
|
||||
this._windowFocused = true;
|
||||
setTimeout(() => this._windowFocused = false, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/** Register an element to receive focus classes. */
|
||||
registerElementForFocusClasses(element: Element, renderer: Renderer): Observable<FocusOrigin> {
|
||||
let subject = new Subject<FocusOrigin>();
|
||||
renderer.listen(element, 'focus',
|
||||
(event: Event) => this._onFocus(event, element, renderer, subject));
|
||||
renderer.listen(element, 'blur', () => this._onBlur(element, renderer, subject));
|
||||
return subject.asObservable();
|
||||
}
|
||||
|
||||
/** Focuses the element via the specified focus origin. */
|
||||
focusVia(element: Node, renderer: Renderer, origin: FocusOrigin) {
|
||||
this._setOriginForCurrentEventQueue(origin);
|
||||
renderer.invokeElementMethod(element, 'focus');
|
||||
}
|
||||
|
||||
/** Sets the origin and schedules an async function to clear it at the end of the event queue. */
|
||||
private _setOriginForCurrentEventQueue(origin: FocusOrigin) {
|
||||
this._origin = origin;
|
||||
setTimeout(() => this._origin = null, 0);
|
||||
}
|
||||
|
||||
/** Checks whether the given focus event was caused by a touchstart event. */
|
||||
private _wasCausedByTouch(event: Event): boolean {
|
||||
// Note(mmalerba): This implementation is not quite perfect, there is a small edge case.
|
||||
// Consider the following dom structure:
|
||||
//
|
||||
// <div #parent tabindex="0" cdkFocusClasses>
|
||||
// <div #child (click)="#parent.focus()"></div>
|
||||
// </div>
|
||||
//
|
||||
// If the user touches the #child element and the #parent is programmatically focused as a
|
||||
// result, this code will still consider it to have been caused by the touch event and will
|
||||
// apply the cdk-touch-focused class rather than the cdk-program-focused class. This is a
|
||||
// relatively small edge-case that can be worked around by using
|
||||
// focusVia(parentEl, renderer, 'program') to focus the parent element.
|
||||
//
|
||||
// If we decide that we absolutely must handle this case correctly, we can do so by listening
|
||||
// for the first focus event after the touchstart, and then the first blur event after that
|
||||
// focus event. When that blur event fires we know that whatever follows is not a result of the
|
||||
// touchstart.
|
||||
let focusTarget = event.target;
|
||||
return this._lastTouchTarget instanceof Node && focusTarget instanceof Node &&
|
||||
(focusTarget == this._lastTouchTarget || focusTarget.contains(this._lastTouchTarget));
|
||||
}
|
||||
|
||||
/** Handles focus events on a registered element. */
|
||||
private _onFocus(event: Event, element: Element, renderer: Renderer,
|
||||
subject: Subject<FocusOrigin>) {
|
||||
// If we couldn't detect a cause for the focus event, it's due to one of two reasons:
|
||||
// 1) The window has just regained focus, in which case we want to restore the focused state of
|
||||
// the element from before the window blurred.
|
||||
// 2) It was caused by a touch event, in which case we mark the origin as 'touch'.
|
||||
// 3) The element was programmatically focused, in which case we should mark the origin as
|
||||
// 'program'.
|
||||
if (!this._origin) {
|
||||
if (this._windowFocused && this._lastFocusOrigin) {
|
||||
this._origin = this._lastFocusOrigin;
|
||||
} else if (this._wasCausedByTouch(event)) {
|
||||
this._origin = 'touch';
|
||||
} else {
|
||||
this._origin = 'program';
|
||||
}
|
||||
}
|
||||
|
||||
renderer.setElementClass(element, 'cdk-focused', true);
|
||||
renderer.setElementClass(element, 'cdk-touch-focused', this._origin == 'touch');
|
||||
renderer.setElementClass(element, 'cdk-keyboard-focused', this._origin == 'keyboard');
|
||||
renderer.setElementClass(element, 'cdk-mouse-focused', this._origin == 'mouse');
|
||||
renderer.setElementClass(element, 'cdk-program-focused', this._origin == 'program');
|
||||
subject.next(this._origin);
|
||||
|
||||
this._lastFocusOrigin = this._origin;
|
||||
this._origin = null;
|
||||
}
|
||||
|
||||
/** Handles blur events on a registered element. */
|
||||
private _onBlur(element: Element, renderer: Renderer, subject: Subject<FocusOrigin>) {
|
||||
renderer.setElementClass(element, 'cdk-focused', false);
|
||||
renderer.setElementClass(element, 'cdk-touch-focused', false);
|
||||
renderer.setElementClass(element, 'cdk-keyboard-focused', false);
|
||||
renderer.setElementClass(element, 'cdk-mouse-focused', false);
|
||||
renderer.setElementClass(element, 'cdk-program-focused', false);
|
||||
subject.next(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Directive that determines how a particular element was focused (via keyboard, mouse, or
|
||||
* programmatically) and adds corresponding classes to the element.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[cdkFocusClasses]',
|
||||
})
|
||||
export class CdkFocusClasses {
|
||||
changes: Observable<FocusOrigin>;
|
||||
|
||||
constructor(elementRef: ElementRef, focusOriginMonitor: FocusOriginMonitor, renderer: Renderer) {
|
||||
this.changes =
|
||||
focusOriginMonitor.registerElementForFocusClasses(elementRef.nativeElement, renderer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function FOCUS_ORIGIN_MONITOR_PROVIDER_FACTORY(parentDispatcher: FocusOriginMonitor) {
|
||||
return parentDispatcher || new FocusOriginMonitor();
|
||||
}
|
||||
|
||||
|
||||
export const FOCUS_ORIGIN_MONITOR_PROVIDER = {
|
||||
// If there is already a FocusOriginMonitor available, use that. Otherwise, provide a new one.
|
||||
provide: FocusOriginMonitor,
|
||||
deps: [[new Optional(), new SkipSelf(), FocusOriginMonitor]],
|
||||
useFactory: FOCUS_ORIGIN_MONITOR_PROVIDER_FACTORY
|
||||
};
|
||||
+224
-105
@@ -1,9 +1,9 @@
|
||||
import {async, ComponentFixture, TestBed, inject} from '@angular/core/testing';
|
||||
import {Component, Renderer, ViewChild} from '@angular/core';
|
||||
import {async, ComponentFixture, inject, TestBed} from '@angular/core/testing';
|
||||
import {Component, Renderer} from '@angular/core';
|
||||
import {StyleModule} from './index';
|
||||
import {By} from '@angular/platform-browser';
|
||||
import {TAB} from '../keyboard/keycodes';
|
||||
import {FocusOriginMonitor, FocusOrigin, CdkFocusClasses, TOUCH_BUFFER_MS} from './focus-classes';
|
||||
import {FocusOrigin, FocusOriginMonitor, TOUCH_BUFFER_MS} from './focus-origin-monitor';
|
||||
|
||||
describe('FocusOriginMonitor', () => {
|
||||
let fixture: ComponentFixture<PlainButton>;
|
||||
@@ -32,8 +32,7 @@ describe('FocusOriginMonitor', () => {
|
||||
focusOriginMonitor = fom;
|
||||
|
||||
changeHandler = jasmine.createSpy('focus origin change handler');
|
||||
focusOriginMonitor.registerElementForFocusClasses(buttonElement, buttonRenderer)
|
||||
.subscribe(changeHandler);
|
||||
focusOriginMonitor.monitor(buttonElement, buttonRenderer, false).subscribe(changeHandler);
|
||||
|
||||
// Patch the element focus to properly emit focus events when the browser is blurred.
|
||||
patchElementFocus(buttonElement);
|
||||
@@ -214,149 +213,269 @@ describe('FocusOriginMonitor', () => {
|
||||
expect(changeHandler).toHaveBeenCalledWith(null);
|
||||
}, 0);
|
||||
}));
|
||||
|
||||
it('should remove classes on unmonitor', async(() => {
|
||||
buttonElement.focus();
|
||||
fixture.detectChanges();
|
||||
|
||||
setTimeout(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(buttonElement.classList.length)
|
||||
.toBe(2, 'button should have exactly 2 focus classes');
|
||||
|
||||
focusOriginMonitor.unmonitor(buttonElement);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(buttonElement.classList.length).toBe(0, 'button should not have any focus classes');
|
||||
}, 0);
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
describe('cdkFocusClasses', () => {
|
||||
let fixture: ComponentFixture<ButtonWithFocusClasses>;
|
||||
let buttonElement: HTMLElement;
|
||||
let changeHandler: (origin: FocusOrigin) => void;
|
||||
|
||||
describe('cdkMonitorFocus', () => {
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [StyleModule],
|
||||
declarations: [
|
||||
ButtonWithFocusClasses,
|
||||
ComplexComponentWithMonitorElementFocus,
|
||||
ComplexComponentWithMonitorSubtreeFocus,
|
||||
],
|
||||
});
|
||||
|
||||
TestBed.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ButtonWithFocusClasses);
|
||||
fixture.detectChanges();
|
||||
describe('button with cdkMonitorElementFocus', () => {
|
||||
let fixture: ComponentFixture<ButtonWithFocusClasses>;
|
||||
let buttonElement: HTMLElement;
|
||||
|
||||
changeHandler = jasmine.createSpy('focus origin change handler');
|
||||
fixture.componentInstance.cdkFocusClasses.changes.subscribe(changeHandler);
|
||||
buttonElement = fixture.debugElement.query(By.css('button')).nativeElement;
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ButtonWithFocusClasses);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Patch the element focus to properly emit focus events when the browser is blurred.
|
||||
patchElementFocus(buttonElement);
|
||||
spyOn(fixture.componentInstance, 'focusChanged');
|
||||
buttonElement = fixture.debugElement.query(By.css('button')).nativeElement;
|
||||
|
||||
// Patch the element focus to properly emit focus events when the browser is blurred.
|
||||
patchElementFocus(buttonElement);
|
||||
});
|
||||
|
||||
it('should initially not be focused', () => {
|
||||
expect(buttonElement.classList.length).toBe(0, 'button should not have focus classes');
|
||||
});
|
||||
|
||||
it('should detect focus via keyboard', async(() => {
|
||||
// Simulate focus via keyboard.
|
||||
dispatchKeydownEvent(document, TAB);
|
||||
buttonElement.focus();
|
||||
fixture.detectChanges();
|
||||
|
||||
setTimeout(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
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(fixture.componentInstance.focusChanged).toHaveBeenCalledWith('keyboard');
|
||||
}, 0);
|
||||
}));
|
||||
|
||||
it('should detect focus via mouse', async(() => {
|
||||
// Simulate focus via mouse.
|
||||
dispatchMousedownEvent(buttonElement);
|
||||
buttonElement.focus();
|
||||
fixture.detectChanges();
|
||||
|
||||
setTimeout(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
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(fixture.componentInstance.focusChanged).toHaveBeenCalledWith('mouse');
|
||||
}, 0);
|
||||
}));
|
||||
|
||||
it('should detect focus via touch', async(() => {
|
||||
// Simulate focus via touch.
|
||||
dispatchTouchstartEvent(buttonElement);
|
||||
buttonElement.focus();
|
||||
fixture.detectChanges();
|
||||
|
||||
setTimeout(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
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-touch-focused'))
|
||||
.toBe(true, 'button should have cdk-touch-focused class');
|
||||
expect(fixture.componentInstance.focusChanged).toHaveBeenCalledWith('touch');
|
||||
}, TOUCH_BUFFER_MS);
|
||||
}));
|
||||
|
||||
it('should detect programmatic focus', async(() => {
|
||||
// Programmatically focus.
|
||||
buttonElement.focus();
|
||||
fixture.detectChanges();
|
||||
|
||||
setTimeout(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
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-program-focused'))
|
||||
.toBe(true, 'button should have cdk-program-focused class');
|
||||
expect(fixture.componentInstance.focusChanged).toHaveBeenCalledWith('program');
|
||||
}, 0);
|
||||
}));
|
||||
|
||||
it('should remove focus classes on blur', async(() => {
|
||||
buttonElement.focus();
|
||||
fixture.detectChanges();
|
||||
|
||||
setTimeout(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(buttonElement.classList.length)
|
||||
.toBe(2, 'button should have exactly 2 focus classes');
|
||||
expect(fixture.componentInstance.focusChanged).toHaveBeenCalledWith('program');
|
||||
|
||||
buttonElement.blur();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(buttonElement.classList.length)
|
||||
.toBe(0, 'button should not have any focus classes');
|
||||
expect(fixture.componentInstance.focusChanged).toHaveBeenCalledWith(null);
|
||||
}, 0);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should initially not be focused', () => {
|
||||
expect(buttonElement.classList.length).toBe(0, 'button should not have focus classes');
|
||||
describe('complex component with cdkMonitorElementFocus', () => {
|
||||
let fixture: ComponentFixture<ComplexComponentWithMonitorElementFocus>;
|
||||
let parentElement: HTMLElement;
|
||||
let childElement: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ComplexComponentWithMonitorElementFocus);
|
||||
fixture.detectChanges();
|
||||
|
||||
parentElement = fixture.debugElement.query(By.css('div')).nativeElement;
|
||||
childElement = fixture.debugElement.query(By.css('button')).nativeElement;
|
||||
|
||||
// Patch the element focus to properly emit focus events when the browser is blurred.
|
||||
patchElementFocus(parentElement);
|
||||
patchElementFocus(childElement);
|
||||
});
|
||||
|
||||
it('should add focus classes on parent focus', async(() => {
|
||||
parentElement.focus();
|
||||
fixture.detectChanges();
|
||||
|
||||
setTimeout(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(parentElement.classList.length)
|
||||
.toBe(2, 'button should have exactly 2 focus classes');
|
||||
}, 0);
|
||||
}));
|
||||
|
||||
it('should not add focus classes on child focus', async(() => {
|
||||
childElement.focus();
|
||||
fixture.detectChanges();
|
||||
|
||||
setTimeout(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(parentElement.classList.length)
|
||||
.toBe(0, 'button should not have any focus classes');
|
||||
}, 0);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should detect focus via keyboard', async(() => {
|
||||
// Simulate focus via keyboard.
|
||||
dispatchKeydownEvent(document, TAB);
|
||||
buttonElement.focus();
|
||||
fixture.detectChanges();
|
||||
describe('complex component with cdkMonitorSubtreeFocus', () => {
|
||||
let fixture: ComponentFixture<ComplexComponentWithMonitorSubtreeFocus>;
|
||||
let parentElement: HTMLElement;
|
||||
let childElement: HTMLElement;
|
||||
|
||||
setTimeout(() => {
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ComplexComponentWithMonitorSubtreeFocus);
|
||||
fixture.detectChanges();
|
||||
|
||||
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).toHaveBeenCalledWith('keyboard');
|
||||
}, 0);
|
||||
}));
|
||||
parentElement = fixture.debugElement.query(By.css('div')).nativeElement;
|
||||
childElement = fixture.debugElement.query(By.css('button')).nativeElement;
|
||||
|
||||
it('should detect focus via mouse', async(() => {
|
||||
// Simulate focus via mouse.
|
||||
dispatchMousedownEvent(buttonElement);
|
||||
buttonElement.focus();
|
||||
fixture.detectChanges();
|
||||
// Patch the element focus to properly emit focus events when the browser is blurred.
|
||||
patchElementFocus(parentElement);
|
||||
patchElementFocus(childElement);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
it('should add focus classes on parent focus', async(() => {
|
||||
parentElement.focus();
|
||||
fixture.detectChanges();
|
||||
|
||||
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).toHaveBeenCalledWith('mouse');
|
||||
}, 0);
|
||||
}));
|
||||
setTimeout(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
it('should detect focus via touch', async(() => {
|
||||
// Simulate focus via touch.
|
||||
dispatchTouchstartEvent(buttonElement);
|
||||
buttonElement.focus();
|
||||
fixture.detectChanges();
|
||||
expect(parentElement.classList.length)
|
||||
.toBe(2, 'button should have exactly 2 focus classes');
|
||||
}, 0);
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
it('should add focus classes on child focus', async(() => {
|
||||
childElement.focus();
|
||||
fixture.detectChanges();
|
||||
|
||||
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-touch-focused'))
|
||||
.toBe(true, 'button should have cdk-touch-focused class');
|
||||
expect(changeHandler).toHaveBeenCalledWith('touch');
|
||||
}, TOUCH_BUFFER_MS);
|
||||
}));
|
||||
setTimeout(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
it('should detect programmatic focus', async(() => {
|
||||
// Programmatically focus.
|
||||
buttonElement.focus();
|
||||
fixture.detectChanges();
|
||||
|
||||
setTimeout(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
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-program-focused'))
|
||||
.toBe(true, 'button should have cdk-program-focused class');
|
||||
expect(changeHandler).toHaveBeenCalledWith('program');
|
||||
}, 0);
|
||||
}));
|
||||
|
||||
it('should remove focus classes on blur', async(() => {
|
||||
buttonElement.focus();
|
||||
fixture.detectChanges();
|
||||
|
||||
setTimeout(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(buttonElement.classList.length)
|
||||
.toBe(2, 'button should have exactly 2 focus classes');
|
||||
expect(changeHandler).toHaveBeenCalledWith('program');
|
||||
|
||||
buttonElement.blur();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(buttonElement.classList.length)
|
||||
.toBe(0, 'button should not have any focus classes');
|
||||
expect(changeHandler).toHaveBeenCalledWith(null);
|
||||
}, 0);
|
||||
}));
|
||||
expect(parentElement.classList.length)
|
||||
.toBe(2, 'button should have exactly 2 focus classes');
|
||||
}, 0);
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@Component({template: `<button>focus me!</button>`})
|
||||
@Component({
|
||||
template: `<button>focus me!</button>`
|
||||
})
|
||||
class PlainButton {
|
||||
constructor(public renderer: Renderer) {}
|
||||
}
|
||||
|
||||
|
||||
@Component({template: `<button cdkFocusClasses>focus me!</button>`})
|
||||
@Component({
|
||||
template: `<button cdkMonitorElementFocus (cdkFocusChange)="focusChanged($event)"></button>`
|
||||
})
|
||||
class ButtonWithFocusClasses {
|
||||
@ViewChild(CdkFocusClasses) cdkFocusClasses: CdkFocusClasses;
|
||||
focusChanged(origin: FocusOrigin) {};
|
||||
}
|
||||
|
||||
|
||||
@Component({
|
||||
template: `<div tabindex="0" cdkMonitorElementFocus><button></button></div>`
|
||||
})
|
||||
class ComplexComponentWithMonitorElementFocus {}
|
||||
|
||||
|
||||
@Component({
|
||||
template: `<div tabindex="0" cdkMonitorSubtreeFocus><button></button></div>`
|
||||
})
|
||||
class ComplexComponentWithMonitorSubtreeFocus {}
|
||||
|
||||
|
||||
// TODO(devversion): move helper functions into a global utility file. See #2902
|
||||
|
||||
/** Dispatches a mousedown event on the specified element. */
|
||||
@@ -0,0 +1,312 @@
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Injectable,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
Optional,
|
||||
Output,
|
||||
Renderer,
|
||||
SkipSelf
|
||||
} from '@angular/core';
|
||||
import {Observable} from 'rxjs/Observable';
|
||||
import {Subject} from 'rxjs/Subject';
|
||||
|
||||
|
||||
// This is the value used by AngularJS Material. Through trial and error (on iPhone 6S) they found
|
||||
// that a value of around 650ms seems appropriate.
|
||||
export const TOUCH_BUFFER_MS = 650;
|
||||
|
||||
|
||||
export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program';
|
||||
|
||||
|
||||
type MonitoredElementInfo = {
|
||||
unlisten: Function,
|
||||
checkChildren: boolean,
|
||||
renderer: Renderer,
|
||||
subject: Subject<FocusOrigin>
|
||||
};
|
||||
|
||||
|
||||
/** Monitors mouse and keyboard events to determine the cause of focus events. */
|
||||
@Injectable()
|
||||
export class FocusOriginMonitor {
|
||||
/** The focus origin that the next focus event is a result of. */
|
||||
private _origin: FocusOrigin = null;
|
||||
|
||||
/** The FocusOrigin of the last focus event tracked by the FocusOriginMonitor. */
|
||||
private _lastFocusOrigin: FocusOrigin;
|
||||
|
||||
/** Whether the window has just been focused. */
|
||||
private _windowFocused = false;
|
||||
|
||||
/** The target of the last touch event. */
|
||||
private _lastTouchTarget: EventTarget;
|
||||
|
||||
/** The timeout id of the touch timeout, used to cancel timeout later. */
|
||||
private _touchTimeout: number;
|
||||
|
||||
/** Weak map of elements being monitored to their info. */
|
||||
private _elementInfo = new WeakMap<Element, MonitoredElementInfo>();
|
||||
|
||||
constructor(private _ngZone: NgZone) {
|
||||
this._ngZone.runOutsideAngular(() => this._registerDocumentEvents());
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitors focus on an element and applies appropriate CSS classes.
|
||||
* @param element The element to monitor
|
||||
* @param renderer The renderer to use to apply CSS classes to the element.
|
||||
* @param checkChildren Whether to count the element as focused when its children are focused.
|
||||
* @returns An observable that emits when the focus state of the element changes.
|
||||
* When the element is blurred, null will be emitted.
|
||||
*/
|
||||
monitor(element: Element, renderer: Renderer, checkChildren: boolean): Observable<FocusOrigin> {
|
||||
// Check if we're already monitoring this element.
|
||||
if (this._elementInfo.has(element)) {
|
||||
let info = this._elementInfo.get(element);
|
||||
info.checkChildren = checkChildren;
|
||||
return info.subject.asObservable();
|
||||
}
|
||||
|
||||
// Create monitored element info.
|
||||
let info: MonitoredElementInfo = {
|
||||
unlisten: null,
|
||||
checkChildren: checkChildren,
|
||||
renderer: renderer,
|
||||
subject: new Subject<FocusOrigin>()
|
||||
};
|
||||
this._elementInfo.set(element, info);
|
||||
|
||||
// Start listening. We need to listen in capture phase since focus events don't bubble.
|
||||
let focusListener = (event: FocusEvent) => this._onFocus(event, element);
|
||||
let blurListener = (event: FocusEvent) => this._onBlur(event, element);
|
||||
this._ngZone.runOutsideAngular(() => {
|
||||
element.addEventListener('focus', focusListener, true);
|
||||
element.addEventListener('blur', blurListener, true);
|
||||
});
|
||||
|
||||
// Create an unlisten function for later.
|
||||
info.unlisten = () => {
|
||||
element.removeEventListener('focus', focusListener, true);
|
||||
element.removeEventListener('blur', blurListener, true);
|
||||
};
|
||||
|
||||
return info.subject.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops monitoring an element and removes all focus classes.
|
||||
* @param element The element to stop monitoring.
|
||||
*/
|
||||
unmonitor(element: Element): void {
|
||||
if (!this._elementInfo.has(element)) {
|
||||
return;
|
||||
}
|
||||
this._elementInfo.get(element).unlisten();
|
||||
this._setClasses(element, null);
|
||||
this._elementInfo.delete(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the element via the specified focus origin.
|
||||
* @param element The element to focus.
|
||||
* @param renderer The renderer to use to invoke the focus method on the element.
|
||||
* @param origin The focus origin.
|
||||
*/
|
||||
focusVia(element: Node, renderer: Renderer, origin: FocusOrigin): void {
|
||||
this._setOriginForCurrentEventQueue(origin);
|
||||
renderer.invokeElementMethod(element, 'focus');
|
||||
}
|
||||
|
||||
/** Register necessary event listeners on the document and window. */
|
||||
private _registerDocumentEvents() {
|
||||
// Note: we listen to events in the capture phase so we can detect them even if the user stops
|
||||
// propagation.
|
||||
|
||||
// On keydown record the origin and clear any touch event that may be in progress.
|
||||
document.addEventListener('keydown', () => {
|
||||
this._lastTouchTarget = null;
|
||||
this._setOriginForCurrentEventQueue('keyboard');
|
||||
}, true);
|
||||
|
||||
// On mousedown record the origin only if there is not touch target, since a mousedown can
|
||||
// happen as a result of a touch event.
|
||||
document.addEventListener('mousedown', () => {
|
||||
if (!this._lastTouchTarget) {
|
||||
this._setOriginForCurrentEventQueue('mouse');
|
||||
}
|
||||
}, true);
|
||||
|
||||
// When the touchstart event fires the focus event is not yet in the event queue. This means
|
||||
// we can't rely on the trick used above (setting timeout of 0ms). Instead we wait 650ms to
|
||||
// see if a focus happens.
|
||||
document.addEventListener('touchstart', (event: Event) => {
|
||||
if (this._touchTimeout != null) {
|
||||
clearTimeout(this._touchTimeout);
|
||||
}
|
||||
this._lastTouchTarget = event.target;
|
||||
this._touchTimeout = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS);
|
||||
}, true);
|
||||
|
||||
// Make a note of when the window regains focus, so we can restore the origin info for the
|
||||
// focused element.
|
||||
window.addEventListener('focus', () => {
|
||||
this._windowFocused = true;
|
||||
setTimeout(() => this._windowFocused = false, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the focus classes on the element based on the given focus origin.
|
||||
* @param element The element to update the classes on.
|
||||
* @param origin The focus origin.
|
||||
*/
|
||||
private _setClasses(element: Element, origin: FocusOrigin): void {
|
||||
let renderer = this._elementInfo.get(element).renderer;
|
||||
renderer.setElementClass(element, 'cdk-focused', !!origin);
|
||||
renderer.setElementClass(element, 'cdk-touch-focused', origin === 'touch');
|
||||
renderer.setElementClass(element, 'cdk-keyboard-focused', origin === 'keyboard');
|
||||
renderer.setElementClass(element, 'cdk-mouse-focused', origin === 'mouse');
|
||||
renderer.setElementClass(element, 'cdk-program-focused', origin === 'program');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the origin and schedules an async function to clear it at the end of the event queue.
|
||||
* @param origin The origin to set.
|
||||
*/
|
||||
private _setOriginForCurrentEventQueue(origin: FocusOrigin): void {
|
||||
this._origin = origin;
|
||||
setTimeout(() => this._origin = null, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given focus event was caused by a touchstart event.
|
||||
* @param event The focus event to check.
|
||||
* @returns Whether the event was caused by a touch.
|
||||
*/
|
||||
private _wasCausedByTouch(event: FocusEvent): boolean {
|
||||
// Note(mmalerba): This implementation is not quite perfect, there is a small edge case.
|
||||
// Consider the following dom structure:
|
||||
//
|
||||
// <div #parent tabindex="0" cdkFocusClasses>
|
||||
// <div #child (click)="#parent.focus()"></div>
|
||||
// </div>
|
||||
//
|
||||
// If the user touches the #child element and the #parent is programmatically focused as a
|
||||
// result, this code will still consider it to have been caused by the touch event and will
|
||||
// apply the cdk-touch-focused class rather than the cdk-program-focused class. This is a
|
||||
// relatively small edge-case that can be worked around by using
|
||||
// focusVia(parentEl, renderer, 'program') to focus the parent element.
|
||||
//
|
||||
// If we decide that we absolutely must handle this case correctly, we can do so by listening
|
||||
// for the first focus event after the touchstart, and then the first blur event after that
|
||||
// focus event. When that blur event fires we know that whatever follows is not a result of the
|
||||
// touchstart.
|
||||
let focusTarget = event.target;
|
||||
return this._lastTouchTarget instanceof Node && focusTarget instanceof Node &&
|
||||
(focusTarget === this._lastTouchTarget || focusTarget.contains(this._lastTouchTarget));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles focus events on a registered element.
|
||||
* @param event The focus event.
|
||||
* @param element The monitored element.
|
||||
*/
|
||||
private _onFocus(event: FocusEvent, element: Element) {
|
||||
// NOTE(mmalerba): We currently set the classes based on the focus origin of the most recent
|
||||
// focus event affecting the monitored element. If we want to use the origin of the first event
|
||||
// instead we should check for the cdk-focused class here and return if the element already has
|
||||
// it. (This only matters for elements that have includesChildren = true).
|
||||
|
||||
// If we are not counting child-element-focus as focused, make sure that the event target is the
|
||||
// monitored element itself.
|
||||
if (!this._elementInfo.get(element).checkChildren && element !== event.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we couldn't detect a cause for the focus event, it's due to one of three reasons:
|
||||
// 1) The window has just regained focus, in which case we want to restore the focused state of
|
||||
// the element from before the window blurred.
|
||||
// 2) It was caused by a touch event, in which case we mark the origin as 'touch'.
|
||||
// 3) The element was programmatically focused, in which case we should mark the origin as
|
||||
// 'program'.
|
||||
if (!this._origin) {
|
||||
if (this._windowFocused && this._lastFocusOrigin) {
|
||||
this._origin = this._lastFocusOrigin;
|
||||
} else if (this._wasCausedByTouch(event)) {
|
||||
this._origin = 'touch';
|
||||
} else {
|
||||
this._origin = 'program';
|
||||
}
|
||||
}
|
||||
|
||||
this._setClasses(element, this._origin);
|
||||
this._elementInfo.get(element).subject.next(this._origin);
|
||||
this._lastFocusOrigin = this._origin;
|
||||
this._origin = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles blur events on a registered element.
|
||||
* @param event The blur event.
|
||||
* @param element The monitored element.
|
||||
*/
|
||||
private _onBlur(event: FocusEvent, element: Element) {
|
||||
// If we are counting child-element-focus as focused, make sure that we aren't just blurring in
|
||||
// order to focus another child of the monitored element.
|
||||
if (this._elementInfo.get(element).checkChildren && event.relatedTarget instanceof Node &&
|
||||
element.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setClasses(element, null);
|
||||
this._elementInfo.get(element).subject.next(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Directive that determines how a particular element was focused (via keyboard, mouse, touch, or
|
||||
* programmatically) and adds corresponding classes to the element.
|
||||
*
|
||||
* There are two variants of this directive:
|
||||
* 1) cdkMonitorElementFocus: does not consider an element to be focused if one of its children is
|
||||
* focused.
|
||||
* 2) cdkMonitorSubtreeFocus: considers an element focused if it or any of its children are focused.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[cdkMonitorElementFocus], [cdkMonitorSubtreeFocus]',
|
||||
})
|
||||
export class CdkMonitorFocus implements OnDestroy {
|
||||
@Output()
|
||||
cdkFocusChange = new EventEmitter<FocusOrigin>();
|
||||
|
||||
constructor(private _elementRef: ElementRef, private _focusOriginMonitor: FocusOriginMonitor,
|
||||
renderer: Renderer) {
|
||||
this._focusOriginMonitor.monitor(
|
||||
this._elementRef.nativeElement, renderer,
|
||||
this._elementRef.nativeElement.hasAttribute('cdkMonitorSubtreeFocus'))
|
||||
.subscribe(origin => this.cdkFocusChange.emit(origin));
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._focusOriginMonitor.unmonitor(this._elementRef.nativeElement);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function FOCUS_ORIGIN_MONITOR_PROVIDER_FACTORY(parentDispatcher: FocusOriginMonitor,
|
||||
ngZone: NgZone) {
|
||||
return parentDispatcher || new FocusOriginMonitor(ngZone);
|
||||
}
|
||||
|
||||
|
||||
export const FOCUS_ORIGIN_MONITOR_PROVIDER = {
|
||||
// If there is already a FocusOriginMonitor available, use that. Otherwise, provide a new one.
|
||||
provide: FocusOriginMonitor,
|
||||
deps: [[new Optional(), new SkipSelf(), FocusOriginMonitor], NgZone],
|
||||
useFactory: FOCUS_ORIGIN_MONITOR_PROVIDER_FACTORY
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CdkFocusClasses, FOCUS_ORIGIN_MONITOR_PROVIDER} from './focus-classes';
|
||||
import {CdkMonitorFocus, FOCUS_ORIGIN_MONITOR_PROVIDER} from './focus-origin-monitor';
|
||||
|
||||
export * from './focus-classes';
|
||||
export * from './focus-origin-monitor';
|
||||
export * from './apply-transform';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [CdkFocusClasses],
|
||||
exports: [CdkFocusClasses],
|
||||
declarations: [CdkMonitorFocus],
|
||||
exports: [CdkMonitorFocus],
|
||||
providers: [FOCUS_ORIGIN_MONITOR_PROVIDER],
|
||||
})
|
||||
export class StyleModule {}
|
||||
|
||||
Reference in New Issue
Block a user