Skip to content
This repository was archived by the owner on Sep 5, 2024. It is now read-only.

Commit 8e8c6a6

Browse files
committed
feat(interaction): added service to detect last interaction
Fixes #5563 Fixes #5434 Closes #5583
1 parent 72d0685 commit 8e8c6a6

File tree

5 files changed

+248
-4
lines changed

5 files changed

+248
-4
lines changed

src/components/sidenav/sidenav.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,8 @@ function SidenavFocusDirective() {
243243
* - `<md-sidenav md-is-locked-open="$mdMedia('min-width: 1000px')"></md-sidenav>`
244244
* - `<md-sidenav md-is-locked-open="$mdMedia('sm')"></md-sidenav>` (locks open on small screens)
245245
*/
246-
function SidenavDirective($mdMedia, $mdUtil, $mdConstant, $mdTheming,
247-
$animate, $compile, $parse, $log, $q, $document, $window) {
246+
function SidenavDirective($mdMedia, $mdUtil, $mdConstant, $mdTheming, $mdInteraction, $animate, $compile,
247+
$parse, $log, $q, $document, $window) {
248248
return {
249249
restrict: 'E',
250250
scope: {
@@ -265,6 +265,7 @@ function SidenavDirective($mdMedia, $mdUtil, $mdConstant, $mdTheming,
265265
var lastParentOverFlow;
266266
var backdrop;
267267
var disableScrollTarget = null;
268+
var triggeringInteractionType;
268269
var triggeringElement = null;
269270
var previousContainerStyles;
270271
var promise = $q.when(true);
@@ -356,6 +357,7 @@ function SidenavDirective($mdMedia, $mdUtil, $mdConstant, $mdTheming,
356357
if ( isOpen ) {
357358
// Capture upon opening..
358359
triggeringElement = $document[0].activeElement;
360+
triggeringInteractionType = $mdInteraction.getLastInteractionType();
359361
}
360362

361363
disableParentScroll(isOpen);
@@ -455,9 +457,9 @@ function SidenavDirective($mdMedia, $mdUtil, $mdConstant, $mdTheming,
455457
// When the current `updateIsOpen()` animation finishes
456458
promise.then(function(result) {
457459

458-
if ( !scope.isOpen ) {
460+
if ( !scope.isOpen && triggeringElement && triggeringInteractionType === 'keyboard') {
459461
// reset focus to originating element (if available) upon close
460-
triggeringElement && triggeringElement.focus();
462+
triggeringElement.focus();
461463
triggeringElement = null;
462464
}
463465

src/components/sidenav/sidenav.spec.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,82 @@ describe('mdSidenav', function() {
256256

257257
});
258258

259+
describe("focus", function() {
260+
261+
var $material, $mdInteraction, $mdConstant;
262+
var triggerElement;
263+
264+
beforeEach(inject(function($injector) {
265+
$material = $injector.get('$material');
266+
$mdInteraction = $injector.get('$mdInteraction');
267+
$mdConstant = $injector.get('$mdInteraction');
268+
269+
triggerElement = angular.element('<button>Trigger Element</button>');
270+
document.body.appendChild(triggerElement[0]);
271+
}));
272+
273+
afterEach(function() {
274+
triggerElement.remove();
275+
});
276+
277+
function dispatchEvent(eventName) {
278+
angular.element(document.body).triggerHandler(eventName);
279+
}
280+
281+
function flush() {
282+
$material.flushInterimElement();
283+
}
284+
285+
function blur() {
286+
if ('documentMode' in document) {
287+
document.body.focus();
288+
} else {
289+
triggerElement.blur();
290+
}
291+
}
292+
293+
it("should restore after sidenav triggered by keyboard", function() {
294+
var sidenavEl = setup('');
295+
var controller = sidenavEl.controller('mdSidenav');
296+
297+
triggerElement.focus();
298+
299+
dispatchEvent('keydown');
300+
301+
controller.$toggleOpen(true);
302+
flush();
303+
304+
blur();
305+
306+
controller.$toggleOpen(false);
307+
flush();
308+
309+
expect($mdInteraction.getLastInteractionType()).toBe("keyboard");
310+
expect(document.activeElement).toBe(triggerElement[0]);
311+
});
312+
313+
it("should not restore after sidenav triggered by mouse", function() {
314+
var sidenavEl = setup('');
315+
var controller = sidenavEl.controller('mdSidenav');
316+
317+
triggerElement.focus();
318+
319+
dispatchEvent('mousedown');
320+
321+
controller.$toggleOpen(true);
322+
flush();
323+
324+
blur();
325+
326+
controller.$toggleOpen(false);
327+
flush();
328+
329+
expect($mdInteraction.getLastInteractionType()).toBe("mouse");
330+
expect(document.activeElement).not.toBe(triggerElement[0]);
331+
});
332+
333+
});
334+
259335
describe("controller Promise API", function() {
260336
var $material, $rootScope, $timeout;
261337

src/core/core.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ angular
77
'ngAnimate',
88
'material.core.animate',
99
'material.core.layout',
10+
'material.core.interaction',
1011
'material.core.gestures',
1112
'material.core.theming'
1213
])
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* @ngdoc module
3+
* @name material.core.interaction
4+
* @description
5+
* User interaction detection to provide proper accessibility.
6+
*/
7+
angular
8+
.module('material.core.interaction', [])
9+
.service('$mdInteraction', MdInteractionService);
10+
11+
12+
/**
13+
* @ngdoc service
14+
* @name $mdInteraction
15+
* @module material.core.interaction
16+
*
17+
* @description
18+
*
19+
* Service which keeps track of the last interaction type and validates them for several browsers.
20+
* The service hooks into the document's body and listens for touch, mouse and keyboard events.
21+
*
22+
* The most recent interaction type can be retrieved by calling the `getLastInteractionType` method.
23+
*
24+
* Here is an example markup for using the interaction service.
25+
*
26+
* <hljs lang="js">
27+
* var lastType = $mdInteraction.getLastInteractionType();
28+
*
29+
* if (lastType === 'keyboard') {
30+
* // We only restore the focus for keyboard users.
31+
* restoreFocus();
32+
* }
33+
* </hljs>
34+
*
35+
*/
36+
function MdInteractionService($timeout) {
37+
this.$timeout = $timeout;
38+
39+
this.bodyElement = angular.element(document.body);
40+
this.isBuffering = false;
41+
this.bufferTimeout = null;
42+
this.lastInteractionType = null;
43+
44+
// Type Mappings for the different events
45+
// There will be three three interaction types
46+
// `keyboard`, `mouse` and `touch`
47+
// type `pointer` will be evaluated in `pointerMap` for IE Browser events
48+
this.inputEventMap = {
49+
'keydown': 'keyboard',
50+
'mousedown': 'mouse',
51+
'mouseenter': 'mouse',
52+
'touchstart': 'touch',
53+
'pointerdown': 'pointer',
54+
'MSPointerDown': 'pointer'
55+
};
56+
57+
// IE PointerDown events will be validated in `touch` or `mouse`
58+
// Index numbers referenced here: https://msdn.microsoft.com/library/windows/apps/hh466130.aspx
59+
this.iePointerMap = {
60+
2: 'touch',
61+
3: 'touch',
62+
4: 'mouse'
63+
};
64+
65+
this.initializeEvents();
66+
}
67+
68+
/**
69+
* Initializes the interaction service, by registering all interaction events to the
70+
* body element.
71+
*/
72+
MdInteractionService.prototype.initializeEvents = function() {
73+
// IE browsers can also trigger pointer events, which also leads to an interaction.
74+
var pointerEvent = 'MSPointerEvent' in window ? 'MSPointerDown' : 'PointerEvent' in window ? 'pointerdown' : null;
75+
76+
this.bodyElement.on('keydown mousedown', this.onInputEvent.bind(this));
77+
78+
if ('ontouchstart' in document.documentElement) {
79+
this.bodyElement.on('touchstart', this.onBufferInputEvent.bind(this));
80+
}
81+
82+
if (pointerEvent) {
83+
this.bodyElement.on(pointerEvent, this.onInputEvent.bind(this));
84+
}
85+
86+
};
87+
88+
/**
89+
* Event listener for normal interaction events, which should be tracked.
90+
* @param event {MouseEvent|KeyboardEvent|PointerEvent}
91+
*/
92+
MdInteractionService.prototype.onInputEvent = function(event) {
93+
if (this.isBuffering) {
94+
return;
95+
}
96+
97+
var type = this.inputEventMap[event.type];
98+
99+
if (type === 'pointer') {
100+
type = this.iePointerMap[event.pointerType] || event.pointerType;
101+
}
102+
103+
this.lastInteractionType = type;
104+
};
105+
106+
/**
107+
* Event listener for interaction events which should be buffered (touch events).
108+
* @param event {TouchEvent}
109+
*/
110+
MdInteractionService.prototype.onBufferInputEvent = function(event) {
111+
this.$timeout.cancel(this.bufferTimeout);
112+
113+
this.onInputEvent(event);
114+
this.isBuffering = true;
115+
116+
// The timeout of 650ms is needed to delay the touchstart, because otherwise the touch will call
117+
// the `onInput` function multiple times.
118+
this.bufferTimeout = this.$timeout(function() {
119+
this.isBuffering = false;
120+
}.bind(this), 650, false);
121+
122+
};
123+
124+
/**
125+
* @ngdoc method
126+
* @name $mdInteraction#getLastInteractionType
127+
* @description Retrieves the last interaction type triggered in body.
128+
* @returns {string|null} Last interaction type.
129+
*/
130+
MdInteractionService.prototype.getLastInteractionType = function() {
131+
return this.lastInteractionType;
132+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
describe("$mdInteraction service", function() {
2+
var $mdInteraction;
3+
4+
beforeEach(module('material.core'));
5+
6+
beforeEach(inject(function($injector) {
7+
$mdInteraction = $injector.get('$mdInteraction');
8+
}));
9+
10+
describe("last interaction type", function() {
11+
12+
var bodyElement = null;
13+
14+
beforeEach(function() {
15+
bodyElement = angular.element(document.body);
16+
});
17+
18+
it("should detect a keyboard interaction", function() {
19+
20+
bodyElement.triggerHandler('keydown');
21+
22+
expect($mdInteraction.getLastInteractionType()).toBe('keyboard');
23+
});
24+
25+
it("should detect a mouse interaction", function() {
26+
27+
bodyElement.triggerHandler('mousedown');
28+
29+
expect($mdInteraction.getLastInteractionType()).toBe("mouse");
30+
});
31+
32+
});
33+
});

0 commit comments

Comments
 (0)