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

Commit

Permalink
keep overflow, prevent events that cause scrolling (#93)
Browse files Browse the repository at this point in the history
* keep overflow, calculate scroll size

* check either horizontal or vertical scroll delta, not both

* restore scroll position when scroll happens

* scroll listener on dropdown, call refit if allowScrollOutside

* normalize wheel delta

* prevent scrolling keys

* store EVENT_PATH on touchstart

* use touches to find the deltaX/Y

* restore elementIsScrollLocked, make _shouldPreventScrolling private

* ensure touchstart is prevented only if happens outside the locking element

* ensure touchstart is not prevented

* store last touch position instead of the event

* return an object scrollDeltaInfo since event.deltaX is readonly

* store only scrollable nodes

* avoid preventing events that are not cancelable, e.g. scroll that cannot be interrupted

* fix bug on touch events, update docs

* check only one axis according to where there is more scroll
  • Loading branch information
valdrinkoshi authored Jul 19, 2016
1 parent c3332bf commit 4a00d0b
Show file tree
Hide file tree
Showing 5 changed files with 392 additions and 86 deletions.
219 changes: 176 additions & 43 deletions iron-dropdown-scroll-manager.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@
<script>
(function() {
'use strict';
// Used to calculate the scroll direction during touch events.
var LAST_TOUCH_POSITION = {
pageX: 0,
pageY: 0
};
// Used to avoid computing event.path and filter scrollable nodes (better perf).
var ROOT_TARGET = null;
var SCROLLABLE_NODES = [];

/**
* The IronDropdownScrollManager is intended to provide a central source
Expand All @@ -31,9 +39,8 @@
return this._lockingElements[this._lockingElements.length - 1];
},


/**
* Returns true if the provided element is "scroll locked," which is to
* Returns true if the provided element is "scroll locked", which is to
* say that it cannot be scrolled via pointer or keyboard interactions.
*
* @param {HTMLElement} element An HTML element instance which may or may
Expand Down Expand Up @@ -126,8 +133,6 @@

_unlockedElementCache: null,

_originalBodyStyles: {},

_isScrollingKeypress: function(event) {
return Polymer.IronA11yKeysBehavior.keyboardEventMatchesKeys(
event, 'pageup pagedown home end up left down right');
Expand Down Expand Up @@ -175,58 +180,186 @@
},

_scrollInteractionHandler: function(event) {
var scrolledElement =
/** @type {HTMLElement} */(Polymer.dom(event).rootTarget);
if (Polymer
.IronDropdownScrollManager
.elementIsScrollLocked(scrolledElement)) {
if (event.type === 'keydown' &&
!Polymer.IronDropdownScrollManager._isScrollingKeypress(event)) {
return;
}

// Avoid canceling an event with cancelable=false, e.g. scrolling is in
// progress and cannot be interrupted.
if (event.cancelable && this._shouldPreventScrolling(event)) {
event.preventDefault();
}
// If event has targetTouches (touch event), update last touch position.
if (event.targetTouches) {
var touch = event.targetTouches[0];
LAST_TOUCH_POSITION.pageX = touch.pageX;
LAST_TOUCH_POSITION.pageY = touch.pageY;
}
},

_lockScrollInteractions: function() {
// Memoize body inline styles:
this._originalBodyStyles.overflow = document.body.style.overflow;
this._originalBodyStyles.overflowX = document.body.style.overflowX;
this._originalBodyStyles.overflowY = document.body.style.overflowY;

// Disable overflow scrolling on body:
// TODO(cdata): It is technically not sufficient to hide overflow on
// body alone. A better solution might be to traverse all ancestors of
// the current scroll locking element and hide overflow on them. This
// becomes expensive, though, as it would have to be redone every time
// a new scroll locking element is added.
document.body.style.overflow = 'hidden';
document.body.style.overflowX = 'hidden';
document.body.style.overflowY = 'hidden';

this._boundScrollHandler = this._boundScrollHandler ||
this._scrollInteractionHandler.bind(this);
// Modern `wheel` event for mouse wheel scrolling:
document.addEventListener('wheel', this._scrollInteractionHandler, true);
document.addEventListener('wheel', this._boundScrollHandler, true);
// Older, non-standard `mousewheel` event for some FF:
document.addEventListener('mousewheel', this._scrollInteractionHandler, true);
document.addEventListener('mousewheel', this._boundScrollHandler, true);
// IE:
document.addEventListener('DOMMouseScroll', this._scrollInteractionHandler, true);
document.addEventListener('DOMMouseScroll', this._boundScrollHandler, true);
// Save the SCROLLABLE_NODES on touchstart, to be used on touchmove.
document.addEventListener('touchstart', this._boundScrollHandler, true);
// Mobile devices can scroll on touch move:
document.addEventListener('touchmove', this._scrollInteractionHandler, true);
document.addEventListener('touchmove', this._boundScrollHandler, true);
// Capture keydown to prevent scrolling keys (pageup, pagedown etc.)
document.addEventListener('keydown', this._scrollInteractionHandler, true);
document.addEventListener('keydown', this._boundScrollHandler, true);
},

_unlockScrollInteractions: function() {
document.body.style.overflow = this._originalBodyStyles.overflow;
document.body.style.overflowX = this._originalBodyStyles.overflowX;
document.body.style.overflowY = this._originalBodyStyles.overflowY;

document.removeEventListener('wheel', this._scrollInteractionHandler, true);
document.removeEventListener('mousewheel', this._scrollInteractionHandler, true);
document.removeEventListener('DOMMouseScroll', this._scrollInteractionHandler, true);
document.removeEventListener('touchmove', this._scrollInteractionHandler, true);
document.removeEventListener('keydown', this._scrollInteractionHandler, true);
document.removeEventListener('wheel', this._boundScrollHandler, true);
document.removeEventListener('mousewheel', this._boundScrollHandler, true);
document.removeEventListener('DOMMouseScroll', this._boundScrollHandler, true);
document.removeEventListener('touchstart', this._boundScrollHandler, true);
document.removeEventListener('touchmove', this._boundScrollHandler, true);
document.removeEventListener('keydown', this._boundScrollHandler, true);
},

/**
* Returns true if the event causes scroll outside the current locking
* element, e.g. pointer/keyboard interactions, or scroll "leaking"
* outside the locking element when it is already at its scroll boundaries.
* @param {!Event} event
* @return {boolean}
* @private
*/
_shouldPreventScrolling: function(event) {
// Avoid expensive checks if the event is not one of the observed keys.
if (event.type === 'keydown') {
// Prevent event if it is one of the scrolling keys.
return this._isScrollingKeypress(event);
}

// Update if root target changed. For touch events, ensure we don't
// update during touchmove.
var target = Polymer.dom(event).rootTarget;
if (event.type !== 'touchmove' && ROOT_TARGET !== target) {
ROOT_TARGET = target;
SCROLLABLE_NODES = this._getScrollableNodes(Polymer.dom(event).path);
}

// Prevent event if no scrollable nodes.
if (!SCROLLABLE_NODES.length) {
return true;
}
// Don't prevent touchstart event inside the locking element when it has
// scrollable nodes.
if (event.type === 'touchstart') {
return false;
}
// Get deltaX/Y.
var info = this._getScrollInfo(event);
// Prevent if there is no child that can scroll.
return !this._getScrollingNode(SCROLLABLE_NODES, info.deltaX, info.deltaY);
},

/**
* Returns an array of scrollable nodes up to the current locking element,
* which is included too if scrollable.
* @param {!Array<Node>} nodes
* @return {Array<Node>} scrollables
* @private
*/
_getScrollableNodes: function(nodes) {
var scrollables = [];
var lockingIndex = nodes.indexOf(this.currentLockingElement);
// Loop from root target to locking element (included).
for (var i = 0; i <= lockingIndex; i++) {
var node = nodes[i];
// Skip document fragments.
if (node.nodeType === 11) {
continue;
}
// Check inline style before checking computed style.
var style = node.style;
if (style.overflow !== 'scroll' && style.overflow !== 'auto') {
style = window.getComputedStyle(node);
}
if (style.overflow === 'scroll' || style.overflow === 'auto') {
scrollables.push(node);
}
}
return scrollables;
},

/**
* Returns the node that is scrolling. If there is no scrolling,
* returns undefined.
* @param {!Array<Node>} nodes
* @param {number} deltaX Scroll delta on the x-axis
* @param {number} deltaY Scroll delta on the y-axis
* @return {Node|undefined}
* @private
*/
_getScrollingNode: function(nodes, deltaX, deltaY) {
// No scroll.
if (!deltaX && !deltaY) {
return;
}
// Check only one axis according to where there is more scroll.
// Prefer vertical to horizontal.
var verticalScroll = Math.abs(deltaY) >= Math.abs(deltaX);
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];
var canScroll = false;
if (verticalScroll) {
// delta < 0 is scroll up, delta > 0 is scroll down.
canScroll = deltaY < 0 ? node.scrollTop > 0 :
node.scrollTop < node.scrollHeight - node.clientHeight;
} else {
// delta < 0 is scroll left, delta > 0 is scroll right.
canScroll = deltaX < 0 ? node.scrollLeft > 0 :
node.scrollLeft < node.scrollWidth - node.clientWidth;
}
if (canScroll) {
return node;
}
}
},

/**
* Returns scroll `deltaX` and `deltaY`.
* @param {!Event} event The scroll event
* @return {{
* deltaX: number The x-axis scroll delta (positive: scroll right,
* negative: scroll left, 0: no scroll),
* deltaY: number The y-axis scroll delta (positive: scroll down,
* negative: scroll up, 0: no scroll)
* }} info
* @private
*/
_getScrollInfo: function(event) {
var info = {
deltaX: event.deltaX,
deltaY: event.deltaY
};
// Already available.
if ('deltaX' in event) {
// do nothing, values are already good.
}
// Safari has scroll info in `wheelDeltaX/Y`.
else if ('wheelDeltaX' in event) {
info.deltaX = -event.wheelDeltaX;
info.deltaY = -event.wheelDeltaY;
}
// Firefox has scroll info in `detail` and `axis`.
else if ('axis' in event) {
info.deltaX = event.axis === 1 ? event.detail : 0;
info.deltaY = event.axis === 2 ? event.detail : 0;
}
// On mobile devices, calculate scroll direction.
else if (event.targetTouches) {
var touch = event.targetTouches[0];
// Touch moves from right to left => scrolling goes right.
info.deltaX = LAST_TOUCH_POSITION.pageX - touch.pageX;
// Touch moves from down to up => scrolling goes down.
info.deltaY = LAST_TOUCH_POSITION.pageY - touch.pageY;
}
return info;
}
};
})();
Expand Down
69 changes: 67 additions & 2 deletions iron-dropdown.html
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,18 @@
allowOutsideScroll: {
type: Boolean,
value: false
},

/**
* Callback for scroll events.
* @type {Function}
* @private
*/
_boundOnCaptureScroll: {
type: Function,
value: function() {
return this._onCaptureScroll.bind(this);
}
}
},

Expand All @@ -169,6 +181,14 @@
return this.focusTarget || this.containedElement;
},

ready: function() {
// Memoized scrolling position, used to block scrolling outside.
this._scrollTop = 0;
this._scrollLeft = 0;
// Used to perform a non-blocking refit on scroll.
this._refitOnScrollRAF = null;
},

detached: function() {
this.cancelAnimation();
Polymer.IronDropdownScrollManager.removeScrollLock(this);
Expand All @@ -185,9 +205,12 @@
this.cancelAnimation();
this.sizingTarget = this.containedElement || this.sizingTarget;
this._updateAnimationConfig();
if (this.opened && !this.allowOutsideScroll) {
Polymer.IronDropdownScrollManager.pushScrollLock(this);
this._saveScrollPosition();
if (this.opened) {
document.addEventListener('scroll', this._boundOnCaptureScroll);
!this.allowOutsideScroll && Polymer.IronDropdownScrollManager.pushScrollLock(this);
} else {
document.removeEventListener('scroll', this._boundOnCaptureScroll);
Polymer.IronDropdownScrollManager.removeScrollLock(this);
}
Polymer.IronOverlayBehaviorImpl._openedChanged.apply(this, arguments);
Expand All @@ -210,6 +233,7 @@
* Overridden from `IronOverlayBehavior`.
*/
_renderClosed: function() {

if (!this.noAnimations && this.animationConfig.close) {
this.$.contentWrapper.classList.add('animating');
this.playAnimation('close');
Expand All @@ -233,6 +257,47 @@
}
},

_onCaptureScroll: function() {
if (!this.allowOutsideScroll) {
this._restoreScrollPosition();
} else {
this._refitOnScrollRAF && window.cancelAnimationFrame(this._refitOnScrollRAF);
this._refitOnScrollRAF = window.requestAnimationFrame(this.refit.bind(this));
}
},

/**
* Memoizes the scroll position of the outside scrolling element.
* @private
*/
_saveScrollPosition: function() {
if (document.scrollingElement) {
this._scrollTop = document.scrollingElement.scrollTop;
this._scrollLeft = document.scrollingElement.scrollLeft;
} else {
// Since we don't know if is the body or html, get max.
this._scrollTop = Math.max(document.documentElement.scrollTop, document.body.scrollTop);
this._scrollLeft = Math.max(document.documentElement.scrollLeft, document.body.scrollLeft);
}
},

/**
* Resets the scroll position of the outside scrolling element.
* @private
*/
_restoreScrollPosition: function() {
if (document.scrollingElement) {
document.scrollingElement.scrollTop = this._scrollTop;
document.scrollingElement.scrollLeft = this._scrollLeft;
} else {
// Since we don't know if is the body or html, set both.
document.documentElement.scrollTop = this._scrollTop;
document.documentElement.scrollLeft = this._scrollLeft;
document.body.scrollTop = this._scrollTop;
document.body.scrollLeft = this._scrollLeft;
}
},

/**
* Constructs the final animation config from different properties used
* to configure specific parts of the opening and closing animations.
Expand Down
Loading

0 comments on commit 4a00d0b

Please sign in to comment.