diff options
| -rw-r--r-- | Gruntfile.js | 8 | ||||
| -rw-r--r-- | angularFiles.js | 11 | ||||
| -rw-r--r-- | src/ngMobile/directive/ngClick.js | 244 | ||||
| -rw-r--r-- | src/ngMobile/mobile.js | 16 | ||||
| -rw-r--r-- | src/ngScenario/Scenario.js | 8 | ||||
| -rw-r--r-- | test/ngMobile/directive/ngClickSpec.js | 295 |
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(); + })); + }); +}); |
