blob: 02ef35347c15a015f2f2dd09908abe3c6d0b1108 [file] [log] [blame]
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Behavior for scrollable containers with <iron-list>.
*
* Any containers with the 'scrollable' attribute set will have the following
* classes toggled appropriately: can-scroll, is-scrolled, scrolled-to-bottom.
* These classes are used to style the container div and list elements
* appropriately, see shared_style_css.html.
*
* The associated HTML should look something like:
* <div id="container" scrollable>
* <iron-list items="[[items]]" scroll-target="container">
* <template>
* <my-element item="[[item]] tabindex$="[[tabIndex]]"></my-element>
* </template>
* </iron-list>
* </div>
*
* In order to get correct keyboard focus (tab) behavior within the list,
* any elements with tabbable sub-elements also need to set tabindex, e.g:
*
* <dom-module id="my-element>
* <template>
* ...
* <paper-icon-button toggles active="{{opened}}" tabindex$="[[tabindex]]">
* </template>
* </dom-module>
*
* NOTE: If 'container' is not fixed size, it is important to call
* updateScrollableContents() when [[items]] changes, otherwise the container
* will not be sized correctly.
*/
/** @polymerBehavior */
const CrScrollableBehavior = {
/** @private {number|null} */
intervalId_: null,
ready: function() {
const readyAsync = () => {
this.requestUpdateScroll();
// Listen to the 'scroll' event for each scrollable container.
const scrollableElements = this.root.querySelectorAll('[scrollable]');
for (let i = 0; i < scrollableElements.length; i++) {
scrollableElements[i].addEventListener(
'scroll', this.updateScrollEvent_.bind(this));
}
};
// TODO(dpapad): Remove Polymer 1 codepath when Polymer 2 migration has
// completed.
if (Polymer.DomIf) {
Polymer.RenderStatus.beforeNextRender(this, readyAsync);
return;
}
readyAsync();
},
detached: function() {
if (this.intervalId_ !== null) {
clearInterval(this.intervalId_);
}
},
/**
* Called any time the contents of a scrollable container may have changed.
* This ensures that the <iron-list> contents of dynamically sized
* containers are resized correctly.
*/
updateScrollableContents: function() {
if (this.intervalId_ !== null) {
return;
} // notifyResize is already in progress.
this.requestUpdateScroll();
const nodeList = this.root.querySelectorAll('[scrollable] iron-list');
if (!nodeList.length) {
return;
}
let nodesToResize = Array.from(nodeList).map(node => ({
node: node,
lastScrollHeight: 0,
}));
// Use setInterval to avoid initial render / sizing issues.
this.intervalId_ = window.setInterval(() => {
const checkAgain = [];
nodesToResize.forEach(({node, lastScrollHeight}) => {
const scrollHeight = node.parentNode.scrollHeight;
// A hidden scroll-container has a height of 0. When not hidden, it has
// a min-height of 1px and the iron-list needs a resize to show the
// initial items and update the |scrollHeight|. The initial item count
// is determined by the |scrollHeight|. A scrollHeight of 1px will
// result in the minimum default item count (currently 3). After the
// |scrollHeight| is updated to be greater than 1px, another resize is
// needed to correctly calculate the number of physical iron-list items
// to render.
if (scrollHeight != lastScrollHeight) {
const ironList = /** @type {!IronListElement} */ (node);
ironList.notifyResize();
}
if (scrollHeight <= 1) {
checkAgain.push({
node: node,
lastScrollHeight: scrollHeight,
});
}
});
if (checkAgain.length == 0) {
window.clearInterval(this.intervalId_);
this.intervalId_ = null;
} else {
nodesToResize = checkAgain;
}
}, 10);
},
/**
* Setup the initial scrolling related classes for each scrollable container.
* Called from ready() and updateScrollableContents(). May also be called
* directly when the contents change (e.g. when not using iron-list).
*/
requestUpdateScroll: function() {
requestAnimationFrame(function() {
const scrollableElements = this.root.querySelectorAll('[scrollable]');
for (let i = 0; i < scrollableElements.length; i++) {
this.updateScroll_(/** @type {!HTMLElement} */ (scrollableElements[i]));
}
}.bind(this));
},
/** @param {!IronListElement} list */
saveScroll: function(list) {
// Store a FIFO of saved scroll positions so that multiple updates in a
// frame are applied correctly. Specifically we need to track when '0' is
// saved (but not apply it), and still handle patterns like [30, 0, 32].
list.savedScrollTops = list.savedScrollTops || [];
list.savedScrollTops.push(list.scrollTarget.scrollTop);
},
/** @param {!IronListElement} list */
restoreScroll: function(list) {
this.async(function() {
const scrollTop = list.savedScrollTops.shift();
// Ignore scrollTop of 0 in case it was intermittent (we do not need to
// explicitly scroll to 0).
if (scrollTop != 0) {
list.scroll(0, scrollTop);
}
});
},
/**
* Event wrapper for updateScroll_.
* @param {!Event} event
* @private
*/
updateScrollEvent_: function(event) {
const scrollable = /** @type {!HTMLElement} */ (event.target);
this.updateScroll_(scrollable);
},
/**
* This gets called once initially and any time a scrollable container
* scrolls.
* @param {!HTMLElement} scrollable
* @private
*/
updateScroll_: function(scrollable) {
scrollable.classList.toggle(
'can-scroll', scrollable.clientHeight < scrollable.scrollHeight);
scrollable.classList.toggle('is-scrolled', scrollable.scrollTop > 0);
scrollable.classList.toggle(
'scrolled-to-bottom',
scrollable.scrollTop + scrollable.clientHeight >=
scrollable.scrollHeight);
},
};