Skip to content

feat(FocusOriginMonitor): add support for touch events #3020

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Feb 14, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/demo-app/style/style-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<button (click)="b.focus()">focus programmatically</button>

<button (click)="fom.focusVia(b, renderer, 'mouse')">focusVia: mouse</button>
<button (click)="fom.focusVia(b, renderer, 'touch')">focusVia: touch</button>
<button (click)="fom.focusVia(b, renderer, 'keyboard')">focusVia: keyboard</button>
<button (click)="fom.focusVia(b, renderer, 'program')">focusVia: program</button>

Expand Down
4 changes: 4 additions & 0 deletions src/demo-app/style/style-demo.scss
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@
.demo-button.cdk-program-focused {
background: blue;
}

.demo-button.cdk-touch-focused {
background: purple;
}
68 changes: 65 additions & 3 deletions src/lib/core/style/focus-classes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {Component, Renderer, ViewChild} from '@angular/core';
import {StyleModule} from './index';
import {By} from '@angular/platform-browser';
import {TAB} from '../keyboard/keycodes';
import {FocusOriginMonitor, FocusOrigin, CdkFocusClasses} from './focus-classes';
import {FocusOriginMonitor, FocusOrigin, CdkFocusClasses, TOUCH_BUFFER_MS} from './focus-classes';

describe('FocusOriginMonitor', () => {
let fixture: ComponentFixture<PlainButton>;
Expand Down Expand Up @@ -73,7 +73,7 @@ describe('FocusOriginMonitor', () => {

it('should detect focus via mouse', async(() => {
// Simulate focus via mouse.
dispatchMousedownEvent(document);
dispatchMousedownEvent(buttonElement);
buttonElement.focus();
fixture.detectChanges();

Expand All @@ -90,6 +90,25 @@ describe('FocusOriginMonitor', () => {
}, 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(changeHandler).toHaveBeenCalledWith('touch');
}, TOUCH_BUFFER_MS);
}));

it('should detect programmatic focus', async(() => {
// Programmatically focus.
buttonElement.focus();
Expand Down Expand Up @@ -142,6 +161,23 @@ describe('FocusOriginMonitor', () => {
}, 0);
}));

it('focusVia mouse should simulate mouse focus', async(() => {
focusOriginMonitor.focusVia(buttonElement, buttonRenderer, 'touch');
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(changeHandler).toHaveBeenCalledWith('touch');
}, 0);
}));

it('focusVia program should simulate programmatic focus', async(() => {
focusOriginMonitor.focusVia(buttonElement, buttonRenderer, 'program');
fixture.detectChanges();
Expand Down Expand Up @@ -234,7 +270,7 @@ describe('cdkFocusClasses', () => {

it('should detect focus via mouse', async(() => {
// Simulate focus via mouse.
dispatchMousedownEvent(document);
dispatchMousedownEvent(buttonElement);
buttonElement.focus();
fixture.detectChanges();

Expand All @@ -251,6 +287,25 @@ describe('cdkFocusClasses', () => {
}, 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(changeHandler).toHaveBeenCalledWith('touch');
}, TOUCH_BUFFER_MS);
}));

it('should detect programmatic focus', async(() => {
// Programmatically focus.
buttonElement.focus();
Expand Down Expand Up @@ -312,6 +367,13 @@ function dispatchMousedownEvent(element: Node) {
element.dispatchEvent(event);
}

/** Dispatches a mousedown event on the specified element. */
function dispatchTouchstartEvent(element: Node) {
let event = document.createEvent('MouseEvent');
event.initMouseEvent(
'touchstart', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
element.dispatchEvent(event);
}

/** Dispatches a keydown event on the specified element. */
function dispatchKeydownEvent(element: Node, keyCode: number) {
Expand Down
86 changes: 74 additions & 12 deletions src/lib/core/style/focus-classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';


export type FocusOrigin = 'mouse' | 'keyboard' | 'program';
// 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. */
Expand All @@ -18,14 +23,40 @@ export class FocusOriginMonitor {
/** 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() {
// Listen to keydown and mousedown in the capture phase so we can detect them even if the user
// stops propagation.
// TODO(mmalerba): Figure out how to handle touchstart
document.addEventListener(
'keydown', () => this._setOriginForCurrentEventQueue('keyboard'), true);
document.addEventListener(
'mousedown', () => this._setOriginForCurrentEventQueue('mouse'), true);
// 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.
Expand All @@ -38,7 +69,8 @@ export class FocusOriginMonitor {
/** Register an element to receive focus classes. */
registerElementForFocusClasses(element: Element, renderer: Renderer): Observable<FocusOrigin> {
let subject = new Subject<FocusOrigin>();
renderer.listen(element, 'focus', () => this._onFocus(element, renderer, subject));
renderer.listen(element, 'focus',
(event: Event) => this._onFocus(event, element, renderer, subject));
renderer.listen(element, 'blur', () => this._onBlur(element, renderer, subject));
return subject.asObservable();
}
Expand All @@ -55,34 +87,64 @@ export class FocusOriginMonitor {
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(element: Element, renderer: Renderer, subject: Subject<FocusOrigin>) {
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) The element was programmatically focused, in which case we should mark the origin as
// 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);
Expand Down