diff --git a/e2e/components/dialog/dialog.e2e.ts b/e2e/components/dialog/dialog.e2e.ts
index 7c160f62b561..7a9d862384c0 100644
--- a/e2e/components/dialog/dialog.e2e.ts
+++ b/e2e/components/dialog/dialog.e2e.ts
@@ -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();
diff --git a/src/demo-app/style/style-demo.html b/src/demo-app/style/style-demo.html
index a3aee1f7bb3f..66c6ad6641dd 100644
--- a/src/demo-app/style/style-demo.html
+++ b/src/demo-app/style/style-demo.html
@@ -2,6 +2,7 @@
+
diff --git a/src/demo-app/style/style-demo.scss b/src/demo-app/style/style-demo.scss
index a04570ce0dee..95b98232e98f 100644
--- a/src/demo-app/style/style-demo.scss
+++ b/src/demo-app/style/style-demo.scss
@@ -13,3 +13,7 @@
.demo-button.cdk-program-focused {
background: blue;
}
+
+.demo-button.cdk-touch-focused {
+ background: purple;
+}
diff --git a/src/lib/button-toggle/_button-toggle-theme.scss b/src/lib/button-toggle/_button-toggle-theme.scss
index 995677eac65e..4b7f6c9309f3 100644
--- a/src/lib/button-toggle/_button-toggle-theme.scss
+++ b/src/lib/button-toggle/_button-toggle-theme.scss
@@ -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);
+ }
}
}
diff --git a/src/lib/core/style/focus-classes.spec.ts b/src/lib/core/style/focus-classes.spec.ts
index 2ef8c0e9fd9c..02cd142a7507 100644
--- a/src/lib/core/style/focus-classes.spec.ts
+++ b/src/lib/core/style/focus-classes.spec.ts
@@ -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;
@@ -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();
@@ -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();
@@ -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();
@@ -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();
@@ -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();
@@ -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) {
diff --git a/src/lib/core/style/focus-classes.ts b/src/lib/core/style/focus-classes.ts
index e8f0057f0ff7..9eefdf85dbc9 100644
--- a/src/lib/core/style/focus-classes.ts
+++ b/src/lib/core/style/focus-classes.ts
@@ -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. */
@@ -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.
@@ -38,7 +69,8 @@ export class FocusOriginMonitor {
/** Register an element to receive focus classes. */
registerElementForFocusClasses(element: Element, renderer: Renderer): Observable {
let subject = new Subject();
- 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();
}
@@ -55,27 +87,56 @@ 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:
+ //
+ //
+ //
+ //
+ //
+ // 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) {
+ private _onFocus(event: Event, element: Element, renderer: Renderer,
+ subject: Subject) {
// 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;
}
@@ -83,6 +144,7 @@ export class FocusOriginMonitor {
/** Handles blur events on a registered element. */
private _onBlur(element: Element, renderer: Renderer, subject: Subject) {
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);
diff --git a/src/lib/dialog/dialog-container.ts b/src/lib/dialog/dialog-container.ts
index 06576496cbde..c9948e53b7b4 100644
--- a/src/lib/dialog/dialog-container.ts
+++ b/src/lib/dialog/dialog-container.ts
@@ -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,
})
@@ -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
diff --git a/src/lib/dialog/dialog-ref.ts b/src/lib/dialog/dialog-ref.ts
index bd9a45df84ac..bbbd31664792 100644
--- a/src/lib/dialog/dialog-ref.ts
+++ b/src/lib/dialog/dialog-ref.ts
@@ -1,4 +1,5 @@
import {OverlayRef} from '../core';
+import {MdDialogConfig} from './dialog-config';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
@@ -17,7 +18,7 @@ export class MdDialogRef {
/** Subject for notifying the user that the dialog has finished closing. */
private _afterClosed: Subject = new Subject();
- constructor(private _overlayRef: OverlayRef) { }
+ constructor(private _overlayRef: OverlayRef, public config: MdDialogConfig) { }
/**
* Close the dialog.
diff --git a/src/lib/dialog/dialog.spec.ts b/src/lib/dialog/dialog.spec.ts
index 103d0474a7e3..dbb02ac81aed 100644
--- a/src/lib/dialog/dialog.spec.ts
+++ b/src/lib/dialog/dialog.spec.ts
@@ -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', () => {
@@ -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();
});
@@ -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();
});
@@ -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);
+}
diff --git a/src/lib/dialog/dialog.ts b/src/lib/dialog/dialog.ts
index a4bd05585fe1..90cba743464a 100644
--- a/src/lib/dialog/dialog.ts
+++ b/src/lib/dialog/dialog.ts
@@ -3,6 +3,7 @@ import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
import {Overlay, OverlayRef, ComponentType, OverlayState, ComponentPortal} from '../core';
import {extendObject} from '../core/util/object-extend';
+import {ESCAPE} from '../core/keyboard/keycodes';
import {DialogInjector} from './dialog-injector';
import {MdDialogConfig} from './dialog-config';
import {MdDialogRef} from './dialog-ref';
@@ -22,6 +23,7 @@ export class MdDialog {
private _openDialogsAtThisLevel: MdDialogRef[] = [];
private _afterAllClosedAtThisLevel = new Subject();
private _afterOpenAtThisLevel = new Subject>();
+ private _boundKeydown = this._handleKeydown.bind(this);
/** Keeps track of the currently-open dialogs. */
get _openDialogs(): MdDialogRef[] {
@@ -65,6 +67,10 @@ export class MdDialog {
let dialogRef =
this._attachDialogContent(componentOrTemplateRef, dialogContainer, overlayRef, config);
+ if (!this._openDialogs.length && !this._parentDialog) {
+ document.addEventListener('keydown', this._boundKeydown);
+ }
+
this._openDialogs.push(dialogRef);
dialogRef.afterClosed().subscribe(() => this._removeOpenDialog(dialogRef));
this._afterOpen.next(dialogRef);
@@ -129,7 +135,7 @@ export class MdDialog {
config?: MdDialogConfig): MdDialogRef {
// Create a reference to the dialog we're creating in order to give the user a handle
// to modify and close it.
- let dialogRef = > new MdDialogRef(overlayRef);
+ let dialogRef = > new MdDialogRef(overlayRef, config);
if (!config.disableClose) {
// When the dialog backdrop is clicked, we want to close it.
@@ -199,9 +205,22 @@ export class MdDialog {
// no open dialogs are left, call next on afterAllClosed Subject
if (!this._openDialogs.length) {
this._afterAllClosed.next();
+ document.removeEventListener('keydown', this._boundKeydown);
}
}
}
+
+ /**
+ * Handles global key presses while there are open dialogs. Closes the
+ * top dialog when the user presses escape.
+ */
+ private _handleKeydown(event: KeyboardEvent): void {
+ let topDialog = this._openDialogs[this._openDialogs.length - 1];
+
+ if (event.keyCode === ESCAPE && topDialog && !topDialog.config.disableClose) {
+ topDialog.close();
+ }
+ }
}
/**
diff --git a/src/lib/sidenav/sidenav-transitions.scss b/src/lib/sidenav/sidenav-transitions.scss
index 6dcdd22814a2..d9222e5da47a 100644
--- a/src/lib/sidenav/sidenav-transitions.scss
+++ b/src/lib/sidenav/sidenav-transitions.scss
@@ -1,18 +1,20 @@
// Separate transitions to be able to disable them in unit tests by omitting this file.
@import '../core/style/variables';
-.mat-sidenav {
- transition: transform $swift-ease-out-duration $swift-ease-out-timing-function;
-}
+.mat-sidenav-transition {
+ .mat-sidenav {
+ transition: transform $swift-ease-out-duration $swift-ease-out-timing-function;
+ }
-.mat-sidenav-content {
- transition: {
- duration: $swift-ease-out-duration;
- timing-function: $swift-ease-out-timing-function;
- property: transform, margin-left, margin-right;
+ .mat-sidenav-content {
+ transition: {
+ duration: $swift-ease-out-duration;
+ timing-function: $swift-ease-out-timing-function;
+ property: transform, margin-left, margin-right;
+ }
}
-}
-.mat-sidenav-backdrop.mat-sidenav-shown {
- transition: background-color $swift-ease-out-duration $swift-ease-out-timing-function;
+ .mat-sidenav-backdrop.mat-sidenav-shown {
+ transition: background-color $swift-ease-out-duration $swift-ease-out-timing-function;
+ }
}
diff --git a/src/lib/sidenav/sidenav.ts b/src/lib/sidenav/sidenav.ts
index cb17778c86d5..b1abe6ffe7ff 100644
--- a/src/lib/sidenav/sidenav.ts
+++ b/src/lib/sidenav/sidenav.ts
@@ -11,11 +11,13 @@ import {
EventEmitter,
Renderer,
ViewEncapsulation,
- ViewChild
+ ViewChild,
+ NgZone
} from '@angular/core';
import {Dir, MdError, coerceBooleanProperty} from '../core';
import {FocusTrap} from '../core/a11y/focus-trap';
import {ESCAPE} from '../core/keyboard/keycodes';
+import 'rxjs/add/operator/first';
/** Exception thrown when two MdSidenav are matching the same side. */
@@ -316,6 +318,7 @@ export class MdSidenav implements AfterContentInit {
],
host: {
'[class.mat-sidenav-container]': 'true',
+ '[class.mat-sidenav-transition]': '_enableTransitions',
},
encapsulation: ViewEncapsulation.None,
})
@@ -344,8 +347,11 @@ export class MdSidenavContainer implements AfterContentInit {
private _left: MdSidenav;
private _right: MdSidenav;
+ /** Whether to enable open/close trantions. */
+ _enableTransitions = false;
+
constructor(@Optional() private _dir: Dir, private _element: ElementRef,
- private _renderer: Renderer) {
+ private _renderer: Renderer, private _ngZone: NgZone) {
// If a `Dir` directive exists up the tree, listen direction changes and update the left/right
// properties to point to the proper start/end.
if (_dir != null) {
@@ -361,6 +367,9 @@ export class MdSidenavContainer implements AfterContentInit {
this._watchSidenavAlign(sidenav);
});
this._validateDrawers();
+
+ // Give the view a chance to render the initial state, then enable transitions.
+ this._ngZone.onMicrotaskEmpty.first().subscribe(() => this._enableTransitions = true);
}
/**