diff --git a/src/components/sidenav/sidenav.js b/src/components/sidenav/sidenav.js index dd4bc9b82f4..6c4ee13813c 100644 --- a/src/components/sidenav/sidenav.js +++ b/src/components/sidenav/sidenav.js @@ -243,8 +243,8 @@ function SidenavFocusDirective() { * - `` * - `` (locks open on small screens) */ -function SidenavDirective($mdMedia, $mdUtil, $mdConstant, $mdTheming, - $animate, $compile, $parse, $log, $q, $document, $window) { +function SidenavDirective($mdMedia, $mdUtil, $mdConstant, $mdTheming, $mdInteraction, $animate, $compile, + $parse, $log, $q, $document, $window) { return { restrict: 'E', scope: { @@ -265,6 +265,7 @@ function SidenavDirective($mdMedia, $mdUtil, $mdConstant, $mdTheming, var lastParentOverFlow; var backdrop; var disableScrollTarget = null; + var triggeringInteractionType; var triggeringElement = null; var previousContainerStyles; var promise = $q.when(true); @@ -356,6 +357,7 @@ function SidenavDirective($mdMedia, $mdUtil, $mdConstant, $mdTheming, if ( isOpen ) { // Capture upon opening.. triggeringElement = $document[0].activeElement; + triggeringInteractionType = $mdInteraction.getLastInteractionType(); } disableParentScroll(isOpen); @@ -455,9 +457,9 @@ function SidenavDirective($mdMedia, $mdUtil, $mdConstant, $mdTheming, // When the current `updateIsOpen()` animation finishes promise.then(function(result) { - if ( !scope.isOpen ) { + if ( !scope.isOpen && triggeringElement && triggeringInteractionType === 'keyboard') { // reset focus to originating element (if available) upon close - triggeringElement && triggeringElement.focus(); + triggeringElement.focus(); triggeringElement = null; } diff --git a/src/components/sidenav/sidenav.spec.js b/src/components/sidenav/sidenav.spec.js index 21ad211c3ce..815414720ec 100644 --- a/src/components/sidenav/sidenav.spec.js +++ b/src/components/sidenav/sidenav.spec.js @@ -256,6 +256,82 @@ describe('mdSidenav', function() { }); + describe("focus", function() { + + var $material, $mdInteraction, $mdConstant; + var triggerElement; + + beforeEach(inject(function($injector) { + $material = $injector.get('$material'); + $mdInteraction = $injector.get('$mdInteraction'); + $mdConstant = $injector.get('$mdInteraction'); + + triggerElement = angular.element(''); + document.body.appendChild(triggerElement[0]); + })); + + afterEach(function() { + triggerElement.remove(); + }); + + function dispatchEvent(eventName) { + angular.element(document.body).triggerHandler(eventName); + } + + function flush() { + $material.flushInterimElement(); + } + + function blur() { + if ('documentMode' in document) { + document.body.focus(); + } else { + triggerElement.blur(); + } + } + + it("should restore after sidenav triggered by keyboard", function() { + var sidenavEl = setup(''); + var controller = sidenavEl.controller('mdSidenav'); + + triggerElement.focus(); + + dispatchEvent('keydown'); + + controller.$toggleOpen(true); + flush(); + + blur(); + + controller.$toggleOpen(false); + flush(); + + expect($mdInteraction.getLastInteractionType()).toBe("keyboard"); + expect(document.activeElement).toBe(triggerElement[0]); + }); + + it("should not restore after sidenav triggered by mouse", function() { + var sidenavEl = setup(''); + var controller = sidenavEl.controller('mdSidenav'); + + triggerElement.focus(); + + dispatchEvent('mousedown'); + + controller.$toggleOpen(true); + flush(); + + blur(); + + controller.$toggleOpen(false); + flush(); + + expect($mdInteraction.getLastInteractionType()).toBe("mouse"); + expect(document.activeElement).not.toBe(triggerElement[0]); + }); + + }); + describe("controller Promise API", function() { var $material, $rootScope, $timeout; diff --git a/src/core/core.js b/src/core/core.js index 7383a891544..6fea6dd2bdc 100644 --- a/src/core/core.js +++ b/src/core/core.js @@ -7,6 +7,7 @@ angular 'ngAnimate', 'material.core.animate', 'material.core.layout', + 'material.core.interaction', 'material.core.gestures', 'material.core.theming' ]) diff --git a/src/core/services/interaction/interaction.js b/src/core/services/interaction/interaction.js new file mode 100644 index 00000000000..e39211b7b80 --- /dev/null +++ b/src/core/services/interaction/interaction.js @@ -0,0 +1,132 @@ +/** + * @ngdoc module + * @name material.core.interaction + * @description + * User interaction detection to provide proper accessibility. + */ +angular + .module('material.core.interaction', []) + .service('$mdInteraction', MdInteractionService); + + +/** + * @ngdoc service + * @name $mdInteraction + * @module material.core.interaction + * + * @description + * + * Service which keeps track of the last interaction type and validates them for several browsers. + * The service hooks into the document's body and listens for touch, mouse and keyboard events. + * + * The most recent interaction type can be retrieved by calling the `getLastInteractionType` method. + * + * Here is an example markup for using the interaction service. + * + * + * var lastType = $mdInteraction.getLastInteractionType(); + * + * if (lastType === 'keyboard') { + * // We only restore the focus for keyboard users. + * restoreFocus(); + * } + * + * + */ +function MdInteractionService($timeout) { + this.$timeout = $timeout; + + this.bodyElement = angular.element(document.body); + this.isBuffering = false; + this.bufferTimeout = null; + this.lastInteractionType = null; + + // Type Mappings for the different events + // There will be three three interaction types + // `keyboard`, `mouse` and `touch` + // type `pointer` will be evaluated in `pointerMap` for IE Browser events + this.inputEventMap = { + 'keydown': 'keyboard', + 'mousedown': 'mouse', + 'mouseenter': 'mouse', + 'touchstart': 'touch', + 'pointerdown': 'pointer', + 'MSPointerDown': 'pointer' + }; + + // IE PointerDown events will be validated in `touch` or `mouse` + // Index numbers referenced here: https://msdn.microsoft.com/library/windows/apps/hh466130.aspx + this.iePointerMap = { + 2: 'touch', + 3: 'touch', + 4: 'mouse' + }; + + this.initializeEvents(); +} + +/** + * Initializes the interaction service, by registering all interaction events to the + * body element. + */ +MdInteractionService.prototype.initializeEvents = function() { + // IE browsers can also trigger pointer events, which also leads to an interaction. + var pointerEvent = 'MSPointerEvent' in window ? 'MSPointerDown' : 'PointerEvent' in window ? 'pointerdown' : null; + + this.bodyElement.on('keydown mousedown', this.onInputEvent.bind(this)); + + if ('ontouchstart' in document.documentElement) { + this.bodyElement.on('touchstart', this.onBufferInputEvent.bind(this)); + } + + if (pointerEvent) { + this.bodyElement.on(pointerEvent, this.onInputEvent.bind(this)); + } + +}; + +/** + * Event listener for normal interaction events, which should be tracked. + * @param event {MouseEvent|KeyboardEvent|PointerEvent} + */ +MdInteractionService.prototype.onInputEvent = function(event) { + if (this.isBuffering) { + return; + } + + var type = this.inputEventMap[event.type]; + + if (type === 'pointer') { + type = this.iePointerMap[event.pointerType] || event.pointerType; + } + + this.lastInteractionType = type; +}; + +/** + * Event listener for interaction events which should be buffered (touch events). + * @param event {TouchEvent} + */ +MdInteractionService.prototype.onBufferInputEvent = function(event) { + this.$timeout.cancel(this.bufferTimeout); + + this.onInputEvent(event); + this.isBuffering = true; + + // The timeout of 650ms is needed to delay the touchstart, because otherwise the touch will call + // the `onInput` function multiple times. + this.bufferTimeout = this.$timeout(function() { + this.isBuffering = false; + }.bind(this), 650, false); + +}; + +/** + * @ngdoc method + * @name $mdInteraction#getLastInteractionType + * @description Retrieves the last interaction type triggered in body. + * @returns {string|null} Last interaction type. + */ +MdInteractionService.prototype.getLastInteractionType = function() { + return this.lastInteractionType; +}; \ No newline at end of file diff --git a/src/core/services/interaction/interaction.spec.js b/src/core/services/interaction/interaction.spec.js new file mode 100644 index 00000000000..0344efed340 --- /dev/null +++ b/src/core/services/interaction/interaction.spec.js @@ -0,0 +1,33 @@ +describe("$mdInteraction service", function() { + var $mdInteraction; + + beforeEach(module('material.core')); + + beforeEach(inject(function($injector) { + $mdInteraction = $injector.get('$mdInteraction'); + })); + + describe("last interaction type", function() { + + var bodyElement = null; + + beforeEach(function() { + bodyElement = angular.element(document.body); + }); + + it("should detect a keyboard interaction", function() { + + bodyElement.triggerHandler('keydown'); + + expect($mdInteraction.getLastInteractionType()).toBe('keyboard'); + }); + + it("should detect a mouse interaction", function() { + + bodyElement.triggerHandler('mousedown'); + + expect($mdInteraction.getLastInteractionType()).toBe("mouse"); + }); + + }); +}); \ No newline at end of file