Skip to content

Commit

Permalink
Merge branch 'master' into exports
Browse files Browse the repository at this point in the history
  • Loading branch information
crisbeto authored Feb 15, 2017
2 parents a31aeb1 + 37e4bad commit 2640da3
Show file tree
Hide file tree
Showing 12 changed files with 219 additions and 53 deletions.
10 changes: 10 additions & 0 deletions e2e/components/dialog/dialog.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ describe('dialog', () => {
});
});

it('should close by pressing escape when the first tabbable element has lost focus', () => {
element(by.id('default')).click();

waitForDialog().then(() => {
clickElementAtPoint('md-dialog-container', { x: 0, y: 0 });
pressKeys(Key.ESCAPE);
expectToExist('md-dialog-container', false);
});
});

it('should close by clicking on the "close" button', () => {
element(by.id('default')).click();

Expand Down
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;
}
4 changes: 4 additions & 0 deletions src/lib/button-toggle/_button-toggle-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,9 @@
.mat-button-toggle-disabled {
background-color: map_get($mat-grey, 200);
color: mat-color($foreground, disabled-button);

&.mat-button-toggle-checked {
background-color: mat-color($mat-grey, 400);
}
}
}
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
11 changes: 0 additions & 11 deletions src/lib/dialog/dialog-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import 'rxjs/add/operator/first';
host: {
'[class.mat-dialog-container]': 'true',
'[attr.role]': 'dialogConfig?.role',
'(keydown.escape)': 'handleEscapeKey()',
},
encapsulation: ViewEncapsulation.None,
})
Expand Down Expand Up @@ -93,16 +92,6 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
});
}

/**
* Handles the user pressing the Escape key.
* @docs-private
*/
handleEscapeKey() {
if (!this.dialogConfig.disableClose) {
this.dialogRef.close();
}
}

ngOnDestroy() {
// When the dialog is destroyed, return focus to the element that originally had it before
// the dialog was opened. Wait for the DOM to finish settling before changing the focus so
Expand Down
3 changes: 2 additions & 1 deletion src/lib/dialog/dialog-ref.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {OverlayRef} from '../core';
import {MdDialogConfig} from './dialog-config';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';

Expand All @@ -17,7 +18,7 @@ export class MdDialogRef<T> {
/** Subject for notifying the user that the dialog has finished closing. */
private _afterClosed: Subject<any> = new Subject();

constructor(private _overlayRef: OverlayRef) { }
constructor(private _overlayRef: OverlayRef, public config: MdDialogConfig) { }

/**
* Close the dialog.
Expand Down
27 changes: 15 additions & 12 deletions src/lib/dialog/dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,12 @@ import {NgModule,
Injector,
Inject,
} from '@angular/core';
import {By} from '@angular/platform-browser';
import {MdDialogModule} from './index';
import {MdDialog} from './dialog';
import {OverlayContainer} from '../core';
import {MdDialogRef} from './dialog-ref';
import {MdDialogContainer} from './dialog-container';
import {MD_DIALOG_DATA} from './dialog-injector';
import {ESCAPE} from '../core/keyboard/keycodes';


describe('MdDialog', () => {
Expand Down Expand Up @@ -136,11 +135,7 @@ describe('MdDialog', () => {

viewContainerFixture.detectChanges();

let dialogContainer: MdDialogContainer =
viewContainerFixture.debugElement.query(By.directive(MdDialogContainer)).componentInstance;

// Fake the user pressing the escape key by calling the handler directly.
dialogContainer.handleEscapeKey();
dispatchKeydownEvent(document, ESCAPE);

expect(overlayContainerElement.querySelector('md-dialog-container')).toBeNull();
});
Expand Down Expand Up @@ -324,11 +319,7 @@ describe('MdDialog', () => {

viewContainerFixture.detectChanges();

let dialogContainer: MdDialogContainer = viewContainerFixture.debugElement.query(
By.directive(MdDialogContainer)).componentInstance;

// Fake the user pressing the escape key by calling the handler directly.
dialogContainer.handleEscapeKey();
dispatchKeydownEvent(document, ESCAPE);

expect(overlayContainerElement.querySelector('md-dialog-container')).toBeTruthy();
});
Expand Down Expand Up @@ -565,3 +556,15 @@ const TEST_DIRECTIVES = [
],
})
class DialogTestModule { }


// TODO(crisbeto): switch to using function from common testing utils once #2943 is merged.
function dispatchKeydownEvent(element: Node, keyCode: number) {
let event: any = document.createEvent('KeyboardEvent');
(event.initKeyEvent || event.initKeyboardEvent).bind(event)(
'keydown', true, true, window, 0, 0, 0, 0, 0, keyCode);
Object.defineProperty(event, 'keyCode', {
get: function() { return keyCode; }
});
element.dispatchEvent(event);
}
Loading

0 comments on commit 2640da3

Please sign in to comment.