• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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