From 5590adb1e5b2616bf38e8c6ac95f2c7eeec5f763 Mon Sep 17 00:00:00 2001 From: Max Lynch Date: Wed, 3 Jun 2015 16:05:35 -0500 Subject: [PATCH] ADAM CLICK --- ionic/ionic.js | 1 + ionic/platform/platform.js | 20 +- ionic/util/tap.js | 604 +++++++++++++++++++++++++++++++++++++ 3 files changed, 623 insertions(+), 2 deletions(-) create mode 100644 ionic/util/tap.js diff --git a/ionic/ionic.js b/ionic/ionic.js index f46399a914..54b8c21ed2 100644 --- a/ionic/ionic.js +++ b/ionic/ionic.js @@ -6,6 +6,7 @@ export * from 'ionic/routing/router' export * from 'ionic/util/click-block' export * from 'ionic/util/focus' +export * from 'ionic/util/tap' export * from 'ionic/engine/engine' export * from 'ionic/engine/cordova/cordova' diff --git a/ionic/platform/platform.js b/ionic/platform/platform.js index 5e2fecc3d0..d247a1d674 100644 --- a/ionic/platform/platform.js +++ b/ionic/platform/platform.js @@ -1,5 +1,5 @@ import * as util from '../util/util'; - +import {Tap} from '../util/tap'; let registry = {}; let defaultPlatform; @@ -61,6 +61,10 @@ class PlatformController { return null; } + is(name) { + return activePlatform.name === name; + } + _applyBodyClasses() { if(!activePlatform) { return; @@ -68,6 +72,10 @@ class PlatformController { document.body.classList.add('platform-' + activePlatform.name); } + + run() { + activePlatform && activePlatform.run(); + } } export let Platform = new PlatformController((util.getQuerystring('ionicplatform')).toLowerCase(), window.navigator.userAgent); @@ -81,6 +89,9 @@ Platform.register({ return platformQuerystring == 'android'; } return /android/i.test(userAgent); + }, + run() { + } }); @@ -91,6 +102,9 @@ Platform.register({ return platformQuerystring == 'ios'; } return /ipad|iphone|ipod/i.test(userAgent); + }, + run() { + //Tap.run(); } }); @@ -99,4 +113,6 @@ Platform.setDefault({ name: 'ios' }); -Platform.set( Platform.get('ios') );//Platform.detect() ); +Platform.set( Platform.getPlatform('ios') );//Platform.detect() ); + +Platform.run(); diff --git a/ionic/util/tap.js b/ionic/util/tap.js new file mode 100644 index 0000000000..05bbaa14f9 --- /dev/null +++ b/ionic/util/tap.js @@ -0,0 +1,604 @@ +import {dom} from 'ionic/util' +import {Platform} from 'ionic/platform/platform' + +/** + * @ngdoc page + * @name tap + * @module ionic + * @description + * On touch devices such as a phone or tablet, some browsers implement a 300ms delay between + * the time the user stops touching the display and the moment the browser executes the + * click. This delay was initially introduced so the browser can know whether the user wants to + * double-tap to zoom in on the webpage. Basically, the browser waits roughly 300ms to see if + * the user is double-tapping, or just tapping on the display once. + * + * Out of the box, Ionic automatically removes the 300ms delay in order to make Ionic apps + * feel more "native" like. Resultingly, other solutions such as + * [fastclick](https://github.com/ftlabs/fastclick) and Angular's + * [ngTouch](https://docs.angularjs.org/api/ngTouch) should not be included, to avoid conflicts. + * + * Some browsers already remove the delay with certain settings, such as the CSS property + * `touch-events: none` or with specific meta tag viewport values. However, each of these + * browsers still handle clicks differently, such as when to fire off or cancel the event + * (like scrolling when the target is a button, or holding a button down). + * For browsers that already remove the 300ms delay, consider Ionic's tap system as a way to + * normalize how clicks are handled across the various devices so there's an expected response + * no matter what the device, platform or version. Additionally, Ionic will prevent + * ghostclicks which even browsers that remove the delay still experience. + * + * In some cases, third-party libraries may also be working with touch events which can interfere + * with the tap system. For example, mapping libraries like Google or Leaflet Maps often implement + * a touch detection system which conflicts with Ionic's tap system. + * + * ### Disabling the tap system + * + * To disable the tap for an element and all of its children elements, + * add the attribute `data-tap-disabled="true"`. + * + * ```html + *
+ *
+ *
+ * ``` + * + * ### Additional Notes: + * + * - Ionic tap works with Ionic's JavaScript scrolling + * - Elements can come and go from the DOM and Ionic tap doesn't keep adding and removing + * listeners + * - No "tap delay" after the first "tap" (you can tap as fast as you want, they all click) + * - Minimal events listeners, only being added to document + * - Correct focus in/out on each input type (select, textearea, range) on each platform/device + * - Shows and hides virtual keyboard correctly for each platform/device + * - Works with labels surrounding inputs + * - Does not fire off a click if the user moves the pointer too far + * - Adds and removes an 'activated' css class + * - Multiple [unit tests](https://github.com/driftyco/ionic/blob/master/test/unit/utils/tap.unit.js) for each scenario + * + */ +/* + + IONIC TAP + --------------- + - Both touch and mouse events are added to the document.body on DOM ready + - If a touch event happens, it does not use mouse event listeners + - On touchend, if the distance between start and end was small, trigger a click + - In the triggered click event, add a 'isIonicTap' property + - The triggered click receives the same x,y coordinates as as the end event + - On document.body click listener (with useCapture=true), only allow clicks with 'isIonicTap' + - Triggering clicks with mouse events work the same as touch, except with mousedown/mouseup + - Tapping inputs is disabled during scrolling +*/ + +var tapDoc; // the element which the listeners are on (document.body) +var tapActiveEle; // the element which is active (probably has focus) +var tapEnabledTouchEvents; +var tapMouseResetTimer; +var tapPointerMoved; +var tapPointerStart; +var tapTouchFocusedInput; +var tapLastTouchTarget; +var tapTouchMoveListener = 'touchmove'; + +// how much the coordinates can be off between start/end, but still a click +var TAP_RELEASE_TOLERANCE = 12; // default tolerance +var TAP_RELEASE_BUTTON_TOLERANCE = 50; // button elements should have a larger tolerance + +var tapEventListeners = { + 'click': tapClickGateKeeper, + + 'mousedown': tapMouseDown, + 'mouseup': tapMouseUp, + 'mousemove': tapMouseMove, + + 'touchstart': tapTouchStart, + 'touchend': tapTouchEnd, + 'touchcancel': tapTouchCancel, + 'touchmove': tapTouchMove, + + 'pointerdown': tapTouchStart, + 'pointerup': tapTouchEnd, + 'pointercancel': tapTouchCancel, + 'pointermove': tapTouchMove, + + 'MSPointerDown': tapTouchStart, + 'MSPointerUp': tapTouchEnd, + 'MSPointerCancel': tapTouchCancel, + 'MSPointerMove': tapTouchMove, + + 'focusin': tapFocusIn, + 'focusout': tapFocusOut +}; + +export let Tap = { + + run: function() { + dom.ready().then(() => { + console.log('ADAM CLICK: GOOOO!!!!'); + + Tap.register(document); + }); + }, + + register: function(ele) { + tapDoc = ele; + + tapEventListener('click', true, true); + tapEventListener('mouseup'); + tapEventListener('mousedown'); + + if (window.navigator.pointerEnabled) { + tapEventListener('pointerdown'); + tapEventListener('pointerup'); + tapEventListener('pointcancel'); + tapTouchMoveListener = 'pointermove'; + + } else if (window.navigator.msPointerEnabled) { + tapEventListener('MSPointerDown'); + tapEventListener('MSPointerUp'); + tapEventListener('MSPointerCancel'); + tapTouchMoveListener = 'MSPointerMove'; + + } else { + tapEventListener('touchstart'); + tapEventListener('touchend'); + tapEventListener('touchcancel'); + } + + tapEventListener('focusin'); + tapEventListener('focusout'); + + return function() { + for (var type in tapEventListeners) { + tapEventListener(type, false); + } + tapDoc = null; + tapActiveEle = null; + tapEnabledTouchEvents = false; + tapPointerMoved = false; + tapPointerStart = null; + }; + }, + + ignoreScrollStart: function(e) { + return (e.defaultPrevented) || // defaultPrevented has been assigned by another component handling the event + (/^(file|range)$/i).test(e.target.type) || + (e.target.dataset ? e.target.dataset.preventScroll : e.target.getAttribute('data-prevent-scroll')) == 'true' || // manually set within an elements attributes + (!!(/^(object|embed)$/i).test(e.target.tagName)) || // flash/movie/object touches should not try to scroll + Tap.isElementTapDisabled(e.target); // check if this element, or an ancestor, has `data-tap-disabled` attribute + }, + + isTextInput: function(ele) { + return !!ele && + (ele.tagName == 'TEXTAREA' || + ele.contentEditable === 'true' || + (ele.tagName == 'INPUT' && !(/^(radio|checkbox|range|file|submit|reset|color|image|button)$/i).test(ele.type))); + }, + + isDateInput: function(ele) { + return !!ele && + (ele.tagName == 'INPUT' && (/^(date|time|datetime-local|month|week)$/i).test(ele.type)); + }, + + isKeyboardElement: function(ele) { + if ( !ionic.Platform.isIOS() || ionic.Platform.isIPad() ) { + return Tap.isTextInput(ele) && !Tap.isDateInput(ele); + } else { + return Tap.isTextInput(ele) || ( !!ele && ele.tagName == "SELECT"); + } + }, + + isLabelWithTextInput: function(ele) { + var container = tapContainingElement(ele, false); + + return !!container && + Tap.isTextInput(tapTargetElement(container)); + }, + + containsOrIsTextInput: function(ele) { + return Tap.isTextInput(ele) || Tap.isLabelWithTextInput(ele); + }, + + cloneFocusedInput: function(container) { + if (Tap.hasCheckedClone) return; + Tap.hasCheckedClone = true; + + ionic.requestAnimationFrame(function() { + var focusInput = container.querySelector(':focus'); + if (Tap.isTextInput(focusInput)) { + var clonedInput = focusInput.cloneNode(true); + + clonedInput.value = focusInput.value; + clonedInput.classList.add('cloned-text-input'); + clonedInput.readOnly = true; + if (focusInput.isContentEditable) { + clonedInput.contentEditable = focusInput.contentEditable; + clonedInput.innerHTML = focusInput.innerHTML; + } + focusInput.parentElement.insertBefore(clonedInput, focusInput); + focusInput.classList.add('previous-input-focus'); + + clonedInput.scrollTop = focusInput.scrollTop; + } + }); + }, + + hasCheckedClone: false, + + removeClonedInputs: function(container) { + Tap.hasCheckedClone = false; + + ionic.requestAnimationFrame(function() { + var clonedInputs = container.querySelectorAll('.cloned-text-input'); + var previousInputFocus = container.querySelectorAll('.previous-input-focus'); + var x; + + for (x = 0; x < clonedInputs.length; x++) { + clonedInputs[x].parentElement.removeChild(clonedInputs[x]); + } + + for (x = 0; x < previousInputFocus.length; x++) { + previousInputFocus[x].classList.remove('previous-input-focus'); + previousInputFocus[x].style.top = ''; + if ( ionic.keyboard.isOpen && !ionic.keyboard.isClosing ) previousInputFocus[x].focus(); + } + }); + }, + + requiresNativeClick: function(ele) { + if (!ele || ele.disabled || (/^(file|range)$/i).test(ele.type) || (/^(object|video)$/i).test(ele.tagName) || Tap.isLabelContainingFileInput(ele)) { + return true; + } + return Tap.isElementTapDisabled(ele); + }, + + isLabelContainingFileInput: function(ele) { + var lbl = tapContainingElement(ele); + if (lbl.tagName !== 'LABEL') return false; + var fileInput = lbl.querySelector('input[type=file]'); + if (fileInput && fileInput.disabled === false) return true; + return false; + }, + + isElementTapDisabled: function(ele) { + if (ele && ele.nodeType === 1) { + var element = ele; + while (element) { + if ((element.dataset ? element.dataset.tapDisabled : element.getAttribute('data-tap-disabled')) == 'true') { + return true; + } + element = element.parentElement; + } + } + return false; + }, + + setTolerance: function(releaseTolerance, releaseButtonTolerance) { + TAP_RELEASE_TOLERANCE = releaseTolerance; + TAP_RELEASE_BUTTON_TOLERANCE = releaseButtonTolerance; + }, + + cancelClick: function() { + // used to cancel any simulated clicks which may happen on a touchend/mouseup + // gestures uses this method within its tap and hold events + tapPointerMoved = true; + }, + + pointerCoord: function(event) { + // This method can get coordinates for both a mouse click + // or a touch depending on the given event + var c = { x: 0, y: 0 }; + if (event) { + var touches = event.touches && event.touches.length ? event.touches : [event]; + var e = (event.changedTouches && event.changedTouches[0]) || touches[0]; + if (e) { + c.x = e.clientX || e.pageX || 0; + c.y = e.clientY || e.pageY || 0; + } + } + return c; + } + +}; + +function tapEventListener(type, enable, useCapture) { + if (enable !== false) { + tapDoc.addEventListener(type, tapEventListeners[type], useCapture); + } else { + tapDoc.removeEventListener(type, tapEventListeners[type]); + } +} + +function tapClick(e) { + // simulate a normal click by running the element's click method then focus on it + var container = tapContainingElement(e.target); + var ele = tapTargetElement(container); + + if (Tap.requiresNativeClick(ele) || tapPointerMoved) return false; + + var c = Tap.pointerCoord(e); + + //console.log('tapClick', e.type, ele.tagName, '('+c.x+','+c.y+')'); + triggerMouseEvent('click', ele, c.x, c.y); + + // if it's an input, focus in on the target, otherwise blur + tapHandleFocus(ele); +} + +function triggerMouseEvent(type, ele, x, y) { + // using initMouseEvent instead of MouseEvent for our Android friends + var clickEvent = document.createEvent("MouseEvents"); + clickEvent.initMouseEvent(type, true, true, window, 1, 0, 0, x, y, false, false, false, false, 0, null); + clickEvent.isIonicTap = true; + ele.dispatchEvent(clickEvent); +} + +function tapClickGateKeeper(e) { + //console.log('click ' + Date.now() + ' isIonicTap: ' + (e.isIonicTap ? true : false)); + if (e.target.type == 'submit' && e.detail === 0) { + // do not prevent click if it came from an "Enter" or "Go" keypress submit + return null; + } + + // do not allow through any click events that were not created by Tap + if ((ionic.scroll.isScrolling && Tap.containsOrIsTextInput(e.target)) || + (!e.isIonicTap && !Tap.requiresNativeClick(e.target))) { + //console.log('clickPrevent', e.target.tagName); + e.stopPropagation(); + + if (!Tap.isLabelWithTextInput(e.target)) { + // labels clicks from native should not preventDefault othersize keyboard will not show on input focus + e.preventDefault(); + } + return false; + } +} + +// MOUSE +function tapMouseDown(e) { + //console.log('mousedown ' + Date.now()); + if (e.isIonicTap || tapIgnoreEvent(e)) return null; + + if (tapEnabledTouchEvents) { + console.log('mousedown', 'stop event'); + e.stopPropagation(); + + if ((!Tap.isTextInput(e.target) || tapLastTouchTarget !== e.target) && !(/^(select|option)$/i).test(e.target.tagName)) { + // If you preventDefault on a text input then you cannot move its text caret/cursor. + // Allow through only the text input default. However, without preventDefault on an + // input the 300ms delay can change focus on inputs after the keyboard shows up. + // The focusin event handles the chance of focus changing after the keyboard shows. + e.preventDefault(); + } + + return false; + } + + tapPointerMoved = false; + tapPointerStart = Tap.pointerCoord(e); + + tapEventListener('mousemove'); + ionic.activator.start(e); +} + +function tapMouseUp(e) { + //console.log("mouseup " + Date.now()); + if (tapEnabledTouchEvents) { + e.stopPropagation(); + e.preventDefault(); + return false; + } + + if (tapIgnoreEvent(e) || (/^(select|option)$/i).test(e.target.tagName)) return false; + + if (!tapHasPointerMoved(e)) { + tapClick(e); + } + tapEventListener('mousemove', false); + ionic.activator.end(); + tapPointerMoved = false; +} + +function tapMouseMove(e) { + if (tapHasPointerMoved(e)) { + tapEventListener('mousemove', false); + ionic.activator.end(); + tapPointerMoved = true; + return false; + } +} + + +// TOUCH +function tapTouchStart(e) { + //console.log("touchstart " + Date.now()); + if (tapIgnoreEvent(e)) return; + + tapPointerMoved = false; + + tapEnableTouchEvents(); + tapPointerStart = Tap.pointerCoord(e); + + tapEventListener(tapTouchMoveListener); + ionic.activator.start(e); + + if (ionic.Platform.isIOS() && Tap.isLabelWithTextInput(e.target)) { + // if the tapped element is a label, which has a child input + // then preventDefault so iOS doesn't ugly auto scroll to the input + // but do not prevent default on Android or else you cannot move the text caret + // and do not prevent default on Android or else no virtual keyboard shows up + + var textInput = tapTargetElement(tapContainingElement(e.target)); + if (textInput !== tapActiveEle) { + // don't preventDefault on an already focused input or else iOS's text caret isn't usable + e.preventDefault(); + } + } +} + +function tapTouchEnd(e) { + //console.log('touchend ' + Date.now()); + if (tapIgnoreEvent(e)) return; + + tapEnableTouchEvents(); + if (!tapHasPointerMoved(e)) { + tapClick(e); + + if ((/^(select|option)$/i).test(e.target.tagName)) { + e.preventDefault(); + } + } + + tapLastTouchTarget = e.target; + tapTouchCancel(); +} + +function tapTouchMove(e) { + if (tapHasPointerMoved(e)) { + tapPointerMoved = true; + tapEventListener(tapTouchMoveListener, false); + ionic.activator.end(); + return false; + } +} + +function tapTouchCancel() { + tapEventListener(tapTouchMoveListener, false); + ionic.activator.end(); + tapPointerMoved = false; +} + +function tapEnableTouchEvents() { + tapEnabledTouchEvents = true; + clearTimeout(tapMouseResetTimer); + tapMouseResetTimer = setTimeout(function() { + tapEnabledTouchEvents = false; + }, 600); +} + +function tapIgnoreEvent(e) { + if (e.isTapHandled) return true; + e.isTapHandled = true; + + if (ionic.scroll.isScrolling && Tap.containsOrIsTextInput(e.target)) { + e.preventDefault(); + return true; + } +} + +function tapHandleFocus(ele) { + tapTouchFocusedInput = null; + + var triggerFocusIn = false; + + if (ele.tagName == 'SELECT') { + // trick to force Android options to show up + triggerMouseEvent('mousedown', ele, 0, 0); + ele.focus && ele.focus(); + triggerFocusIn = true; + + } else if (tapActiveElement() === ele) { + // already is the active element and has focus + triggerFocusIn = true; + + } else if ((/^(input|textarea)$/i).test(ele.tagName) || ele.isContentEditable) { + triggerFocusIn = true; + ele.focus && ele.focus(); + ele.value = ele.value; + if (tapEnabledTouchEvents) { + tapTouchFocusedInput = ele; + } + + } else { + tapFocusOutActive(); + } + + if (triggerFocusIn) { + tapActiveElement(ele); + ionic.trigger('ionic.focusin', { + target: ele + }, true); + } +} + +function tapFocusOutActive() { + var ele = tapActiveElement(); + if (ele && ((/^(input|textarea|select)$/i).test(ele.tagName) || ele.isContentEditable)) { + console.log('tapFocusOutActive', ele.tagName); + ele.blur(); + } + tapActiveElement(null); +} + +function tapFocusIn(e) { + //console.log('focusin ' + Date.now()); + // Because a text input doesn't preventDefault (so the caret still works) there's a chance + // that its mousedown event 300ms later will change the focus to another element after + // the keyboard shows up. + + if (tapEnabledTouchEvents && + Tap.isTextInput(tapActiveElement()) && + Tap.isTextInput(tapTouchFocusedInput) && + tapTouchFocusedInput !== e.target) { + + // 1) The pointer is from touch events + // 2) There is an active element which is a text input + // 3) A text input was just set to be focused on by a touch event + // 4) A new focus has been set, however the target isn't the one the touch event wanted + console.log('focusin', 'tapTouchFocusedInput'); + tapTouchFocusedInput.focus(); + tapTouchFocusedInput = null; + } + ionic.scroll.isScrolling = false; +} + +function tapFocusOut() { + //console.log("focusout"); + tapActiveElement(null); +} + +function tapActiveElement(ele) { + if (arguments.length) { + tapActiveEle = ele; + } + return tapActiveEle || document.activeElement; +} + +function tapHasPointerMoved(endEvent) { + if (!endEvent || endEvent.target.nodeType !== 1 || !tapPointerStart || (tapPointerStart.x === 0 && tapPointerStart.y === 0)) { + return false; + } + var endCoordinates = Tap.pointerCoord(endEvent); + + var hasClassList = !!(endEvent.target.classList && endEvent.target.classList.contains && + typeof endEvent.target.classList.contains === 'function'); + var releaseTolerance = hasClassList && endEvent.target.classList.contains('button') ? + TAP_RELEASE_BUTTON_TOLERANCE : + TAP_RELEASE_TOLERANCE; + + return Math.abs(tapPointerStart.x - endCoordinates.x) > releaseTolerance || + Math.abs(tapPointerStart.y - endCoordinates.y) > releaseTolerance; +} + +function tapContainingElement(ele, allowSelf) { + var climbEle = ele; + for (var x = 0; x < 6; x++) { + if (!climbEle) break; + if (climbEle.tagName === 'LABEL') return climbEle; + climbEle = climbEle.parentElement; + } + if (allowSelf !== false) return ele; +} + +function tapTargetElement(ele) { + if (ele && ele.tagName === 'LABEL') { + if (ele.control) return ele.control; + + // older devices do not support the "control" property + if (ele.querySelector) { + var control = ele.querySelector('input,textarea,select'); + if (control) return control; + } + } + return ele; +}