/** * @preserve FastClick: polyfill to remove click delays on browsers with touch UIs. * * @version 0.4.6 * @codingstandard ftlabs-jsv2 * @copyright The Financial Times Limited [All Rights Reserved] * @license MIT License (see LICENSE.txt) */ /*jslint browser:true, node:true*/ /*global define, Event, Node*/ /** * Instantiate fast-clicking listeners on the specificed layer. * * @constructor * @param {Element} layer The layer to listen on */ function FastClick(layer) { 'use strict'; var oldOnClick, self = this; /** * Whether a click is currently being tracked. * * @type boolean */ this.trackingClick = false; /** * Timestamp for when when click tracking started. * * @type number */ this.trackingClickStart = 0; /** * The element being tracked for a click. * * @type EventTarget */ this.targetElement = null; /** * X-coordinate of touch start event. * * @type number */ this.touchStartX = 0; /** * Y-coordinate of touch start event. * * @type number */ this.touchStartY = 0; /** * ID of the last touch, retrieved from Touch.identifier. * * @type number */ this.lastTouchIdentifier = 0; /** * The FastClick layer. * * @type Element */ this.layer = layer; if (!layer || !layer.nodeType) { throw new TypeError('Layer must be a document node'); } /** @type function() */ this.onClick = function() { FastClick.prototype.onClick.apply(self, arguments); }; /** @type function() */ this.onTouchStart = function() { FastClick.prototype.onTouchStart.apply(self, arguments); }; /** @type function() */ this.onTouchMove = function() { FastClick.prototype.onTouchMove.apply(self, arguments); }; /** @type function() */ this.onTouchEnd = function() { FastClick.prototype.onTouchEnd.apply(self, arguments); }; /** @type function() */ this.onTouchCancel = function() { FastClick.prototype.onTouchCancel.apply(self, arguments); }; // Devices that don't support touch don't need FastClick if (typeof window.ontouchstart === 'undefined') { return; } // Set up event handlers as required layer.addEventListener('click', this.onClick, true); layer.addEventListener('touchstart', this.onTouchStart, false); layer.addEventListener('touchmove', this.onTouchMove, false); layer.addEventListener('touchend', this.onTouchEnd, false); layer.addEventListener('touchcancel', this.onTouchCancel, false); // Hack is required for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2) // which is how FastClick normally stops click events bubbling to callbacks registered on the FastClick // layer when they are cancelled. if (!Event.prototype.stopImmediatePropagation) { layer.removeEventListener = function(type, callback, capture) { var rmv = Node.prototype.removeEventListener; if (type === 'click') { rmv.call(layer, type, callback.hijacked || callback, capture); } else { rmv.call(layer, type, callback, capture); } }; layer.addEventListener = function(type, callback, capture) { var adv = Node.prototype.addEventListener; if (type === 'click') { adv.call(layer, type, callback.hijacked || (callback.hijacked = function(event) { if (!event.propagationStopped) { callback(event); } }), capture); } else { adv.call(layer, type, callback, capture); } }; } // If a handler is already declared in the element's onclick attribute, it will be fired before // FastClick's onClick handler. Fix this by pulling out the user-defined handler function and // adding it as listener. if (typeof layer.onclick === 'function') { // Android browser on at least 3.2 requires a new reference to the function in layer.onclick // - the old one won't work if passed to addEventListener directly. oldOnClick = layer.onclick; layer.addEventListener('click', function(event) { oldOnClick(event); }, false); layer.onclick = null; } } /** * Android requires an exception for labels. * * @type boolean */ FastClick.prototype.deviceIsAndroid = navigator.userAgent.indexOf('Android') > 0; /** * iOS requires an exception for alert confirm dialogs. * * @type boolean */ FastClick.prototype.deviceIsIOS = /iP(ad|hone|od)/.test(navigator.userAgent); /** * iOS 4 requires an exception for select elements. * * @type boolean */ FastClick.prototype.deviceIsIOS4 = FastClick.prototype.deviceIsIOS && (/OS 4_\d(_\d)?/).test(navigator.userAgent); /** * Determine whether a given element requires a native click. * * @param {EventTarget|Element} target Target DOM element * @returns {boolean} Returns true if the element needs a native click */ FastClick.prototype.needsClick = function(target) { 'use strict'; switch (target.nodeName.toLowerCase()) { case 'label': case 'video': return true; default: return (/\bneedsclick\b/).test(target.className); } }; /** * Determine whether a given element requires a call to focus to simulate click into element. * * @param {EventTarget|Element} target Target DOM element * @returns {boolean} Returns true if the element requires a call to focus to simulate native click. */ FastClick.prototype.needsFocus = function(target) { 'use strict'; switch (target.nodeName.toLowerCase()) { case 'textarea': case 'select': return true; case 'input': switch (target.type) { case 'button': case 'checkbox': case 'file': case 'image': case 'radio': case 'submit': return false; } return true; default: return (/\bneedsfocus\b/).test(target.className); } }; /** * Send a click event to the specified element. * * @param {EventTarget|Element} targetElement * @param {Event} event */ FastClick.prototype.sendClick = function(targetElement, event) { 'use strict'; var clickEvent, touch; // On some Android devices activeElement needs to be blurred otherwise the synthetic click will have no effect (#24) if (document.activeElement && document.activeElement !== targetElement) { document.activeElement.blur(); } touch = event.changedTouches[0]; // Synthesise a click event, with an extra attribute so it can be tracked clickEvent = document.createEvent('MouseEvents'); clickEvent.initMouseEvent('click', true, true, window, 1, touch.screenX, touch.screenY, touch.clientX, touch.clientY, false, false, false, false, 0, null); clickEvent.forwardedTouchEvent = true; targetElement.dispatchEvent(clickEvent); }; /** * On touch start, record the position and scroll offset. * * @param {Event} event * @returns {boolean} */ FastClick.prototype.onTouchStart = function(event) { 'use strict'; var touch = event.targetTouches[0]; this.trackingClick = true; this.trackingClickStart = event.timeStamp; this.targetElement = event.target; this.theTarget = $(this.targetElement).closest('a').get(0); this.theTarget.className += ' pressed'; this.touchStartX = touch.pageX; this.touchStartY = touch.pageY; this.startClickTime = new Date; // Prevent phantom clicks on fast double-tap (issue #36) if ((event.timeStamp - this.lastClickTime) < 200) { event.preventDefault(); } return true; }; /** * Based on a touchmove event object, check whether the touch has moved past a boundary since it started. * * @param {Event} event * @returns {boolean} */ FastClick.prototype.touchHasMoved = function(event) { 'use strict'; var touch = event.targetTouches[0]; if (Math.abs(touch.pageX - this.touchStartX) > 10 || Math.abs(touch.pageY - this.touchStartY) > 10) { return true; } return false; }; /** * Update the last position. * * @param {Event} event * @returns {boolean} */ FastClick.prototype.onTouchMove = function(event) { 'use strict'; if (!this.trackingClick) { return true; } // If the touch has moved, cancel the click tracking if (this.targetElement !== event.target || this.touchHasMoved(event)) { this.trackingClick = false; this.theTarget.className = this.theTarget.className.replace(/ ?pressed/gi, ''); this.targetElement = null; } return true; }; /** * Attempt to find the labelled control for the given label element. * * @param {EventTarget|HTMLLabelElement} labelElement * @returns {Element|null} */ FastClick.prototype.findControl = function(labelElement) { 'use strict'; // Fast path for newer browsers supporting the HTML5 control attribute if (labelElement.control !== undefined) { return labelElement.control; } // All browsers under test that support touch events also support the HTML5 htmlFor attribute if (labelElement.htmlFor) { return document.getElementById(labelElement.htmlFor); } // If no for attribute exists, attempt to retrieve the first labellable descendant element // the list of which is defined here: http://www.w3.org/TR/html5/forms.html#category-label return labelElement.querySelector('button, input:not([type=hidden]), keygen, meter, output, progress, select, textarea'); }; /** * On touch end, determine whether to send a click event at once. * * @param {Event} event * @returns {boolean} */ FastClick.prototype.onTouchEnd = function(event) { 'use strict'; var forElement, trackingClickStart, targetElement = this.targetElement, touch = event.changedTouches[0]; if (!this.trackingClick) { return true; } // Weird things happen on iOS when an alert or confirm dialog is opened from a click event callback (issue #23): // when the user next taps anywhere else on the page, new touchstart and touchend events are dispatched // with the same identifier as the touch event that previously triggered the click that triggered the alert. if (this.deviceIsIOS) { if (touch.identifier === this.lastTouchIdentifier) { event.preventDefault(); return false; } this.lastTouchIdentifier = touch.identifier; } // Prevent phantom clicks on fast double-tap (issue #36) if ((event.timeStamp - this.lastClickTime) < 200) { this.cancelNextClick = true; return true; } if ((new Date - this.startClickTime) > 115) { return false; } this.lastClickTime = event.timeStamp; trackingClickStart = this.trackingClickStart; this.trackingClick = false; this.theTarget.className = this.theTarget.className.replace(/ ?pressed/gi, ''); this.trackingClickStart = 0; if (targetElement.nodeName.toLowerCase() === 'label') { forElement = this.findControl(targetElement); if (forElement) { targetElement.focus(); if (this.deviceIsAndroid) { return false; } if (!this.needsClick(forElement)) { event.preventDefault(); this.sendClick(forElement, event); } return false; } } else if (this.needsFocus(targetElement)) { // If the touch started a while ago (best guess is 100ms based on tests for issue #36) then focus will be triggered anyway. Return early and unset the target element reference so that the subsequent click will be allowed through. if ((event.timeStamp - trackingClickStart) > 100) { this.targetElement = null; return true; } targetElement.focus(); // Select elements need the event to go through on iOS 4, otherwise the selector menu won't open. if (!this.deviceIsIOS4 || targetElement.tagName.toLowerCase() !== 'select') { this.targetElement = null; event.preventDefault(); } return false; } // Prevent the actual click from going though - unless the target node is marked as requiring // real clicks or if it is in the whitelist in which case only non-programmatic clicks are permitted. if (!this.needsClick(targetElement)) { event.preventDefault(); this.sendClick(targetElement, event); } return false; }; /** * On touch cancel, stop tracking the click. * * @returns {void} */ FastClick.prototype.onTouchCancel = function() { 'use strict'; this.theTarget.className = this.theTarget.className.replace(/ ?pressed/gi, ''); this.trackingClick = false; this.targetElement = null; }; /** * On actual clicks, determine whether this is a touch-generated click, a click action occurring * naturally after a delay after a touch (which needs to be cancelled to avoid duplication), or * an actual click which should be permitted. * * @param {Event} event * @returns {boolean} */ FastClick.prototype.onClick = function(event) { 'use strict'; var oldTargetElement; // If a target element was never set (because a touch event was never fired) allow the click if (!this.targetElement) { return true; } if (event.forwardedTouchEvent) { return true; } oldTargetElement = this.targetElement; this.targetElement = null; // It's possible for another FastClick-like library delivered with third-party code to fire a click event before FastClick does (issue #44). In that case, set the click-tracking flag back to false and return early. This will cause onTouchEnd to return early. if (this.trackingClick) { this.trackingClick = false; return true; } // Programmatically generated events targeting a specific element should be permitted if (!event.cancelable) { return true; } // Very odd behaviour on iOS (issue #18): if a submit element is present inside a form and the user hits enter in the iOS simulator or clicks the Go button on the pop-up OS keyboard the a kind of 'fake' click event will be triggered with the submit-type input element as the target. if (event.target.type === 'submit' && event.detail === 0) { return true; } // Derive and check the target element to see whether the click needs to be permitted; // unless explicitly enabled, prevent non-touch click events from triggering actions, // to prevent ghost/doubleclicks. if (!this.needsClick(oldTargetElement) || this.cancelNextClick) { this.cancelNextClick = false; // Prevent any user-added listeners declared on FastClick element from being fired. if (event.stopImmediatePropagation) { event.stopImmediatePropagation(); } else { // Part of the hack for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2) event.propagationStopped = true; } // Cancel the event event.stopPropagation(); event.preventDefault(); return false; } // If clicks are permitted, return true for the action to go through. return true; }; /** * Remove all FastClick's event listeners. * * @returns {void} */ FastClick.prototype.destroy = function() { 'use strict'; var layer = this.layer; layer.removeEventListener('click', this.onClick, true); layer.removeEventListener('touchstart', this.onTouchStart, false); layer.removeEventListener('touchmove', this.onTouchMove, false); layer.removeEventListener('touchend', this.onTouchEnd, false); layer.removeEventListener('touchcancel', this.onTouchCancel, false); }; if (typeof define !== 'undefined' && define.amd) { // AMD. Register as an anonymous module. define(function() { 'use strict'; return FastClick; }); } if (typeof module !== 'undefined' && module.exports) { module.exports = function(layer) { 'use strict'; return new FastClick(layer); }; module.exports.FastClick = FastClick; } function attachFastClick(options) { options = options || {}; var avatars = document.getElementsByClassName("NB-show-profile"); Array.prototype.slice.call(avatars, 0).forEach(function(avatar) { new FastClick(avatar, options); }); var tags = document.getElementsByClassName("NB-story-tag"); Array.prototype.slice.call(tags, 0).forEach(function(tag) { new FastClick(tag, options); }); var authors = document.getElementsByClassName("NB-story-author"); Array.prototype.slice.call(authors, 0).forEach(function(author) { new FastClick(author, options); }); var publishers = document.getElementsByClassName("NB-story-publisher"); Array.prototype.slice.call(publishers, 0).forEach(function(publisher) { new FastClick(publisher, options); }); var titles = document.getElementsByClassName("NB-story-title"); Array.prototype.slice.call(titles, 0).forEach(function(title) { new FastClick(title, options); }); var author = document.getElementById("NB-story-author"); if (author) { new FastClick(author, options); } } Zepto(function($) { attachFastClick(); });