1<!-- 2@license 3Copyright (c) 2015 The Polymer Project Authors. All rights reserved. 4This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 5The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 6The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 7Code distributed by Google as part of the polymer project is also 8subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 9--> 10 11<link rel="import" href="../polymer/polymer.html"> 12 13<script> 14 (function() { 15 'use strict'; 16 /** 17 * Used to calculate the scroll direction during touch events. 18 * @type {!Object} 19 */ 20 var lastTouchPosition = { 21 pageX: 0, 22 pageY: 0 23 }; 24 /** 25 * Used to avoid computing event.path and filter scrollable nodes (better perf). 26 * @type {?EventTarget} 27 */ 28 var lastRootTarget = null; 29 /** 30 * @type {!Array<Node>} 31 */ 32 var lastScrollableNodes = []; 33 34 var scrollEvents = [ 35 // Modern `wheel` event for mouse wheel scrolling: 36 'wheel', 37 // Older, non-standard `mousewheel` event for some FF: 38 'mousewheel', 39 // IE: 40 'DOMMouseScroll', 41 // Touch enabled devices 42 'touchstart', 43 'touchmove' 44 ]; 45 46 /** 47 * The IronDropdownScrollManager is intended to provide a central source 48 * of authority and control over which elements in a document are currently 49 * allowed to scroll. 50 */ 51 52 Polymer.IronDropdownScrollManager = { 53 54 /** 55 * The current element that defines the DOM boundaries of the 56 * scroll lock. This is always the most recently locking element. 57 */ 58 get currentLockingElement() { 59 return this._lockingElements[this._lockingElements.length - 1]; 60 }, 61 62 /** 63 * Returns true if the provided element is "scroll locked", which is to 64 * say that it cannot be scrolled via pointer or keyboard interactions. 65 * 66 * @param {HTMLElement} element An HTML element instance which may or may 67 * not be scroll locked. 68 */ 69 elementIsScrollLocked: function(element) { 70 var currentLockingElement = this.currentLockingElement; 71 72 if (currentLockingElement === undefined) 73 return false; 74 75 var scrollLocked; 76 77 if (this._hasCachedLockedElement(element)) { 78 return true; 79 } 80 81 if (this._hasCachedUnlockedElement(element)) { 82 return false; 83 } 84 85 scrollLocked = !!currentLockingElement && 86 currentLockingElement !== element && 87 !this._composedTreeContains(currentLockingElement, element); 88 89 if (scrollLocked) { 90 this._lockedElementCache.push(element); 91 } else { 92 this._unlockedElementCache.push(element); 93 } 94 95 return scrollLocked; 96 }, 97 98 /** 99 * Push an element onto the current scroll lock stack. The most recently 100 * pushed element and its children will be considered scrollable. All 101 * other elements will not be scrollable. 102 * 103 * Scroll locking is implemented as a stack so that cases such as 104 * dropdowns within dropdowns are handled well. 105 * 106 * @param {HTMLElement} element The element that should lock scroll. 107 */ 108 pushScrollLock: function(element) { 109 // Prevent pushing the same element twice 110 if (this._lockingElements.indexOf(element) >= 0) { 111 return; 112 } 113 114 if (this._lockingElements.length === 0) { 115 this._lockScrollInteractions(); 116 } 117 118 this._lockingElements.push(element); 119 120 this._lockedElementCache = []; 121 this._unlockedElementCache = []; 122 }, 123 124 /** 125 * Remove an element from the scroll lock stack. The element being 126 * removed does not need to be the most recently pushed element. However, 127 * the scroll lock constraints only change when the most recently pushed 128 * element is removed. 129 * 130 * @param {HTMLElement} element The element to remove from the scroll 131 * lock stack. 132 */ 133 removeScrollLock: function(element) { 134 var index = this._lockingElements.indexOf(element); 135 136 if (index === -1) { 137 return; 138 } 139 140 this._lockingElements.splice(index, 1); 141 142 this._lockedElementCache = []; 143 this._unlockedElementCache = []; 144 145 if (this._lockingElements.length === 0) { 146 this._unlockScrollInteractions(); 147 } 148 }, 149 150 _lockingElements: [], 151 152 _lockedElementCache: null, 153 154 _unlockedElementCache: null, 155 156 _hasCachedLockedElement: function(element) { 157 return this._lockedElementCache.indexOf(element) > -1; 158 }, 159 160 _hasCachedUnlockedElement: function(element) { 161 return this._unlockedElementCache.indexOf(element) > -1; 162 }, 163 164 _composedTreeContains: function(element, child) { 165 // NOTE(cdata): This method iterates over content elements and their 166 // corresponding distributed nodes to implement a contains-like method 167 // that pierces through the composed tree of the ShadowDOM. Results of 168 // this operation are cached (elsewhere) on a per-scroll-lock basis, to 169 // guard against potentially expensive lookups happening repeatedly as 170 // a user scrolls / touchmoves. 171 var contentElements; 172 var distributedNodes; 173 var contentIndex; 174 var nodeIndex; 175 176 if (element.contains(child)) { 177 return true; 178 } 179 180 contentElements = Polymer.dom(element).querySelectorAll('content'); 181 182 for (contentIndex = 0; contentIndex < contentElements.length; ++contentIndex) { 183 184 distributedNodes = Polymer.dom(contentElements[contentIndex]).getDistributedNodes(); 185 186 for (nodeIndex = 0; nodeIndex < distributedNodes.length; ++nodeIndex) { 187 188 if (this._composedTreeContains(distributedNodes[nodeIndex], child)) { 189 return true; 190 } 191 } 192 } 193 194 return false; 195 }, 196 197 _scrollInteractionHandler: function(event) { 198 // Avoid canceling an event with cancelable=false, e.g. scrolling is in 199 // progress and cannot be interrupted. 200 if (event.cancelable && this._shouldPreventScrolling(event)) { 201 event.preventDefault(); 202 } 203 // If event has targetTouches (touch event), update last touch position. 204 if (event.targetTouches) { 205 var touch = event.targetTouches[0]; 206 lastTouchPosition.pageX = touch.pageX; 207 lastTouchPosition.pageY = touch.pageY; 208 } 209 }, 210 211 _lockScrollInteractions: function() { 212 this._boundScrollHandler = this._boundScrollHandler || 213 this._scrollInteractionHandler.bind(this); 214 for (var i = 0, l = scrollEvents.length; i < l; i++) { 215 // NOTE: browsers that don't support objects as third arg will 216 // interpret it as boolean, hence useCapture = true in this case. 217 document.addEventListener(scrollEvents[i], this._boundScrollHandler, { 218 capture: true, 219 passive: false 220 }); 221 } 222 }, 223 224 _unlockScrollInteractions: function() { 225 for (var i = 0, l = scrollEvents.length; i < l; i++) { 226 // NOTE: browsers that don't support objects as third arg will 227 // interpret it as boolean, hence useCapture = true in this case. 228 document.removeEventListener(scrollEvents[i], this._boundScrollHandler, { 229 capture: true, 230 passive: false 231 }); 232 } 233 }, 234 235 /** 236 * Returns true if the event causes scroll outside the current locking 237 * element, e.g. pointer/keyboard interactions, or scroll "leaking" 238 * outside the locking element when it is already at its scroll boundaries. 239 * @param {!Event} event 240 * @return {boolean} 241 * @private 242 */ 243 _shouldPreventScrolling: function(event) { 244 245 // Update if root target changed. For touch events, ensure we don't 246 // update during touchmove. 247 var target = Polymer.dom(event).rootTarget; 248 if (event.type !== 'touchmove' && lastRootTarget !== target) { 249 lastRootTarget = target; 250 lastScrollableNodes = this._getScrollableNodes(Polymer.dom(event).path); 251 } 252 253 // Prevent event if no scrollable nodes. 254 if (!lastScrollableNodes.length) { 255 return true; 256 } 257 // Don't prevent touchstart event inside the locking element when it has 258 // scrollable nodes. 259 if (event.type === 'touchstart') { 260 return false; 261 } 262 // Get deltaX/Y. 263 var info = this._getScrollInfo(event); 264 // Prevent if there is no child that can scroll. 265 return !this._getScrollingNode(lastScrollableNodes, info.deltaX, info.deltaY); 266 }, 267 268 /** 269 * Returns an array of scrollable nodes up to the current locking element, 270 * which is included too if scrollable. 271 * @param {!Array<Node>} nodes 272 * @return {Array<Node>} scrollables 273 * @private 274 */ 275 _getScrollableNodes: function(nodes) { 276 var scrollables = []; 277 var lockingIndex = nodes.indexOf(this.currentLockingElement); 278 // Loop from root target to locking element (included). 279 for (var i = 0; i <= lockingIndex; i++) { 280 // Skip non-Element nodes. 281 if (nodes[i].nodeType !== Node.ELEMENT_NODE) { 282 continue; 283 } 284 var node = /** @type {!Element} */ (nodes[i]); 285 // Check inline style before checking computed style. 286 var style = node.style; 287 if (style.overflow !== 'scroll' && style.overflow !== 'auto') { 288 style = window.getComputedStyle(node); 289 } 290 if (style.overflow === 'scroll' || style.overflow === 'auto') { 291 scrollables.push(node); 292 } 293 } 294 return scrollables; 295 }, 296 297 /** 298 * Returns the node that is scrolling. If there is no scrolling, 299 * returns undefined. 300 * @param {!Array<Node>} nodes 301 * @param {number} deltaX Scroll delta on the x-axis 302 * @param {number} deltaY Scroll delta on the y-axis 303 * @return {Node|undefined} 304 * @private 305 */ 306 _getScrollingNode: function(nodes, deltaX, deltaY) { 307 // No scroll. 308 if (!deltaX && !deltaY) { 309 return; 310 } 311 // Check only one axis according to where there is more scroll. 312 // Prefer vertical to horizontal. 313 var verticalScroll = Math.abs(deltaY) >= Math.abs(deltaX); 314 for (var i = 0; i < nodes.length; i++) { 315 var node = nodes[i]; 316 var canScroll = false; 317 if (verticalScroll) { 318 // delta < 0 is scroll up, delta > 0 is scroll down. 319 canScroll = deltaY < 0 ? node.scrollTop > 0 : 320 node.scrollTop < node.scrollHeight - node.clientHeight; 321 } else { 322 // delta < 0 is scroll left, delta > 0 is scroll right. 323 canScroll = deltaX < 0 ? node.scrollLeft > 0 : 324 node.scrollLeft < node.scrollWidth - node.clientWidth; 325 } 326 if (canScroll) { 327 return node; 328 } 329 } 330 }, 331 332 /** 333 * Returns scroll `deltaX` and `deltaY`. 334 * @param {!Event} event The scroll event 335 * @return {{deltaX: number, deltaY: number}} Object containing the 336 * x-axis scroll delta (positive: scroll right, negative: scroll left, 337 * 0: no scroll), and the y-axis scroll delta (positive: scroll down, 338 * negative: scroll up, 0: no scroll). 339 * @private 340 */ 341 _getScrollInfo: function(event) { 342 var info = { 343 deltaX: event.deltaX, 344 deltaY: event.deltaY 345 }; 346 // Already available. 347 if ('deltaX' in event) { 348 // do nothing, values are already good. 349 } 350 // Safari has scroll info in `wheelDeltaX/Y`. 351 else if ('wheelDeltaX' in event) { 352 info.deltaX = -event.wheelDeltaX; 353 info.deltaY = -event.wheelDeltaY; 354 } 355 // Firefox has scroll info in `detail` and `axis`. 356 else if ('axis' in event) { 357 info.deltaX = event.axis === 1 ? event.detail : 0; 358 info.deltaY = event.axis === 2 ? event.detail : 0; 359 } 360 // On mobile devices, calculate scroll direction. 361 else if (event.targetTouches) { 362 var touch = event.targetTouches[0]; 363 // Touch moves from right to left => scrolling goes right. 364 info.deltaX = lastTouchPosition.pageX - touch.pageX; 365 // Touch moves from down to up => scrolling goes down. 366 info.deltaY = lastTouchPosition.pageY - touch.pageY; 367 } 368 return info; 369 } 370 }; 371 })(); 372</script> 373