Skip to content
This repository has been archived by the owner on Sep 5, 2024. It is now read-only.

Commit

Permalink
fix(autocomplete): improve handling of touch pads and touchscreens
Browse files Browse the repository at this point in the history
- open options pop-up on `touchstart`
  - since a `click` is often not sent on touch devices
  - this usually happens when the start/end point are not the same
- use `touchend` on the document to close the options panel on iOS
  - iOS mostly does not send `click` events for taps on the backdrop
  - call `doBlur()`` since iOS doesn't blur in this case
- combine some jQuery event handler calls
- combine duplicate `onMouseup()` and `focusInputElement()` functions
- don't let touchstart or touchend events bubble out of the component
- focus the input for `mousedown` events
  - this covers an edge case on touch pads where a `click` isn't sent
- move `isIos` and `isAndroid` logic out of gestures into `$mdUtil`
- add and correct JSDoc

Fixes #11778. Relates to #11625. Relates to #11757. Relates to #11758.
  • Loading branch information
Splaktar committed Aug 26, 2019
1 parent 8c159aa commit e731953
Show file tree
Hide file tree
Showing 6 changed files with 139 additions and 93 deletions.
5 changes: 3 additions & 2 deletions docs/app/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,9 @@ function(SERVICES, COMPONENTS, DEMOS, PAGES,

}])

.config(['AngularyticsProvider', function(AngularyticsProvider) {
AngularyticsProvider.setEventHandlers(['GoogleUniversal']);
.config(['$mdGestureProvider', 'AngularyticsProvider', function($mdGestureProvider, AngularyticsProvider) {
$mdGestureProvider.skipClickHijack();
AngularyticsProvider.setEventHandlers(['GoogleUniversal']);
}])

.run(['$rootScope', '$window', 'Angularytics', function($rootScope, $window, Angularytics) {
Expand Down
8 changes: 3 additions & 5 deletions src/components/autocomplete/autocomplete.scss
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ md-autocomplete {
}

.md-show-clear-button {

button {
display: block;
position: absolute;
Expand All @@ -43,10 +42,8 @@ md-autocomplete {
@include rtl-prop(padding-right, padding-left, $md-autocomplete-clear-size, 0);
}
}

}
md-autocomplete-wrap {

// Layout [layout='row']
display: flex;
flex-direction: row;
Expand All @@ -59,9 +56,10 @@ md-autocomplete {
z-index: $z-index-backdrop + 1;
}

md-input-container, input {
md-input-container,
input {
// Layout [flex]
flex: 1 1 0%;
flex: 1 1 0;
box-sizing: border-box;
min-width : 0;
}
Expand Down
98 changes: 61 additions & 37 deletions src/components/autocomplete/js/autocompleteController.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
ctrl.select = select;
ctrl.listEnter = onListEnter;
ctrl.listLeave = onListLeave;
ctrl.mouseUp = onMouseup;
ctrl.focusInput = focusInputElement;
ctrl.getCurrentDisplayValue = getCurrentDisplayValue;
ctrl.registerSelectedItemWatcher = registerSelectedItemWatcher;
ctrl.unregisterSelectedItemWatcher = unregisterSelectedItemWatcher;
Expand Down Expand Up @@ -103,6 +103,10 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
gatherElements();
moveDropdown();

// Touch devices often do not send a click event on tap. We still want to focus the input
// and open the options pop-up in these cases.
$element.on('touchstart', focusInputElement);

// Forward all focus events to the input element when autofocus is enabled
if ($scope.autofocus) {
$element.on('focus', focusInputElement);
Expand Down Expand Up @@ -366,12 +370,31 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,

// event/change handlers

/**
* @param {Event} $event
*/
function preventDefault($event) {
$event.preventDefault();
}

/**
* @param {Event} $event
*/
function stopPropagation($event) {
$event.stopPropagation();
}

/**
* Handles changes to the `hidden` property.
* @param {boolean} hidden
* @param {boolean} oldHidden
* @param {boolean} hidden true to hide the options pop-up, false to show it.
* @param {boolean} oldHidden the previous value of hidden
*/
function handleHiddenChange (hidden, oldHidden) {
var scrollContainerElement;

if (elements) {
scrollContainerElement = angular.element(elements.scrollContainer);
}
if (!hidden && oldHidden) {
positionDropdown();

Expand All @@ -380,13 +403,23 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
reportMessages(true, ReportType.Count | ReportType.Selected);

if (elements) {
$mdUtil.disableScrollAround(elements.ul);
enableWrapScroll = disableElementScrollEvents(angular.element(elements.wrap));
ctrl.documentElement.on('click', handleClickOutside);
$mdUtil.disableScrollAround(elements.scrollContainer);
enableWrapScroll = disableElementScrollEvents(elements.wrap);
if ($mdUtil.isIos) {
ctrl.documentElement.on('touchend', handleTouchOutsidePanel);
if (scrollContainerElement) {
scrollContainerElement.on('touchstart touchmove touchend', stopPropagation);
}
}
$mdUtil.nextTick(updateActiveOption);
}
} else if (hidden && !oldHidden) {
ctrl.documentElement.off('click', handleClickOutside);
if ($mdUtil.isIos) {
ctrl.documentElement.off('touchend', handleTouchOutsidePanel);
if (scrollContainerElement) {
scrollContainerElement.off('touchstart touchmove touchend', stopPropagation);
}
}
$mdUtil.enableScrolling();

if (enableWrapScroll) {
Expand All @@ -397,29 +430,27 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
}

/**
* Handling click events that bubble up to the document is required for closing the dropdown
* panel on click outside of the panel on iOS.
* Handling touch events that bubble up to the document is required for closing the dropdown
* panel on touch outside of the options pop-up panel on iOS.
* @param {Event} $event
*/
function handleClickOutside($event) {
function handleTouchOutsidePanel($event) {
ctrl.hidden = true;
// iOS does not blur the pop-up for touches on the scroll mask, so we have to do it.
doBlur(true);
}

/**
* Disables scrolling for a specific element
* Disables scrolling for a specific element.
* @param {!string|!DOMElement} element to disable scrolling
* @return {Function} function to call to re-enable scrolling for the element
*/
function disableElementScrollEvents(element) {

function preventDefault(e) {
e.preventDefault();
}

element.on('wheel', preventDefault);
element.on('touchmove', preventDefault);
var elementToDisable = angular.element(element);
elementToDisable.on('wheel touchmove', preventDefault);

return function() {
element.off('wheel', preventDefault);
element.off('touchmove', preventDefault);
elementToDisable.off('wheel touchmove', preventDefault);
};
}

Expand All @@ -439,13 +470,6 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
ctrl.hidden = shouldHide();
}

/**
* When the mouse button is released, send focus back to the input field.
*/
function onMouseup () {
elements.input.focus();
}

/**
* Handles changes to the selected item.
* @param selectedItem
Expand Down Expand Up @@ -673,7 +697,7 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,

/**
* Returns the display value for an item.
* @param item
* @param {*} item
* @returns {*}
*/
function getDisplayValue (item) {
Expand All @@ -689,7 +713,7 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
/**
* Getter function to invoke user-defined expression (in the directive)
* to convert your object to a single string.
* @param item
* @param {*} item
* @returns {string|null}
*/
function getItemText (item) {
Expand All @@ -699,7 +723,7 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,

/**
* Returns the locals object for compiling item templates.
* @param item
* @param {*} item
* @returns {Object|undefined}
*/
function getItemAsNameVal (item) {
Expand Down Expand Up @@ -837,14 +861,14 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
* Defines a public property with a handler and a default value.
* @param {string} key
* @param {Function} handler function
* @param {*} value default value
* @param {*} defaultValue default value
*/
function defineProperty (key, handler, value) {
function defineProperty (key, handler, defaultValue) {
Object.defineProperty(ctrl, key, {
get: function () { return value; },
get: function () { return defaultValue; },
set: function (newValue) {
var oldValue = value;
value = newValue;
var oldValue = defaultValue;
defaultValue = newValue;
handler(newValue, oldValue);
}
});
Expand Down Expand Up @@ -1014,7 +1038,7 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
function updateVirtualScroll() {
// elements in virtual scroll have consistent heights
var optionHeight = elements.li[0].offsetHeight,
top = optionHeight * ctrl.index,
top = optionHeight * Math.max(0, ctrl.index),
bottom = top + optionHeight,
containerHeight = elements.scroller.clientHeight,
scrollTop = elements.scroller.scrollTop;
Expand All @@ -1028,7 +1052,7 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,

function updateStandardScroll() {
// elements in standard scroll have variable heights
var selected = elements.li[ctrl.index] || elements.li[0];
var selected = elements.li[Math.max(0, ctrl.index)];
var containerHeight = elements.scrollContainer.offsetHeight,
top = selected && selected.offsetTop || 0,
bottom = top + selected.clientHeight,
Expand Down
8 changes: 5 additions & 3 deletions src/components/autocomplete/js/autocompleteDirective.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ angular
* @param {number=} md-delay Specifies the amount of time (in milliseconds) to wait before looking
* for results.
* @param {boolean=} md-clear-button Whether the clear button for the autocomplete input should show
* up or not.
* up or not. When `md-floating-label` is set, defaults to false, defaults to true otherwise.
* @param {boolean=} md-autofocus If true, the autocomplete will be automatically focused when a
* `$mdDialog`, `$mdBottomsheet` or `$mdSidenav`, which contains the autocomplete, is opening.
* <br/><br/>
Expand Down Expand Up @@ -365,7 +365,7 @@ function MdAutocomplete ($$mdSvgRegistry) {

// Stop click events from bubbling up to the document and triggering a flicker of the
// options panel while still supporting ng-click to be placed on md-autocomplete.
element.on('click', function(event) {
element.on('click touchstart touchend', function(event) {
event.stopPropagation();
});
};
Expand Down Expand Up @@ -402,7 +402,7 @@ function MdAutocomplete ($$mdSvgRegistry) {
id="ul-{{$mdAutocompleteCtrl.id}}"\
ng-mouseenter="$mdAutocompleteCtrl.listEnter()"\
ng-mouseleave="$mdAutocompleteCtrl.listLeave()"\
ng-mouseup="$mdAutocompleteCtrl.mouseUp()"\
ng-mouseup="$mdAutocompleteCtrl.focusInput()"\
role="listbox">\
<li class="md-autocomplete-suggestion" ' + getRepeatType(attr.mdMode) + ' ="item in $mdAutocompleteCtrl.matches"\
ng-class="{ selected: $index === $mdAutocompleteCtrl.index }"\
Expand Down Expand Up @@ -496,6 +496,7 @@ function MdAutocomplete ($$mdSvgRegistry) {
ng-disabled="$mdAutocompleteCtrl.isDisabled"\
ng-model="$mdAutocompleteCtrl.scope.searchText"\
ng-model-options="{ allowInvalid: true }"\
ng-mousedown="$mdAutocompleteCtrl.focusInput()"\
ng-keydown="$mdAutocompleteCtrl.keydown($event)"\
ng-blur="$mdAutocompleteCtrl.blur($event)"\
ng-focus="$mdAutocompleteCtrl.focus($event)"\
Expand Down Expand Up @@ -523,6 +524,7 @@ function MdAutocomplete ($$mdSvgRegistry) {
ng-minlength="inputMinlength"\
ng-maxlength="inputMaxlength"\
ng-model="$mdAutocompleteCtrl.scope.searchText"\
ng-mousedown="$mdAutocompleteCtrl.focusInput()"\
ng-keydown="$mdAutocompleteCtrl.keydown($event)"\
ng-blur="$mdAutocompleteCtrl.blur($event)"\
ng-focus="$mdAutocompleteCtrl.focus($event)"\
Expand Down
40 changes: 19 additions & 21 deletions src/core/services/gesture/gesture.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,18 @@ var forceSkipClickHijack = false, disableAllGestures = false;
*/
var lastLabelClickPos = null;

// Used to attach event listeners once when multiple ng-apps are running.
/**
* Used to attach event listeners once when multiple ng-apps are running.
* @type {boolean}
*/
var isInitialized = false;

// Support material-tools builds.
if (window.navigator) {
var userAgent = window.navigator.userAgent || window.navigator.vendor || window.opera;
var isIos = userAgent.match(/ipad|iphone|ipod/i);
var isAndroid = userAgent.match(/android/i);
}

/**
* @ngdoc module
* @name material.core.gestures
* @description
* AngularJS Material Gesture handling for touch devices. This module replaced the usage of the hammerjs library.
* AngularJS Material Gesture handling for touch devices.
* This module replaced the usage of the HammerJS library.
*/
angular
.module('material.core.gestures', [])
Expand All @@ -43,10 +40,11 @@ angular
*
* @description
* In some scenarios on mobile devices (without jQuery), the click events should NOT be hijacked.
* `$mdGestureProvider` is used to configure the Gesture module to ignore or skip click hijacking on mobile
* devices.
* `$mdGestureProvider` is used to configure the Gesture module to ignore or skip click hijacking
* on mobile devices.
*
* You can also change the max click distance, `6px` by default, if you have issues on some touch screens.
* You can also change the max click distance, `6px` by default, if you have issues on some touch
* screens.
*
* <hljs lang="js">
* app.config(function($mdGestureProvider) {
Expand Down Expand Up @@ -105,8 +103,8 @@ MdGestureProvider.prototype = {
* $get is used to build an instance of $mdGesture
* @ngInject
*/
$get : function($$MdGestureHandler, $$rAF, $timeout) {
return new MdGesture($$MdGestureHandler, $$rAF, $timeout);
$get : function($$MdGestureHandler, $$rAF, $timeout, $mdUtil) {
return new MdGesture($$MdGestureHandler, $$rAF, $timeout, $mdUtil);
}
};

Expand All @@ -116,17 +114,17 @@ MdGestureProvider.prototype = {
* MdGesture factory construction function
* @ngInject
*/
function MdGesture($$MdGestureHandler, $$rAF, $timeout) {
function MdGesture($$MdGestureHandler, $$rAF, $timeout, $mdUtil) {
var touchActionProperty = getTouchAction();
var hasJQuery = (typeof window.jQuery !== 'undefined') && (angular.element === window.jQuery);
var hasJQuery = (typeof window.jQuery !== 'undefined') && (angular.element === window.jQuery);

var self = {
handler: addHandler,
register: register,
isAndroid: isAndroid,
isIos: isIos,
isAndroid: $mdUtil.isAndroid,
isIos: $mdUtil.isIos,
// On mobile w/out jQuery, we normally intercept clicks. Should we skip that?
isHijackingClicks: (isIos || isAndroid) && !hasJQuery && !forceSkipClickHijack
isHijackingClicks: ($mdUtil.isIos || $mdUtil.isAndroid) && !hasJQuery && !forceSkipClickHijack
};

if (self.isHijackingClicks) {
Expand Down Expand Up @@ -575,7 +573,7 @@ function MdGestureHandler() {
* Attach Gestures: hook document and check shouldHijack clicks
* @ngInject
*/
function attachToDocument($mdGesture, $$MdGestureHandler) {
function attachToDocument($mdGesture, $$MdGestureHandler, $mdUtil) {
if (disableAllGestures) {
return;
}
Expand Down Expand Up @@ -623,7 +621,7 @@ function attachToDocument($mdGesture, $$MdGestureHandler) {
*/
function clickHijacker(ev) {
var isKeyClick;
if (isIos) {
if ($mdUtil.isIos) {
isKeyClick = angular.isDefined(ev.webkitForce) && ev.webkitForce === 0;
} else {
isKeyClick = ev.clientX === 0 && ev.clientY === 0;
Expand Down
Loading

0 comments on commit e731953

Please sign in to comment.