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

keep overflow, prevent events that cause scrolling #93

Merged
merged 17 commits into from
Jul 19, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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