awips2/components/core-list/core-list.html
2016-04-03 22:04:09 -05:00

1316 lines
47 KiB
HTML

<!--
Copyright (c) 2014 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
-->
<!--
`core-list` displays a virtual, 'infinite' list. The template inside the
`core-list` element represents the DOM to create for each list item. The
`data` property specifies an array of list item data.
For performance reasons, not every item in the list is rendered at once; instead
a small subset of actual template elements (enough to fill the viewport) are
rendered and reused as the user scrolls. As such, it is important that all
state of the list template be bound to the model driving it, since the view
may be reused with a new model at any time. Particularly, any state that
may change as the result of a user interaction with the list item must be
bound to the model to avoid view state inconsistency.
IMPORTANT: `core-list` must ether be explicitly sized, or delegate scrolling to
an explicitly sized parent. By "explicitly sized", we mean it either has an
explicit CSS `height` property set via a class or inline style, or else is sized
by other layout means (e.g. `flex` or `fit`). Alternatively, `core-list` can
delegate scrolling to a scrollable element that contains the list by setting the
`scrollTarget` property, and the same explicit sizing requiremets will apply
to that element.
### Template model
List item templates should bind to template models of the following structure:
{
index: 0, // data index for this item
selected: false, // selection state for this item
model: { // user data corresponding to data[index]
/* user item data */
}
}
For example, given the following data array:
[
{name: 'Bob', checked: true},
{name: 'Tim', checked: false},
...
]
The following code would render the list (note the `name` and `checked`
properties are bound from the `model` object provided to the template
scope):
<core-list data="{{data}}">
<template>
<div class="row {{ {selected: selected} | tokenList }}">
List row: {{index}}, User data from model: {{model.name}}
<input type="checkbox" checked="{{model.checked}}">
</div>
</template>
</core-list>
### Selection
By default, the list supports selection via tapping. Styling selected items
should be done via binding to the `selected` property of each model (see examples
above. The data model for the selected item (for single-selection) or array of
models (for multi-selection) is published to the `selection` property.
### Grouping **(experimental)**
`core-list` supports showing dividers between groups of data by setting the
`groups` property to an array containing group information. An element with
a `divider` attribute set should be supplied a the top level of the template
next to the template item to provide the divider template. The template model
contains extra fields when `groups` is used, as follows:
{
index: 0, // data index for this item
groupIndex: 0, // group index for this item
groupItemIndex: 0, // index within group for this item
selected: false, // selection state for this item
model: { // user data corresponding to data[index]
/* user item data */
},
groupModel: { // user group data corresponding to groups[index]
/* user group data */
}
}
Groups may be specified one of two ways (users should choose the data format
that closest matches their source data, to avoid the performance impact of
needing totransform data to fit the required structure):
1. Flat data array - In this scenario, the `data` array is provided as
a flat list of models. Group lengths are determined by the `length` property
on each group object, with the `data` property providing user-specified group
data, typically for binding to dividers. For example:
data = [
{ name: 'Adam' },
{ name: 'Alex' },
{ name: 'Bob' },
{ name: 'Chuck' },
{ name: 'Cathy' },
...
];
groups = [
{ length: 2, data: { letter: 'A' } },
{ length: 1, data: { letter: 'B' } },
{ length: 2, data: { letter: 'C' } },
...
];
<core-list data="{{data}}" groups="{{groups}}">
<template>
<div divider class="divider">{{groupModel.letter}}</div>
<div class="item">{{model.name}}</div>
</template>
</core-list>
2. Nested data array - In this scenario, the `data` array is a nested
array of arrays of models, where each array determines the length of the
group, and the `groups` models provide the user-specified data directly.
For example:
data = [
[ { name: 'Adam' }, { name: 'Alex' } ],
[ { name: 'Bob' } ],
[ { name: 'Chuck' }, { name: 'Cathy' } ],
...
];
groups = [
{ letter: 'A' },
{ letter: 'B' },
{ letter: 'C' },
...
];
<core-list data="{{data}}" groups="{{groups}}">
<template>
<div divider class="divider">{{groupModel.letter}}</div>
<div class="item">{{model.name}}</div>
</template>
</core-list>
### Grid layout **(experimental)**
`core-list` supports a grid layout in addition to linear layout by setting
the `grid` attribute. In this case, the list template item must have both fixed
width and height (e.g. via CSS), with the desired width of each grid item
specified by the `width` attribute. Based on this, the number of items
per row are determined automatically based on the size of the list viewport.
### Non-native scrollers **(experimental)**
By default, core-list assumes the `scrollTarget` (if set) is a native scrollable
element (e.g. `overflow:auto` or `overflow:y`) that fires the `scroll` event and
whose scroll position can be read/set via the `scrollTop` property.
`core-list` provides experimental support for setting `scrollTarget`
to a custom scroller element (e.g. a JS-based scroller) as long as it provides
the following abstract API:
- `getScrollTop()` - returns the current scroll position
- `setScrollTop(y)` - sets the current scroll position
- Fires a `scroll` event indicating when the scroll position has changed
@group Polymer Core Elements
@element core-list
@mixins Polymer.CoreResizable https://github.com/polymer/core-resizable
-->
<link rel="import" href="../polymer/polymer.html">
<link rel="import" href="../core-selection/core-selection.html">
<link rel="import" href="../core-resizable/core-resizable.html">
<polymer-element name="core-list" tabindex="-1">
<template>
<core-selection id="selection" multi="{{multi}}" on-core-select="{{selectedHandler}}"></core-selection>
<link rel="stylesheet" href="core-list.css">
<div id="viewport" class="core-list-viewport"><content></content></div>
</template>
<script>
(function() {
var IOS = navigator.userAgent.match(/iP(?:hone|ad;(?: U;)? CPU) OS (\d+)/);
var IOS_TOUCH_SCROLLING = IOS && IOS[1] >= 8;
Polymer(Polymer.mixin({
publish: {
/**
* Fired when an item element is tapped.
*
* @event core-activate
* @param {Object} detail
* @param {Object} detail.item the item element
*/
/**
* An array of source data for the list to display. Elements
* from this array will be set to the `model` peroperty on each
* template instance scope for binding.
*
* When `groups` is used, this array may either be flat, with
* the group lengths specified in the `groups` array; otherwise
* `data` may be specified as an array of arrays, such that the
* each array in `data` specifies a group. See examples above.
*
* @attribute data
* @type array
* @default null
*/
data: null,
/**
* An array of data conveying information about groupings of items
* in the `data` array. Elements from this array will be set to the
* `groupModel` property of each template instance scope for binding.
*
* When `groups` is used, template children with the `divider` attribute
* will be shown above each group. Typically data from the `groupModel`
* would be bound to dividers.
*
* If `data` is specified as a flat array, the `groups` array must
* contain objects of the format `{ length: n, data: {...} }`, where
* `length` determines the number of items from the `data` array
* that should be grouped, and `data` specifies the user data that will
* be assigned to the `groupModel` property on the template instance
* scope.
*
* If `data` is specified as a nested array of arrays, group lengths
* are derived from these arrays, so each object in `groups` need only
* contain the user data to be assigned to `groupModel`.
*
* @attribute groups
* @type array
* @default null
*/
groups: null,
/**
*
* An optional element on which to listen for scroll events.
*
* @attribute scrollTarget
* @type Element
* @default core-list
*/
scrollTarget: null,
/**
*
* When true, tapping a row will select the item, placing its data model
* in the set of selected items retrievable via the `selection` property.
*
* Note that tapping focusable elements within the list item will not
* result in selection, since they are presumed to have their own action.
*
* @attribute selectionEnabled
* @type {boolean}
* @default true
*/
selectionEnabled: true,
/**
*
* Set to true to support multiple selection. Note, existing selection
* state is maintained only when changing `multi` from `false` to `true`;
* it is cleared when changing from `true` to `false`.
*
* @attribute multi
* @type boolean
* @default false
*/
multi: false,
/**
*
* Data record (or array of records, if `multi: true`) corresponding to
* the currently selected set of items.
*
* @attribute selection
* @type {any}
* @default null
*/
selection: null,
/**
*
* When true, the list is rendered as a grid. Grid items must be fixed
* height and width, with the width of each item specified in the `width`
* property.
*
* @attribute grid
* @type boolean
* @default false
*/
grid: false,
/**
*
* When `grid` is used, `width` determines the width of each grid item.
* This property has no meaning when not in `grid` mode.
*
* @attribute width
* @type number
* @default null
*/
width: null,
/**
* The approximate height of a list item, in pixels. This is used only for determining
* the number of physical elements to render based on the viewport size
* of the list. Items themselves may vary in height between each other
* depending on their data model. There is typically no need to adjust
* this value unless the average size is much larger or smaller than the default.
*
* @attribute height
* @type number
* @default 200
*/
height: 200,
/**
* The amount of scrolling runway the list keeps rendered, as a factor of
* the list viewport size. There is typically no need to adjust this value
* other than for performance tuning. Larger value correspond to more
* physical elements being rendered.
*
* @attribute runwayFactor
* @type number
* @default 4
*/
runwayFactor: 4
},
eventDelegates: {
tap: 'tapHandler',
'core-resize': 'updateSize'
},
// Local cache of scrollTop
_scrollTop: 0,
observe: {
'isAttached data grid width template scrollTarget': 'initialize',
'multi selectionEnabled': '_resetSelection'
},
ready: function() {
this._boundScrollHandler = this.scrollHandler.bind(this);
this._boundPositionItems = this._positionItems.bind(this);
this._oldMulti = this.multi;
this._oldSelectionEnabled = this.selectionEnabled;
this._virtualStart = 0;
this._virtualCount = 0;
this._physicalStart = 0;
this._physicalOffset = 0;
this._physicalSize = 0;
this._physicalSizes = [];
this._physicalAverage = 0;
this._itemSizes = [];
this._dividerSizes = [];
this._repositionedItems = [];
this._aboveSize = 0;
this._nestedGroups = false;
this._groupStart = 0;
this._groupStartIndex = 0;
},
attached: function() {
this.isAttached = true;
this.template = this.querySelector('template');
if (!this.template.bindingDelegate) {
this.template.bindingDelegate = this.element.syntax;
}
this.resizableAttachedHandler();
},
detached: function() {
this.isAttached = false;
if (this._target) {
this._target.removeEventListener('scroll', this._boundScrollHandler);
}
this.resizableDetachedHandler();
},
/**
* To be called by the user when the list is manually resized
* or shown after being hidden.
*
* @method updateSize
*/
updateSize: function() {
if (!this._positionPending && !this._needItemInit) {
this._resetIndex(this._getFirstVisibleIndex() || 0);
this.initialize();
}
},
_resetSelection: function() {
if (((this._oldMulti != this.multi) && !this.multi) ||
((this._oldSelectionEnabled != this.selectionEnabled) &&
!this.selectionEnabled)) {
this._clearSelection();
this.refresh();
} else {
this.selection = this.$.selection.getSelection();
}
this._oldMulti = this.multi;
this._oldSelectionEnabled = this.selectionEnabled;
},
// Adjust virtual start index based on changes to backing data
_adjustVirtualIndex: function(splices, group) {
if (this._targetSize === 0) {
return;
}
var totalDelta = 0;
for (var i=0; i<splices.length; i++) {
var s = splices[i];
var idx = s.index;
var gidx, gitem;
if (group) {
gidx = this.data.indexOf(group);
idx += this.virtualIndexForGroup(gidx);
}
// We only need to care about changes happening above the current position
if (idx >= this._virtualStart) {
break;
}
var delta = Math.max(s.addedCount - s.removed.length, idx - this._virtualStart);
totalDelta += delta;
this._physicalStart += delta;
this._virtualStart += delta;
if (this._grouped) {
if (group) {
gitem = s.index;
} else {
var g = this.groupForVirtualIndex(s.index);
gidx = g.group;
gitem = g.groupIndex;
}
if (gidx == this._groupStart && gitem < this._groupStartIndex) {
this._groupStartIndex += delta;
}
}
}
// Adjust offset/scroll position based on total number of items changed
if (this._virtualStart < this._physicalCount) {
this._resetIndex(this._getFirstVisibleIndex() || 0);
} else {
totalDelta = Math.max((totalDelta / this._rowFactor) * this._physicalAverage, -this._physicalOffset);
this._physicalOffset += totalDelta;
this._scrollTop = this.setScrollTop(this._scrollTop + totalDelta);
}
},
_updateSelection: function(splices) {
for (var i=0; i<splices.length; i++) {
var s = splices[i];
for (var j=0; j<s.removed.length; j++) {
var d = s.removed[j];
this.$.selection.setItemSelected(d, false);
}
}
},
groupsChanged: function() {
if (!!this.groups != this._grouped) {
this.updateSize();
}
},
initialize: function() {
if (!this.template || !this.isAttached) {
return;
}
// TODO(kschaaf): Checking arguments.length currently the only way to
// know that the array was mutated as opposed to newly assigned; need
// a better API for Polymer observers
var splices;
if (arguments.length == 1) {
splices = arguments[0];
if (!this._nestedGroups) {
this._adjustVirtualIndex(splices);
}
this._updateSelection(splices);
} else {
this._clearSelection();
}
// Initialize scroll target
var target = this.scrollTarget || this;
if (this._target !== target) {
this.initializeScrollTarget(target);
}
// Initialize data
this.initializeData(splices, false);
},
initializeScrollTarget: function(target) {
// Listen for scroll events
if (this._target) {
this._target.removeEventListener('scroll', this._boundScrollHandler, false);
}
this._target = target;
target.addEventListener('scroll', this._boundScrollHandler, false);
// Support for non-native scrollers (must implement abstract API):
// getScrollTop, setScrollTop, sync
if ((target != this) && target.setScrollTop && target.getScrollTop) {
this.setScrollTop = function(val) {
target.setScrollTop(val);
return target.getScrollTop();
};
this.getScrollTop = target.getScrollTop.bind(target);
this.syncScroller = target.sync ? target.sync.bind(target) : function() {};
// Adjusting scroll position on non-native scrollers is risky
this.adjustPositionAllowed = false;
} else {
this.setScrollTop = function(val) {
target.scrollTop = val;
return target.scrollTop;
};
this.getScrollTop = function() {
return target.scrollTop;
};
this.syncScroller = function() {};
this.adjustPositionAllowed = true;
}
// Only use -webkit-overflow-touch from iOS8+, where scroll events are fired
if (IOS_TOUCH_SCROLLING) {
target.style.webkitOverflowScrolling = 'touch';
// Adjusting scrollTop during iOS momentum scrolling is "no bueno"
this.adjustPositionAllowed = false;
}
// Force overflow as necessary
this._target.style.willChange = 'transform';
if (getComputedStyle(this._target).position == 'static') {
this._target.style.position = 'relative';
}
this._target.style.boxSizing = this._target.style.mozBoxSizing = 'border-box';
this.style.overflowY = (target == this) ? 'auto' : null;
},
updateGroupObservers: function(splices) {
// If we're going from grouped to non-grouped, remove all observers
if (!this._nestedGroups) {
if (this._groupObservers && this._groupObservers.length) {
splices = [{
index: 0,
addedCount: 0,
removed: this._groupObservers
}];
} else {
splices = null;
}
}
// Otherwise, create observers for all groups, unless this is a group splice
if (this._nestedGroups) {
splices = splices || [{
index: 0,
addedCount: this.data.length,
removed: []
}];
}
if (splices) {
var observers = this._groupObservers || [];
// Apply the splices to the observer array
for (var i=0; i<splices.length; i++) {
var s = splices[i], j;
var args = [s.index, s.removed.length];
if (s.removed.length) {
for (j=s.index; j<s.removed.length; j++) {
observers[j].close();
}
}
if (s.addedCount) {
for (j=s.index; j<s.addedCount; j++) {
var o = new ArrayObserver(this.data[j]);
args.push(o);
o.open(this.getGroupDataHandler(this.data[j]));
}
}
observers.splice.apply(observers, args);
}
this._groupObservers = observers;
}
},
getGroupDataHandler: function(group) {
return function(splices) {
this.groupDataChanged(splices, group);
}.bind(this);
},
groupDataChanged: function(splices, group) {
this._adjustVirtualIndex(splices, group);
this._updateSelection(splices);
this.initializeData(null, true);
},
initializeData: function(splices, groupUpdate) {
var i;
// Calculate row-factor for grid layout
if (this.grid) {
if (!this.width) {
throw 'Grid requires the `width` property to be set';
}
var cs = getComputedStyle(this._target);
var padding = parseInt(cs.paddingLeft || 0) + parseInt(cs.paddingRight || 0);
this._rowFactor = Math.floor((this._target.offsetWidth - padding) / this.width) || 1;
this._rowMargin = (this._target.offsetWidth - (this._rowFactor * this.width) - padding) / 2;
} else {
this._rowFactor = 1;
this._rowMargin = 0;
}
// Count virtual data size, depending on whether grouping is enabled
if (!this.data || !this.data.length) {
this._virtualCount = 0;
this._grouped = false;
this._nestedGroups = false;
} else if (this.groups) {
this._grouped = true;
this._nestedGroups = Array.isArray(this.data[0]);
if (this._nestedGroups) {
if (this.groups.length != this.data.length) {
throw 'When using nested grouped data, data.length and groups.length must agree!';
}
this._virtualCount = 0;
for (i=0; i<this.groups.length; i++) {
this._virtualCount += this.data[i] && this.data[i].length;
}
} else {
this._virtualCount = this.data.length;
var len = 0;
for (i=0; i<this.groups.length; i++) {
len += this.groups[i].length;
}
if (len != this.data.length) {
throw 'When using groups data, the sum of group[n].length\'s and data.length must agree!';
}
}
var g = this.groupForVirtualIndex(this._virtualStart);
this._groupStart = g.group;
this._groupStartIndex = g.groupIndex;
} else {
this._grouped = false;
this._nestedGroups = false;
this._virtualCount = this.data.length;
}
// Update grouped array observers used when group data is nested
if (!groupUpdate) {
this.updateGroupObservers(splices);
}
// Add physical items up to a max based on data length, viewport size, and extra item overhang
var currentCount = this._physicalCount || 0;
var height = this._target.offsetHeight;
if (!height && this._target.offsetParent) {
console.warn('core-list must either be sized or be inside an overflow:auto div that is sized');
}
this._physicalCount = Math.min(Math.ceil(height / (this._physicalAverage || this.height)) * this.runwayFactor * this._rowFactor, this._virtualCount);
this._physicalCount = Math.max(currentCount, this._physicalCount);
this._physicalData = this._physicalData || new Array(this._physicalCount);
var needItemInit = false;
while (currentCount < this._physicalCount) {
var model = this.templateInstance ? Object.create(this.templateInstance.model) : {};
this._physicalData[currentCount++] = model;
needItemInit = true;
}
this.template.model = this._physicalData;
this.template.setAttribute('repeat', '');
this._dir = 0;
// If we've added new items, wait until the template renders then
// initialize the new items before refreshing
if (!this._needItemInit) {
if (needItemInit) {
this._needItemInit = true;
this.resetMetrics();
this.onMutation(this, this.initializeItems);
} else {
this.refresh();
}
}
},
initializeItems: function() {
var currentCount = this._physicalItems && this._physicalItems.length || 0;
this._physicalItems = this._physicalItems || [new Array(this._physicalCount)];
this._physicalDividers = this._physicalDividers || new Array(this._physicalCount);
for (var i = 0, item = this.template.nextElementSibling;
item && i < this._physicalCount;
item = item.nextElementSibling) {
if (item.getAttribute('divider') != null) {
this._physicalDividers[i] = item;
} else {
this._physicalItems[i++] = item;
}
}
this.refresh();
this._needItemInit = false;
},
_updateItemData: function(force, physicalIndex, virtualIndex, groupIndex, groupItemIndex) {
var physicalItem = this._physicalItems[physicalIndex];
var physicalDatum = this._physicalData[physicalIndex];
var virtualDatum = this.dataForIndex(virtualIndex, groupIndex, groupItemIndex);
var needsReposition;
if (force || physicalDatum.model != virtualDatum) {
// Set model, index, and selected fields
physicalDatum.model = virtualDatum;
physicalDatum.index = virtualIndex;
physicalDatum.physicalIndex = physicalIndex;
physicalDatum.selected = this.selectionEnabled && virtualDatum ?
this._selectedData.get(virtualDatum) : null;
// Set group-related fields
if (this._grouped) {
var groupModel = this.groups[groupIndex];
physicalDatum.groupModel = groupModel && (this._nestedGroups ? groupModel : groupModel.data);
physicalDatum.groupIndex = groupIndex;
physicalDatum.groupItemIndex = groupItemIndex;
physicalItem._isDivider = this.data.length && (groupItemIndex === 0);
physicalItem._isRowStart = (groupItemIndex % this._rowFactor) === 0;
} else {
physicalDatum.groupModel = null;
physicalDatum.groupIndex = null;
physicalDatum.groupItemIndex = null;
physicalItem._isDivider = false;
physicalItem._isRowStart = (virtualIndex % this._rowFactor) === 0;
}
// Hide physical items when not in use (no model assigned)
physicalItem.hidden = !virtualDatum;
var divider = this._physicalDividers[physicalIndex];
if (divider && (divider.hidden == physicalItem._isDivider)) {
divider.hidden = !physicalItem._isDivider;
}
needsReposition = !force;
} else {
needsReposition = false;
}
return needsReposition || force;
},
scrollHandler: function() {
if (IOS_TOUCH_SCROLLING) {
// iOS sends multiple scroll events per rAF
// Align work to rAF to reduce overhead & artifacts
if (!this._raf) {
this._raf = requestAnimationFrame(function() {
this._raf = null;
this.refresh();
}.bind(this));
}
} else {
this.refresh();
}
},
resetMetrics: function() {
this._physicalAverage = 0;
this._physicalAverageCount = 0;
},
updateMetrics: function(force) {
// Measure physical items & dividers
var totalSize = 0;
var count = 0;
for (var i=0; i<this._physicalCount; i++) {
var item = this._physicalItems[i];
if (!item.hidden) {
var size = this._itemSizes[i] = item.offsetHeight;
if (item._isDivider) {
var divider = this._physicalDividers[i];
if (divider) {
size += (this._dividerSizes[i] = divider.offsetHeight);
}
}
this._physicalSizes[i] = size;
if (item._isRowStart) {
totalSize += size;
count++;
}
}
}
this._physicalSize = totalSize;
// Measure other DOM
this._viewportSize = this.$.viewport.offsetHeight;
this._targetSize = this._target.offsetHeight;
// Measure content in scroller before virtualized items
if (this._target != this) {
this._aboveSize = this.offsetTop;
} else {
this._aboveSize = parseInt(getComputedStyle(this._target).paddingTop);
}
// Calculate average height
if (count) {
totalSize = (this._physicalAverage * this._physicalAverageCount) + totalSize;
this._physicalAverageCount += count;
this._physicalAverage = Math.round(totalSize / this._physicalAverageCount);
}
},
getGroupLen: function(group) {
group = arguments.length ? group : this._groupStart;
if (this._nestedGroups) {
return this.data[group].length;
} else {
return this.groups[group].length;
}
},
changeStartIndex: function(inc) {
this._virtualStart += inc;
if (this._grouped) {
while (inc > 0) {
var groupMax = this.getGroupLen() - this._groupStartIndex - 1;
if (inc > groupMax) {
inc -= (groupMax + 1);
this._groupStart++;
this._groupStartIndex = 0;
} else {
this._groupStartIndex += inc;
inc = 0;
}
}
while (inc < 0) {
if (-inc > this._groupStartIndex) {
inc += this._groupStartIndex;
this._groupStart--;
this._groupStartIndex = this.getGroupLen();
} else {
this._groupStartIndex += inc;
inc = this.getGroupLen();
}
}
}
// In grid mode, virtualIndex must alway start on a row start!
if (this.grid) {
if (this._grouped) {
inc = this._groupStartIndex % this._rowFactor;
} else {
inc = this._virtualStart % this._rowFactor;
}
if (inc) {
this.changeStartIndex(-inc);
}
}
},
getRowCount: function(dir) {
if (!this.grid) {
return dir;
} else if (!this._grouped) {
return dir * this._rowFactor;
} else {
if (dir < 0) {
if (this._groupStartIndex > 0) {
return -Math.min(this._rowFactor, this._groupStartIndex);
} else {
var prevLen = this.getGroupLen(this._groupStart-1);
return -Math.min(this._rowFactor, prevLen % this._rowFactor || this._rowFactor);
}
} else {
return Math.min(this._rowFactor, this.getGroupLen() - this._groupStartIndex);
}
}
},
_virtualToPhysical: function(virtualIndex) {
var physicalIndex = (virtualIndex - this._physicalStart) % this._physicalCount;
return physicalIndex < 0 ? this._physicalCount + physicalIndex : physicalIndex;
},
groupForVirtualIndex: function(virtual) {
if (!this._grouped) {
return {};
} else {
var group;
for (group=0; group<this.groups.length; group++) {
var groupLen = this.getGroupLen(group);
if (groupLen > virtual) {
break;
} else {
virtual -= groupLen;
}
}
return {group: group, groupIndex: virtual };
}
},
virtualIndexForGroup: function(group, groupIndex) {
groupIndex = groupIndex ? Math.min(groupIndex, this.getGroupLen(group)) : 0;
group--;
while (group >= 0) {
groupIndex += this.getGroupLen(group--);
}
return groupIndex;
},
dataForIndex: function(virtual, group, groupIndex) {
if (this.data) {
if (this._nestedGroups) {
if (virtual < this._virtualCount) {
return this.data[group][groupIndex];
}
} else {
return this.data[virtual];
}
}
},
// Refresh the list at the current scroll position.
refresh: function() {
var i, deltaCount;
// Determine scroll position & any scrollDelta that may have occurred
var lastScrollTop = this._scrollTop;
this._scrollTop = this.getScrollTop();
var scrollDelta = this._scrollTop - lastScrollTop;
this._dir = scrollDelta < 0 ? -1 : scrollDelta > 0 ? 1 : 0;
// Adjust virtual items and positioning offset if scroll occurred
if (Math.abs(scrollDelta) > Math.max(this._physicalSize, this._targetSize)) {
// Random access to point in list: guess new index based on average size
deltaCount = Math.round((scrollDelta / this._physicalAverage) * this._rowFactor);
deltaCount = Math.max(deltaCount, -this._virtualStart);
deltaCount = Math.min(deltaCount, this._virtualCount - this._virtualStart - 1);
this._physicalOffset += Math.max(scrollDelta, -this._physicalOffset);
this.changeStartIndex(deltaCount);
// console.log(this._scrollTop, 'Random access to ' + this._virtualStart, this._physicalOffset);
} else {
// Incremental movement: adjust index by flipping items
var base = this._aboveSize + this._physicalOffset;
var margin = 0.3 * Math.max((this._physicalSize - this._targetSize, this._physicalSize));
this._upperBound = base + margin;
this._lowerBound = base + this._physicalSize - this._targetSize - margin;
var flipBound = this._dir > 0 ? this._upperBound : this._lowerBound;
if (((this._dir > 0 && this._scrollTop > flipBound) ||
(this._dir < 0 && this._scrollTop < flipBound))) {
var flipSize = Math.abs(this._scrollTop - flipBound);
for (i=0; (i<this._physicalCount) && (flipSize > 0) &&
((this._dir < 0 && this._virtualStart > 0) ||
(this._dir > 0 && this._virtualStart < this._virtualCount-this._physicalCount)); i++) {
var idx = this._virtualToPhysical(this._dir > 0 ?
this._virtualStart :
this._virtualStart + this._physicalCount -1);
var size = this._physicalSizes[idx];
flipSize -= size;
var cnt = this.getRowCount(this._dir);
// console.log(this._scrollTop, 'flip ' + (this._dir > 0 ? 'down' : 'up'), cnt, this._virtualStart, this._physicalOffset);
if (this._dir > 0) {
// When scrolling down, offset is adjusted based on previous item's size
this._physicalOffset += size;
// console.log(' ->', this._virtualStart, size, this._physicalOffset);
}
this.changeStartIndex(cnt);
if (this._dir < 0) {
this._repositionedItems.push(this._virtualStart);
}
}
}
}
// Assign data to items lazily if scrolling, otherwise force
if (this._updateItems(!scrollDelta)) {
// Position items after bindings resolve (method varies based on O.o impl)
if (Observer.hasObjectObserve) {
this.async(this._boundPositionItems);
} else {
Platform.flush();
Platform.endOfMicrotask(this._boundPositionItems);
}
}
},
_updateItems: function(force) {
var i, virtualIndex, physicalIndex;
var needsReposition = false;
var groupIndex = this._groupStart;
var groupItemIndex = this._groupStartIndex;
for (i = 0; i < this._physicalCount; ++i) {
virtualIndex = this._virtualStart + i;
physicalIndex = this._virtualToPhysical(virtualIndex);
// Update physical item with new user data and list metadata
needsReposition =
this._updateItemData(force, physicalIndex, virtualIndex, groupIndex, groupItemIndex) || needsReposition;
// Increment
groupItemIndex++;
if (this.groups && groupIndex < this.groups.length - 1) {
if (groupItemIndex >= this.getGroupLen(groupIndex)) {
groupItemIndex = 0;
groupIndex++;
}
}
}
return needsReposition;
},
_positionItems: function() {
var i, virtualIndex, physicalIndex, physicalItem;
// Measure
this.updateMetrics();
// Pre-positioning tasks
if (this._dir < 0) {
// When going up, remove offset after measuring size for
// new data for item being moved from bottom to top
while (this._repositionedItems.length) {
virtualIndex = this._repositionedItems.pop();
physicalIndex = this._virtualToPhysical(virtualIndex);
this._physicalOffset -= this._physicalSizes[physicalIndex];
// console.log(' <-', virtualIndex, this._physicalSizes[physicalIndex], this._physicalOffset);
}
// Adjust scroll position to home into top when going up
if (this._scrollTop + this._targetSize < this._viewportSize) {
this._updateScrollPosition(this._scrollTop);
}
}
// Position items
var divider, upperBound, lowerBound;
var rowx = 0;
var x = this._rowMargin;
var y = this._physicalOffset;
var lastHeight = 0;
for (i = 0; i < this._physicalCount; ++i) {
// Calculate indices
virtualIndex = this._virtualStart + i;
physicalIndex = this._virtualToPhysical(virtualIndex);
physicalItem = this._physicalItems[physicalIndex];
// Position divider
if (physicalItem._isDivider) {
if (rowx !== 0) {
y += lastHeight;
rowx = 0;
}
divider = this._physicalDividers[physicalIndex];
x = this._rowMargin;
if (divider && (divider._translateX != x || divider._translateY != y)) {
divider.style.opacity = 1;
if (this.grid) {
divider.style.width = this.width * this._rowFactor + 'px';
}
divider.style.transform = divider.style.webkitTransform =
'translate3d(' + x + 'px,' + y + 'px,0)';
divider._translateX = x;
divider._translateY = y;
}
y += this._dividerSizes[physicalIndex];
}
// Position item
if (physicalItem._translateX != x || physicalItem._translateY != y) {
physicalItem.style.opacity = 1;
physicalItem.style.transform = physicalItem.style.webkitTransform =
'translate3d(' + x + 'px,' + y + 'px,0)';
physicalItem._translateX = x;
physicalItem._translateY = y;
}
// Increment offsets
lastHeight = this._itemSizes[physicalIndex];
if (this.grid) {
rowx++;
if (rowx >= this._rowFactor) {
rowx = 0;
y += lastHeight;
}
x = this._rowMargin + rowx * this.width;
} else {
y += lastHeight;
}
}
if (this._scrollTop >= 0) {
this._updateViewportHeight();
}
},
_updateViewportHeight: function() {
var remaining = Math.max(this._virtualCount - this._virtualStart - this._physicalCount, 0);
remaining = Math.ceil(remaining / this._rowFactor);
var vs = this._physicalOffset + this._physicalSize + remaining * this._physicalAverage;
if (this._viewportSize != vs) {
// console.log(this._scrollTop, 'adjusting viewport height', vs - this._viewportSize, vs);
this._viewportSize = vs;
this.$.viewport.style.height = this._viewportSize + 'px';
this.syncScroller();
}
},
_updateScrollPosition: function(scrollTop) {
var deltaHeight = this._virtualStart === 0 ? this._physicalOffset :
Math.min(scrollTop + this._physicalOffset, 0);
if (deltaHeight) {
// console.log(scrollTop, 'adjusting scroll pos', this._virtualStart, -deltaHeight, scrollTop - deltaHeight);
if (this.adjustPositionAllowed) {
this._scrollTop = this.setScrollTop(scrollTop - deltaHeight);
}
this._physicalOffset -= deltaHeight;
}
},
// list selection
tapHandler: function(e) {
var n = e.target;
var p = e.path;
if (!this.selectionEnabled || (n === this)) {
return;
}
requestAnimationFrame(function() {
// Gambit: only select the item if the tap wasn't on a focusable child
// of the list (since anything with its own action should be focusable
// and not result in result in list selection). To check this, we
// asynchronously check that shadowRoot.activeElement is null, which
// means the tapped item wasn't focusable. On polyfill where
// activeElement doesn't follow the data-hinding part of the spec, we
// can check that document.activeElement is the list itself, which will
// catch focus in lieu of the tapped item being focusable, as we make
// the list focusable (tabindex="-1") for this purpose. Note we also
// allow the list items themselves to be focusable if desired, so those
// are excluded as well.
var active = window.ShadowDOMPolyfill ?
wrap(document.activeElement) : this.shadowRoot.activeElement;
if (active && (active != this) && (active.parentElement != this) &&
(document.activeElement != document.body)) {
return;
}
// Unfortunately, Safari does not focus certain form controls via mouse,
// so we also blacklist input, button, & select
// (https://bugs.webkit.org/show_bug.cgi?id=118043)
if ((p[0].localName == 'input') ||
(p[0].localName == 'button') ||
(p[0].localName == 'select')) {
return;
}
var model = n.templateInstance && n.templateInstance.model;
if (model) {
var data = this.dataForIndex(model.index, model.groupIndex, model.groupItemIndex);
var item = this._physicalItems[model.physicalIndex];
if (!this.multi && data == this.selection) {
this.$.selection.select(null);
} else {
this.$.selection.select(data);
}
this.asyncFire('core-activate', {data: data, item: item});
}
}.bind(this));
},
selectedHandler: function(e, detail) {
this.selection = this.$.selection.getSelection();
var id = this.indexesForData(detail.item);
// TODO(sorvell): we should be relying on selection to store the
// selected data but we want to optimize for lookup.
this._selectedData.set(detail.item, detail.isSelected);
if (id.physical >= 0 && id.virtual >= 0) {
this.refresh();
}
},
/**
* Select the list item at the given index.
*
* @method selectItem
* @param {number} index
*/
selectItem: function(index) {
if (!this.selectionEnabled) {
return;
}
var data = this.data[index];
if (data) {
this.$.selection.select(data);
}
},
/**
* Set the selected state of the list item at the given index.
*
* @method setItemSelected
* @param {number} index
* @param {boolean} isSelected
*/
setItemSelected: function(index, isSelected) {
var data = this.data[index];
if (data) {
this.$.selection.setItemSelected(data, isSelected);
}
},
indexesForData: function(data) {
var virtual = -1;
var groupsLen = 0;
if (this._nestedGroups) {
for (var i=0; i<this.groups.length; i++) {
virtual = this.data[i].indexOf(data);
if (virtual < 0) {
groupsLen += this.data[i].length;
} else {
virtual += groupsLen;
break;
}
}
} else {
virtual = this.data.indexOf(data);
}
var physical = this.virtualToPhysicalIndex(virtual);
return { virtual: virtual, physical: physical };
},
virtualToPhysicalIndex: function(index) {
for (var i=0, l=this._physicalData.length; i<l; i++) {
if (this._physicalData[i].index === index) {
return i;
}
}
return -1;
},
/**
* Clears the current selection state of the list.
*
* @method clearSelection
*/
clearSelection: function() {
this._clearSelection();
this.refresh();
},
_clearSelection: function() {
this._selectedData = new WeakMap();
this.$.selection.clear();
this.selection = this.$.selection.getSelection();
},
_getFirstVisibleIndex: function() {
for (var i=0; i<this._physicalCount; i++) {
var virtualIndex = this._virtualStart + i;
var physicalIndex = this._virtualToPhysical(virtualIndex);
var item = this._physicalItems[physicalIndex];
if (!item.hidden && item._translateY >= this._scrollTop - this._aboveSize) {
return virtualIndex;
}
}
},
_resetIndex: function(index) {
index = Math.min(index, this._virtualCount-1);
index = Math.max(index, 0);
this.changeStartIndex(index - this._virtualStart);
this._scrollTop = this.setScrollTop(this._aboveSize + (index / this._rowFactor) * this._physicalAverage);
this._physicalOffset = this._scrollTop - this._aboveSize;
this._dir = 0;
},
/**
* Scroll to an item.
*
* Note, when grouping is used, the index is based on the
* total flattened number of items. For scrolling to an item
* within a group, use the `scrollToGroupItem` API.
*
* @method scrollToItem
* @param {number} index
*/
scrollToItem: function(index) {
this.scrollToGroupItem(null, index);
},
/**
* Scroll to a group.
*
* @method scrollToGroup
* @param {number} group
*/
scrollToGroup: function(group) {
this.scrollToGroupItem(group, 0);
},
/**
* Scroll to an item within a group.
*
* @method scrollToGroupItem
* @param {number} group
* @param {number} index
*/
scrollToGroupItem: function(group, index) {
if (group != null) {
index = this.virtualIndexForGroup(group, index);
}
this._resetIndex(index);
this.refresh();
}
}, Polymer.CoreResizable));
})();
</script>
</polymer-element>