Skip to content

Commit 2640da3

Browse files
authored
Merge branch 'master' into exports
2 parents a31aeb1 + 37e4bad commit 2640da3

File tree

12 files changed

+219
-53
lines changed

12 files changed

+219
-53
lines changed

e2e/components/dialog/dialog.e2e.ts

+10
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ describe('dialog', () => {
3535
});
3636
});
3737

38+
it('should close by pressing escape when the first tabbable element has lost focus', () => {
39+
element(by.id('default')).click();
40+
41+
waitForDialog().then(() => {
42+
clickElementAtPoint('md-dialog-container', { x: 0, y: 0 });
43+
pressKeys(Key.ESCAPE);
44+
expectToExist('md-dialog-container', false);
45+
});
46+
});
47+
3848
it('should close by clicking on the "close" button', () => {
3949
element(by.id('default')).click();
4050

src/demo-app/style/style-demo.html

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<button (click)="b.focus()">focus programmatically</button>
33

44
<button (click)="fom.focusVia(b, renderer, 'mouse')">focusVia: mouse</button>
5+
<button (click)="fom.focusVia(b, renderer, 'touch')">focusVia: touch</button>
56
<button (click)="fom.focusVia(b, renderer, 'keyboard')">focusVia: keyboard</button>
67
<button (click)="fom.focusVia(b, renderer, 'program')">focusVia: program</button>
78

src/demo-app/style/style-demo.scss

+4
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,7 @@
1313
.demo-button.cdk-program-focused {
1414
background: blue;
1515
}
16+
17+
.demo-button.cdk-touch-focused {
18+
background: purple;
19+
}

src/lib/button-toggle/_button-toggle-theme.scss

+4
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,9 @@
1818
.mat-button-toggle-disabled {
1919
background-color: map_get($mat-grey, 200);
2020
color: mat-color($foreground, disabled-button);
21+
22+
&.mat-button-toggle-checked {
23+
background-color: mat-color($mat-grey, 400);
24+
}
2125
}
2226
}

src/lib/core/style/focus-classes.spec.ts

+65-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {Component, Renderer, ViewChild} from '@angular/core';
33
import {StyleModule} from './index';
44
import {By} from '@angular/platform-browser';
55
import {TAB} from '../keyboard/keycodes';
6-
import {FocusOriginMonitor, FocusOrigin, CdkFocusClasses} from './focus-classes';
6+
import {FocusOriginMonitor, FocusOrigin, CdkFocusClasses, TOUCH_BUFFER_MS} from './focus-classes';
77

88
describe('FocusOriginMonitor', () => {
99
let fixture: ComponentFixture<PlainButton>;
@@ -73,7 +73,7 @@ describe('FocusOriginMonitor', () => {
7373

7474
it('should detect focus via mouse', async(() => {
7575
// Simulate focus via mouse.
76-
dispatchMousedownEvent(document);
76+
dispatchMousedownEvent(buttonElement);
7777
buttonElement.focus();
7878
fixture.detectChanges();
7979

@@ -90,6 +90,25 @@ describe('FocusOriginMonitor', () => {
9090
}, 0);
9191
}));
9292

93+
it('should detect focus via touch', async(() => {
94+
// Simulate focus via touch.
95+
dispatchTouchstartEvent(buttonElement);
96+
buttonElement.focus();
97+
fixture.detectChanges();
98+
99+
setTimeout(() => {
100+
fixture.detectChanges();
101+
102+
expect(buttonElement.classList.length)
103+
.toBe(2, 'button should have exactly 2 focus classes');
104+
expect(buttonElement.classList.contains('cdk-focused'))
105+
.toBe(true, 'button should have cdk-focused class');
106+
expect(buttonElement.classList.contains('cdk-touch-focused'))
107+
.toBe(true, 'button should have cdk-touch-focused class');
108+
expect(changeHandler).toHaveBeenCalledWith('touch');
109+
}, TOUCH_BUFFER_MS);
110+
}));
111+
93112
it('should detect programmatic focus', async(() => {
94113
// Programmatically focus.
95114
buttonElement.focus();
@@ -142,6 +161,23 @@ describe('FocusOriginMonitor', () => {
142161
}, 0);
143162
}));
144163

164+
it('focusVia mouse should simulate mouse focus', async(() => {
165+
focusOriginMonitor.focusVia(buttonElement, buttonRenderer, 'touch');
166+
fixture.detectChanges();
167+
168+
setTimeout(() => {
169+
fixture.detectChanges();
170+
171+
expect(buttonElement.classList.length)
172+
.toBe(2, 'button should have exactly 2 focus classes');
173+
expect(buttonElement.classList.contains('cdk-focused'))
174+
.toBe(true, 'button should have cdk-focused class');
175+
expect(buttonElement.classList.contains('cdk-touch-focused'))
176+
.toBe(true, 'button should have cdk-touch-focused class');
177+
expect(changeHandler).toHaveBeenCalledWith('touch');
178+
}, 0);
179+
}));
180+
145181
it('focusVia program should simulate programmatic focus', async(() => {
146182
focusOriginMonitor.focusVia(buttonElement, buttonRenderer, 'program');
147183
fixture.detectChanges();
@@ -234,7 +270,7 @@ describe('cdkFocusClasses', () => {
234270

235271
it('should detect focus via mouse', async(() => {
236272
// Simulate focus via mouse.
237-
dispatchMousedownEvent(document);
273+
dispatchMousedownEvent(buttonElement);
238274
buttonElement.focus();
239275
fixture.detectChanges();
240276

@@ -251,6 +287,25 @@ describe('cdkFocusClasses', () => {
251287
}, 0);
252288
}));
253289

290+
it('should detect focus via touch', async(() => {
291+
// Simulate focus via touch.
292+
dispatchTouchstartEvent(buttonElement);
293+
buttonElement.focus();
294+
fixture.detectChanges();
295+
296+
setTimeout(() => {
297+
fixture.detectChanges();
298+
299+
expect(buttonElement.classList.length)
300+
.toBe(2, 'button should have exactly 2 focus classes');
301+
expect(buttonElement.classList.contains('cdk-focused'))
302+
.toBe(true, 'button should have cdk-focused class');
303+
expect(buttonElement.classList.contains('cdk-touch-focused'))
304+
.toBe(true, 'button should have cdk-touch-focused class');
305+
expect(changeHandler).toHaveBeenCalledWith('touch');
306+
}, TOUCH_BUFFER_MS);
307+
}));
308+
254309
it('should detect programmatic focus', async(() => {
255310
// Programmatically focus.
256311
buttonElement.focus();
@@ -312,6 +367,13 @@ function dispatchMousedownEvent(element: Node) {
312367
element.dispatchEvent(event);
313368
}
314369

370+
/** Dispatches a mousedown event on the specified element. */
371+
function dispatchTouchstartEvent(element: Node) {
372+
let event = document.createEvent('MouseEvent');
373+
event.initMouseEvent(
374+
'touchstart', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
375+
element.dispatchEvent(event);
376+
}
315377

316378
/** Dispatches a keydown event on the specified element. */
317379
function dispatchKeydownEvent(element: Node, keyCode: number) {

src/lib/core/style/focus-classes.ts

+74-12
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import {Observable} from 'rxjs/Observable';
33
import {Subject} from 'rxjs/Subject';
44

55

6-
export type FocusOrigin = 'mouse' | 'keyboard' | 'program';
6+
// This is the value used by AngularJS Material. Through trial and error (on iPhone 6S) they found
7+
// that a value of around 650ms seems appropriate.
8+
export const TOUCH_BUFFER_MS = 650;
9+
10+
11+
export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program';
712

813

914
/** Monitors mouse and keyboard events to determine the cause of focus events. */
@@ -18,14 +23,40 @@ export class FocusOriginMonitor {
1823
/** Whether the window has just been focused. */
1924
private _windowFocused = false;
2025

26+
/** The target of the last touch event. */
27+
private _lastTouchTarget: EventTarget;
28+
29+
/** The timeout id of the touch timeout, used to cancel timeout later. */
30+
private _touchTimeout: number;
31+
2132
constructor() {
22-
// Listen to keydown and mousedown in the capture phase so we can detect them even if the user
23-
// stops propagation.
24-
// TODO(mmalerba): Figure out how to handle touchstart
25-
document.addEventListener(
26-
'keydown', () => this._setOriginForCurrentEventQueue('keyboard'), true);
27-
document.addEventListener(
28-
'mousedown', () => this._setOriginForCurrentEventQueue('mouse'), true);
33+
// Note: we listen to events in the capture phase so we can detect them even if the user stops
34+
// propagation.
35+
36+
// On keydown record the origin and clear any touch event that may be in progress.
37+
document.addEventListener('keydown', () => {
38+
this._lastTouchTarget = null;
39+
this._setOriginForCurrentEventQueue('keyboard');
40+
}, true);
41+
42+
// On mousedown record the origin only if there is not touch target, since a mousedown can
43+
// happen as a result of a touch event.
44+
document.addEventListener('mousedown', () => {
45+
if (!this._lastTouchTarget) {
46+
this._setOriginForCurrentEventQueue('mouse');
47+
}
48+
}, true);
49+
50+
// When the touchstart event fires the focus event is not yet in the event queue. This means we
51+
// can't rely on the trick used above (setting timeout of 0ms). Instead we wait 650ms to see if
52+
// a focus happens.
53+
document.addEventListener('touchstart', (event: Event) => {
54+
if (this._touchTimeout != null) {
55+
clearTimeout(this._touchTimeout);
56+
}
57+
this._lastTouchTarget = event.target;
58+
this._touchTimeout = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS);
59+
}, true);
2960

3061
// Make a note of when the window regains focus, so we can restore the origin info for the
3162
// focused element.
@@ -38,7 +69,8 @@ export class FocusOriginMonitor {
3869
/** Register an element to receive focus classes. */
3970
registerElementForFocusClasses(element: Element, renderer: Renderer): Observable<FocusOrigin> {
4071
let subject = new Subject<FocusOrigin>();
41-
renderer.listen(element, 'focus', () => this._onFocus(element, renderer, subject));
72+
renderer.listen(element, 'focus',
73+
(event: Event) => this._onFocus(event, element, renderer, subject));
4274
renderer.listen(element, 'blur', () => this._onBlur(element, renderer, subject));
4375
return subject.asObservable();
4476
}
@@ -55,34 +87,64 @@ export class FocusOriginMonitor {
5587
setTimeout(() => this._origin = null, 0);
5688
}
5789

90+
/** Checks whether the given focus event was caused by a touchstart event. */
91+
private _wasCausedByTouch(event: Event): boolean {
92+
// Note(mmalerba): This implementation is not quite perfect, there is a small edge case.
93+
// Consider the following dom structure:
94+
//
95+
// <div #parent tabindex="0" cdkFocusClasses>
96+
// <div #child (click)="#parent.focus()"></div>
97+
// </div>
98+
//
99+
// If the user touches the #child element and the #parent is programmatically focused as a
100+
// result, this code will still consider it to have been caused by the touch event and will
101+
// apply the cdk-touch-focused class rather than the cdk-program-focused class. This is a
102+
// relatively small edge-case that can be worked around by using
103+
// focusVia(parentEl, renderer, 'program') to focus the parent element.
104+
//
105+
// If we decide that we absolutely must handle this case correctly, we can do so by listening
106+
// for the first focus event after the touchstart, and then the first blur event after that
107+
// focus event. When that blur event fires we know that whatever follows is not a result of the
108+
// touchstart.
109+
let focusTarget = event.target;
110+
return this._lastTouchTarget instanceof Node && focusTarget instanceof Node &&
111+
(focusTarget == this._lastTouchTarget || focusTarget.contains(this._lastTouchTarget));
112+
}
113+
58114
/** Handles focus events on a registered element. */
59-
private _onFocus(element: Element, renderer: Renderer, subject: Subject<FocusOrigin>) {
115+
private _onFocus(event: Event, element: Element, renderer: Renderer,
116+
subject: Subject<FocusOrigin>) {
60117
// If we couldn't detect a cause for the focus event, it's due to one of two reasons:
61118
// 1) The window has just regained focus, in which case we want to restore the focused state of
62119
// the element from before the window blurred.
63-
// 2) The element was programmatically focused, in which case we should mark the origin as
120+
// 2) It was caused by a touch event, in which case we mark the origin as 'touch'.
121+
// 3) The element was programmatically focused, in which case we should mark the origin as
64122
// 'program'.
65123
if (!this._origin) {
66124
if (this._windowFocused && this._lastFocusOrigin) {
67125
this._origin = this._lastFocusOrigin;
126+
} else if (this._wasCausedByTouch(event)) {
127+
this._origin = 'touch';
68128
} else {
69129
this._origin = 'program';
70130
}
71131
}
72132

73133
renderer.setElementClass(element, 'cdk-focused', true);
134+
renderer.setElementClass(element, 'cdk-touch-focused', this._origin == 'touch');
74135
renderer.setElementClass(element, 'cdk-keyboard-focused', this._origin == 'keyboard');
75136
renderer.setElementClass(element, 'cdk-mouse-focused', this._origin == 'mouse');
76137
renderer.setElementClass(element, 'cdk-program-focused', this._origin == 'program');
77-
78138
subject.next(this._origin);
139+
79140
this._lastFocusOrigin = this._origin;
80141
this._origin = null;
81142
}
82143

83144
/** Handles blur events on a registered element. */
84145
private _onBlur(element: Element, renderer: Renderer, subject: Subject<FocusOrigin>) {
85146
renderer.setElementClass(element, 'cdk-focused', false);
147+
renderer.setElementClass(element, 'cdk-touch-focused', false);
86148
renderer.setElementClass(element, 'cdk-keyboard-focused', false);
87149
renderer.setElementClass(element, 'cdk-mouse-focused', false);
88150
renderer.setElementClass(element, 'cdk-program-focused', false);

src/lib/dialog/dialog-container.ts

-11
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import 'rxjs/add/operator/first';
2727
host: {
2828
'[class.mat-dialog-container]': 'true',
2929
'[attr.role]': 'dialogConfig?.role',
30-
'(keydown.escape)': 'handleEscapeKey()',
3130
},
3231
encapsulation: ViewEncapsulation.None,
3332
})
@@ -93,16 +92,6 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
9392
});
9493
}
9594

96-
/**
97-
* Handles the user pressing the Escape key.
98-
* @docs-private
99-
*/
100-
handleEscapeKey() {
101-
if (!this.dialogConfig.disableClose) {
102-
this.dialogRef.close();
103-
}
104-
}
105-
10695
ngOnDestroy() {
10796
// When the dialog is destroyed, return focus to the element that originally had it before
10897
// the dialog was opened. Wait for the DOM to finish settling before changing the focus so

src/lib/dialog/dialog-ref.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {OverlayRef} from '../core';
2+
import {MdDialogConfig} from './dialog-config';
23
import {Observable} from 'rxjs/Observable';
34
import {Subject} from 'rxjs/Subject';
45

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

20-
constructor(private _overlayRef: OverlayRef) { }
21+
constructor(private _overlayRef: OverlayRef, public config: MdDialogConfig) { }
2122

2223
/**
2324
* Close the dialog.

src/lib/dialog/dialog.spec.ts

+15-12
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,12 @@ import {NgModule,
1515
Injector,
1616
Inject,
1717
} from '@angular/core';
18-
import {By} from '@angular/platform-browser';
1918
import {MdDialogModule} from './index';
2019
import {MdDialog} from './dialog';
2120
import {OverlayContainer} from '../core';
2221
import {MdDialogRef} from './dialog-ref';
23-
import {MdDialogContainer} from './dialog-container';
2422
import {MD_DIALOG_DATA} from './dialog-injector';
23+
import {ESCAPE} from '../core/keyboard/keycodes';
2524

2625

2726
describe('MdDialog', () => {
@@ -136,11 +135,7 @@ describe('MdDialog', () => {
136135

137136
viewContainerFixture.detectChanges();
138137

139-
let dialogContainer: MdDialogContainer =
140-
viewContainerFixture.debugElement.query(By.directive(MdDialogContainer)).componentInstance;
141-
142-
// Fake the user pressing the escape key by calling the handler directly.
143-
dialogContainer.handleEscapeKey();
138+
dispatchKeydownEvent(document, ESCAPE);
144139

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

325320
viewContainerFixture.detectChanges();
326321

327-
let dialogContainer: MdDialogContainer = viewContainerFixture.debugElement.query(
328-
By.directive(MdDialogContainer)).componentInstance;
329-
330-
// Fake the user pressing the escape key by calling the handler directly.
331-
dialogContainer.handleEscapeKey();
322+
dispatchKeydownEvent(document, ESCAPE);
332323

333324
expect(overlayContainerElement.querySelector('md-dialog-container')).toBeTruthy();
334325
});
@@ -565,3 +556,15 @@ const TEST_DIRECTIVES = [
565556
],
566557
})
567558
class DialogTestModule { }
559+
560+
561+
// TODO(crisbeto): switch to using function from common testing utils once #2943 is merged.
562+
function dispatchKeydownEvent(element: Node, keyCode: number) {
563+
let event: any = document.createEvent('KeyboardEvent');
564+
(event.initKeyEvent || event.initKeyboardEvent).bind(event)(
565+
'keydown', true, true, window, 0, 0, 0, 0, 0, keyCode);
566+
Object.defineProperty(event, 'keyCode', {
567+
get: function() { return keyCode; }
568+
});
569+
element.dispatchEvent(event);
570+
}

0 commit comments

Comments
 (0)