From ae3318a0815447e79a6bfaf7833bedc0573c9503 Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Thu, 24 Apr 2014 20:22:16 -0500 Subject: [PATCH] refactor(keyboard): Scroll to inputs hidden by keyboard --- config/build.config.js | 1 + js/utils/events.js | 2 +- js/utils/keyboard.js | 286 +++++-- js/utils/tap.js | 86 ++- js/utils/viewport.js | 57 ++ js/views/scrollView.js | 55 +- scss/_form.scss | 6 +- test/html/input.html | 55 +- test/unit/utils/keyboard.unit.js | 204 +++++ test/unit/utils/tap.unit.js | 1148 ++++++++++++++++++++++++++++ test/unit/utils/viewport.unit.js | 125 +++ test/unit/views/scrollView.unit.js | 32 + 12 files changed, 1904 insertions(+), 153 deletions(-) create mode 100644 js/utils/viewport.js create mode 100644 test/unit/utils/keyboard.unit.js create mode 100644 test/unit/utils/tap.unit.js create mode 100644 test/unit/utils/viewport.unit.js diff --git a/config/build.config.js b/config/build.config.js index dae8d1b532..01746fc6b0 100644 --- a/config/build.config.js +++ b/config/build.config.js @@ -42,6 +42,7 @@ module.exports = { 'js/utils/activator.js', 'js/utils/utils.js', 'js/utils/keyboard.js', + 'js/utils/viewport.js', // Views 'js/views/view.js', diff --git a/js/utils/events.js b/js/utils/events.js index 91cf4f3c7b..6f2c437cb1 100644 --- a/js/utils/events.js +++ b/js/utils/events.js @@ -73,7 +73,7 @@ // Make sure to trigger the event on the given target, or dispatch it from // the window if we don't have an event target - data && data.target && data.target.dispatchEvent(event) || window.dispatchEvent(event); + data && data.target && data.target.dispatchEvent && data.target.dispatchEvent(event) || window.dispatchEvent(event); }, /** diff --git a/js/utils/keyboard.js b/js/utils/keyboard.js index c4716a0873..a7f367ccbb 100644 --- a/js/utils/keyboard.js +++ b/js/utils/keyboard.js @@ -1,84 +1,214 @@ -(function(ionic) { + +/* +IONIC KEYBOARD +--------------- + +*/ + +var keyboardViewportHeight = window.innerHeight; +var keyboardIsOpen; +var keyboardActiveElement; +var keyboardFocusOutTimer; +var keyboardFocusInTimer; + +var KEYBOARD_OPEN_CSS = 'keyboard-open'; +var SCROLL_CONTAINER_CSS = 'scroll'; + +ionic.keyboard = { + isOpen: false, + height: null +}; + +function keyboardInit() { + if( keyboardHasPlugin() ) { + window.addEventListener('native.showkeyboard', keyboardNativeShow); + } + + document.body.addEventListener('ionic.focusin', keyboardBrowserFocusIn); + document.body.addEventListener('focusin', keyboardBrowserFocusIn); + + document.body.addEventListener('focusout', keyboardFocusOut); + document.body.addEventListener('orientationchange', keyboardOrientationChange); + + document.removeEventListener('touchstart', keyboardInit); +} + +function keyboardNativeShow(e) { + ionic.keyboard.height = e.keyboardHeight; +} + +function keyboardBrowserFocusIn(e) { + if( !e.target || !ionic.tap.isTextInput(e.target) || !keyboardIsWithinScroll(e.target) ) return; + + document.addEventListener('keydown', keyboardOnKeyDown, false); + + document.body.scrollTop = 0; + document.body.querySelector('.scroll-content').scrollTop = 0; + + keyboardActiveElement = e.target; + + keyboardSetShow(e); +} + +function keyboardSetShow(e) { + clearTimeout(keyboardFocusInTimer); + clearTimeout(keyboardFocusOutTimer); + + keyboardFocusInTimer = setTimeout(function(){ + var keyboardHeight = keyboardGetHeight(); + var elementBounds = keyboardActiveElement.getBoundingClientRect(); + + keyboardShow(e.target, elementBounds.top, elementBounds.bottom, keyboardViewportHeight, keyboardHeight); + }, 32); +} + +function keyboardShow(element, elementTop, elementBottom, viewportHeight, keyboardHeight) { + var details = { + target: element, + elementTop: Math.round(elementTop), + elementBottom: Math.round(elementBottom), + keyboardHeight: keyboardHeight + }; + + if( keyboardIsOverWebView() ) { + // keyboard sits on top of the view, but doesn't adjust the view's height + // lower the content height by subtracting the keyboard height from the view height + details.contentHeight = viewportHeight - keyboardHeight; + } else { + // view's height was shrunk down and the keyboard takes up the space the view doesn't fill + // do not add extra padding at the bottom of the scroll view, native already did that + details.contentHeight = viewportHeight; + } + + console.debug('keyboardShow', keyboardHeight, details.contentHeight); + + // distance from top of input to the top of the keyboard + details.keyboardTopOffset = details.elementTop - details.contentHeight; + + console.debug('keyboardTopOffset', details.elementTop, details.contentHeight, details.keyboardTopOffset); + + // figure out if the element is under the keyboard + details.isElementUnderKeyboard = (details.elementBottom > details.contentHeight); + + ionic.keyboard.isOpen = true; + + // send event so the scroll view adjusts + keyboardActiveElement = element; + ionic.trigger('scrollChildIntoView', details, true); + + ionic.requestAnimationFrame(function(){ + document.body.classList.add(KEYBOARD_OPEN_CSS); + }); + + // any showing part of the document that isn't within the scroll the user + // could touchmove and cause some ugly changes to the app, so disable + // any touchmove events while the keyboard is open using e.preventDefault() + document.addEventListener('touchmove', keyboardPreventDefault, false); + + return details; +} + +function keyboardFocusOut(e) { + clearTimeout(keyboardFocusInTimer); + clearTimeout(keyboardFocusOutTimer); + + keyboardFocusOutTimer = setTimeout(keyboardHide, 350); +} + +function keyboardHide() { + console.debug('keyboardHide'); + ionic.keyboard.isOpen = false; + + ionic.trigger('resetScrollView', { + target: keyboardActiveElement + }, true); + + ionic.requestAnimationFrame(function(){ + document.body.classList.remove(KEYBOARD_OPEN_CSS); + }); + + // the keyboard is gone now, remove the touchmove that disables native scroll + document.removeEventListener('touchmove', keyboardPreventDefault); + document.removeEventListener('keydown', keyboardOnKeyDown); +} + +function keyboardUpdateViewportHeight() { + if( window.innerHeight > keyboardViewportHeight ) { + keyboardViewportHeight = window.innerHeight; + } +} + +function keyboardOnKeyDown(e) { + if( ionic.scroll.isScrolling ) { + keyboardPreventDefault(e); + } +} + +function keyboardPreventDefault(e) { + e.preventDefault(); +} + +function keyboardOrientationChange() { + keyboardViewportHeight = window.innerHeight; + setTimeout(function(){ + keyboardViewportHeight = window.innerHeight; + }, 999); +} + +function keyboardGetHeight() { + // check if we are already have a keyboard height from the plugin + if (ionic.keyboard.height ) { + return ionic.keyboard.height; + } + + // fallback for when its the webview without the plugin + // or for just the standard web browser + if( ionic.Platform.isIOS() ) { + if( ionic.Platform.isWebView() ) { + return 260; + } + return 216; + } else if( ionic.Platform.isAndroid() ) { + if( ionic.Platform.isWebView() ) { + return 220; + } + if( ionic.Platform.version() <= 4.3) { + return 230; + } + } + + // safe guess + return 275; +} + +function keyboardIsWithinScroll(ele) { + while(ele) { + if(ele.classList.contains(SCROLL_CONTAINER_CSS)) { + return true; + } + ele = ele.parentElement; + } + return false; +} + +function keyboardIsOverWebView() { + return ( ionic.Platform.isIOS() ) || + ( ionic.Platform.isAndroid() && !ionic.Platform.isWebView() ); +} + +function keyboardHasPlugin() { + return !!(window.cordova && cordova.plugins && cordova.plugins.Keyboard); +} ionic.Platform.ready(function() { - var rememberedDeviceWidth = window.innerWidth; - var rememberedDeviceHeight = window.innerHeight; - var keyboardHeight; - var rememberedActiveEl; - var alreadyOpen = false; + keyboardUpdateViewportHeight(); - window.addEventListener('focusin', onBrowserFocusIn); - - if(ionic.Platform.isWebView() && window.cordova && cordova.plugins && cordova.plugins.Keyboard) { - window.addEventListener('native.showkeyboard', onNativeKeyboardShow); - window.addEventListener('native.hidekeyboard', onNativeKeyboardHide); - - } else if (ionic.Platform.isAndroid()){ - window.addEventListener('resize', onBrowserResize); - } - - function onBrowserFocusIn(e) { - if (ionic.tap.containsOrIsTextInput(e.target) || e.srcElement.isContentEditable){ - document.body.scrollTop = 0; - } - - rememberedActiveEl = e.srcElement; - } - - function onBrowserResize() { - if(rememberedDeviceWidth !== window.innerWidth) { - // If the width of the window changes, we have an orientation change - rememberedDeviceWidth = window.innerWidth; - rememberedDeviceHeight = window.innerHeight; - - } else if(rememberedDeviceHeight !== window.innerHeight && - window.innerHeight < rememberedDeviceHeight) { - // If the height changes, and it's less than before, we have a keyboard open - document.body.classList.add('keyboard-open'); - - keyboardHeight = rememberedDeviceHeight - window.innerHeight; - setTimeout(function() { - ionic.trigger('scrollChildIntoView', { - target: rememberedActiveEl, - }, true); - }, 100); - - } else { - // Otherwise we have a keyboard close or a *really* weird resize - document.body.classList.remove('keyboard-open'); - } - } - - function onNativeKeyboardShow(e) { - if(rememberedActiveEl) { - // This event is caught by the nearest parent scrollView - // of the activeElement - if(cordova.plugins.Keyboard.isVisible) { - document.body.classList.add('keyboard-open'); - ionic.trigger('scrollChildIntoView', { - keyboardHeight: e.keyboardHeight, - target: rememberedActiveEl, - firstKeyboardShow: !alreadyOpen - }, true); - - if(!alreadyOpen) alreadyOpen = true; - } - } - } - - function onNativeKeyboardHide() { - // wait to see if we're just switching inputs - setTimeout(function() { - if(!cordova.plugins.Keyboard.isVisible) { - document.body.classList.remove('keyboard-open'); - alreadyOpen = false; - ionic.trigger('resetScrollView', { - target: rememberedActiveEl - }, true); - } - }, 100); - } + // Android sometimes reports bad innerHeight on window.load + // try it again in a lil bit to play it safe + setTimeout(keyboardUpdateViewportHeight, 999); + // only initialize the adjustments for the virtual keyboard + // if a touchstart event happens + document.addEventListener('touchstart', keyboardInit, false); }); -})(window.ionic); diff --git a/js/utils/tap.js b/js/utils/tap.js index a9204ce201..5f63e783f1 100644 --- a/js/utils/tap.js +++ b/js/utils/tap.js @@ -92,7 +92,8 @@ ionic.tap = { isTextInput: function(ele) { return !!ele && (ele.tagName == 'TEXTAREA' || - (ele.tagName == 'INPUT' && !(/radio|checkbox|range|file|submit|reset/i).test(ele.type))); + ele.contentEditable === 'true' || + (ele.tagName == 'INPUT' && !(/radio|checkbox|range|file|submit|reset/i).test(ele.type)) ); }, isLabelWithTextInput: function(ele) { @@ -106,7 +107,7 @@ ionic.tap = { return ionic.tap.isTextInput(ele) || ionic.tap.isLabelWithTextInput(ele); }, - cloneFocusedInput: function(container, instance) { + cloneFocusedInput: function(container, scrollIntance) { if(ionic.tap.hasCheckedClone) return; ionic.tap.hasCheckedClone = true; @@ -119,6 +120,7 @@ ionic.tap = { clonedInput.type = focusInput.type; clonedInput.value = focusInput.value; clonedInput.className = 'cloned-text-input'; + clonedInput.readOnly = true; focusInput.parentElement.insertBefore(clonedInput, focusInput); focusInput.style.top = focusInput.offsetTop; focusInput.classList.add('previous-input-focus'); @@ -129,7 +131,7 @@ ionic.tap = { hasCheckedClone: false, - removeClonedInputs: function(container) { + removeClonedInputs: function(container, scrollIntance) { ionic.tap.hasCheckedClone = false; ionic.requestAnimationFrame(function(){ @@ -184,6 +186,11 @@ function triggerMouseEvent(type, ele, x, y) { } function tapClickGateKeeper(e) { + if(e.target.type == 'submit' && e.detail === 0) { + // do not prevent click if it came from an "Enter" or "Go" keypress submit + return; + } + // do not allow through any click events that were not created by ionic.tap if( (ionic.scroll.isScrolling && ionic.tap.containsOrIsTextInput(e.target) ) || (!e.isIonicTap && !tapRequiresNativeClick(e.target)) ) { @@ -241,6 +248,12 @@ function tapMouseDown(e) { } function tapMouseUp(e) { + if(tapEnabledTouchEvents) { + e.stopPropagation(); + e.preventDefault(); + return false; + } + if( tapIgnoreEvent(e) ) return; if( !tapHasPointerMoved(e) ) { @@ -272,6 +285,19 @@ function tapTouchStart(e) { tapEventListener('touchmove'); ionic.activator.start(e); + + if( ionic.Platform.isIOS() && ionic.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) { @@ -301,17 +327,11 @@ function tapTouchCancel(e) { } function tapEnableTouchEvents() { - if(!tapEnabledTouchEvents) { - tapEventListener('mouseup', false); - tapEnabledTouchEvents = true; - } + tapEnabledTouchEvents = true; clearTimeout(tapMouseResetTimer); - tapMouseResetTimer = setTimeout(tapResetMouseEvent, 2500); -} - -function tapResetMouseEvent() { - tapEventListener('mouseup', false); - tapEnabledTouchEvents = false; + tapMouseResetTimer = setTimeout(function(){ + tapEnabledTouchEvents = false; + }, 2000); } function tapIgnoreEvent(e) { @@ -327,25 +347,35 @@ function tapIgnoreEvent(e) { function tapHandleFocus(ele) { tapTouchFocusedInput = null; + var triggerFocusIn = false; + if(ele.tagName == 'SELECT') { // trick to force Android options to show up - console.debug('tapHandleFocus', ele.tagName); triggerMouseEvent('mousedown', ele, 0, 0); - tapActiveElement(ele); ele.focus && ele.focus(); + triggerFocusIn = true; - } else if(tapActiveElement() !== ele) { - if( (/input|textarea/i).test(ele.tagName) ) { - console.debug('tapHandleFocus', ele.tagName, ele.id); - tapActiveElement(ele); - ele.focus && ele.focus(); - ele.value = ele.value; - if( tapEnabledTouchEvents ) { - tapTouchFocusedInput = ele; - } - } else { - tapFocusOutActive(); + } else if(tapActiveElement() === ele) { + // already is the active element and has focus + triggerFocusIn = true; + + } else if( (/input|textarea/i).test(ele.tagName) ) { + 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); } } @@ -439,7 +469,5 @@ function tapTargetElement(ele) { } ionic.DomUtil.ready(function(){ - - ionic.tap.register(document.body); - + ionic.tap.register(document); }); diff --git a/js/utils/viewport.js b/js/utils/viewport.js new file mode 100644 index 0000000000..68ef3cee67 --- /dev/null +++ b/js/utils/viewport.js @@ -0,0 +1,57 @@ + +var viewportTag; +var viewportProperties = {}; + + +function viewportLoadTag() { + var x; + + for(x=0; x= 7.0 || (!ionic.Platform.isWebView() && ionic.Platform.isAndroid())){ - frameHeight = deviceHeight; - } - else { - frameHeight = deviceHeight - keyboardHeight; - } - - var element = e.target; - - //getBoundingClientRect() will give us position relative to the viewport - var elementDeviceBottom = element.getBoundingClientRect().bottom; - - if (e.detail.firstKeyboardShow){ - //shrink scrollview so we can actually scroll if the input is hidden - //if it isn't shrink so we can scroll to inputs under the keyboard - container.style.height = (container.clientHeight - keyboardHeight) + "px"; + if( !self.isScrolledIntoView ) { + // shrink scrollview so we can actually scroll if the input is hidden + // if it isn't shrink so we can scroll to inputs under the keyboard + container.style.height = (container.clientHeight - e.detail.keyboardHeight) + "px"; container.style.overflow = "visible"; - + self.isScrolledIntoView = true; //update scroll view self.resize(); } //If the element is positioned under the keyboard... - if (elementDeviceBottom > frameHeight) { + if( e.detail.isElementUnderKeyboard ) { //Put element in middle of visible screen //Wait for resize() to reset scroll position + ionic.scroll.isScrolling = true; setTimeout(function(){ - //distance from top of input to the top of the keyboard - var keyboardTopOffset = element.getBoundingClientRect().top - frameHeight; //middle of the scrollview, where we want to scroll to var scrollViewMidpointOffset = container.clientHeight * 0.5; - var scrollOffset = keyboardTopOffset + scrollViewMidpointOffset; - self.scrollBy(0, scrollOffset, true); - - //please someone tell me there's a better way to do this - //wait until input is scrolled into view, then fix focus - setTimeout(function(){ - element.value = element.value; //thanks @adambradley 1337h4x - }, 600); - }, 32); + var scrollTop = e.detail.keyboardTopOffset + scrollViewMidpointOffset; + console.debug('scrollChildIntoView', scrollTop); + ionic.tap.cloneFocusedInput(container, self); + self.scrollBy(0, scrollTop, true); + }, + (ionic.Platform.isIOS() ? 80 : 350) + ); } //Only the first scrollView parent of the element that broadcasted this event @@ -679,9 +660,11 @@ ionic.views.Scroll = ionic.views.View.inherit({ container.addEventListener('resetScrollView', function(e) { //return scrollview to original height once keyboard has hidden + self.isScrolledIntoView = false; container.style.height = ""; container.style.overflow = ""; self.resize(); + ionic.scroll.isScrolling = false; }); @@ -737,7 +720,7 @@ ionic.views.Scroll = ionic.views.View.inherit({ // disabled being able to select text on an input // hide the input which has focus, and show a cloned one that doesn't have focus self.__isSelectable = false; - ionic.tap.cloneFocusedInput(self.__container); + ionic.tap.cloneFocusedInput(container, self); } } @@ -751,13 +734,13 @@ ionic.views.Scroll = ionic.views.View.inherit({ self.__enableScrollY = true; if( !self.__isDragging && !self.__isDecelerating && !self.__isAnimating ) { - ionic.tap.removeClonedInputs(self.__container); + ionic.tap.removeClonedInputs(container, self); } }; self.options.orgScrollingComplete = self.options.scrollingComplete; self.options.scrollingComplete = function() { - ionic.tap.removeClonedInputs(self.__container); + ionic.tap.removeClonedInputs(container, self); self.options.orgScrollingComplete(); }; diff --git a/scss/_form.scss b/scss/_form.scss index e3327c7df7..d3f7cbd395 100644 --- a/scss/_form.scss +++ b/scss/_form.scss @@ -261,9 +261,9 @@ textarea { input[disabled], select[disabled], textarea[disabled], -input[readonly], -select[readonly], -textarea[readonly] { +input[readonly]:not(.cloned-text-input), +textarea[readonly]:not(.cloned-text-input), +select[readonly] { background-color: $input-bg-disabled; cursor: not-allowed; } diff --git a/test/html/input.html b/test/html/input.html index 8bc0350939..6a30a70fca 100644 --- a/test/html/input.html +++ b/test/html/input.html @@ -2,7 +2,9 @@ Inputs - + + + - +
-
+ -
+ - + + +
- + + + +

Click Tests - Tap Inputs - @@ -220,6 +253,10 @@ focusFirstInput: true }); + $scope.formSubmit = function(){ + alert('SUBMIT!'); + }; + }) .controller('ModalCtrl', function($scope) { @@ -233,6 +270,11 @@ var timeId; var consoleDebug = console.debug; + function getTime() { + var d = new Date(); + return d.getMilliseconds(); + } + console.debug = function() { index++; var msg = []; @@ -240,6 +282,7 @@ for (var i = 0, j = arguments.length; i < j; i++){ msg.push(arguments[i]); } + //msg.push(getTime()); msg = msg.join(', '); diff --git a/test/unit/utils/keyboard.unit.js b/test/unit/utils/keyboard.unit.js new file mode 100644 index 0000000000..9b10221f85 --- /dev/null +++ b/test/unit/utils/keyboard.unit.js @@ -0,0 +1,204 @@ + +/* + +Physical Device Testing Scenarios +--------------------------------- +- focusing inputs below the keyboard should scroll them into the middle of the view +- focusing inputs that are above the keyboard should not scroll, but still resize the scrollable content area +- focusing inputs should resize the scroll view so the user can scroll to inputs at the bottom of the page +- clicking the label of an input should focus that input +- focusing an input that is mostly offscreen should scroll into view using js scrolling, not the browser scrolling it into view +- focusing an input while another input already has focus should not (visibly) close and re-open the keyboard +- focusing an input that is above the keyboard while another input already has focus should not do anything +- focusing an input that is below the keyboard while another input already has focus should scroll it into view +- the header should not move when an input is focused +- entering an input on a popup or modal should resize and un-resize that scrollview +- opening a popup or a modal while the keyboard is up should un-resize the scrollview before opening the modal or popup +- changing the orientation of the device should not break any of the above^ +- quickly tap different text inputs then end up tapping an element that isn't a text input, the scroll resize should go away + +- focusing inputs at the bottom of the page should scroll into view normally (broken on iOS 7.0 w/o height meta tag) +- on iOS in safari, shrinking the view should account for the button-bar at the bottom (currently not working) + +Tentative: +- height=device-height not needed on iOS 6.1 +- height=device-height needed on iOS 7.0 Cordova + ** without it, fires 4 resize events when the keyboard comes up, and the scroll view resizes incorrectly, with it, does not fire resize events? ** +- height=device-height not needed on iOS 7.1 + + +Tested On +----------------------- +- iOS 7.1 Safari +- iOS 7.1 Cordova +- iOS 7.0 Safari +- iOS 7.0 Cordova +- iOS 6.1 Safari +- iOS 6.1 Cordova +- Android 4.4 Browser +- Android 4.4 Cordova +- Android 4.2 Browser +- Android 4.2 Cordova + + + +Notes: +--------------------------------- +iOS 7 keyboard is 216px tall without the accessory bar +iOS 7 keyboard is 260px tall with the accessory bar + +*/ + + +describe('Ionic Keyboard', function() { + var window; + + beforeEach(inject(function($window) { + window = $window; + window._setTimeout = window.setTimeout; + window.setTimeout = function(){}; + _activeElement = null; // the element which has focus + window.cordova = undefined; + window.device = undefined; + ionic.Platform.ua = ''; + ionic.Platform.platforms = null; + ionic.Platform.setPlatform(''); + ionic.Platform.setVersion(''); + ionic.keyboard.isOpen = false; + })); + + afterEach(function(){ + window.setTimeout = window._setTimeout; + }); + + it('Should keyboardShow', function(){ + var element = document.createElement('textarea'); + var elementTop = 100; + var elementBottom = 200; + var keyboardHeight = 200; + var deviceHeight = 500; + var details = keyboardShow(element, elementTop, elementBottom, deviceHeight, keyboardHeight); + + expect( details.keyboardHeight ).toEqual(200); + }); + + it('Should keyboardIsOverWebView()=false if Android and not isWebView', function(){ + // Android browser places the keyboard on top of the content and doesn't resize the window + ionic.Platform.setPlatform('Android'); + expect( ionic.Platform.isAndroid() ).toEqual(true); + expect( ionic.Platform.isWebView() ).toEqual(false); + + expect( ionic.Platform.isIOS() ).toEqual(false); + + expect( keyboardIsOverWebView() ).toEqual(true); + }); + + it('Should keyboardIsOverWebView()=false if Android and isWebView', function(){ + // Android webview gets shrunk by cordova and the keyboard fills the gap + ionic.Platform.setPlatform('Android'); + window.cordova = {}; + expect( ionic.Platform.isAndroid() ).toEqual(true); + expect( ionic.Platform.isWebView() ).toEqual(true); + + expect( keyboardIsOverWebView() ).toEqual(false); + }); + + it('Should keyboardIsOverWebView()=true if iOS 7.0 or greater', function(){ + ionic.Platform.setPlatform('iOS'); + ionic.Platform.setVersion('7.0'); + expect( ionic.Platform.isAndroid() ).toEqual(false); + expect( ionic.Platform.isIOS() ).toEqual(true); + + expect( keyboardIsOverWebView() ).toEqual(true); + }); + + it('Should keyboardIsOverWebView()=true if less than iOS 7.0', function(){ + ionic.Platform.setPlatform('iOS'); + ionic.Platform.setVersion('6.0'); + expect( ionic.Platform.isAndroid() ).toEqual(false); + expect( ionic.Platform.isIOS() ).toEqual(true); + + expect( keyboardIsOverWebView() ).toEqual(true); + }); + + it('Should keyboardHasPlugin', function() { + expect( keyboardHasPlugin() ).toEqual(false); + + window.cordova = {}; + expect( keyboardHasPlugin() ).toEqual(false); + + window.cordova.plugins = {}; + expect( keyboardHasPlugin() ).toEqual(false); + + window.cordova.plugins.Keyboard = {}; + expect( keyboardHasPlugin() ).toEqual(true); + }); + + it('keyboardGetHeight() should = DEFAULT_KEYBOARD_HEIGHT if no plugin or resized view', function(){ + expect( keyboardGetHeight() ).toEqual(275); + }); + + it('keyboardUpdateViewportHeight() should update when window.innerHeight > keyboardViewportHeight', function(){ + window.innerHeight = 460; + keyboardViewportHeight = 320; + keyboardUpdateViewportHeight(); + + expect( keyboardViewportHeight ).toEqual(460); + }); + + it('keyboardUpdateViewportHeight() should not update when window.innerHeight < keyboardViewportHeight', function(){ + window.innerHeight = 100; + keyboardViewportHeight = 320; + keyboardUpdateViewportHeight(); + + expect( keyboardViewportHeight ).toEqual(320); + }); + + it('Should scroll input into view if it is under the keyboard', function(){ + var element = document.createElement('textarea'); + var elementTop = 300; + var elementBottom = 400; + var keyboardHeight = 200; + var deviceHeight = 260; + var details = keyboardShow(element, elementTop, elementBottom, deviceHeight, keyboardHeight); + + expect( details.isElementUnderKeyboard ).toEqual(true); + }); + + it('Should not scroll input into view if it is not under the keyboard', function(){ + var element = document.createElement('textarea'); + var elementTop = 100; + var elementBottom = 200; + var keyboardHeight = 200; + var deviceHeight = 500; + var details = keyboardShow(element, elementTop, elementBottom, deviceHeight, keyboardHeight); + + expect( details.isElementUnderKeyboard ).toEqual(false); + }); + + it('Should not subtract the keyboard height from the contentHeight if not keyboardIsOverWebView()', function(){ + var element = document.createElement('textarea'); + var elementTop = 300; + var elementBottom = 400; + var keyboardHeight = 200; + var deviceHeight = 260; + var details = keyboardShow(element, elementTop, elementBottom, deviceHeight, keyboardHeight); + + expect( details.contentHeight ).toEqual(260); + }); + + it('Should subtract the keyboard height from the contentHeight if keyboardIsOverWebView()', function(){ + ionic.Platform.setPlatform('iOS'); + ionic.Platform.setVersion('7.1'); + + var element = document.createElement('textarea'); + var elementTop = 300; + var elementBottom = 400; + var keyboardHeight = 200; + var deviceHeight = 568; + var details = keyboardShow(element, elementTop, elementBottom, deviceHeight, keyboardHeight); + + expect( details.contentHeight ).toEqual(368); + }); + +}); diff --git a/test/unit/utils/tap.unit.js b/test/unit/utils/tap.unit.js new file mode 100644 index 0000000000..5963522f4f --- /dev/null +++ b/test/unit/utils/tap.unit.js @@ -0,0 +1,1148 @@ + +/* + +Physical Device Testing Scenarios +--------------------------------- +- Keyboard should show up when tapping on a text input +- Keyboard should show up when tapping on a label which surrounds a text input +- Keyboard should stay up if focused text input is tapped again +- Keyboard should go away when text input is focused, then tapped outside of input +- Keyboard should hide when tapping the virtual keyboard's "Done" or down arrow, but tapping +- Should be able to move an inputs text caret when its focused +- Should be able to move an inputs text caret when its focused, and is wrapped with a label + the input again will bring up the keyboard again +- Options dialog should show when tapping on a select +- Options dialog should show when tapping on a label which surrounds a select (not working in Android 2.3) +- Tapping a button element should fire one click +- Tapping an anchor element should fire one click +- Tapping a checkbox should fire one click +- Tapping a label which surrounds a checkbox should fire one click +- Tapping a radio button should fire one click +- Tapping a label which surrounds a radio button should fire one click +- Moving an input[range] slider should work +- Moving an input[range] slider when its in menu content of a should work +- Tapping the track on an input[range] slider should move the knob to that location (not a default in iOS) +- Tapping an input[file] should bring up the file dialog +- After tapping an input[file] and closing the input file dialog, tap a different + element and the file dialog should NOT show up +- Element which is disabled should not be clicked +- Holding a touchstart, and not moving, should fire the click no matter how long the hold +- Holding a mousedown, and not moving, should fire the click no matter how long the hold +- Holding touchstart, then moving a few pixels cancels the click +- Holding mousedown, then moving a few pixels cancels the click +- Touchstart should set and remove the activated css class +- Mousedown should set and remove the activated css class +- Holding touchstart, then moving a few pixels removes the activated css class +- Holding mousedown, then moving a few pixels removes the activated css class +- An element or one of its parents with data-tap-disabled attribute should still click, but w/ a delay +- ALL THE ABOVE, BUT NOW WITH NG-CLICK ON THE INPUT! +- Tapping a div with an click event added should fire one click +- Tapping an img with an click event added should fire one click +- Can scroll when target is a text input, but it does not have focus +- Can scroll when the target is a text input and it already has focus +- Can hold a text input and move its text caret/cursor +- Can scroll when the target is a label +- Does not change focus 300ms after when tapping an input and the keyboard shows up +- The blinking cursor says in the input 300ms after the tap +- Can hit the keyboard's "next" button to change focus to the next input +- When flicking down a page, and a target was an input, it shouldn't focus and jank scrolling +- Do not show text caret of a focused input while scrolling, no matter what the target is +- After scrolling has come to a halt, previously focused input should be focused again +- Keyboard should stay up while scrolling if an input was focused +- Keyboard should not go away after scrolling stops and it focuses back on the previous focused input +- Should not create clones for tap inputs to hide cursor, like for checkboxes, radio, range, select +- Focus on an input, flick up, let go, and during animation flick down, and clone should go away +- While inputs are actively scrolling you should not be able to change focus +- If you touchstart on a text input, then scrolland touchend, it should not bring up the keyboard +- If you touchstart on a label wrapping a text input, then scroll and touchend, it should not bring up the keyboard +- When focused in a text input, be able to get out when tapping a checkbox +- Can scroll when the target is a select element (touch events only) +- Can scroll when the target is a label which wraps select element +- Can open the select options on touch when not scrolling +- Can open the select options mouse click when target is a select +- Can open the select options when target is a label wrapping a select + +Tested on: +---------------------------- +- iOS 6.1 iPad 3 +- iOS 7.0 iPhone 4 +- iOS 7.0 iPhone 5 +- iOS 7.1 iPhone 5 +- iOS 7.1 Simulator +- Android 2.3 HTC Incredible +- Android 2.3 Samsung Galaxy S +- Android 4.0 HTC Incredible +- Android 4.2 Nexus 4 +- Android 4.3 Samsung S3 +- Android 4.4 Motorola Moto G +- Android 4.4 Nexus 5 +- OSX Chrome +- OSX Chrome (emulated touch screen) +- OSX Firefox + +*/ + +window.console.debug = function(){}; + +describe('Ionic Tap', function() { + var deregisterTap; + + beforeEach(function() { + window._setTimeout = window.setTimeout; + window.setTimeout = function(){}; + _activeElement = null; // the element which has focus + + deregisterTap = ionic.tap.register(document.createElement('div')); + ionic.scroll = { isScrolling: false }; + }); + + afterEach(function(){ + window.setTimeout = window._setTimeout; + deregisterTap(); + }); + + it('Should trigger a labels child inputs click, and should not stop the labels end event so Android allows text editing', function() { + var e = { + type: 'touchstart', + target: { + tagName: 'LABEL', + dispatchEvent: function() { e.target.dispatchedEvent = true; }, + focus: function() { e.target.focused = true; }, + control: { + tagName: 'INPUT', + dispatchEvent: function() { e.target.control.dispatchedEvent = true; }, + focus: function() { e.target.control.focused = true; } + } + }, + stopPropagation: function() { e.stoppedPropagation = true; }, + preventDefault: function() { e.preventedDefault = true; } + }; + + tapClick(e); + + expect( e.target.dispatchedEvent ).toBeUndefined(); + expect( e.target.focused ).toBeUndefined(); + + expect( e.target.control.dispatchedEvent ).toBeDefined(); + expect( e.target.control.focused ).toBeDefined(); + + expect( e.stoppedPropagation ).toBeUndefined(); + expect( e.preventedDefault ).toBeUndefined(); + }); + + it('Should trigger a click for an element w/out a wrapping label', function() { + var e = { + type: 'touchstart', + target: { + tagName: 'INPUT', + dispatchEvent: function() { e.target.dispatchedEvent = true; }, + focus: function() { e.target.focused = true; } + }, + stopPropagation: function() { e.stoppedPropagation = true; }, + preventDefault: function() { e.preventedDefault = true; } + }; + + tapClick(e); + + expect( e.target.dispatchedEvent ).toBeDefined(); + expect( e.target.focused ).toBeDefined(); + + expect( e.stoppedPropagation ).toBeUndefined(); + expect( e.preventedDefault ).toBeUndefined(); + + }); + + it('Should not trigger a click if tapPointerMoved has moved', function() { + var e = { + type: 'touchstart', + target: { + tagName: 'INPUT', + dispatchEvent: function() { e.target.dispatchedEvent = true; }, + focus: function() { e.target.focused = true; } + }, + stopPropagation: function() { e.stoppedPropagation = true; }, + preventDefault: function() { e.preventedDefault = true; } + }; + + tapPointerMoved = true; + expect( tapClick(e) ).toEqual(false); + + }); + + it('Should trigger click on mouseup and when nearby mousedown happened', function() { + var e = { + type: 'mousedown', + clientX: 100, clientY: 100, + target: { + tagName: 'INPUT', + dispatchEvent: function() { e.target.dispatchedEvent = true; }, + focus: function() { e.target.focused = true; } + }, + stopPropagation: function() { e.stoppedPropagation = true; }, + preventDefault: function() { e.preventedDefault = true; } + }; + + expect( e.target.dispatchedEvent ).toBeUndefined(); + + tapMouseDown({clientX: 101, clientY: 101}); + tapMouseUp(e); + + expect( e.target.dispatchedEvent ).toBeDefined(); + }); + + it('Should not trigger click on mouseup because mousedown coordinates too far away', function() { + var e = { + clientX: 100, clientY: 100, + target: { + tagName: 'INPUT', + dispatchEvent: function() { e.target.dispatchedEvent = true; }, + focus: function() { e.target.focused = true; } + }, + stopPropagation: function() { e.stoppedPropagation = true; }, + preventDefault: function() { e.preventedDefault = true; } + }; + + expect( e.target.dispatchedEvent ).toBeUndefined(); + + tapMouseDown({clientX: 201, clientY: 101}); + tapMouseUp(e); + + expect( e.target.dispatchedEvent ).toBeUndefined(); + }); + + it('Should set tapHasPointerMoved=false on tapTouchStart', function() { + tapPointerMoved = null; + tapTouchStart({ preventDefault:function(){} }); + expect( tapPointerMoved ).toEqual(false); + }); + + it('Should not preventDefault on text input that already has focus and is iOS', function() { + // prevent the default so iOS doesn't auto scroll to the input + ionic.Platform.setPlatform('ios'); + var label = document.createElement('label'); + var textarea = document.createElement('textarea'); + label.appendChild(textarea); + var e = { + target: label, + preventDefault: function() { e.preventedDefault = true; } + }; + + tapActiveEle = textarea; + + tapTouchStart(e); + expect( e.preventedDefault ).toBeUndefined(); + }); + + it('Should preventDefault on text input that does not have focus and is iOS', function() { + // prevent the default so iOS doesn't auto scroll to the input + ionic.Platform.setPlatform('ios'); + var label = document.createElement('label'); + var textarea = document.createElement('textarea'); + label.appendChild(textarea); + var e = { + target: label, + preventDefault: function() { e.preventedDefault = true; } + }; + + tapTouchStart(e); + expect( e.preventedDefault ).toEqual(true); + }); + + it('Should not preventDefault on text input target thats not iOS', function() { + // do not prevent default on touchend of a text input or else you cannot move the text caret + ionic.Platform.setPlatform('android'); + var label = document.createElement('label'); + var textarea = document.createElement('textarea'); + label.appendChild(textarea); + var e = { + target: label, + preventDefault: function() { e.preventedDefault = true; } + }; + tapTouchStart(e); + expect( e.preventedDefault ).toBeUndefined(); + }); + + it('Should set tapPointerMoved=false on tapTouchCancel', function() { + tapPointerMoved = true; + tapTouchCancel(); + expect( tapPointerMoved ).toEqual(false); + }); + + it('Should set tapHasPointerMoved=true on tapTouchMove', function() { + tapPointerMoved = null; + tapTouchStart({ clientX: 100, clientY: 100, preventDefault:function(){} }); + expect( tapPointerMoved ).toEqual(false); + tapTouchMove({ clientX: 200, clientY: 100 }); + expect( tapPointerMoved ).toEqual(true); + }); + + it('Should set tapHasPointerMoved=false on tapMouseDown', function() { + tapPointerMoved = null; + tapMouseDown({}); + expect( tapPointerMoved ).toEqual(false); + }); + + it('Should set tapPointerMoved=false on tapMouseUp', function() { + tapPointerMoved = true; + tapMouseUp({}); + expect( tapPointerMoved ).toEqual(false); + }); + + it('Should set tapHasPointerMoved=true on tapMouseMove', function() { + tapPointerMoved = null; + tapMouseDown({ clientX: 100, clientY: 100 }); + expect( tapPointerMoved ).toEqual(false); + tapMouseMove({ clientX: 200, clientY: 100 }); + expect( tapPointerMoved ).toEqual(true); + }); + + it('Should stop event on mouseup if touch is enabled', function() { + tapEnabledTouchEvents = true; + var e = { + stopPropagation: function() { this.stoppedPropagation = true; }, + preventDefault: function() { this.preventedDefault = true; } + } + tapMouseUp(e); + expect( e.stoppedPropagation ).toEqual(true); + expect( e.preventedDefault ).toEqual(true); + }); + + it('Should not stop event on mouseup if touch is not enabled', function() { + tapEnabledTouchEvents = false; + e = { + stopPropagation: function() { this.stoppedPropagation = true; }, + preventDefault: function() { this.preventedDefault = true; } + } + tapMouseUp(e); + expect( e.stoppedPropagation ).toBeUndefined(); + expect( e.preventedDefault ).toBeUndefined(); + }); + + it('Should trigger click on touchend and nearby touchstart happened', function() { + var e = { + type: 'touchend', + clientX: 101, clientY: 101, + target: { + tagName: 'INPUT', + dispatchEvent: function() { e.target.dispatchedEvent = true; }, + focus: function() { e.target.focused = true; } + }, + stopPropagation: function() { e.stoppedPropagation = true; }, + preventDefault: function() { e.preventedDefault = true; } + }; + + tapTouchStart({clientX: 100, clientY: 100, preventDefault:function(){}}); + tapTouchEnd(e); + + expect( e.target.dispatchedEvent ).toBeDefined(); + }); + + it('Should not trigger click on touchend because touchstart coordinates too far away', function() { + var e = { + type: 'touchstart', + clientX: 100, clientY: 100, + target: { + tagName: 'INPUT', + dispatchEvent: function() { e.target.dispatchedEvent = true; }, + focus: function() { e.target.focused = true; } + }, + stopPropagation: function() { e.stoppedPropagation = true; }, + preventDefault: function() { e.preventedDefault = true; } + }; + + expect( e.target.dispatchedEvent ).toBeUndefined(); + + tapTouchStart({clientX: 200, clientY: 100, preventDefault:function(){}}); + + tapTouchEnd(e); + + expect( e.target.dispatchedEvent ).toBeUndefined(); + }); + + it('Should tapEnabledTouchEvents because of touchstart', function() { + tapEnabledTouchEvents = false; + tapTouchStart({preventDefault:function(){}}); + tapEnabledTouchEvents = true; + }); + + it('Should cancel click on touchcancel', function() { + tapTouchCancel(); + expect(tapPointerMoved).toEqual(false); + }); + + it('Should cancel click when touchmove coordinates goes too far from touchstart coordinates', function() { + var e = { clientX: 100, clientY: 100, preventDefault:function(){} }; + tapTouchStart(e); + + expect( tapTouchMove({ clientX: 102, clientY: 100 }) ).toBeUndefined(); + + expect( tapTouchMove({ clientX: 105, clientY: 100 }) ).toBeUndefined(); + + expect( tapTouchMove({ clientX: 200, clientY: 100 }) ).toEqual(false); + }); + + it('Should cancel click when touchend coordinates are too far from touchstart coordinates', function() { + var e = { + clientX: 100, clientY: 100, + dispatchEvent: function(){ this.dispatchedEvent = true; }, + preventDefault:function(){} + }; + tapTouchStart(e); + tapTouchEnd({ clientX: 200, clientY: 100 }); + expect( e.dispatchedEvent ).toBeUndefined(); + }); + + it('Should cancel click when mousemove coordinates goes too far from mousedown coordinates', function() { + var e = { clientX: 100, clientY: 100 }; + tapMouseDown(e); + + expect( tapMouseMove({ clientX: 102, clientY: 100 }) ).toBeUndefined(); + + expect( tapMouseMove({ clientX: 105, clientY: 100 }) ).toBeUndefined(); + + expect( tapMouseMove({ clientX: 200, clientY: 100 }) ).toEqual(false); + }); + + it('Should cancel click when mouseup coordinates are too far from mousedown coordinates', function() { + var e = { + clientX: 100, clientY: 100, + dispatchEvent: function(){ this.dispatchedEvent = true; } + }; + tapMouseDown(e); + tapMouseUp({ clientX: 200, clientY: 100 }); + expect( e.dispatchedEvent ).toBeUndefined(); + }); + + it('Should do nothing if mousedown is a custom event from ionic tap', function() { + var e = { + isTapHandled: false, + isIonicTap: true + }; + tapMouseDown(e); + expect( e.isTapHandled ).toEqual(false); + }); + + it('Should tapClick with touchend and fire immediately', function() { + var e = { + target: { + tagName: 'button', + dispatchEvent: function(){ + this.dispatchedEvent = true; + } + } + }; + tapClick(e); + expect(e.target.dispatchedEvent).toEqual(true); + }); + + it('Should tapHasPointerMoved false if are null', function() { + expect( tapHasPointerMoved(null) ).toEqual(false); + }); + + it('Should tapPointerStart false if are null', function() { + tapPointerStart = null; + expect( tapHasPointerMoved(null) ).toEqual(false); + }); + + it('Should tapPointerStart false if are null', function() { + var e = {}; + tapPointerStart = {x:0, y:0}; + expect( tapHasPointerMoved(e) ).toEqual(false); + }); + + it('Should tapHasPointerMoved true if greater than or equal to release tolerance', function() { + tapPointerStart = { x: 100, y: 100 }; + + var s = tapHasPointerMoved({ clientX: 111, clientY: 100 }); + expect(s).toEqual(true); + + s = tapHasPointerMoved({ clientX: 89, clientY: 100 }); + expect(s).toEqual(true); + + s = tapHasPointerMoved({ clientX: 100, clientY: 109 }); + expect(s).toEqual(true); + + s = tapHasPointerMoved({ clientX: 100, clientY: 91 }); + expect(s).toEqual(true); + + s = tapHasPointerMoved({ clientX: 100, clientY: 200 }); + expect(s).toEqual(true); + }); + + it('Should tapHasPointerMoved false if less than release tolerance', function() { + tapPointerStart = { x: 100, y: 100 }; + + var s = tapHasPointerMoved({ clientX: 100, clientY: 100 }); + expect(s).toEqual(false); + + s = tapHasPointerMoved({ clientX: 104, clientY: 100 }); + expect(s).toEqual(false); + + s = tapHasPointerMoved({ clientX: 96, clientY: 100 }); + expect(s).toEqual(false); + + s = tapHasPointerMoved({ clientX: 100, clientY: 102 }); + expect(s).toEqual(false); + + s = tapHasPointerMoved({ clientX: 100, clientY: 98 }); + expect(s).toEqual(false); + }); + + it('Should not be tapHasPointerMoved if 0 coordinates', function() { + var e = { clientX: 0, clientY: 0 }; + var s = tapHasPointerMoved(e, { clientX: 100, clientY: 100 }); + expect(s).toEqual(false); + }); + + it('Should get coordinates from page mouse event', function() { + var e = { pageX: 77, pageY: 77 }; + var c = getPointerCoordinates(e); + expect(c).toEqual({x:77, y: 77}); + }); + + it('Should get coordinates from client mouse event', function() { + var e = { clientX: 77, clientY: 77 }; + var c = getPointerCoordinates(e); + expect(c).toEqual({x:77, y: 77}); + }); + + it('Should get coordinates from changedTouches touches', function() { + var e = { + touches: [{ clientX: 99, clientY: 99 }], + changedTouches: [{ clientX: 88, clientY: 88 }] + }; + var c = getPointerCoordinates(e); + expect(c).toEqual({x:88, y: 88}); + }); + + it('Should get coordinates from page touches', function() { + var e = { + touches: [{ pageX: 99, pageY: 99 }] + }; + var c = getPointerCoordinates(e); + expect(c).toEqual({x:99, y: 99}); + }); + + it('Should get coordinates from client touches', function() { + var e = { + touches: [{ clientX: 99, clientY: 99 }] + }; + var c = getPointerCoordinates(e); + expect(c).toEqual({x:99, y: 99}); + }); + + it('Should get 0 coordinates', function() { + var e = {}; + var c = getPointerCoordinates(e); + expect(c).toEqual({x:0, y: 0}); + }); + + it('Should not tapClick for disabled elements', function() { + // Disabled elements should not be tapped + var targetEle = document.createElement('input'); + targetEle.disabled = true; + + var e = { + target: targetEle + }; + + expect( tapClick(e) ).toEqual(false); + }); + + it('Should tapRequiresNativeClick for invalid element', function() { + expect( tapRequiresNativeClick( null ) ).toEqual(true); + }); + + it('Should tapRequiresNativeClick for input.disabled', function() { + var ele = document.createElement('input'); + ele.disabled = true; + expect( tapRequiresNativeClick( ele ) ).toEqual(true); + }); + + it('Should tapRequiresNativeClick for input[range]', function() { + var ele = document.createElement('input'); + ele.type = 'range'; + expect( tapRequiresNativeClick( ele ) ).toEqual(true); + }); + + it('Should tapRequiresNativeClick for input[file]', function() { + var ele = document.createElement('input'); + ele.type = 'file'; + expect( tapRequiresNativeClick( ele ) ).toEqual(true); + }); + + it('Should tapRequiresNativeClick for video element', function() { + var ele = document.createElement('video'); + expect( tapRequiresNativeClick( ele ) ).toEqual(true); + }); + + it('Should tapRequiresNativeClick for object element', function() { + var ele = document.createElement('object'); + expect( tapRequiresNativeClick( ele ) ).toEqual(true); + }); + + it('Should not tapRequiresNativeClick for common inputs', function() { + var inputTypes = ['text', 'email', 'search', 'tel', 'number', 'date', 'month', 'password', null, undefined, '']; + for(var x=0; x