aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Gruntfile.js8
-rw-r--r--angularFiles.js11
-rw-r--r--src/ngMobile/directive/ngClick.js244
-rw-r--r--src/ngMobile/mobile.js16
-rw-r--r--src/ngScenario/Scenario.js8
-rw-r--r--test/ngMobile/directive/ngClickSpec.js295
6 files changed, 578 insertions, 4 deletions
diff --git a/Gruntfile.js b/Gruntfile.js
index eea8b8ab..8facd45f 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -85,6 +85,13 @@ module.exports = function(grunt) {
dest: 'build/angular-loader.js',
src: util.wrap(['src/loader.js'], 'loader')
},
+ mobile: {
+ dest: 'build/angular-mobile.js',
+ src: util.wrap([
+ 'src/ngMobile/mobile.js',
+ 'src/ngMobile/directive/ngClick.js'
+ ], 'module')
+ },
mocks: {
dest: 'build/angular-mocks.js',
src: ['src/ngMock/angular-mocks.js'],
@@ -125,6 +132,7 @@ module.exports = function(grunt) {
angular: 'build/angular.js',
cookies: 'build/angular-cookies.js',
loader: 'build/angular-loader.js',
+ mobile: 'build/angular-mobile.js',
resource: 'build/angular-resource.js',
sanitize: 'build/angular-sanitize.js',
bootstrap: 'build/angular-bootstrap.js',
diff --git a/angularFiles.js b/angularFiles.js
index 47f18961..44614a8c 100644
--- a/angularFiles.js
+++ b/angularFiles.js
@@ -69,6 +69,8 @@ angularFiles = {
'src/ngSanitize/directive/ngBindHtml.js',
'src/ngSanitize/filter/linky.js',
'src/ngMock/angular-mocks.js',
+ 'src/ngMobile/mobile.js',
+ 'src/ngMobile/directive/ngClick.js',
'src/bootstrap/bootstrap.js'
],
@@ -106,7 +108,8 @@ angularFiles = {
'test/ngSanitize/*.js',
'test/ngSanitize/directive/*.js',
'test/ngSanitize/filter/*.js',
- 'test/ngMock/*.js'
+ 'test/ngMock/*.js',
+ 'test/ngMobile/directive/*.js'
],
'jstd': [
@@ -141,9 +144,12 @@ angularFiles = {
'lib/jasmine/jasmine.js',
'lib/jasmine-jstd-adapter/JasmineAdapter.js',
'build/angular.js',
+ 'build/angular-scenario.js',
'src/ngMock/angular-mocks.js',
'src/ngCookies/cookies.js',
'src/ngResource/resource.js',
+ 'src/ngMobile/mobile.js',
+ 'src/ngMobile/directive/ngClick.js',
'src/ngSanitize/sanitize.js',
'src/ngSanitize/directive/ngBindHtml.js',
'src/ngSanitize/filter/linky.js',
@@ -153,7 +159,8 @@ angularFiles = {
'test/ngResource/*.js',
'test/ngSanitize/*.js',
'test/ngSanitize/directive/*.js',
- 'test/ngSanitize/filter/*.js'
+ 'test/ngSanitize/filter/*.js',
+ 'test/ngMobile/directive/*.js'
],
'jstdPerf': [
diff --git a/src/ngMobile/directive/ngClick.js b/src/ngMobile/directive/ngClick.js
new file mode 100644
index 00000000..9b8e225c
--- /dev/null
+++ b/src/ngMobile/directive/ngClick.js
@@ -0,0 +1,244 @@
+'use strict';
+
+/**
+ * @ngdoc directive
+ * @name ngMobile.directive:ngTap
+ *
+ * @description
+ * Specify custom behavior when element is tapped on a touchscreen device.
+ * A tap is a brief, down-and-up touch without much motion.
+ *
+ * @element ANY
+ * @param {expression} ngClick {@link guide/expression Expression} to evaluate
+ * upon tap. (Event object is available as `$event`)
+ *
+ * @example
+ <doc:example>
+ <doc:source>
+ <button ng-tap="count = count + 1" ng-init="count=0">
+ Increment
+ </button>
+ count: {{ count }}
+ </doc:source>
+ </doc:example>
+ */
+
+ngMobile.config(function($provide) {
+ $provide.decorator('ngClickDirective', function($delegate) {
+ // drop the default ngClick directive
+ $delegate.shift();
+ return $delegate;
+ });
+});
+
+ngMobile.directive('ngClick', ['$parse', '$timeout', '$rootElement',
+ function($parse, $timeout, $rootElement) {
+ var TAP_DURATION = 750; // Shorter than 750ms is a tap, longer is a taphold or drag.
+ var MOVE_TOLERANCE = 12; // 12px seems to work in most mobile browsers.
+ var PREVENT_DURATION = 2500; // 2.5 seconds maximum from preventGhostClick call to click
+ var CLICKBUSTER_THRESHOLD = 25; // 25 pixels in any dimension is the limit for busting clicks.
+ var lastPreventedTime;
+ var touchCoordinates;
+
+
+ // TAP EVENTS AND GHOST CLICKS
+ //
+ // Why tap events?
+ // Mobile browsers detect a tap, then wait a moment (usually ~300ms) to see if you're
+ // double-tapping, and then fire a click event.
+ //
+ // This delay sucks and makes mobile apps feel unresponsive.
+ // So we detect touchstart, touchmove, touchcancel and touchend ourselves and determine when
+ // the user has tapped on something.
+ //
+ // What happens when the browser then generates a click event?
+ // The browser, of course, also detects the tap and fires a click after a delay. This results in
+ // tapping/clicking twice. So we do "clickbusting" to prevent it.
+ //
+ // How does it work?
+ // We attach global touchstart and click handlers, that run during the capture (early) phase.
+ // So the sequence for a tap is:
+ // - global touchstart: Sets an "allowable region" at the point touched.
+ // - element's touchstart: Starts a touch
+ // (- touchmove or touchcancel ends the touch, no click follows)
+ // - element's touchend: Determines if the tap is valid (didn't move too far away, didn't hold
+ // too long) and fires the user's tap handler. The touchend also calls preventGhostClick().
+ // - preventGhostClick() removes the allowable region the global touchstart created.
+ // - The browser generates a click event.
+ // - The global click handler catches the click, and checks whether it was in an allowable region.
+ // - If preventGhostClick was called, the region will have been removed, the click is busted.
+ // - If the region is still there, the click proceeds normally. Therefore clicks on links and
+ // other elements without ngTap on them work normally.
+ //
+ // This is an ugly, terrible hack!
+ // Yeah, tell me about it. The alternatives are using the slow click events, or making our users
+ // deal with the ghost clicks, so I consider this the least of evils. Fortunately Angular
+ // encapsulates this ugly logic away from the user.
+ //
+ // Why not just put click handlers on the element?
+ // We do that too, just to be sure. The problem is that the tap event might have caused the DOM
+ // to change, so that the click fires in the same position but something else is there now. So
+ // the handlers are global and care only about coordinates and not elements.
+
+ // Checks if the coordinates are close enough to be within the region.
+ function hit(x1, y1, x2, y2) {
+ return Math.abs(x1 - x2) < CLICKBUSTER_THRESHOLD && Math.abs(y1 - y2) < CLICKBUSTER_THRESHOLD;
+ }
+
+ // Checks a list of allowable regions against a click location.
+ // Returns true if the click should be allowed.
+ // Splices out the allowable region from the list after it has been used.
+ function checkAllowableRegions(touchCoordinates, x, y) {
+ for (var i = 0; i < touchCoordinates.length; i += 2) {
+ if (hit(touchCoordinates[i], touchCoordinates[i+1], x, y)) {
+ touchCoordinates.splice(i, i + 2);
+ return true; // allowable region
+ }
+ }
+ return false; // No allowable region; bust it.
+ }
+
+ // Global click handler that prevents the click if it's in a bustable zone and preventGhostClick
+ // was called recently.
+ function onClick(event) {
+ if (Date.now() - lastPreventedTime > PREVENT_DURATION) {
+ return; // Too old.
+ }
+
+ var touches = event.touches && event.touches.length ? event.touches : [event];
+ var x = touches[0].clientX;
+ var y = touches[0].clientY;
+ // Work around desktop Webkit quirk where clicking a label will fire two clicks (on the label
+ // and on the input element). Depending on the exact browser, this second click we don't want
+ // to bust has either (0,0) or negative coordinates.
+ if (x < 1 && y < 1) {
+ return; // offscreen
+ }
+
+ // Look for an allowable region containing this click.
+ // If we find one, that means it was created by touchstart and not removed by
+ // preventGhostClick, so we don't bust it.
+ if (checkAllowableRegions(touchCoordinates, x, y)) {
+ return;
+ }
+
+ // If we didn't find an allowable region, bust the click.
+ event.stopPropagation();
+ event.preventDefault();
+ }
+
+
+ // Global touchstart handler that creates an allowable region for a click event.
+ // This allowable region can be removed by preventGhostClick if we want to bust it.
+ function onTouchStart(event) {
+ var touches = event.touches && event.touches.length ? event.touches : [event];
+ var x = touches[0].clientX;
+ var y = touches[0].clientY;
+ touchCoordinates.push(x, y);
+
+ $timeout(function() {
+ // Remove the allowable region.
+ for (var i = 0; i < touchCoordinates.length; i += 2) {
+ if (touchCoordinates[i] == x && touchCoordinates[i+1] == y) {
+ touchCoordinates.splice(i, i + 2);
+ return;
+ }
+ }
+ }, PREVENT_DURATION, false);
+ }
+
+ // On the first call, attaches some event handlers. Then whenever it gets called, it creates a
+ // zone around the touchstart where clicks will get busted.
+ function preventGhostClick(x, y) {
+ if (!touchCoordinates) {
+ $rootElement[0].addEventListener('click', onClick, true);
+ $rootElement[0].addEventListener('touchstart', onTouchStart, true);
+ touchCoordinates = [];
+ }
+
+ lastPreventedTime = Date.now();
+
+ checkAllowableRegions(touchCoordinates, x, y);
+ }
+
+ // Actual linking function.
+ return function(scope, element, attr) {
+ var expressionFn = $parse(attr.ngClick),
+ tapping = false,
+ tapElement, // Used to blur the element after a tap.
+ startTime, // Used to check if the tap was held too long.
+ touchStartX,
+ touchStartY;
+
+ function resetState() {
+ tapping = false;
+ }
+
+ element.bind('touchstart', function(event) {
+ tapping = true;
+ tapElement = event.target ? event.target : event.srcElement; // IE uses srcElement.
+ // Hack for Safari, which can target text nodes instead of containers.
+ if(tapElement.nodeType == 3) {
+ tapElement = tapElement.parentNode;
+ }
+
+ startTime = Date.now();
+
+ var touches = event.touches && event.touches.length ? event.touches : [event];
+ var e = touches[0].originalEvent || touches[0];
+ touchStartX = e.clientX;
+ touchStartY = e.clientY;
+ });
+
+ element.bind('touchmove', function(event) {
+ resetState();
+ });
+
+ element.bind('touchcancel', function(event) {
+ resetState();
+ });
+
+ element.bind('touchend', function(event) {
+ var diff = Date.now() - startTime;
+
+ var touches = (event.changedTouches && event.changedTouches.length) ? event.changedTouches :
+ ((event.touches && event.touches.length) ? event.touches : [event]);
+ var e = touches[0].originalEvent || touches[0];
+ var x = e.clientX;
+ var y = e.clientY;
+ var dist = Math.sqrt( Math.pow(x - touchStartX, 2) + Math.pow(y - touchStartY, 2) );
+
+ if (tapping && diff < TAP_DURATION && dist < MOVE_TOLERANCE) {
+ // Call preventGhostClick so the clickbuster will catch the corresponding click.
+ preventGhostClick(x, y);
+
+ // Blur the focused element (the button, probably) before firing the callback.
+ // This doesn't work perfectly on Android Chrome, but seems to work elsewhere.
+ // I couldn't get anything to work reliably on Android Chrome.
+ if (tapElement) {
+ tapElement.blur();
+ }
+
+ scope.$apply(function() {
+ // TODO(braden): This is sending the touchend, not a tap or click. Is that kosher?
+ expressionFn(scope, {$event: event});
+ });
+ }
+ tapping = false;
+ });
+
+ // Hack for iOS Safari's benefit. It goes searching for onclick handlers and is liable to click
+ // something else nearby.
+ element.onclick = function(event) { };
+
+ // Fallback click handler.
+ // Busted clicks don't get this far, and adding this handler allows ng-tap to be used on
+ // desktop as well, to allow more portable sites.
+ element.bind('click', function(event) {
+ scope.$apply(function() {
+ expressionFn(scope, {$event: event});
+ });
+ });
+ };
+}]);
+
diff --git a/src/ngMobile/mobile.js b/src/ngMobile/mobile.js
new file mode 100644
index 00000000..c8fd8843
--- /dev/null
+++ b/src/ngMobile/mobile.js
@@ -0,0 +1,16 @@
+'use strict';
+
+/**
+ * @ngdoc overview
+ * @name ngMobile
+ * @description
+ */
+
+/*
+ * Touch events and other mobile helpers by Braden Shepherdson (braden.shepherdson@gmail.com)
+ * Based on jQuery Mobile touch event handling (jquerymobile.com)
+ */
+
+// define ngSanitize module and register $sanitize service
+var ngMobile = angular.module('ngMobile', []);
+
diff --git a/src/ngScenario/Scenario.js b/src/ngScenario/Scenario.js
index 4833e629..1f10fc21 100644
--- a/src/ngScenario/Scenario.js
+++ b/src/ngScenario/Scenario.js
@@ -231,8 +231,10 @@ function callerFile(offset) {
* @param {string} type Optional event type.
* @param {Array.<string>=} keys Optional list of pressed keys
* (valid values: 'alt', 'meta', 'shift', 'ctrl')
+ * @param {number} x Optional x-coordinate for mouse/touch events.
+ * @param {number} y Optional y-coordinate for mouse/touch events.
*/
-function browserTrigger(element, type, keys) {
+function browserTrigger(element, type, keys, x, y) {
if (element && !element.nodeName) element = element[0];
if (!element) return;
if (!type) {
@@ -304,7 +306,9 @@ function browserTrigger(element, type, keys) {
return originalPreventDefault.apply(evnt, arguments);
};
- evnt.initMouseEvent(type, true, true, window, 0, 0, 0, 0, 0, pressed('ctrl'), pressed('alt'),
+ x = x || 0;
+ y = y || 0;
+ evnt.initMouseEvent(type, true, true, window, 0, x, y, x, y, pressed('ctrl'), pressed('alt'),
pressed('shift'), pressed('meta'), 0, element);
element.dispatchEvent(evnt);
diff --git a/test/ngMobile/directive/ngClickSpec.js b/test/ngMobile/directive/ngClickSpec.js
new file mode 100644
index 00000000..2f42662d
--- /dev/null
+++ b/test/ngMobile/directive/ngClickSpec.js
@@ -0,0 +1,295 @@
+'use strict';
+
+describe('ngClick (mobile)', function() {
+ var element, time, orig_now;
+
+ // TODO(braden): Once we have other touch-friendly browsers on CI, allow them here.
+ // Currently Firefox and IE refuse to fire touch events.
+ var chrome = /chrome/.test(navigator.userAgent.toLowerCase());
+ if (!chrome) {
+ return;
+ }
+
+ function mockTime() {
+ return time;
+ }
+
+
+ beforeEach(function() {
+ module('ngMobile');
+ orig_now = Date.now;
+ time = 0;
+ Date.now = mockTime;
+ });
+
+ afterEach(function() {
+ dealoc(element);
+ Date.now = orig_now;
+ });
+
+
+ it('should get called on a tap', inject(function($rootScope, $compile) {
+ element = $compile('<div ng-click="tapped = true"></div>')($rootScope);
+ $rootScope.$digest();
+ expect($rootScope.tapped).toBeUndefined();
+
+ browserTrigger(element, 'touchstart');
+ browserTrigger(element, 'touchend');
+ expect($rootScope.tapped).toEqual(true);
+ }));
+
+
+ it('should pass event object', inject(function($rootScope, $compile) {
+ element = $compile('<div ng-click="event = $event"></div>')($rootScope);
+ $rootScope.$digest();
+
+ browserTrigger(element, 'touchstart');
+ browserTrigger(element, 'touchend');
+ expect($rootScope.event).toBeDefined();
+ }));
+
+
+ it('should not click if the touch is held too long', inject(function($rootScope, $compile, $rootElement) {
+ element = $compile('<div ng-click="count = count + 1"></div>')($rootScope);
+ $rootElement.append(element);
+ $rootScope.count = 0;
+ $rootScope.$digest();
+
+ expect($rootScope.count).toBe(0);
+
+ time = 10;
+ browserTrigger(element, 'touchstart', [], 10, 10);
+
+ time = 900;
+ browserTrigger(element, 'touchend', [], 10, 10);
+
+ expect($rootScope.count).toBe(0);
+ }));
+
+
+ it('should not click if the touchend is too far away', inject(function($rootScope, $compile, $rootElement) {
+ element = $compile('<div ng-click="tapped = true"></div>')($rootScope);
+ $rootElement.append(element);
+ $rootScope.$digest();
+
+ expect($rootScope.tapped).toBeUndefined();
+
+ browserTrigger(element, 'touchstart', [], 10, 10);
+ browserTrigger(element, 'touchend', [], 400, 400);
+
+ expect($rootScope.tapped).toBeUndefined();
+ }));
+
+
+ it('should not click if a touchmove comes before touchend', inject(function($rootScope, $compile, $rootElement) {
+ element = $compile('<div ng-tap="tapped = true"></div>')($rootScope);
+ $rootElement.append(element);
+ $rootScope.$digest();
+
+ expect($rootScope.tapped).toBeUndefined();
+
+ browserTrigger(element, 'touchstart', [], 10, 10);
+ browserTrigger(element, 'touchmove');
+ browserTrigger(element, 'touchend', [], 400, 400);
+
+ expect($rootScope.tapped).toBeUndefined();
+ }));
+
+
+ describe('the clickbuster', function() {
+ var element1, element2;
+
+ beforeEach(inject(function($rootElement, $document) {
+ $document.find('body').append($rootElement);
+ }));
+
+ afterEach(inject(function($document) {
+ $document.find('body').html('');
+ }));
+
+
+ it('should cancel the following click event', inject(function($rootScope, $compile, $rootElement, $document) {
+ element = $compile('<div ng-click="count = count + 1"></div>')($rootScope);
+ $rootElement.append(element);
+
+ $rootScope.count = 0;
+ $rootScope.$digest();
+
+ expect($rootScope.count).toBe(0);
+
+ // Fire touchstart at 10ms, touchend at 50ms, the click at 300ms.
+ time = 10;
+ browserTrigger(element, 'touchstart', [], 10, 10);
+
+ time = 50;
+ browserTrigger(element, 'touchend', [], 10, 10);
+
+ expect($rootScope.count).toBe(1);
+
+ time = 100;
+ browserTrigger(element, 'click', [], 10, 10);
+
+ expect($rootScope.count).toBe(1);
+ }));
+
+
+ it('should cancel the following click event even when the element has changed', inject(
+ function($rootScope, $compile, $rootElement) {
+ $rootElement.append(
+ '<div ng-show="!tapped" ng-click="count1 = count1 + 1; tapped = true">x</div>' +
+ '<div ng-show="tapped" ng-click="count2 = count2 + 1">y</div>'
+ );
+ $compile($rootElement)($rootScope);
+
+ element1 = $rootElement.find('div').eq(0);
+ element2 = $rootElement.find('div').eq(1);
+
+ $rootScope.count1 = 0;
+ $rootScope.count2 = 0;
+
+ $rootScope.$digest();
+
+ expect($rootScope.count1).toBe(0);
+ expect($rootScope.count2).toBe(0);
+
+ time = 10;
+ browserTrigger(element1, 'touchstart', [], 10, 10);
+
+ time = 50;
+ browserTrigger(element1, 'touchend', [], 10, 10);
+
+ expect($rootScope.count1).toBe(1);
+
+ time = 100;
+ browserTrigger(element2, 'click', [], 10, 10);
+
+ expect($rootScope.count1).toBe(1);
+ expect($rootScope.count2).toBe(0);
+ }));
+
+
+ it('should not cancel clicks on distant elements', inject(function($rootScope, $compile, $rootElement) {
+ $rootElement.append(
+ '<div ng-click="count1 = count1 + 1">x</div>' +
+ '<div ng-click="count2 = count2 + 1">y</div>'
+ );
+ $compile($rootElement)($rootScope);
+
+ element1 = $rootElement.find('div').eq(0);
+ element2 = $rootElement.find('div').eq(1);
+
+ $rootScope.count1 = 0;
+ $rootScope.count2 = 0;
+
+ $rootScope.$digest();
+
+ expect($rootScope.count1).toBe(0);
+ expect($rootScope.count2).toBe(0);
+
+ time = 10;
+ browserTrigger(element1, 'touchstart', [], 10, 10);
+
+ time = 50;
+ browserTrigger(element1, 'touchend', [], 10, 10);
+
+ expect($rootScope.count1).toBe(1);
+
+ time = 90;
+ browserTrigger(element1, 'click', [], 10, 10);
+
+ expect($rootScope.count1).toBe(1);
+
+ time = 100;
+ browserTrigger(element1, 'touchstart', [], 10, 10);
+
+ time = 130;
+ browserTrigger(element1, 'touchend', [], 10, 10);
+
+ expect($rootScope.count1).toBe(2);
+
+ // Click on other element that should go through.
+ time = 150;
+ browserTrigger(element2, 'touchstart', [], 100, 120);
+ browserTrigger(element2, 'touchend', [], 100, 120);
+ browserTrigger(element2, 'click', [], 100, 120);
+
+ expect($rootScope.count2).toBe(1);
+
+ // Click event for the element that should be busted.
+ time = 200;
+ browserTrigger(element1, 'click', [], 10, 10);
+
+ expect($rootScope.count1).toBe(2);
+ expect($rootScope.count2).toBe(1);
+ }));
+
+
+ it('should not cancel clicks that come long after', inject(function($rootScope, $compile) {
+ element1 = $compile('<div ng-click="count = count + 1"></div>')($rootScope);
+
+ $rootScope.count = 0;
+
+ $rootScope.$digest();
+
+ expect($rootScope.count).toBe(0);
+
+ time = 10;
+ browserTrigger(element1, 'touchstart', [], 10, 10);
+
+ time = 50;
+ browserTrigger(element1, 'touchend', [], 10, 10);
+ expect($rootScope.count).toBe(1);
+
+ time = 2700;
+ browserTrigger(element1, 'click', [], 10, 10);
+
+ expect($rootScope.count).toBe(2);
+ }));
+
+
+ it('should not cancel clicks that come long after', inject(function($rootScope, $compile) {
+ element1 = $compile('<div ng-click="count = count + 1"></div>')($rootScope);
+
+ $rootScope.count = 0;
+
+ $rootScope.$digest();
+
+ expect($rootScope.count).toBe(0);
+
+ time = 10;
+ browserTrigger(element1, 'touchstart', [], 10, 10);
+
+ time = 50;
+ browserTrigger(element1, 'touchend', [], 10, 10);
+
+ expect($rootScope.count).toBe(1);
+
+ time = 2700;
+ browserTrigger(element1, 'click', [], 10, 10);
+
+ expect($rootScope.count).toBe(2);
+ }));
+ });
+
+
+ describe('click fallback', function() {
+
+ it('should treat a click as a tap on desktop', inject(function($rootScope, $compile) {
+ element = $compile('<div ng-click="tapped = true"></div>')($rootScope);
+ $rootScope.$digest();
+ expect($rootScope.tapped).toBeFalsy();
+
+ browserTrigger(element, 'click');
+ expect($rootScope.tapped).toEqual(true);
+ }));
+
+
+ it('should pass event object', inject(function($rootScope, $compile) {
+ element = $compile('<div ng-click="event = $event"></div>')($rootScope);
+ $rootScope.$digest();
+
+ browserTrigger(element, 'click');
+ expect($rootScope.event).toBeDefined();
+ }));
+ });
+});