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); } /**