| <!-- |
| @license |
| Copyright (c) 2015 The Polymer Project Authors. All rights reserved. |
| This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt |
| The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt |
| The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt |
| Code distributed by Google as part of the polymer project is also |
| subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt |
| --> |
| |
| <link rel="import" href="../polymer/polymer.html"> |
| |
| <script> |
| (function() { |
| 'use strict'; |
| /** |
| * Used to calculate the scroll direction during touch events. |
| * @type {!Object} |
| */ |
| var lastTouchPosition = { |
| pageX: 0, |
| pageY: 0 |
| }; |
| /** |
| * Used to avoid computing event.path and filter scrollable nodes (better perf). |
| * @type {?EventTarget} |
| */ |
| var lastRootTarget = null; |
| /** |
| * @type {!Array<Node>} |
| */ |
| var lastScrollableNodes = []; |
| |
| var scrollEvents = [ |
| // Modern `wheel` event for mouse wheel scrolling: |
| 'wheel', |
| // Older, non-standard `mousewheel` event for some FF: |
| 'mousewheel', |
| // IE: |
| 'DOMMouseScroll', |
| // Touch enabled devices |
| 'touchstart', |
| 'touchmove' |
| ]; |
| |
| /** |
| * The IronDropdownScrollManager is intended to provide a central source |
| * of authority and control over which elements in a document are currently |
| * allowed to scroll. |
| */ |
| |
| Polymer.IronDropdownScrollManager = { |
| |
| /** |
| * The current element that defines the DOM boundaries of the |
| * scroll lock. This is always the most recently locking element. |
| */ |
| get currentLockingElement() { |
| return this._lockingElements[this._lockingElements.length - 1]; |
| }, |
| |
| /** |
| * 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 |
| * not be scroll locked. |
| */ |
| elementIsScrollLocked: function(element) { |
| var currentLockingElement = this.currentLockingElement; |
| |
| if (currentLockingElement === undefined) |
| return false; |
| |
| var scrollLocked; |
| |
| if (this._hasCachedLockedElement(element)) { |
| return true; |
| } |
| |
| if (this._hasCachedUnlockedElement(element)) { |
| return false; |
| } |
| |
| scrollLocked = !!currentLockingElement && |
| currentLockingElement !== element && |
| !this._composedTreeContains(currentLockingElement, element); |
| |
| if (scrollLocked) { |
| this._lockedElementCache.push(element); |
| } else { |
| this._unlockedElementCache.push(element); |
| } |
| |
| return scrollLocked; |
| }, |
| |
| /** |
| * Push an element onto the current scroll lock stack. The most recently |
| * pushed element and its children will be considered scrollable. All |
| * other elements will not be scrollable. |
| * |
| * Scroll locking is implemented as a stack so that cases such as |
| * dropdowns within dropdowns are handled well. |
| * |
| * @param {HTMLElement} element The element that should lock scroll. |
| */ |
| pushScrollLock: function(element) { |
| // Prevent pushing the same element twice |
| if (this._lockingElements.indexOf(element) >= 0) { |
| return; |
| } |
| |
| if (this._lockingElements.length === 0) { |
| this._lockScrollInteractions(); |
| } |
| |
| this._lockingElements.push(element); |
| |
| this._lockedElementCache = []; |
| this._unlockedElementCache = []; |
| }, |
| |
| /** |
| * Remove an element from the scroll lock stack. The element being |
| * removed does not need to be the most recently pushed element. However, |
| * the scroll lock constraints only change when the most recently pushed |
| * element is removed. |
| * |
| * @param {HTMLElement} element The element to remove from the scroll |
| * lock stack. |
| */ |
| removeScrollLock: function(element) { |
| var index = this._lockingElements.indexOf(element); |
| |
| if (index === -1) { |
| return; |
| } |
| |
| this._lockingElements.splice(index, 1); |
| |
| this._lockedElementCache = []; |
| this._unlockedElementCache = []; |
| |
| if (this._lockingElements.length === 0) { |
| this._unlockScrollInteractions(); |
| } |
| }, |
| |
| _lockingElements: [], |
| |
| _lockedElementCache: null, |
| |
| _unlockedElementCache: null, |
| |
| _hasCachedLockedElement: function(element) { |
| return this._lockedElementCache.indexOf(element) > -1; |
| }, |
| |
| _hasCachedUnlockedElement: function(element) { |
| return this._unlockedElementCache.indexOf(element) > -1; |
| }, |
| |
| _composedTreeContains: function(element, child) { |
| // NOTE(cdata): This method iterates over content elements and their |
| // corresponding distributed nodes to implement a contains-like method |
| // that pierces through the composed tree of the ShadowDOM. Results of |
| // this operation are cached (elsewhere) on a per-scroll-lock basis, to |
| // guard against potentially expensive lookups happening repeatedly as |
| // a user scrolls / touchmoves. |
| var contentElements; |
| var distributedNodes; |
| var contentIndex; |
| var nodeIndex; |
| |
| if (element.contains(child)) { |
| return true; |
| } |
| |
| contentElements = Polymer.dom(element).querySelectorAll('content'); |
| |
| for (contentIndex = 0; contentIndex < contentElements.length; ++contentIndex) { |
| |
| distributedNodes = Polymer.dom(contentElements[contentIndex]).getDistributedNodes(); |
| |
| for (nodeIndex = 0; nodeIndex < distributedNodes.length; ++nodeIndex) { |
| |
| if (this._composedTreeContains(distributedNodes[nodeIndex], child)) { |
| return true; |
| } |
| } |
| } |
| |
| return false; |
| }, |
| |
| _scrollInteractionHandler: function(event) { |
| // 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]; |
| lastTouchPosition.pageX = touch.pageX; |
| lastTouchPosition.pageY = touch.pageY; |
| } |
| }, |
| |
| _lockScrollInteractions: function() { |
| this._boundScrollHandler = this._boundScrollHandler || |
| this._scrollInteractionHandler.bind(this); |
| for (var i = 0, l = scrollEvents.length; i < l; i++) { |
| // NOTE: browsers that don't support objects as third arg will |
| // interpret it as boolean, hence useCapture = true in this case. |
| document.addEventListener(scrollEvents[i], this._boundScrollHandler, { |
| capture: true, |
| passive: false |
| }); |
| } |
| }, |
| |
| _unlockScrollInteractions: function() { |
| for (var i = 0, l = scrollEvents.length; i < l; i++) { |
| // NOTE: browsers that don't support objects as third arg will |
| // interpret it as boolean, hence useCapture = true in this case. |
| document.removeEventListener(scrollEvents[i], this._boundScrollHandler, { |
| capture: true, |
| passive: false |
| }); |
| } |
| }, |
| |
| /** |
| * 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) { |
| |
| // 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' && lastRootTarget !== target) { |
| lastRootTarget = target; |
| lastScrollableNodes = this._getScrollableNodes(Polymer.dom(event).path); |
| } |
| |
| // Prevent event if no scrollable nodes. |
| if (!lastScrollableNodes.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(lastScrollableNodes, 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++) { |
| // Skip non-Element nodes. |
| if (nodes[i].nodeType !== Node.ELEMENT_NODE) { |
| continue; |
| } |
| var node = /** @type {!Element} */ (nodes[i]); |
| // 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, deltaY: number}} Object containing the |
| * x-axis scroll delta (positive: scroll right, negative: scroll left, |
| * 0: no scroll), and the y-axis scroll delta (positive: scroll down, |
| * negative: scroll up, 0: no scroll). |
| * @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 = lastTouchPosition.pageX - touch.pageX; |
| // Touch moves from down to up => scrolling goes down. |
| info.deltaY = lastTouchPosition.pageY - touch.pageY; |
| } |
| return info; |
| } |
| }; |
| })(); |
| </script> |