iron-dropdown-scroll-manager.html 7.97 KB
<!--
@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">
<link rel="import" href="../iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">

<script>
  (function() {
    'use strict';

    /**
     * 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;
        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) {
        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,

      _originalBodyStyles: {},

      _isScrollingKeypress: function(event) {
        return Polymer.IronA11yKeysBehavior.keyboardEventMatchesKeys(
          event, 'pageup pagedown home end up left down right');
      },

      _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) {
        if (Polymer
              .IronDropdownScrollManager
              .elementIsScrollLocked(event.target)) {
          if (event.type === 'keydown' &&
              !Polymer.IronDropdownScrollManager._isScrollingKeypress(event)) {
            return;
          }

          event.preventDefault();
        }
      },

      _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';

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

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

        window.removeEventListener('wheel', this._scrollInteractionHandler, true);
        window.removeEventListener('mousewheel', this._scrollInteractionHandler, true);
        window.removeEventListener('DOMMouseScroll', this._scrollInteractionHandler, true);
        window.removeEventListener('touchmove', this._scrollInteractionHandler, true);
        document.removeEventListener('keydown', this._scrollInteractionHandler, true);
      }
    };
  })();
</script>