Index: chrome/browser/resources/md_downloads/crisper.js |
diff --git a/chrome/browser/resources/md_downloads/crisper.js b/chrome/browser/resources/md_downloads/crisper.js |
index 1c61c9f3b1c42ff5511a3214684a5ebaa8835875..0c4f256ec3ce12f648de0f3422812bf0baea1277 100644 |
--- a/chrome/browser/resources/md_downloads/crisper.js |
+++ b/chrome/browser/resources/md_downloads/crisper.js |
@@ -9,6 +9,9 @@ |
*/ |
var global = this; |
+/** @typedef {{eventName: string, uid: number}} */ |
+var WebUIListener; |
+ |
/** Platform, package, object property, and Event support. **/ |
var cr = function() { |
'use strict'; |
@@ -357,11 +360,13 @@ var cr = function() { |
} |
/** |
- * A registry of callbacks keyed by event name. Used by addWebUIListener to |
- * register listeners. |
- * @type {!Object<Array<Function>>} |
+ * A map of maps associating event names with listeners. The 2nd level map |
+ * associates a listener ID with the callback function, such that individual |
+ * listeners can be removed from an event without affecting other listeners of |
+ * the same event. |
+ * @type {!Object<!Object<!Function>>} |
*/ |
- var webUIListenerMap = Object.create(null); |
+ var webUIListenerMap = {}; |
/** |
* The named method the WebUI handler calls directly when an event occurs. |
@@ -369,26 +374,52 @@ var cr = function() { |
* of the JS invocation; additionally, the handler may supply any number of |
* other arguments that will be forwarded to the listener callbacks. |
* @param {string} event The name of the event that has occurred. |
+ * @param {...*} var_args Additional arguments passed from C++. |
*/ |
- function webUIListenerCallback(event) { |
- var listenerCallbacks = webUIListenerMap[event]; |
- for (var i = 0; i < listenerCallbacks.length; i++) { |
- var callback = listenerCallbacks[i]; |
- callback.apply(null, Array.prototype.slice.call(arguments, 1)); |
+ function webUIListenerCallback(event, var_args) { |
+ var eventListenersMap = webUIListenerMap[event]; |
+ if (!eventListenersMap) { |
+ // C++ event sent for an event that has no listeners. |
+ // TODO(dpapad): Should a warning be displayed here? |
+ return; |
+ } |
+ |
+ var args = Array.prototype.slice.call(arguments, 1); |
+ for (var listenerId in eventListenersMap) { |
+ eventListenersMap[listenerId].apply(null, args); |
} |
} |
/** |
* Registers a listener for an event fired from WebUI handlers. Any number of |
* listeners may register for a single event. |
- * @param {string} event The event to listen to. |
- * @param {Function} callback The callback run when the event is fired. |
+ * @param {string} eventName The event to listen to. |
+ * @param {!Function} callback The callback run when the event is fired. |
+ * @return {!WebUIListener} An object to be used for removing a listener via |
+ * cr.removeWebUIListener. Should be treated as read-only. |
*/ |
- function addWebUIListener(event, callback) { |
- if (event in webUIListenerMap) |
- webUIListenerMap[event].push(callback); |
- else |
- webUIListenerMap[event] = [callback]; |
+ function addWebUIListener(eventName, callback) { |
+ webUIListenerMap[eventName] = webUIListenerMap[eventName] || {}; |
+ var uid = createUid(); |
+ webUIListenerMap[eventName][uid] = callback; |
+ return {eventName: eventName, uid: uid}; |
+ } |
+ |
+ /** |
+ * Removes a listener. Does nothing if the specified listener is not found. |
+ * @param {!WebUIListener} listener The listener to be removed (as returned by |
+ * addWebUIListener). |
+ * @return {boolean} Whether the given listener was found and actually |
+ * removed. |
+ */ |
+ function removeWebUIListener(listener) { |
+ var listenerExists = webUIListenerMap[listener.eventName] && |
+ webUIListenerMap[listener.eventName][listener.uid]; |
+ if (listenerExists) { |
+ delete webUIListenerMap[listener.eventName][listener.uid]; |
+ return true; |
+ } |
+ return false; |
} |
return { |
@@ -401,11 +432,14 @@ var cr = function() { |
exportPath: exportPath, |
getUid: getUid, |
makePublic: makePublic, |
- webUIResponse: webUIResponse, |
+ PropertyKind: PropertyKind, |
+ |
+ // C++ <-> JS communication related methods. |
+ addWebUIListener: addWebUIListener, |
+ removeWebUIListener: removeWebUIListener, |
sendWithPromise: sendWithPromise, |
webUIListenerCallback: webUIListenerCallback, |
- addWebUIListener: addWebUIListener, |
- PropertyKind: PropertyKind, |
+ webUIResponse: webUIResponse, |
get doc() { |
return document; |
@@ -430,6 +464,11 @@ var cr = function() { |
get isLinux() { |
return /Linux/.test(navigator.userAgent); |
}, |
+ |
+ /** Whether this is on Android. */ |
+ get isAndroid() { |
+ return /Android/.test(navigator.userAgent); |
+ } |
}; |
}(); |
// Copyright (c) 2012 The Chromium Authors. All rights reserved. |
@@ -987,9 +1026,19 @@ cr.define('cr.ui', function() { |
*/ |
function $(id) { |
var el = document.getElementById(id); |
- var message = |
- 'Element ' + el + ' with id "' + id + '" is not an HTMLElement.'; |
- return el ? assertInstanceof(el, HTMLElement, message) : null; |
+ return el ? assertInstanceof(el, HTMLElement) : null; |
+} |
+ |
+// TODO(devlin): This should return SVGElement, but closure compiler is missing |
+// those externs. |
+/** |
+ * Alias for document.getElementById. Found elements must be SVGElements. |
+ * @param {string} id The ID of the element to find. |
+ * @return {Element} The found element or null if not found. |
+ */ |
+function getSVGElement(id) { |
+ var el = document.getElementById(id); |
+ return el ? assertInstanceof(el, Element) : null; |
} |
/** |
@@ -1505,8 +1554,12 @@ function assertNotReached(opt_message) { |
* @template T |
*/ |
function assertInstanceof(value, type, opt_message) { |
- assert(value instanceof type, |
- opt_message || value + ' is not a[n] ' + (type.name || typeof type)); |
+ // We don't use assert immediately here so that we avoid constructing an error |
+ // message if we don't have to. |
+ if (!(value instanceof type)) { |
+ assertNotReached(opt_message || 'Value ' + value + |
+ ' is not a[n] ' + (type.name || typeof type)); |
+ } |
return value; |
}; |
// Copyright 2015 The Chromium Authors. All rights reserved. |
@@ -1979,2149 +2032,2521 @@ i18nTemplate.process(document, loadTimeData); |
} |
}; |
(function() { |
- |
- var IOS = navigator.userAgent.match(/iP(?:hone|ad;(?: U;)? CPU) OS (\d+)/); |
- var IOS_TOUCH_SCROLLING = IOS && IOS[1] >= 8; |
- var DEFAULT_PHYSICAL_COUNT = 3; |
- var MAX_PHYSICAL_COUNT = 500; |
- |
- Polymer({ |
- |
- is: 'iron-list', |
- |
- properties: { |
- |
- /** |
- * An array containing items determining how many instances of the template |
- * to stamp and that that each template instance should bind to. |
- */ |
- items: { |
- type: Array |
- }, |
- |
- /** |
- * The name of the variable to add to the binding scope for the array |
- * element associated with a given template instance. |
- */ |
- as: { |
- type: String, |
- value: 'item' |
- }, |
- |
- /** |
- * The name of the variable to add to the binding scope with the index |
- * for the row. |
- */ |
- indexAs: { |
- type: String, |
- value: 'index' |
- }, |
- |
- /** |
- * The name of the variable to add to the binding scope to indicate |
- * if the row is selected. |
- */ |
- selectedAs: { |
- type: String, |
- value: 'selected' |
- }, |
- |
- /** |
- * 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. |
- */ |
- selectionEnabled: { |
- type: Boolean, |
- value: false |
- }, |
- |
- /** |
- * When `multiSelection` is false, this is the currently selected item, or `null` |
- * if no item is selected. |
- */ |
- selectedItem: { |
- type: Object, |
- notify: true |
- }, |
- |
- /** |
- * When `multiSelection` is true, this is an array that contains the selected items. |
- */ |
- selectedItems: { |
- type: Object, |
- notify: true |
- }, |
- |
- /** |
- * When `true`, multiple items may be selected at once (in this case, |
- * `selected` is an array of currently selected items). When `false`, |
- * only one item may be selected at a time. |
- */ |
- multiSelection: { |
- type: Boolean, |
- value: false |
- } |
- }, |
- |
- observers: [ |
- '_itemsChanged(items.*)', |
- '_selectionEnabledChanged(selectionEnabled)', |
- '_multiSelectionChanged(multiSelection)' |
- ], |
- |
- behaviors: [ |
- Polymer.Templatizer, |
- Polymer.IronResizableBehavior |
- ], |
- |
- listeners: { |
- 'iron-resize': '_resizeHandler' |
- }, |
- |
- /** |
- * The ratio of hidden tiles that should remain in the scroll direction. |
- * Recommended value ~0.5, so it will distribute tiles evely in both directions. |
- */ |
- _ratio: 0.5, |
- |
- /** |
- * The element that controls the scroll |
- * @type {?Element} |
- */ |
- _scroller: null, |
- |
- /** |
- * The padding-top value of the `scroller` element |
- */ |
- _scrollerPaddingTop: 0, |
+ 'use strict'; |
/** |
- * This value is the same as `scrollTop`. |
+ * Chrome uses an older version of DOM Level 3 Keyboard Events |
+ * |
+ * Most keys are labeled as text, but some are Unicode codepoints. |
+ * Values taken from: http://www.w3.org/TR/2007/WD-DOM-Level-3-Events-20071221/keyset.html#KeySet-Set |
*/ |
- _scrollPosition: 0, |
+ var KEY_IDENTIFIER = { |
+ 'U+0008': 'backspace', |
+ 'U+0009': 'tab', |
+ 'U+001B': 'esc', |
+ 'U+0020': 'space', |
+ 'U+007F': 'del' |
+ }; |
/** |
- * The number of tiles in the DOM. |
+ * Special table for KeyboardEvent.keyCode. |
+ * KeyboardEvent.keyIdentifier is better, and KeyBoardEvent.key is even better |
+ * than that. |
+ * |
+ * Values from: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent.keyCode#Value_of_keyCode |
*/ |
- _physicalCount: 0, |
+ var KEY_CODE = { |
+ 8: 'backspace', |
+ 9: 'tab', |
+ 13: 'enter', |
+ 27: 'esc', |
+ 33: 'pageup', |
+ 34: 'pagedown', |
+ 35: 'end', |
+ 36: 'home', |
+ 32: 'space', |
+ 37: 'left', |
+ 38: 'up', |
+ 39: 'right', |
+ 40: 'down', |
+ 46: 'del', |
+ 106: '*' |
+ }; |
/** |
- * The k-th tile that is at the top of the scrolling list. |
+ * MODIFIER_KEYS maps the short name for modifier keys used in a key |
+ * combo string to the property name that references those same keys |
+ * in a KeyboardEvent instance. |
*/ |
- _physicalStart: 0, |
+ var MODIFIER_KEYS = { |
+ 'shift': 'shiftKey', |
+ 'ctrl': 'ctrlKey', |
+ 'alt': 'altKey', |
+ 'meta': 'metaKey' |
+ }; |
/** |
- * The k-th tile that is at the bottom of the scrolling list. |
+ * KeyboardEvent.key is mostly represented by printable character made by |
+ * the keyboard, with unprintable keys labeled nicely. |
+ * |
+ * However, on OS X, Alt+char can make a Unicode character that follows an |
+ * Apple-specific mapping. In this case, we fall back to .keyCode. |
*/ |
- _physicalEnd: 0, |
+ var KEY_CHAR = /[a-z0-9*]/; |
/** |
- * The sum of the heights of all the tiles in the DOM. |
+ * Matches a keyIdentifier string. |
*/ |
- _physicalSize: 0, |
+ var IDENT_CHAR = /U\+/; |
/** |
- * The average `offsetHeight` of the tiles observed till now. |
+ * Matches arrow keys in Gecko 27.0+ |
*/ |
- _physicalAverage: 0, |
+ var ARROW_KEY = /^arrow/; |
/** |
- * The number of tiles which `offsetHeight` > 0 observed until now. |
+ * Matches space keys everywhere (notably including IE10's exceptional name |
+ * `spacebar`). |
*/ |
- _physicalAverageCount: 0, |
+ var SPACE_KEY = /^space(bar)?/; |
/** |
- * The Y position of the item rendered in the `_physicalStart` |
- * tile relative to the scrolling list. |
+ * Transforms the key. |
+ * @param {string} key The KeyBoardEvent.key |
+ * @param {Boolean} [noSpecialChars] Limits the transformation to |
+ * alpha-numeric characters. |
*/ |
- _physicalTop: 0, |
+ function transformKey(key, noSpecialChars) { |
+ var validKey = ''; |
+ if (key) { |
+ var lKey = key.toLowerCase(); |
+ if (lKey === ' ' || SPACE_KEY.test(lKey)) { |
+ validKey = 'space'; |
+ } else if (lKey.length == 1) { |
+ if (!noSpecialChars || KEY_CHAR.test(lKey)) { |
+ validKey = lKey; |
+ } |
+ } else if (ARROW_KEY.test(lKey)) { |
+ validKey = lKey.replace('arrow', ''); |
+ } else if (lKey == 'multiply') { |
+ // numpad '*' can map to Multiply on IE/Windows |
+ validKey = '*'; |
+ } else { |
+ validKey = lKey; |
+ } |
+ } |
+ return validKey; |
+ } |
- /** |
- * The number of items in the list. |
- */ |
- _virtualCount: 0, |
+ function transformKeyIdentifier(keyIdent) { |
+ var validKey = ''; |
+ if (keyIdent) { |
+ if (keyIdent in KEY_IDENTIFIER) { |
+ validKey = KEY_IDENTIFIER[keyIdent]; |
+ } else if (IDENT_CHAR.test(keyIdent)) { |
+ keyIdent = parseInt(keyIdent.replace('U+', '0x'), 16); |
+ validKey = String.fromCharCode(keyIdent).toLowerCase(); |
+ } else { |
+ validKey = keyIdent.toLowerCase(); |
+ } |
+ } |
+ return validKey; |
+ } |
- /** |
- * The n-th item rendered in the `_physicalStart` tile. |
- */ |
- _virtualStartVal: 0, |
+ function transformKeyCode(keyCode) { |
+ var validKey = ''; |
+ if (Number(keyCode)) { |
+ if (keyCode >= 65 && keyCode <= 90) { |
+ // ascii a-z |
+ // lowercase is 32 offset from uppercase |
+ validKey = String.fromCharCode(32 + keyCode); |
+ } else if (keyCode >= 112 && keyCode <= 123) { |
+ // function keys f1-f12 |
+ validKey = 'f' + (keyCode - 112); |
+ } else if (keyCode >= 48 && keyCode <= 57) { |
+ // top 0-9 keys |
+ validKey = String(48 - keyCode); |
+ } else if (keyCode >= 96 && keyCode <= 105) { |
+ // num pad 0-9 |
+ validKey = String(96 - keyCode); |
+ } else { |
+ validKey = KEY_CODE[keyCode]; |
+ } |
+ } |
+ return validKey; |
+ } |
/** |
- * A map between an item key and its physical item index |
+ * Calculates the normalized key for a KeyboardEvent. |
+ * @param {KeyboardEvent} keyEvent |
+ * @param {Boolean} [noSpecialChars] Set to true to limit keyEvent.key |
+ * transformation to alpha-numeric chars. This is useful with key |
+ * combinations like shift + 2, which on FF for MacOS produces |
+ * keyEvent.key = @ |
+ * To get 2 returned, set noSpecialChars = true |
+ * To get @ returned, set noSpecialChars = false |
*/ |
- _physicalIndexForKey: null, |
+ function normalizedKeyForEvent(keyEvent, noSpecialChars) { |
+ // Fall back from .key, to .keyIdentifier, to .keyCode, and then to |
+ // .detail.key to support artificial keyboard events. |
+ return transformKey(keyEvent.key, noSpecialChars) || |
+ transformKeyIdentifier(keyEvent.keyIdentifier) || |
+ transformKeyCode(keyEvent.keyCode) || |
+ transformKey(keyEvent.detail.key, noSpecialChars) || ''; |
+ } |
- /** |
- * The estimated scroll height based on `_physicalAverage` |
- */ |
- _estScrollHeight: 0, |
+ function keyComboMatchesEvent(keyCombo, event) { |
+ // For combos with modifiers we support only alpha-numeric keys |
+ var keyEvent = normalizedKeyForEvent(event, keyCombo.hasModifiers); |
+ return keyEvent === keyCombo.key && |
+ (!keyCombo.hasModifiers || ( |
+ !!event.shiftKey === !!keyCombo.shiftKey && |
+ !!event.ctrlKey === !!keyCombo.ctrlKey && |
+ !!event.altKey === !!keyCombo.altKey && |
+ !!event.metaKey === !!keyCombo.metaKey) |
+ ); |
+ } |
- /** |
- * The scroll height of the dom node |
- */ |
- _scrollHeight: 0, |
+ function parseKeyComboString(keyComboString) { |
+ if (keyComboString.length === 1) { |
+ return { |
+ combo: keyComboString, |
+ key: keyComboString, |
+ event: 'keydown' |
+ }; |
+ } |
+ return keyComboString.split('+').reduce(function(parsedKeyCombo, keyComboPart) { |
+ var eventParts = keyComboPart.split(':'); |
+ var keyName = eventParts[0]; |
+ var event = eventParts[1]; |
- /** |
- * The height of the list. This is referred as the viewport in the context of list. |
- */ |
- _viewportSize: 0, |
+ if (keyName in MODIFIER_KEYS) { |
+ parsedKeyCombo[MODIFIER_KEYS[keyName]] = true; |
+ parsedKeyCombo.hasModifiers = true; |
+ } else { |
+ parsedKeyCombo.key = keyName; |
+ parsedKeyCombo.event = event || 'keydown'; |
+ } |
- /** |
- * An array of DOM nodes that are currently in the tree |
- * @type {?Array<!TemplatizerNode>} |
- */ |
- _physicalItems: null, |
+ return parsedKeyCombo; |
+ }, { |
+ combo: keyComboString.split(':').shift() |
+ }); |
+ } |
- /** |
- * An array of heights for each item in `_physicalItems` |
- * @type {?Array<number>} |
- */ |
- _physicalSizes: null, |
+ function parseEventString(eventString) { |
+ return eventString.trim().split(' ').map(function(keyComboString) { |
+ return parseKeyComboString(keyComboString); |
+ }); |
+ } |
/** |
- * A cached value for the visible index. |
- * See `firstVisibleIndex` |
- * @type {?number} |
+ * `Polymer.IronA11yKeysBehavior` provides a normalized interface for processing |
+ * keyboard commands that pertain to [WAI-ARIA best practices](http://www.w3.org/TR/wai-aria-practices/#kbd_general_binding). |
+ * The element takes care of browser differences with respect to Keyboard events |
+ * and uses an expressive syntax to filter key presses. |
+ * |
+ * Use the `keyBindings` prototype property to express what combination of keys |
+ * will trigger the event to fire. |
+ * |
+ * Use the `key-event-target` attribute to set up event handlers on a specific |
+ * node. |
+ * The `keys-pressed` event will fire when one of the key combinations set with the |
+ * `keys` property is pressed. |
+ * |
+ * @demo demo/index.html |
+ * @polymerBehavior |
*/ |
- _firstVisibleIndexVal: null, |
+ Polymer.IronA11yKeysBehavior = { |
+ properties: { |
+ /** |
+ * The HTMLElement that will be firing relevant KeyboardEvents. |
+ */ |
+ keyEventTarget: { |
+ type: Object, |
+ value: function() { |
+ return this; |
+ } |
+ }, |
- /** |
- * A Polymer collection for the items. |
- * @type {?Polymer.Collection} |
- */ |
- _collection: null, |
+ /** |
+ * If true, this property will cause the implementing element to |
+ * automatically stop propagation on any handled KeyboardEvents. |
+ */ |
+ stopKeyboardEventPropagation: { |
+ type: Boolean, |
+ value: false |
+ }, |
- /** |
- * True if the current item list was rendered for the first time |
- * after attached. |
- */ |
- _itemsRendered: false, |
+ _boundKeyHandlers: { |
+ type: Array, |
+ value: function() { |
+ return []; |
+ } |
+ }, |
- /** |
- * The page that is currently rendered. |
- */ |
- _lastPage: null, |
+ // We use this due to a limitation in IE10 where instances will have |
+ // own properties of everything on the "prototype". |
+ _imperativeKeyBindings: { |
+ type: Object, |
+ value: function() { |
+ return {}; |
+ } |
+ } |
+ }, |
- /** |
- * The max number of pages to render. One page is equivalent to the height of the list. |
- */ |
- _maxPages: 3, |
+ observers: [ |
+ '_resetKeyEventListeners(keyEventTarget, _boundKeyHandlers)' |
+ ], |
- /** |
- * The bottom of the physical content. |
- */ |
- get _physicalBottom() { |
- return this._physicalTop + this._physicalSize; |
- }, |
+ keyBindings: {}, |
- /** |
- * The bottom of the scroll. |
- */ |
- get _scrollBottom() { |
- return this._scrollPosition + this._viewportSize; |
- }, |
+ registered: function() { |
+ this._prepKeyBindings(); |
+ }, |
- /** |
- * The n-th item rendered in the last physical item. |
- */ |
- get _virtualEnd() { |
- return this._virtualStartVal + this._physicalCount - 1; |
- }, |
+ attached: function() { |
+ this._listenKeyEventListeners(); |
+ }, |
- /** |
- * The lowest n-th value for an item such that it can be rendered in `_physicalStart`. |
- */ |
- _minVirtualStart: 0, |
+ detached: function() { |
+ this._unlistenKeyEventListeners(); |
+ }, |
- /** |
- * The largest n-th value for an item such that it can be rendered in `_physicalStart`. |
- */ |
- get _maxVirtualStart() { |
- return Math.max(0, this._virtualCount - this._physicalCount); |
- }, |
+ /** |
+ * Can be used to imperatively add a key binding to the implementing |
+ * element. This is the imperative equivalent of declaring a keybinding |
+ * in the `keyBindings` prototype property. |
+ */ |
+ addOwnKeyBinding: function(eventString, handlerName) { |
+ this._imperativeKeyBindings[eventString] = handlerName; |
+ this._prepKeyBindings(); |
+ this._resetKeyEventListeners(); |
+ }, |
- /** |
- * The height of the physical content that isn't on the screen. |
- */ |
- get _hiddenContentSize() { |
- return this._physicalSize - this._viewportSize; |
- }, |
+ /** |
+ * When called, will remove all imperatively-added key bindings. |
+ */ |
+ removeOwnKeyBindings: function() { |
+ this._imperativeKeyBindings = {}; |
+ this._prepKeyBindings(); |
+ this._resetKeyEventListeners(); |
+ }, |
- /** |
- * The maximum scroll top value. |
- */ |
- get _maxScrollTop() { |
- return this._estScrollHeight - this._viewportSize; |
- }, |
+ keyboardEventMatchesKeys: function(event, eventString) { |
+ var keyCombos = parseEventString(eventString); |
+ for (var i = 0; i < keyCombos.length; ++i) { |
+ if (keyComboMatchesEvent(keyCombos[i], event)) { |
+ return true; |
+ } |
+ } |
+ return false; |
+ }, |
- /** |
- * Sets the n-th item rendered in `_physicalStart` |
- */ |
- set _virtualStart(val) { |
- // clamp the value so that _minVirtualStart <= val <= _maxVirtualStart |
- this._virtualStartVal = Math.min(this._maxVirtualStart, Math.max(this._minVirtualStart, val)); |
- this._physicalStart = this._virtualStartVal % this._physicalCount; |
- this._physicalEnd = (this._physicalStart + this._physicalCount - 1) % this._physicalCount; |
+ _collectKeyBindings: function() { |
+ var keyBindings = this.behaviors.map(function(behavior) { |
+ return behavior.keyBindings; |
+ }); |
+ |
+ if (keyBindings.indexOf(this.keyBindings) === -1) { |
+ keyBindings.push(this.keyBindings); |
+ } |
+ |
+ return keyBindings; |
+ }, |
+ |
+ _prepKeyBindings: function() { |
+ this._keyBindings = {}; |
+ |
+ this._collectKeyBindings().forEach(function(keyBindings) { |
+ for (var eventString in keyBindings) { |
+ this._addKeyBinding(eventString, keyBindings[eventString]); |
+ } |
+ }, this); |
+ |
+ for (var eventString in this._imperativeKeyBindings) { |
+ this._addKeyBinding(eventString, this._imperativeKeyBindings[eventString]); |
+ } |
+ |
+ // Give precedence to combos with modifiers to be checked first. |
+ for (var eventName in this._keyBindings) { |
+ this._keyBindings[eventName].sort(function (kb1, kb2) { |
+ var b1 = kb1[0].hasModifiers; |
+ var b2 = kb2[0].hasModifiers; |
+ return (b1 === b2) ? 0 : b1 ? -1 : 1; |
+ }) |
+ } |
+ }, |
+ |
+ _addKeyBinding: function(eventString, handlerName) { |
+ parseEventString(eventString).forEach(function(keyCombo) { |
+ this._keyBindings[keyCombo.event] = |
+ this._keyBindings[keyCombo.event] || []; |
+ |
+ this._keyBindings[keyCombo.event].push([ |
+ keyCombo, |
+ handlerName |
+ ]); |
+ }, this); |
+ }, |
+ |
+ _resetKeyEventListeners: function() { |
+ this._unlistenKeyEventListeners(); |
+ |
+ if (this.isAttached) { |
+ this._listenKeyEventListeners(); |
+ } |
+ }, |
+ |
+ _listenKeyEventListeners: function() { |
+ Object.keys(this._keyBindings).forEach(function(eventName) { |
+ var keyBindings = this._keyBindings[eventName]; |
+ var boundKeyHandler = this._onKeyBindingEvent.bind(this, keyBindings); |
+ |
+ this._boundKeyHandlers.push([this.keyEventTarget, eventName, boundKeyHandler]); |
+ |
+ this.keyEventTarget.addEventListener(eventName, boundKeyHandler); |
+ }, this); |
+ }, |
+ |
+ _unlistenKeyEventListeners: function() { |
+ var keyHandlerTuple; |
+ var keyEventTarget; |
+ var eventName; |
+ var boundKeyHandler; |
+ |
+ while (this._boundKeyHandlers.length) { |
+ // My kingdom for block-scope binding and destructuring assignment.. |
+ keyHandlerTuple = this._boundKeyHandlers.pop(); |
+ keyEventTarget = keyHandlerTuple[0]; |
+ eventName = keyHandlerTuple[1]; |
+ boundKeyHandler = keyHandlerTuple[2]; |
+ |
+ keyEventTarget.removeEventListener(eventName, boundKeyHandler); |
+ } |
+ }, |
+ |
+ _onKeyBindingEvent: function(keyBindings, event) { |
+ if (this.stopKeyboardEventPropagation) { |
+ event.stopPropagation(); |
+ } |
+ |
+ // if event has been already prevented, don't do anything |
+ if (event.defaultPrevented) { |
+ return; |
+ } |
+ |
+ for (var i = 0; i < keyBindings.length; i++) { |
+ var keyCombo = keyBindings[i][0]; |
+ var handlerName = keyBindings[i][1]; |
+ if (keyComboMatchesEvent(keyCombo, event)) { |
+ this._triggerKeyHandler(keyCombo, handlerName, event); |
+ // exit the loop if eventDefault was prevented |
+ if (event.defaultPrevented) { |
+ return; |
+ } |
+ } |
+ } |
+ }, |
+ |
+ _triggerKeyHandler: function(keyCombo, handlerName, keyboardEvent) { |
+ var detail = Object.create(keyCombo); |
+ detail.keyboardEvent = keyboardEvent; |
+ var event = new CustomEvent(keyCombo.event, { |
+ detail: detail, |
+ cancelable: true |
+ }); |
+ this[handlerName].call(this, event); |
+ if (event.defaultPrevented) { |
+ keyboardEvent.preventDefault(); |
+ } |
+ } |
+ }; |
+ })(); |
+/** |
+ * `Polymer.IronScrollTargetBehavior` allows an element to respond to scroll events from a |
+ * designated scroll target. |
+ * |
+ * Elements that consume this behavior can override the `_scrollHandler` |
+ * method to add logic on the scroll event. |
+ * |
+ * @demo demo/scrolling-region.html Scrolling Region |
+ * @demo demo/document.html Document Element |
+ * @polymerBehavior |
+ */ |
+ Polymer.IronScrollTargetBehavior = { |
+ |
+ properties: { |
+ |
+ /** |
+ * Specifies the element that will handle the scroll event |
+ * on the behalf of the current element. This is typically a reference to an `Element`, |
+ * but there are a few more posibilities: |
+ * |
+ * ### Elements id |
+ * |
+ *```html |
+ * <div id="scrollable-element" style="overflow-y: auto;"> |
+ * <x-element scroll-target="scrollable-element"> |
+ * Content |
+ * </x-element> |
+ * </div> |
+ *``` |
+ * In this case, `scrollTarget` will point to the outer div element. Alternatively, |
+ * you can set the property programatically: |
+ * |
+ *```js |
+ * appHeader.scrollTarget = document.querySelector('#scrollable-element'); |
+ *``` |
+ * |
+ * @type {HTMLElement} |
+ */ |
+ scrollTarget: { |
+ type: HTMLElement, |
+ value: function() { |
+ return this._defaultScrollTarget; |
+ } |
+ } |
+ }, |
+ |
+ observers: [ |
+ '_scrollTargetChanged(scrollTarget, isAttached)' |
+ ], |
+ |
+ _scrollTargetChanged: function(scrollTarget, isAttached) { |
+ // Remove lister to the current scroll target |
+ if (this._oldScrollTarget) { |
+ if (this._oldScrollTarget === this._doc) { |
+ window.removeEventListener('scroll', this._boundScrollHandler); |
+ } else if (this._oldScrollTarget.removeEventListener) { |
+ this._oldScrollTarget.removeEventListener('scroll', this._boundScrollHandler); |
+ } |
+ this._oldScrollTarget = null; |
+ } |
+ if (isAttached) { |
+ // Support element id references |
+ if (typeof scrollTarget === 'string') { |
+ |
+ var ownerRoot = Polymer.dom(this).getOwnerRoot(); |
+ this.scrollTarget = (ownerRoot && ownerRoot.$) ? |
+ ownerRoot.$[scrollTarget] : Polymer.dom(this.ownerDocument).querySelector('#' + scrollTarget); |
+ |
+ } else if (this._scrollHandler) { |
+ |
+ this._boundScrollHandler = this._boundScrollHandler || this._scrollHandler.bind(this); |
+ // Add a new listener |
+ if (scrollTarget === this._doc) { |
+ window.addEventListener('scroll', this._boundScrollHandler); |
+ if (this._scrollTop !== 0 || this._scrollLeft !== 0) { |
+ this._scrollHandler(); |
+ } |
+ } else if (scrollTarget && scrollTarget.addEventListener) { |
+ scrollTarget.addEventListener('scroll', this._boundScrollHandler); |
+ } |
+ this._oldScrollTarget = scrollTarget; |
+ } |
+ } |
}, |
/** |
- * Gets the n-th item rendered in `_physicalStart` |
+ * Runs on every scroll event. Consumer of this behavior may want to override this method. |
+ * |
+ * @protected |
*/ |
- get _virtualStart() { |
- return this._virtualStartVal; |
- }, |
+ _scrollHandler: function scrollHandler() {}, |
/** |
- * An optimal physical size such that we will have enough physical items |
- * to fill up the viewport and recycle when the user scrolls. |
+ * The default scroll target. Consumers of this behavior may want to customize |
+ * the default scroll target. |
* |
- * This default value assumes that we will at least have the equivalent |
- * to a viewport of physical items above and below the user's viewport. |
+ * @type {Element} |
*/ |
- get _optPhysicalSize() { |
- return this._viewportSize * this._maxPages; |
+ get _defaultScrollTarget() { |
+ return this._doc; |
}, |
- /** |
- * True if the current list is visible. |
- */ |
- get _isVisible() { |
- return this._scroller && Boolean(this._scroller.offsetWidth || this._scroller.offsetHeight); |
+ /** |
+ * Shortcut for the document element |
+ * |
+ * @type {Element} |
+ */ |
+ get _doc() { |
+ return this.ownerDocument.documentElement; |
}, |
/** |
- * Gets the index of the first visible item in the viewport. |
+ * Gets the number of pixels that the content of an element is scrolled upward. |
* |
* @type {number} |
*/ |
- get firstVisibleIndex() { |
- var physicalOffset; |
- |
- if (this._firstVisibleIndexVal === null) { |
- physicalOffset = this._physicalTop; |
- |
- this._firstVisibleIndexVal = this._iterateItems( |
- function(pidx, vidx) { |
- physicalOffset += this._physicalSizes[pidx]; |
- |
- if (physicalOffset > this._scrollPosition) { |
- return vidx; |
- } |
- }) || 0; |
+ get _scrollTop() { |
+ if (this._isValidScrollTarget()) { |
+ return this.scrollTarget === this._doc ? window.pageYOffset : this.scrollTarget.scrollTop; |
} |
- |
- return this._firstVisibleIndexVal; |
+ return 0; |
}, |
- ready: function() { |
- if (IOS_TOUCH_SCROLLING) { |
- this._scrollListener = function() { |
- requestAnimationFrame(this._scrollHandler.bind(this)); |
- }.bind(this); |
- } else { |
- this._scrollListener = this._scrollHandler.bind(this); |
+ /** |
+ * Gets the number of pixels that the content of an element is scrolled to the left. |
+ * |
+ * @type {number} |
+ */ |
+ get _scrollLeft() { |
+ if (this._isValidScrollTarget()) { |
+ return this.scrollTarget === this._doc ? window.pageXOffset : this.scrollTarget.scrollLeft; |
} |
+ return 0; |
}, |
/** |
- * When the element has been attached to the DOM tree. |
+ * Sets the number of pixels that the content of an element is scrolled upward. |
+ * |
+ * @type {number} |
*/ |
- attached: function() { |
- // delegate to the parent's scroller |
- // e.g. paper-scroll-header-panel |
- var el = Polymer.dom(this); |
- |
- var parentNode = /** @type {?{scroller: ?Element}} */ (el.parentNode); |
- if (parentNode && parentNode.scroller) { |
- this._scroller = parentNode.scroller; |
- } else { |
- this._scroller = this; |
- this.classList.add('has-scroller'); |
+ set _scrollTop(top) { |
+ if (this.scrollTarget === this._doc) { |
+ window.scrollTo(window.pageXOffset, top); |
+ } else if (this._isValidScrollTarget()) { |
+ this.scrollTarget.scrollTop = top; |
} |
+ }, |
- if (IOS_TOUCH_SCROLLING) { |
- this._scroller.style.webkitOverflowScrolling = 'touch'; |
+ /** |
+ * Sets the number of pixels that the content of an element is scrolled to the left. |
+ * |
+ * @type {number} |
+ */ |
+ set _scrollLeft(left) { |
+ if (this.scrollTarget === this._doc) { |
+ window.scrollTo(left, window.pageYOffset); |
+ } else if (this._isValidScrollTarget()) { |
+ this.scrollTarget.scrollLeft = left; |
} |
+ }, |
- this._scroller.addEventListener('scroll', this._scrollListener); |
- |
- this.updateViewportBoundaries(); |
- this._render(); |
+ /** |
+ * Scrolls the content to a particular place. |
+ * |
+ * @method scroll |
+ * @param {number} top The top position |
+ * @param {number} left The left position |
+ */ |
+ scroll: function(top, left) { |
+ if (this.scrollTarget === this._doc) { |
+ window.scrollTo(top, left); |
+ } else if (this._isValidScrollTarget()) { |
+ this.scrollTarget.scrollTop = top; |
+ this.scrollTarget.scrollLeft = left; |
+ } |
}, |
/** |
- * When the element has been removed from the DOM tree. |
+ * Gets the width of the scroll target. |
+ * |
+ * @type {number} |
*/ |
- detached: function() { |
- this._itemsRendered = false; |
- if (this._scroller) { |
- this._scroller.removeEventListener('scroll', this._scrollListener); |
+ get _scrollTargetWidth() { |
+ if (this._isValidScrollTarget()) { |
+ return this.scrollTarget === this._doc ? window.innerWidth : this.scrollTarget.offsetWidth; |
} |
+ return 0; |
}, |
/** |
- * Invoke this method if you dynamically update the viewport's |
- * size or CSS padding. |
+ * Gets the height of the scroll target. |
* |
- * @method updateViewportBoundaries |
+ * @type {number} |
*/ |
- updateViewportBoundaries: function() { |
- var scrollerStyle = window.getComputedStyle(this._scroller); |
- this._scrollerPaddingTop = parseInt(scrollerStyle['padding-top'], 10); |
- this._viewportSize = this._scroller.offsetHeight; |
+ get _scrollTargetHeight() { |
+ if (this._isValidScrollTarget()) { |
+ return this.scrollTarget === this._doc ? window.innerHeight : this.scrollTarget.offsetHeight; |
+ } |
+ return 0; |
}, |
/** |
- * Update the models, the position of the |
- * items in the viewport and recycle tiles as needed. |
+ * Returns true if the scroll target is a valid HTMLElement. |
+ * |
+ * @return {boolean} |
*/ |
- _refresh: function() { |
- // clamp the `scrollTop` value |
- // IE 10|11 scrollTop may go above `_maxScrollTop` |
- // iOS `scrollTop` may go below 0 and above `_maxScrollTop` |
- var scrollTop = Math.max(0, Math.min(this._maxScrollTop, this._scroller.scrollTop)); |
- var tileHeight, tileTop, kth, recycledTileSet, scrollBottom, physicalBottom; |
- var ratio = this._ratio; |
- var delta = scrollTop - this._scrollPosition; |
- var recycledTiles = 0; |
- var hiddenContentSize = this._hiddenContentSize; |
- var currentRatio = ratio; |
- var movingUp = []; |
+ _isValidScrollTarget: function() { |
+ return this.scrollTarget instanceof HTMLElement; |
+ } |
+ }; |
+(function() { |
- // track the last `scrollTop` |
- this._scrollPosition = scrollTop; |
+ var IOS = navigator.userAgent.match(/iP(?:hone|ad;(?: U;)? CPU) OS (\d+)/); |
+ var IOS_TOUCH_SCROLLING = IOS && IOS[1] >= 8; |
+ var DEFAULT_PHYSICAL_COUNT = 3; |
+ var MAX_PHYSICAL_COUNT = 500; |
+ var HIDDEN_Y = '-10000px'; |
- // clear cached visible index |
- this._firstVisibleIndexVal = null; |
+ Polymer({ |
- scrollBottom = this._scrollBottom; |
- physicalBottom = this._physicalBottom; |
+ is: 'iron-list', |
- // random access |
- if (Math.abs(delta) > this._physicalSize) { |
- this._physicalTop += delta; |
- recycledTiles = Math.round(delta / this._physicalAverage); |
- } |
- // scroll up |
- else if (delta < 0) { |
- var topSpace = scrollTop - this._physicalTop; |
- var virtualStart = this._virtualStart; |
- |
- recycledTileSet = []; |
- |
- kth = this._physicalEnd; |
- currentRatio = topSpace / hiddenContentSize; |
- |
- // move tiles from bottom to top |
- while ( |
- // approximate `currentRatio` to `ratio` |
- currentRatio < ratio && |
- // recycle less physical items than the total |
- recycledTiles < this._physicalCount && |
- // ensure that these recycled tiles are needed |
- virtualStart - recycledTiles > 0 && |
- // ensure that the tile is not visible |
- physicalBottom - this._physicalSizes[kth] > scrollBottom |
- ) { |
+ properties: { |
- tileHeight = this._physicalSizes[kth]; |
- currentRatio += tileHeight / hiddenContentSize; |
- physicalBottom -= tileHeight; |
- recycledTileSet.push(kth); |
- recycledTiles++; |
- kth = (kth === 0) ? this._physicalCount - 1 : kth - 1; |
- } |
+ /** |
+ * An array containing items determining how many instances of the template |
+ * to stamp and that that each template instance should bind to. |
+ */ |
+ items: { |
+ type: Array |
+ }, |
- movingUp = recycledTileSet; |
- recycledTiles = -recycledTiles; |
- } |
- // scroll down |
- else if (delta > 0) { |
- var bottomSpace = physicalBottom - scrollBottom; |
- var virtualEnd = this._virtualEnd; |
- var lastVirtualItemIndex = this._virtualCount-1; |
+ /** |
+ * The name of the variable to add to the binding scope for the array |
+ * element associated with a given template instance. |
+ */ |
+ as: { |
+ type: String, |
+ value: 'item' |
+ }, |
- recycledTileSet = []; |
+ /** |
+ * The name of the variable to add to the binding scope with the index |
+ * for the row. |
+ */ |
+ indexAs: { |
+ type: String, |
+ value: 'index' |
+ }, |
- kth = this._physicalStart; |
- currentRatio = bottomSpace / hiddenContentSize; |
+ /** |
+ * The name of the variable to add to the binding scope to indicate |
+ * if the row is selected. |
+ */ |
+ selectedAs: { |
+ type: String, |
+ value: 'selected' |
+ }, |
- // move tiles from top to bottom |
- while ( |
- // approximate `currentRatio` to `ratio` |
- currentRatio < ratio && |
- // recycle less physical items than the total |
- recycledTiles < this._physicalCount && |
- // ensure that these recycled tiles are needed |
- virtualEnd + recycledTiles < lastVirtualItemIndex && |
- // ensure that the tile is not visible |
- this._physicalTop + this._physicalSizes[kth] < scrollTop |
- ) { |
+ /** |
+ * 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. |
+ */ |
+ selectionEnabled: { |
+ type: Boolean, |
+ value: false |
+ }, |
- tileHeight = this._physicalSizes[kth]; |
- currentRatio += tileHeight / hiddenContentSize; |
+ /** |
+ * When `multiSelection` is false, this is the currently selected item, or `null` |
+ * if no item is selected. |
+ */ |
+ selectedItem: { |
+ type: Object, |
+ notify: true |
+ }, |
- this._physicalTop += tileHeight; |
- recycledTileSet.push(kth); |
- recycledTiles++; |
- kth = (kth + 1) % this._physicalCount; |
- } |
- } |
+ /** |
+ * When `multiSelection` is true, this is an array that contains the selected items. |
+ */ |
+ selectedItems: { |
+ type: Object, |
+ notify: true |
+ }, |
- if (recycledTiles === 0) { |
- // If the list ever reach this case, the physical average is not significant enough |
- // to create all the items needed to cover the entire viewport. |
- // e.g. A few items have a height that differs from the average by serveral order of magnitude. |
- if (physicalBottom < scrollBottom || this._physicalTop > scrollTop) { |
- this.async(this._increasePool.bind(this, 1)); |
- } |
- } else { |
- this._virtualStart = this._virtualStart + recycledTiles; |
- this._update(recycledTileSet, movingUp); |
+ /** |
+ * When `true`, multiple items may be selected at once (in this case, |
+ * `selected` is an array of currently selected items). When `false`, |
+ * only one item may be selected at a time. |
+ */ |
+ multiSelection: { |
+ type: Boolean, |
+ value: false |
} |
}, |
- /** |
- * Update the list of items, starting from the `_virtualStartVal` item. |
- * @param {!Array<number>=} itemSet |
- * @param {!Array<number>=} movingUp |
- */ |
- _update: function(itemSet, movingUp) { |
- // update models |
- this._assignModels(itemSet); |
- |
- // measure heights |
- this._updateMetrics(itemSet); |
+ observers: [ |
+ '_itemsChanged(items.*)', |
+ '_selectionEnabledChanged(selectionEnabled)', |
+ '_multiSelectionChanged(multiSelection)', |
+ '_setOverflow(scrollTarget)' |
+ ], |
- // adjust offset after measuring |
- if (movingUp) { |
- while (movingUp.length) { |
- this._physicalTop -= this._physicalSizes[movingUp.pop()]; |
- } |
- } |
- // update the position of the items |
- this._positionItems(); |
+ behaviors: [ |
+ Polymer.Templatizer, |
+ Polymer.IronResizableBehavior, |
+ Polymer.IronA11yKeysBehavior, |
+ Polymer.IronScrollTargetBehavior |
+ ], |
- // set the scroller size |
- this._updateScrollerSize(); |
+ listeners: { |
+ 'iron-resize': '_resizeHandler' |
+ }, |
- // increase the pool of physical items |
- this._increasePoolIfNeeded(); |
+ keyBindings: { |
+ 'up': '_didMoveUp', |
+ 'down': '_didMoveDown', |
+ 'enter': '_didEnter' |
}, |
/** |
- * Creates a pool of DOM elements and attaches them to the local dom. |
+ * The ratio of hidden tiles that should remain in the scroll direction. |
+ * Recommended value ~0.5, so it will distribute tiles evely in both directions. |
*/ |
- _createPool: function(size) { |
- var physicalItems = new Array(size); |
+ _ratio: 0.5, |
- this._ensureTemplatized(); |
+ /** |
+ * The padding-top value of the `scroller` element |
+ */ |
+ _scrollerPaddingTop: 0, |
- for (var i = 0; i < size; i++) { |
- var inst = this.stamp(null); |
- // First element child is item; Safari doesn't support children[0] |
- // on a doc fragment |
- physicalItems[i] = inst.root.querySelector('*'); |
- Polymer.dom(this).appendChild(inst.root); |
- } |
+ /** |
+ * This value is the same as `scrollTop`. |
+ */ |
+ _scrollPosition: 0, |
- return physicalItems; |
- }, |
+ /** |
+ * The number of tiles in the DOM. |
+ */ |
+ _physicalCount: 0, |
/** |
- * Increases the pool of physical items only if needed. |
- * This function will allocate additional physical items |
- * if the physical size is shorter than `_optPhysicalSize` |
+ * The k-th tile that is at the top of the scrolling list. |
*/ |
- _increasePoolIfNeeded: function() { |
- if (this._viewportSize !== 0 && this._physicalSize < this._optPhysicalSize) { |
- // 0 <= `currentPage` <= `_maxPages` |
- var currentPage = Math.floor(this._physicalSize / this._viewportSize); |
- |
- if (currentPage === 0) { |
- // fill the first page |
- this.async(this._increasePool.bind(this, Math.round(this._physicalCount * 0.5))); |
- } else if (this._lastPage !== currentPage) { |
- // once a page is filled up, paint it and defer the next increase |
- requestAnimationFrame(this._increasePool.bind(this, 1)); |
- } else { |
- // fill the rest of the pages |
- this.async(this._increasePool.bind(this, 1)); |
- } |
- this._lastPage = currentPage; |
- return true; |
- } |
- return false; |
- }, |
+ _physicalStart: 0, |
/** |
- * Increases the pool size. |
+ * The k-th tile that is at the bottom of the scrolling list. |
*/ |
- _increasePool: function(missingItems) { |
- // limit the size |
- var nextPhysicalCount = Math.min( |
- this._physicalCount + missingItems, |
- this._virtualCount, |
- MAX_PHYSICAL_COUNT |
- ); |
- var prevPhysicalCount = this._physicalCount; |
- var delta = nextPhysicalCount - prevPhysicalCount; |
+ _physicalEnd: 0, |
- if (delta > 0) { |
- [].push.apply(this._physicalItems, this._createPool(delta)); |
- [].push.apply(this._physicalSizes, new Array(delta)); |
+ /** |
+ * The sum of the heights of all the tiles in the DOM. |
+ */ |
+ _physicalSize: 0, |
- this._physicalCount = prevPhysicalCount + delta; |
- // tail call |
- return this._update(); |
- } |
- }, |
+ /** |
+ * The average `F` of the tiles observed till now. |
+ */ |
+ _physicalAverage: 0, |
/** |
- * Render a new list of items. This method does exactly the same as `update`, |
- * but it also ensures that only one `update` cycle is created. |
+ * The number of tiles which `offsetHeight` > 0 observed until now. |
*/ |
- _render: function() { |
- var requiresUpdate = this._virtualCount > 0 || this._physicalCount > 0; |
+ _physicalAverageCount: 0, |
- if (this.isAttached && !this._itemsRendered && this._isVisible && requiresUpdate) { |
- this._lastPage = 0; |
- this._update(); |
- this._itemsRendered = true; |
- } |
- }, |
+ /** |
+ * The Y position of the item rendered in the `_physicalStart` |
+ * tile relative to the scrolling list. |
+ */ |
+ _physicalTop: 0, |
/** |
- * Templetizes the user template. |
+ * The number of items in the list. |
*/ |
- _ensureTemplatized: function() { |
- if (!this.ctor) { |
- // Template instance props that should be excluded from forwarding |
- var props = {}; |
+ _virtualCount: 0, |
- props.__key__ = true; |
- props[this.as] = true; |
- props[this.indexAs] = true; |
- props[this.selectedAs] = true; |
+ /** |
+ * The n-th item rendered in the `_physicalStart` tile. |
+ */ |
+ _virtualStartVal: 0, |
- this._instanceProps = props; |
- this._userTemplate = Polymer.dom(this).querySelector('template'); |
+ /** |
+ * A map between an item key and its physical item index |
+ */ |
+ _physicalIndexForKey: null, |
- if (this._userTemplate) { |
- this.templatize(this._userTemplate); |
- } else { |
- console.warn('iron-list requires a template to be provided in light-dom'); |
- } |
- } |
- }, |
+ /** |
+ * The estimated scroll height based on `_physicalAverage` |
+ */ |
+ _estScrollHeight: 0, |
/** |
- * Implements extension point from Templatizer mixin. |
+ * The scroll height of the dom node |
*/ |
- _getStampedChildren: function() { |
- return this._physicalItems; |
- }, |
+ _scrollHeight: 0, |
/** |
- * Implements extension point from Templatizer |
- * Called as a side effect of a template instance path change, responsible |
- * for notifying items.<key-for-instance>.<path> change up to host. |
+ * The height of the list. This is referred as the viewport in the context of list. |
*/ |
- _forwardInstancePath: function(inst, path, value) { |
- if (path.indexOf(this.as + '.') === 0) { |
- this.notifyPath('items.' + inst.__key__ + '.' + |
- path.slice(this.as.length + 1), value); |
- } |
- }, |
+ _viewportSize: 0, |
/** |
- * Implements extension point from Templatizer mixin |
- * Called as side-effect of a host property change, responsible for |
- * notifying parent path change on each row. |
+ * An array of DOM nodes that are currently in the tree |
+ * @type {?Array<!TemplatizerNode>} |
*/ |
- _forwardParentProp: function(prop, value) { |
- if (this._physicalItems) { |
- this._physicalItems.forEach(function(item) { |
- item._templateInstance[prop] = value; |
- }, this); |
- } |
- }, |
+ _physicalItems: null, |
/** |
- * Implements extension point from Templatizer |
- * Called as side-effect of a host path change, responsible for |
- * notifying parent.<path> path change on each row. |
+ * An array of heights for each item in `_physicalItems` |
+ * @type {?Array<number>} |
*/ |
- _forwardParentPath: function(path, value) { |
- if (this._physicalItems) { |
- this._physicalItems.forEach(function(item) { |
- item._templateInstance.notifyPath(path, value, true); |
- }, this); |
- } |
- }, |
+ _physicalSizes: null, |
/** |
- * Called as a side effect of a host items.<key>.<path> path change, |
- * responsible for notifying item.<path> changes to row for key. |
+ * A cached value for the first visible index. |
+ * See `firstVisibleIndex` |
+ * @type {?number} |
*/ |
- _forwardItemPath: function(path, value) { |
- if (this._physicalIndexForKey) { |
- var dot = path.indexOf('.'); |
- var key = path.substring(0, dot < 0 ? path.length : dot); |
- var idx = this._physicalIndexForKey[key]; |
- var row = this._physicalItems[idx]; |
- if (row) { |
- var inst = row._templateInstance; |
- if (dot >= 0) { |
- path = this.as + '.' + path.substring(dot+1); |
- inst.notifyPath(path, value, true); |
- } else { |
- inst[this.as] = value; |
- } |
- } |
- } |
- }, |
+ _firstVisibleIndexVal: null, |
/** |
- * Called when the items have changed. That is, ressignments |
- * to `items`, splices or updates to a single item. |
+ * A cached value for the last visible index. |
+ * See `lastVisibleIndex` |
+ * @type {?number} |
*/ |
- _itemsChanged: function(change) { |
- if (change.path === 'items') { |
- // render the new set |
- this._itemsRendered = false; |
+ _lastVisibleIndexVal: null, |
- // update the whole set |
- this._virtualStartVal = 0; |
- this._physicalTop = 0; |
- this._virtualCount = this.items ? this.items.length : 0; |
- this._collection = this.items ? Polymer.Collection.get(this.items) : null; |
- this._physicalIndexForKey = {}; |
- // scroll to the top |
- this._resetScrollPosition(0); |
+ /** |
+ * A Polymer collection for the items. |
+ * @type {?Polymer.Collection} |
+ */ |
+ _collection: null, |
- // create the initial physical items |
- if (!this._physicalItems) { |
- this._physicalCount = Math.max(1, Math.min(DEFAULT_PHYSICAL_COUNT, this._virtualCount)); |
- this._physicalItems = this._createPool(this._physicalCount); |
- this._physicalSizes = new Array(this._physicalCount); |
- } |
+ /** |
+ * True if the current item list was rendered for the first time |
+ * after attached. |
+ */ |
+ _itemsRendered: false, |
- this.debounce('refresh', this._render); |
+ /** |
+ * The page that is currently rendered. |
+ */ |
+ _lastPage: null, |
- } else if (change.path === 'items.splices') { |
- // render the new set |
- this._itemsRendered = false; |
+ /** |
+ * The max number of pages to render. One page is equivalent to the height of the list. |
+ */ |
+ _maxPages: 3, |
- this._adjustVirtualIndex(change.value.indexSplices); |
- this._virtualCount = this.items ? this.items.length : 0; |
+ /** |
+ * The currently focused item index. |
+ */ |
+ _focusedIndex: 0, |
- this.debounce('refresh', this._render); |
+ /** |
+ * The the item that is focused if it is moved offscreen. |
+ * @private {?TemplatizerNode} |
+ */ |
+ _offscreenFocusedItem: null, |
- } else { |
- // update a single item |
- this._forwardItemPath(change.path.split('.').slice(1).join('.'), change.value); |
- } |
- }, |
+ /** |
+ * The item that backfills the `_offscreenFocusedItem` in the physical items |
+ * list when that item is moved offscreen. |
+ */ |
+ _focusBackfillItem: null, |
/** |
- * @param {!Array<!PolymerSplice>} splices |
+ * The bottom of the physical content. |
*/ |
- _adjustVirtualIndex: function(splices) { |
- var i, splice, idx; |
+ get _physicalBottom() { |
+ return this._physicalTop + this._physicalSize; |
+ }, |
- for (i = 0; i < splices.length; i++) { |
- splice = splices[i]; |
+ /** |
+ * The bottom of the scroll. |
+ */ |
+ get _scrollBottom() { |
+ return this._scrollPosition + this._viewportSize; |
+ }, |
- // deselect removed items |
- splice.removed.forEach(this.$.selector.deselect, this.$.selector); |
+ /** |
+ * The n-th item rendered in the last physical item. |
+ */ |
+ get _virtualEnd() { |
+ return this._virtualStart + this._physicalCount - 1; |
+ }, |
- idx = splice.index; |
- // We only need to care about changes happening above the current position |
- if (idx >= this._virtualStartVal) { |
- break; |
- } |
+ /** |
+ * The lowest n-th value for an item such that it can be rendered in `_physicalStart`. |
+ */ |
+ _minVirtualStart: 0, |
- this._virtualStart = this._virtualStart + |
- Math.max(splice.addedCount - splice.removed.length, idx - this._virtualStartVal); |
- } |
+ /** |
+ * The largest n-th value for an item such that it can be rendered in `_physicalStart`. |
+ */ |
+ get _maxVirtualStart() { |
+ return Math.max(0, this._virtualCount - this._physicalCount); |
}, |
- _scrollHandler: function() { |
- this._refresh(); |
+ /** |
+ * The height of the physical content that isn't on the screen. |
+ */ |
+ get _hiddenContentSize() { |
+ return this._physicalSize - this._viewportSize; |
}, |
/** |
- * Executes a provided function per every physical index in `itemSet` |
- * `itemSet` default value is equivalent to the entire set of physical indexes. |
- * |
- * @param {!function(number, number)} fn |
- * @param {!Array<number>=} itemSet |
+ * The maximum scroll top value. |
*/ |
- _iterateItems: function(fn, itemSet) { |
- var pidx, vidx, rtn, i; |
+ get _maxScrollTop() { |
+ return this._estScrollHeight - this._viewportSize; |
+ }, |
- if (arguments.length === 2 && itemSet) { |
- for (i = 0; i < itemSet.length; i++) { |
- pidx = itemSet[i]; |
- if (pidx >= this._physicalStart) { |
- vidx = this._virtualStartVal + (pidx - this._physicalStart); |
- } else { |
- vidx = this._virtualStartVal + (this._physicalCount - this._physicalStart) + pidx; |
- } |
- if ((rtn = fn.call(this, pidx, vidx)) != null) { |
- return rtn; |
- } |
- } |
+ /** |
+ * Sets the n-th item rendered in `_physicalStart` |
+ */ |
+ set _virtualStart(val) { |
+ // clamp the value so that _minVirtualStart <= val <= _maxVirtualStart |
+ this._virtualStartVal = Math.min(this._maxVirtualStart, Math.max(this._minVirtualStart, val)); |
+ if (this._physicalCount === 0) { |
+ this._physicalStart = 0; |
+ this._physicalEnd = 0; |
} else { |
- pidx = this._physicalStart; |
- vidx = this._virtualStartVal; |
- |
- for (; pidx < this._physicalCount; pidx++, vidx++) { |
- if ((rtn = fn.call(this, pidx, vidx)) != null) { |
- return rtn; |
- } |
- } |
- |
- pidx = 0; |
- |
- for (; pidx < this._physicalStart; pidx++, vidx++) { |
- if ((rtn = fn.call(this, pidx, vidx)) != null) { |
- return rtn; |
- } |
- } |
+ this._physicalStart = this._virtualStartVal % this._physicalCount; |
+ this._physicalEnd = (this._physicalStart + this._physicalCount - 1) % this._physicalCount; |
} |
}, |
/** |
- * Assigns the data models to a given set of items. |
- * @param {!Array<number>=} itemSet |
+ * Gets the n-th item rendered in `_physicalStart` |
*/ |
- _assignModels: function(itemSet) { |
- this._iterateItems(function(pidx, vidx) { |
- var el = this._physicalItems[pidx]; |
- var inst = el._templateInstance; |
- var item = this.items && this.items[vidx]; |
- |
- if (item) { |
- inst[this.as] = item; |
- inst.__key__ = this._collection.getKey(item); |
- inst[this.selectedAs] = |
- /** @type {!ArraySelectorElement} */ (this.$.selector).isSelected(item); |
- inst[this.indexAs] = vidx; |
- el.removeAttribute('hidden'); |
- this._physicalIndexForKey[inst.__key__] = pidx; |
- } else { |
- inst.__key__ = null; |
- el.setAttribute('hidden', ''); |
- } |
- |
- }, itemSet); |
+ get _virtualStart() { |
+ return this._virtualStartVal; |
}, |
/** |
- * Updates the height for a given set of items. |
+ * An optimal physical size such that we will have enough physical items |
+ * to fill up the viewport and recycle when the user scrolls. |
* |
- * @param {!Array<number>=} itemSet |
+ * This default value assumes that we will at least have the equivalent |
+ * to a viewport of physical items above and below the user's viewport. |
*/ |
- _updateMetrics: function(itemSet) { |
- var newPhysicalSize = 0; |
- var oldPhysicalSize = 0; |
- var prevAvgCount = this._physicalAverageCount; |
- var prevPhysicalAvg = this._physicalAverage; |
- // Make sure we distributed all the physical items |
- // so we can measure them |
- Polymer.dom.flush(); |
+ get _optPhysicalSize() { |
+ return this._viewportSize * this._maxPages; |
+ }, |
- this._iterateItems(function(pidx, vidx) { |
- oldPhysicalSize += this._physicalSizes[pidx] || 0; |
- this._physicalSizes[pidx] = this._physicalItems[pidx].offsetHeight; |
- newPhysicalSize += this._physicalSizes[pidx]; |
- this._physicalAverageCount += this._physicalSizes[pidx] ? 1 : 0; |
- }, itemSet); |
+ /** |
+ * True if the current list is visible. |
+ */ |
+ get _isVisible() { |
+ return this.scrollTarget && Boolean(this.scrollTarget.offsetWidth || this.scrollTarget.offsetHeight); |
+ }, |
- this._physicalSize = this._physicalSize + newPhysicalSize - oldPhysicalSize; |
- this._viewportSize = this._scroller.offsetHeight; |
+ /** |
+ * Gets the index of the first visible item in the viewport. |
+ * |
+ * @type {number} |
+ */ |
+ get firstVisibleIndex() { |
+ if (this._firstVisibleIndexVal === null) { |
+ var physicalOffset = this._physicalTop; |
- // update the average if we measured something |
- if (this._physicalAverageCount !== prevAvgCount) { |
- this._physicalAverage = Math.round( |
- ((prevPhysicalAvg * prevAvgCount) + newPhysicalSize) / |
- this._physicalAverageCount); |
+ this._firstVisibleIndexVal = this._iterateItems( |
+ function(pidx, vidx) { |
+ physicalOffset += this._physicalSizes[pidx]; |
+ |
+ if (physicalOffset > this._scrollPosition) { |
+ return vidx; |
+ } |
+ }) || 0; |
} |
+ return this._firstVisibleIndexVal; |
}, |
/** |
- * Updates the position of the physical items. |
+ * Gets the index of the last visible item in the viewport. |
+ * |
+ * @type {number} |
*/ |
- _positionItems: function() { |
- this._adjustScrollPosition(); |
+ get lastVisibleIndex() { |
+ if (this._lastVisibleIndexVal === null) { |
+ var physicalOffset = this._physicalTop; |
- var y = this._physicalTop; |
- |
- this._iterateItems(function(pidx) { |
+ this._iterateItems(function(pidx, vidx) { |
+ physicalOffset += this._physicalSizes[pidx]; |
- this.transform('translate3d(0, ' + y + 'px, 0)', this._physicalItems[pidx]); |
- y += this._physicalSizes[pidx]; |
+ if(physicalOffset <= this._scrollBottom) { |
+ this._lastVisibleIndexVal = vidx; |
+ } |
+ }); |
+ } |
+ return this._lastVisibleIndexVal; |
+ }, |
- }); |
+ ready: function() { |
+ this.addEventListener('focus', this._didFocus.bind(this), true); |
}, |
- /** |
- * Adjusts the scroll position when it was overestimated. |
- */ |
- _adjustScrollPosition: function() { |
- var deltaHeight = this._virtualStartVal === 0 ? this._physicalTop : |
- Math.min(this._scrollPosition + this._physicalTop, 0); |
+ attached: function() { |
+ this.updateViewportBoundaries(); |
+ this._render(); |
+ }, |
- if (deltaHeight) { |
- this._physicalTop = this._physicalTop - deltaHeight; |
+ detached: function() { |
+ this._itemsRendered = false; |
+ }, |
- // juking scroll position during interial scrolling on iOS is no bueno |
- if (!IOS_TOUCH_SCROLLING) { |
- this._resetScrollPosition(this._scroller.scrollTop - deltaHeight); |
- } |
- } |
+ get _defaultScrollTarget() { |
+ return this; |
}, |
/** |
- * Sets the position of the scroll. |
+ * Set the overflow property if this element has its own scrolling region |
*/ |
- _resetScrollPosition: function(pos) { |
- if (this._scroller) { |
- this._scroller.scrollTop = pos; |
- this._scrollPosition = this._scroller.scrollTop; |
- } |
+ _setOverflow: function(scrollTarget) { |
+ this.style.webkitOverflowScrolling = scrollTarget === this ? 'touch' : ''; |
+ this.style.overflow = scrollTarget === this ? 'auto' : ''; |
}, |
/** |
- * Sets the scroll height, that's the height of the content, |
+ * Invoke this method if you dynamically update the viewport's |
+ * size or CSS padding. |
* |
- * @param {boolean=} forceUpdate If true, updates the height no matter what. |
+ * @method updateViewportBoundaries |
*/ |
- _updateScrollerSize: function(forceUpdate) { |
- this._estScrollHeight = (this._physicalBottom + |
- Math.max(this._virtualCount - this._physicalCount - this._virtualStartVal, 0) * this._physicalAverage); |
- |
- forceUpdate = forceUpdate || this._scrollHeight === 0; |
- forceUpdate = forceUpdate || this._scrollPosition >= this._estScrollHeight - this._physicalSize; |
- |
- // amortize height adjustment, so it won't trigger repaints very often |
- if (forceUpdate || Math.abs(this._estScrollHeight - this._scrollHeight) >= this._optPhysicalSize) { |
- this.$.items.style.height = this._estScrollHeight + 'px'; |
- this._scrollHeight = this._estScrollHeight; |
- } |
+ updateViewportBoundaries: function() { |
+ var scrollerStyle = window.getComputedStyle(this.scrollTarget); |
+ this._scrollerPaddingTop = parseInt(scrollerStyle['padding-top'], 10); |
+ this._viewportSize = this._scrollTargetHeight; |
}, |
/** |
- * Scroll to a specific item in the virtual list regardless |
- * of the physical items in the DOM tree. |
- * |
- * @method scrollToIndex |
- * @param {number} idx The index of the item |
+ * Update the models, the position of the |
+ * items in the viewport and recycle tiles as needed. |
*/ |
- scrollToIndex: function(idx) { |
- if (typeof idx !== 'number') { |
- return; |
- } |
+ _scrollHandler: function() { |
+ // clamp the `scrollTop` value |
+ // IE 10|11 scrollTop may go above `_maxScrollTop` |
+ // iOS `scrollTop` may go below 0 and above `_maxScrollTop` |
+ var scrollTop = Math.max(0, Math.min(this._maxScrollTop, this._scrollTop)); |
+ var tileHeight, tileTop, kth, recycledTileSet, scrollBottom, physicalBottom; |
+ var ratio = this._ratio; |
+ var delta = scrollTop - this._scrollPosition; |
+ var recycledTiles = 0; |
+ var hiddenContentSize = this._hiddenContentSize; |
+ var currentRatio = ratio; |
+ var movingUp = []; |
- var firstVisible = this.firstVisibleIndex; |
+ // track the last `scrollTop` |
+ this._scrollPosition = scrollTop; |
- idx = Math.min(Math.max(idx, 0), this._virtualCount-1); |
+ // clear cached visible index |
+ this._firstVisibleIndexVal = null; |
+ this._lastVisibleIndexVal = null; |
- // start at the previous virtual item |
- // so we have a item above the first visible item |
- this._virtualStart = idx - 1; |
+ scrollBottom = this._scrollBottom; |
+ physicalBottom = this._physicalBottom; |
- // assign new models |
- this._assignModels(); |
+ // random access |
+ if (Math.abs(delta) > this._physicalSize) { |
+ this._physicalTop += delta; |
+ recycledTiles = Math.round(delta / this._physicalAverage); |
+ } |
+ // scroll up |
+ else if (delta < 0) { |
+ var topSpace = scrollTop - this._physicalTop; |
+ var virtualStart = this._virtualStart; |
- // measure the new sizes |
- this._updateMetrics(); |
+ recycledTileSet = []; |
- // estimate new physical offset |
- this._physicalTop = this._virtualStart * this._physicalAverage; |
+ kth = this._physicalEnd; |
+ currentRatio = topSpace / hiddenContentSize; |
- var currentTopItem = this._physicalStart; |
- var currentVirtualItem = this._virtualStart; |
- var targetOffsetTop = 0; |
- var hiddenContentSize = this._hiddenContentSize; |
+ // move tiles from bottom to top |
+ while ( |
+ // approximate `currentRatio` to `ratio` |
+ currentRatio < ratio && |
+ // recycle less physical items than the total |
+ recycledTiles < this._physicalCount && |
+ // ensure that these recycled tiles are needed |
+ virtualStart - recycledTiles > 0 && |
+ // ensure that the tile is not visible |
+ physicalBottom - this._physicalSizes[kth] > scrollBottom |
+ ) { |
- // scroll to the item as much as we can |
- while (currentVirtualItem < idx && targetOffsetTop < hiddenContentSize) { |
- targetOffsetTop = targetOffsetTop + this._physicalSizes[currentTopItem]; |
- currentTopItem = (currentTopItem + 1) % this._physicalCount; |
- currentVirtualItem++; |
+ tileHeight = this._physicalSizes[kth]; |
+ currentRatio += tileHeight / hiddenContentSize; |
+ physicalBottom -= tileHeight; |
+ recycledTileSet.push(kth); |
+ recycledTiles++; |
+ kth = (kth === 0) ? this._physicalCount - 1 : kth - 1; |
+ } |
+ |
+ movingUp = recycledTileSet; |
+ recycledTiles = -recycledTiles; |
} |
+ // scroll down |
+ else if (delta > 0) { |
+ var bottomSpace = physicalBottom - scrollBottom; |
+ var virtualEnd = this._virtualEnd; |
+ var lastVirtualItemIndex = this._virtualCount-1; |
- // update the scroller size |
- this._updateScrollerSize(true); |
+ recycledTileSet = []; |
- // update the position of the items |
- this._positionItems(); |
+ kth = this._physicalStart; |
+ currentRatio = bottomSpace / hiddenContentSize; |
- // set the new scroll position |
- this._resetScrollPosition(this._physicalTop + targetOffsetTop + 1); |
+ // move tiles from top to bottom |
+ while ( |
+ // approximate `currentRatio` to `ratio` |
+ currentRatio < ratio && |
+ // recycle less physical items than the total |
+ recycledTiles < this._physicalCount && |
+ // ensure that these recycled tiles are needed |
+ virtualEnd + recycledTiles < lastVirtualItemIndex && |
+ // ensure that the tile is not visible |
+ this._physicalTop + this._physicalSizes[kth] < scrollTop |
+ ) { |
- // increase the pool of physical items if needed |
- this._increasePoolIfNeeded(); |
+ tileHeight = this._physicalSizes[kth]; |
+ currentRatio += tileHeight / hiddenContentSize; |
- // clear cached visible index |
- this._firstVisibleIndexVal = null; |
+ this._physicalTop += tileHeight; |
+ recycledTileSet.push(kth); |
+ recycledTiles++; |
+ kth = (kth + 1) % this._physicalCount; |
+ } |
+ } |
+ |
+ if (recycledTiles === 0) { |
+ // If the list ever reach this case, the physical average is not significant enough |
+ // to create all the items needed to cover the entire viewport. |
+ // e.g. A few items have a height that differs from the average by serveral order of magnitude. |
+ if (physicalBottom < scrollBottom || this._physicalTop > scrollTop) { |
+ this.async(this._increasePool.bind(this, 1)); |
+ } |
+ } else { |
+ this._virtualStart = this._virtualStart + recycledTiles; |
+ this._update(recycledTileSet, movingUp); |
+ } |
}, |
/** |
- * Reset the physical average and the average count. |
+ * Update the list of items, starting from the `_virtualStart` item. |
+ * @param {!Array<number>=} itemSet |
+ * @param {!Array<number>=} movingUp |
*/ |
- _resetAverage: function() { |
- this._physicalAverage = 0; |
- this._physicalAverageCount = 0; |
+ _update: function(itemSet, movingUp) { |
+ // manage focus |
+ if (this._isIndexRendered(this._focusedIndex)) { |
+ this._restoreFocusedItem(); |
+ } else { |
+ this._createFocusBackfillItem(); |
+ } |
+ // update models |
+ this._assignModels(itemSet); |
+ // measure heights |
+ this._updateMetrics(itemSet); |
+ // adjust offset after measuring |
+ if (movingUp) { |
+ while (movingUp.length) { |
+ this._physicalTop -= this._physicalSizes[movingUp.pop()]; |
+ } |
+ } |
+ // update the position of the items |
+ this._positionItems(); |
+ // set the scroller size |
+ this._updateScrollerSize(); |
+ // increase the pool of physical items |
+ this._increasePoolIfNeeded(); |
}, |
/** |
- * A handler for the `iron-resize` event triggered by `IronResizableBehavior` |
- * when the element is resized. |
+ * Creates a pool of DOM elements and attaches them to the local dom. |
*/ |
- _resizeHandler: function() { |
- this.debounce('resize', function() { |
- this._render(); |
- if (this._itemsRendered && this._physicalItems && this._isVisible) { |
- this._resetAverage(); |
- this.updateViewportBoundaries(); |
- this.scrollToIndex(this.firstVisibleIndex); |
- } |
- }); |
- }, |
+ _createPool: function(size) { |
+ var physicalItems = new Array(size); |
- _getModelFromItem: function(item) { |
- var key = this._collection.getKey(item); |
- var pidx = this._physicalIndexForKey[key]; |
+ this._ensureTemplatized(); |
- if (pidx !== undefined) { |
- return this._physicalItems[pidx]._templateInstance; |
+ for (var i = 0; i < size; i++) { |
+ var inst = this.stamp(null); |
+ // First element child is item; Safari doesn't support children[0] |
+ // on a doc fragment |
+ physicalItems[i] = inst.root.querySelector('*'); |
+ Polymer.dom(this).appendChild(inst.root); |
} |
- return null; |
+ return physicalItems; |
}, |
/** |
- * Gets a valid item instance from its index or the object value. |
- * |
- * @param {(Object|number)} item The item object or its index |
+ * Increases the pool of physical items only if needed. |
+ * This function will allocate additional physical items |
+ * if the physical size is shorter than `_optPhysicalSize` |
*/ |
- _getNormalizedItem: function(item) { |
- if (typeof item === 'number') { |
- item = this.items[item]; |
- if (!item) { |
- throw new RangeError('<item> not found'); |
- } |
- } else if (this._collection.getKey(item) === undefined) { |
- throw new TypeError('<item> should be a valid item'); |
+ _increasePoolIfNeeded: function() { |
+ if (this._viewportSize === 0 || this._physicalSize >= this._optPhysicalSize) { |
+ return false; |
} |
- return item; |
+ // 0 <= `currentPage` <= `_maxPages` |
+ var currentPage = Math.floor(this._physicalSize / this._viewportSize); |
+ if (currentPage === 0) { |
+ // fill the first page |
+ this._debounceTemplate(this._increasePool.bind(this, Math.round(this._physicalCount * 0.5))); |
+ } else if (this._lastPage !== currentPage) { |
+ // paint the page and defer the next increase |
+ // wait 16ms which is rough enough to get paint cycle. |
+ Polymer.dom.addDebouncer(this.debounce('_debounceTemplate', this._increasePool.bind(this, 1), 16)); |
+ } else { |
+ // fill the rest of the pages |
+ this._debounceTemplate(this._increasePool.bind(this, 1)); |
+ } |
+ this._lastPage = currentPage; |
+ return true; |
}, |
/** |
- * Select the list item at the given index. |
- * |
- * @method selectItem |
- * @param {(Object|number)} item The item object or its index |
+ * Increases the pool size. |
*/ |
- selectItem: function(item) { |
- item = this._getNormalizedItem(item); |
- var model = this._getModelFromItem(item); |
+ _increasePool: function(missingItems) { |
+ // limit the size |
+ var nextPhysicalCount = Math.min( |
+ this._physicalCount + missingItems, |
+ this._virtualCount - this._virtualStart, |
+ MAX_PHYSICAL_COUNT |
+ ); |
+ var prevPhysicalCount = this._physicalCount; |
+ var delta = nextPhysicalCount - prevPhysicalCount; |
- if (!this.multiSelection && this.selectedItem) { |
- this.deselectItem(this.selectedItem); |
- } |
- if (model) { |
- model[this.selectedAs] = true; |
+ if (delta > 0) { |
+ [].push.apply(this._physicalItems, this._createPool(delta)); |
+ [].push.apply(this._physicalSizes, new Array(delta)); |
+ |
+ this._physicalCount = prevPhysicalCount + delta; |
+ // tail call |
+ return this._update(); |
} |
- this.$.selector.select(item); |
}, |
/** |
- * Deselects the given item list if it is already selected. |
- * |
- |
- * @method deselect |
- * @param {(Object|number)} item The item object or its index |
+ * Render a new list of items. This method does exactly the same as `update`, |
+ * but it also ensures that only one `update` cycle is created. |
*/ |
- deselectItem: function(item) { |
- item = this._getNormalizedItem(item); |
- var model = this._getModelFromItem(item); |
+ _render: function() { |
+ var requiresUpdate = this._virtualCount > 0 || this._physicalCount > 0; |
- if (model) { |
- model[this.selectedAs] = false; |
+ if (this.isAttached && !this._itemsRendered && this._isVisible && requiresUpdate) { |
+ this._lastPage = 0; |
+ this._update(); |
+ this._scrollHandler(); |
+ this._itemsRendered = true; |
} |
- this.$.selector.deselect(item); |
}, |
/** |
- * Select or deselect a given item depending on whether the item |
- * has already been selected. |
- * |
- * @method toggleSelectionForItem |
- * @param {(Object|number)} item The item object or its index |
+ * Templetizes the user template. |
*/ |
- toggleSelectionForItem: function(item) { |
- item = this._getNormalizedItem(item); |
- if (/** @type {!ArraySelectorElement} */ (this.$.selector).isSelected(item)) { |
- this.deselectItem(item); |
- } else { |
- this.selectItem(item); |
+ _ensureTemplatized: function() { |
+ if (!this.ctor) { |
+ // Template instance props that should be excluded from forwarding |
+ var props = {}; |
+ props.__key__ = true; |
+ props[this.as] = true; |
+ props[this.indexAs] = true; |
+ props[this.selectedAs] = true; |
+ props.tabIndex = true; |
+ |
+ this._instanceProps = props; |
+ this._userTemplate = Polymer.dom(this).querySelector('template'); |
+ |
+ if (this._userTemplate) { |
+ this.templatize(this._userTemplate); |
+ } else { |
+ console.warn('iron-list requires a template to be provided in light-dom'); |
+ } |
} |
}, |
/** |
- * Clears the current selection state of the list. |
- * |
- * @method clearSelection |
+ * Implements extension point from Templatizer mixin. |
*/ |
- clearSelection: function() { |
- function unselect(item) { |
- var model = this._getModelFromItem(item); |
- if (model) { |
- model[this.selectedAs] = false; |
- } |
- } |
- |
- if (Array.isArray(this.selectedItems)) { |
- this.selectedItems.forEach(unselect, this); |
- } else if (this.selectedItem) { |
- unselect.call(this, this.selectedItem); |
- } |
- |
- /** @type {!ArraySelectorElement} */ (this.$.selector).clearSelection(); |
+ _getStampedChildren: function() { |
+ return this._physicalItems; |
}, |
/** |
- * Add an event listener to `tap` if `selectionEnabled` is true, |
- * it will remove the listener otherwise. |
+ * Implements extension point from Templatizer |
+ * Called as a side effect of a template instance path change, responsible |
+ * for notifying items.<key-for-instance>.<path> change up to host. |
*/ |
- _selectionEnabledChanged: function(selectionEnabled) { |
- if (selectionEnabled) { |
- this.listen(this, 'tap', '_selectionHandler'); |
- this.listen(this, 'keypress', '_selectionHandler'); |
- } else { |
- this.unlisten(this, 'tap', '_selectionHandler'); |
- this.unlisten(this, 'keypress', '_selectionHandler'); |
+ _forwardInstancePath: function(inst, path, value) { |
+ if (path.indexOf(this.as + '.') === 0) { |
+ this.notifyPath('items.' + inst.__key__ + '.' + |
+ path.slice(this.as.length + 1), value); |
} |
}, |
/** |
- * Select an item from an event object. |
+ * Implements extension point from Templatizer mixin |
+ * Called as side-effect of a host property change, responsible for |
+ * notifying parent path change on each row. |
*/ |
- _selectionHandler: function(e) { |
- if (e.type !== 'keypress' || e.keyCode === 13) { |
- var model = this.modelForElement(e.target); |
- if (model) { |
- this.toggleSelectionForItem(model[this.as]); |
- } |
+ _forwardParentProp: function(prop, value) { |
+ if (this._physicalItems) { |
+ this._physicalItems.forEach(function(item) { |
+ item._templateInstance[prop] = value; |
+ }, this); |
} |
}, |
- _multiSelectionChanged: function(multiSelection) { |
- this.clearSelection(); |
- this.$.selector.multi = multiSelection; |
+ /** |
+ * Implements extension point from Templatizer |
+ * Called as side-effect of a host path change, responsible for |
+ * notifying parent.<path> path change on each row. |
+ */ |
+ _forwardParentPath: function(path, value) { |
+ if (this._physicalItems) { |
+ this._physicalItems.forEach(function(item) { |
+ item._templateInstance.notifyPath(path, value, true); |
+ }, this); |
+ } |
}, |
/** |
- * Updates the size of an item. |
- * |
- * @method updateSizeForItem |
- * @param {(Object|number)} item The item object or its index |
+ * Called as a side effect of a host items.<key>.<path> path change, |
+ * responsible for notifying item.<path> changes to row for key. |
*/ |
- updateSizeForItem: function(item) { |
- item = this._getNormalizedItem(item); |
- var key = this._collection.getKey(item); |
- var pidx = this._physicalIndexForKey[key]; |
+ _forwardItemPath: function(path, value) { |
+ if (this._physicalIndexForKey) { |
+ var dot = path.indexOf('.'); |
+ var key = path.substring(0, dot < 0 ? path.length : dot); |
+ var idx = this._physicalIndexForKey[key]; |
+ var row = this._physicalItems[idx]; |
- if (pidx !== undefined) { |
- this._updateMetrics([pidx]); |
- this._positionItems(); |
+ if (idx === this._focusedIndex && this._offscreenFocusedItem) { |
+ row = this._offscreenFocusedItem; |
+ } |
+ if (row) { |
+ var inst = row._templateInstance; |
+ if (dot >= 0) { |
+ path = this.as + '.' + path.substring(dot+1); |
+ inst.notifyPath(path, value, true); |
+ } else { |
+ inst[this.as] = value; |
+ } |
+ } |
} |
- } |
- }); |
+ }, |
-})(); |
-(function() { |
+ /** |
+ * Called when the items have changed. That is, ressignments |
+ * to `items`, splices or updates to a single item. |
+ */ |
+ _itemsChanged: function(change) { |
+ if (change.path === 'items') { |
- // monostate data |
- var metaDatas = {}; |
- var metaArrays = {}; |
- var singleton = null; |
+ this._restoreFocusedItem(); |
+ // render the new set |
+ this._itemsRendered = false; |
+ // update the whole set |
+ this._virtualStart = 0; |
+ this._physicalTop = 0; |
+ this._virtualCount = this.items ? this.items.length : 0; |
+ this._focusedIndex = 0; |
+ this._collection = this.items ? Polymer.Collection.get(this.items) : null; |
+ this._physicalIndexForKey = {}; |
- Polymer.IronMeta = Polymer({ |
+ this._resetScrollPosition(0); |
- is: 'iron-meta', |
+ // create the initial physical items |
+ if (!this._physicalItems) { |
+ this._physicalCount = Math.max(1, Math.min(DEFAULT_PHYSICAL_COUNT, this._virtualCount)); |
+ this._physicalItems = this._createPool(this._physicalCount); |
+ this._physicalSizes = new Array(this._physicalCount); |
+ } |
+ this._debounceTemplate(this._render); |
- properties: { |
+ } else if (change.path === 'items.splices') { |
+ // render the new set |
+ this._itemsRendered = false; |
+ this._adjustVirtualIndex(change.value.indexSplices); |
+ this._virtualCount = this.items ? this.items.length : 0; |
- /** |
- * The type of meta-data. All meta-data of the same type is stored |
- * together. |
- */ |
- type: { |
- type: String, |
- value: 'default', |
- observer: '_typeChanged' |
- }, |
+ this._debounceTemplate(this._render); |
- /** |
- * The key used to store `value` under the `type` namespace. |
- */ |
- key: { |
- type: String, |
- observer: '_keyChanged' |
- }, |
- |
- /** |
- * The meta-data to store or retrieve. |
- */ |
- value: { |
- type: Object, |
- notify: true, |
- observer: '_valueChanged' |
- }, |
+ if (this._focusedIndex < 0 || this._focusedIndex >= this._virtualCount) { |
+ this._focusedIndex = 0; |
+ } |
+ this._debounceTemplate(this._render); |
- /** |
- * If true, `value` is set to the iron-meta instance itself. |
- */ |
- self: { |
- type: Boolean, |
- observer: '_selfChanged' |
- }, |
+ } else { |
+ // update a single item |
+ this._forwardItemPath(change.path.split('.').slice(1).join('.'), change.value); |
+ } |
+ }, |
- /** |
- * Array of all meta-data values for the given type. |
- */ |
- list: { |
- type: Array, |
- notify: true |
- } |
+ /** |
+ * @param {!Array<!PolymerSplice>} splices |
+ */ |
+ _adjustVirtualIndex: function(splices) { |
+ var i, splice, idx; |
- }, |
+ for (i = 0; i < splices.length; i++) { |
+ splice = splices[i]; |
- hostAttributes: { |
- hidden: true |
- }, |
+ // deselect removed items |
+ splice.removed.forEach(this.$.selector.deselect, this.$.selector); |
- /** |
- * Only runs if someone invokes the factory/constructor directly |
- * e.g. `new Polymer.IronMeta()` |
- * |
- * @param {{type: (string|undefined), key: (string|undefined), value}=} config |
- */ |
- factoryImpl: function(config) { |
- if (config) { |
- for (var n in config) { |
- switch(n) { |
- case 'type': |
- case 'key': |
- case 'value': |
- this[n] = config[n]; |
- break; |
- } |
- } |
+ idx = splice.index; |
+ // We only need to care about changes happening above the current position |
+ if (idx >= this._virtualStart) { |
+ break; |
} |
- }, |
- |
- created: function() { |
- // TODO(sjmiles): good for debugging? |
- this._metaDatas = metaDatas; |
- this._metaArrays = metaArrays; |
- }, |
- _keyChanged: function(key, old) { |
- this._resetRegistration(old); |
- }, |
+ this._virtualStart = this._virtualStart + |
+ Math.max(splice.addedCount - splice.removed.length, idx - this._virtualStart); |
+ } |
+ }, |
- _valueChanged: function(value) { |
- this._resetRegistration(this.key); |
- }, |
+ /** |
+ * Executes a provided function per every physical index in `itemSet` |
+ * `itemSet` default value is equivalent to the entire set of physical indexes. |
+ * |
+ * @param {!function(number, number)} fn |
+ * @param {!Array<number>=} itemSet |
+ */ |
+ _iterateItems: function(fn, itemSet) { |
+ var pidx, vidx, rtn, i; |
- _selfChanged: function(self) { |
- if (self) { |
- this.value = this; |
+ if (arguments.length === 2 && itemSet) { |
+ for (i = 0; i < itemSet.length; i++) { |
+ pidx = itemSet[i]; |
+ if (pidx >= this._physicalStart) { |
+ vidx = this._virtualStart + (pidx - this._physicalStart); |
+ } else { |
+ vidx = this._virtualStart + (this._physicalCount - this._physicalStart) + pidx; |
+ } |
+ if ((rtn = fn.call(this, pidx, vidx)) != null) { |
+ return rtn; |
+ } |
} |
- }, |
+ } else { |
+ pidx = this._physicalStart; |
+ vidx = this._virtualStart; |
- _typeChanged: function(type) { |
- this._unregisterKey(this.key); |
- if (!metaDatas[type]) { |
- metaDatas[type] = {}; |
+ for (; pidx < this._physicalCount; pidx++, vidx++) { |
+ if ((rtn = fn.call(this, pidx, vidx)) != null) { |
+ return rtn; |
+ } |
} |
- this._metaData = metaDatas[type]; |
- if (!metaArrays[type]) { |
- metaArrays[type] = []; |
+ for (pidx = 0; pidx < this._physicalStart; pidx++, vidx++) { |
+ if ((rtn = fn.call(this, pidx, vidx)) != null) { |
+ return rtn; |
+ } |
} |
- this.list = metaArrays[type]; |
- this._registerKeyValue(this.key, this.value); |
- }, |
- |
- /** |
- * Retrieves meta data value by key. |
- * |
- * @method byKey |
- * @param {string} key The key of the meta-data to be returned. |
- * @return {*} |
- */ |
- byKey: function(key) { |
- return this._metaData && this._metaData[key]; |
- }, |
- |
- _resetRegistration: function(oldKey) { |
- this._unregisterKey(oldKey); |
- this._registerKeyValue(this.key, this.value); |
- }, |
- |
- _unregisterKey: function(key) { |
- this._unregister(key, this._metaData, this.list); |
- }, |
+ } |
+ }, |
- _registerKeyValue: function(key, value) { |
- this._register(key, value, this._metaData, this.list); |
- }, |
+ /** |
+ * Assigns the data models to a given set of items. |
+ * @param {!Array<number>=} itemSet |
+ */ |
+ _assignModels: function(itemSet) { |
+ this._iterateItems(function(pidx, vidx) { |
+ var el = this._physicalItems[pidx]; |
+ var inst = el._templateInstance; |
+ var item = this.items && this.items[vidx]; |
- _register: function(key, value, data, list) { |
- if (key && data && value !== undefined) { |
- data[key] = value; |
- list.push(value); |
+ if (item !== undefined && item !== null) { |
+ inst[this.as] = item; |
+ inst.__key__ = this._collection.getKey(item); |
+ inst[this.selectedAs] = /** @type {!ArraySelectorElement} */ (this.$.selector).isSelected(item); |
+ inst[this.indexAs] = vidx; |
+ inst.tabIndex = vidx === this._focusedIndex ? 0 : -1; |
+ el.removeAttribute('hidden'); |
+ this._physicalIndexForKey[inst.__key__] = pidx; |
+ } else { |
+ inst.__key__ = null; |
+ el.setAttribute('hidden', ''); |
} |
- }, |
- _unregister: function(key, data, list) { |
- if (key && data) { |
- if (key in data) { |
- var value = data[key]; |
- delete data[key]; |
- this.arrayDelete(list, value); |
- } |
- } |
- } |
+ }, itemSet); |
+ }, |
- }); |
+ /** |
+ * Updates the height for a given set of items. |
+ * |
+ * @param {!Array<number>=} itemSet |
+ */ |
+ _updateMetrics: function(itemSet) { |
+ // Make sure we distributed all the physical items |
+ // so we can measure them |
+ Polymer.dom.flush(); |
- Polymer.IronMeta.getIronMeta = function getIronMeta() { |
- if (singleton === null) { |
- singleton = new Polymer.IronMeta(); |
- } |
- return singleton; |
- }; |
+ var newPhysicalSize = 0; |
+ var oldPhysicalSize = 0; |
+ var prevAvgCount = this._physicalAverageCount; |
+ var prevPhysicalAvg = this._physicalAverage; |
- /** |
- `iron-meta-query` can be used to access infomation stored in `iron-meta`. |
+ this._iterateItems(function(pidx, vidx) { |
- Examples: |
+ oldPhysicalSize += this._physicalSizes[pidx] || 0; |
+ this._physicalSizes[pidx] = this._physicalItems[pidx].offsetHeight; |
+ newPhysicalSize += this._physicalSizes[pidx]; |
+ this._physicalAverageCount += this._physicalSizes[pidx] ? 1 : 0; |
- If I create an instance like this: |
+ }, itemSet); |
- <iron-meta key="info" value="foo/bar"></iron-meta> |
+ this._physicalSize = this._physicalSize + newPhysicalSize - oldPhysicalSize; |
+ this._viewportSize = this._scrollTargetHeight; |
- Note that value="foo/bar" is the metadata I've defined. I could define more |
- attributes or use child nodes to define additional metadata. |
+ // update the average if we measured something |
+ if (this._physicalAverageCount !== prevAvgCount) { |
+ this._physicalAverage = Math.round( |
+ ((prevPhysicalAvg * prevAvgCount) + newPhysicalSize) / |
+ this._physicalAverageCount); |
+ } |
+ }, |
- Now I can access that element (and it's metadata) from any `iron-meta-query` instance: |
+ /** |
+ * Updates the position of the physical items. |
+ */ |
+ _positionItems: function() { |
+ this._adjustScrollPosition(); |
- var value = new Polymer.IronMetaQuery({key: 'info'}).value; |
+ var y = this._physicalTop; |
- @group Polymer Iron Elements |
- @element iron-meta-query |
- */ |
- Polymer.IronMetaQuery = Polymer({ |
+ this._iterateItems(function(pidx) { |
- is: 'iron-meta-query', |
+ this.translate3d(0, y + 'px', 0, this._physicalItems[pidx]); |
+ y += this._physicalSizes[pidx]; |
- properties: { |
+ }); |
+ }, |
- /** |
- * The type of meta-data. All meta-data of the same type is stored |
- * together. |
- */ |
- type: { |
- type: String, |
- value: 'default', |
- observer: '_typeChanged' |
- }, |
+ /** |
+ * Adjusts the scroll position when it was overestimated. |
+ */ |
+ _adjustScrollPosition: function() { |
+ var deltaHeight = this._virtualStart === 0 ? this._physicalTop : |
+ Math.min(this._scrollPosition + this._physicalTop, 0); |
- /** |
- * Specifies a key to use for retrieving `value` from the `type` |
- * namespace. |
- */ |
- key: { |
- type: String, |
- observer: '_keyChanged' |
- }, |
- |
- /** |
- * The meta-data to store or retrieve. |
- */ |
- value: { |
- type: Object, |
- notify: true, |
- readOnly: true |
- }, |
- |
- /** |
- * Array of all meta-data values for the given type. |
- */ |
- list: { |
- type: Array, |
- notify: true |
- } |
- |
- }, |
- |
- /** |
- * Actually a factory method, not a true constructor. Only runs if |
- * someone invokes it directly (via `new Polymer.IronMeta()`); |
- * |
- * @param {{type: (string|undefined), key: (string|undefined)}=} config |
- */ |
- factoryImpl: function(config) { |
- if (config) { |
- for (var n in config) { |
- switch(n) { |
- case 'type': |
- case 'key': |
- this[n] = config[n]; |
- break; |
- } |
- } |
+ if (deltaHeight) { |
+ this._physicalTop = this._physicalTop - deltaHeight; |
+ // juking scroll position during interial scrolling on iOS is no bueno |
+ if (!IOS_TOUCH_SCROLLING) { |
+ this._resetScrollPosition(this._scrollTop - deltaHeight); |
} |
- }, |
+ } |
+ }, |
- created: function() { |
- // TODO(sjmiles): good for debugging? |
- this._metaDatas = metaDatas; |
- this._metaArrays = metaArrays; |
- }, |
+ /** |
+ * Sets the position of the scroll. |
+ */ |
+ _resetScrollPosition: function(pos) { |
+ if (this.scrollTarget) { |
+ this._scrollTop = pos; |
+ this._scrollPosition = this._scrollTop; |
+ } |
+ }, |
- _keyChanged: function(key) { |
- this._setValue(this._metaData && this._metaData[key]); |
- }, |
+ /** |
+ * Sets the scroll height, that's the height of the content, |
+ * |
+ * @param {boolean=} forceUpdate If true, updates the height no matter what. |
+ */ |
+ _updateScrollerSize: function(forceUpdate) { |
+ this._estScrollHeight = (this._physicalBottom + |
+ Math.max(this._virtualCount - this._physicalCount - this._virtualStart, 0) * this._physicalAverage); |
- _typeChanged: function(type) { |
- this._metaData = metaDatas[type]; |
- this.list = metaArrays[type]; |
- if (this.key) { |
- this._keyChanged(this.key); |
- } |
- }, |
+ forceUpdate = forceUpdate || this._scrollHeight === 0; |
+ forceUpdate = forceUpdate || this._scrollPosition >= this._estScrollHeight - this._physicalSize; |
- /** |
- * Retrieves meta data value by key. |
- * @param {string} key The key of the meta-data to be returned. |
- * @return {*} |
- */ |
- byKey: function(key) { |
- return this._metaData && this._metaData[key]; |
+ // amortize height adjustment, so it won't trigger repaints very often |
+ if (forceUpdate || Math.abs(this._estScrollHeight - this._scrollHeight) >= this._optPhysicalSize) { |
+ this.$.items.style.height = this._estScrollHeight + 'px'; |
+ this._scrollHeight = this._estScrollHeight; |
} |
+ }, |
- }); |
+ /** |
+ * Scroll to a specific item in the virtual list regardless |
+ * of the physical items in the DOM tree. |
+ * |
+ * @method scrollToIndex |
+ * @param {number} idx The index of the item |
+ */ |
+ scrollToIndex: function(idx) { |
+ if (typeof idx !== 'number') { |
+ return; |
+ } |
- })(); |
-Polymer({ |
+ Polymer.dom.flush(); |
- is: 'iron-icon', |
+ var firstVisible = this.firstVisibleIndex; |
+ idx = Math.min(Math.max(idx, 0), this._virtualCount-1); |
- properties: { |
+ // start at the previous virtual item |
+ // so we have a item above the first visible item |
+ this._virtualStart = idx - 1; |
+ // assign new models |
+ this._assignModels(); |
+ // measure the new sizes |
+ this._updateMetrics(); |
+ // estimate new physical offset |
+ this._physicalTop = this._virtualStart * this._physicalAverage; |
- /** |
- * The name of the icon to use. The name should be of the form: |
- * `iconset_name:icon_name`. |
- */ |
- icon: { |
- type: String, |
- observer: '_iconChanged' |
- }, |
+ var currentTopItem = this._physicalStart; |
+ var currentVirtualItem = this._virtualStart; |
+ var targetOffsetTop = 0; |
+ var hiddenContentSize = this._hiddenContentSize; |
- /** |
- * The name of the theme to used, if one is specified by the |
- * iconset. |
- */ |
- theme: { |
- type: String, |
- observer: '_updateIcon' |
- }, |
+ // scroll to the item as much as we can |
+ while (currentVirtualItem < idx && targetOffsetTop < hiddenContentSize) { |
+ targetOffsetTop = targetOffsetTop + this._physicalSizes[currentTopItem]; |
+ currentTopItem = (currentTopItem + 1) % this._physicalCount; |
+ currentVirtualItem++; |
+ } |
+ // update the scroller size |
+ this._updateScrollerSize(true); |
+ // update the position of the items |
+ this._positionItems(); |
+ // set the new scroll position |
+ this._resetScrollPosition(this._physicalTop + this._scrollerPaddingTop + targetOffsetTop + 1); |
+ // increase the pool of physical items if needed |
+ this._increasePoolIfNeeded(); |
+ // clear cached visible index |
+ this._firstVisibleIndexVal = null; |
+ this._lastVisibleIndexVal = null; |
+ }, |
- /** |
- * If using iron-icon without an iconset, you can set the src to be |
- * the URL of an individual icon image file. Note that this will take |
- * precedence over a given icon attribute. |
- */ |
- src: { |
- type: String, |
- observer: '_srcChanged' |
- }, |
+ /** |
+ * Reset the physical average and the average count. |
+ */ |
+ _resetAverage: function() { |
+ this._physicalAverage = 0; |
+ this._physicalAverageCount = 0; |
+ }, |
- /** |
- * @type {!Polymer.IronMeta} |
- */ |
- _meta: { |
- value: Polymer.Base.create('iron-meta', {type: 'iconset'}) |
+ /** |
+ * A handler for the `iron-resize` event triggered by `IronResizableBehavior` |
+ * when the element is resized. |
+ */ |
+ _resizeHandler: function() { |
+ // iOS fires the resize event when the address bar slides up |
+ if (IOS && Math.abs(this._viewportSize - this._scrollTargetHeight) < 100) { |
+ return; |
+ } |
+ this._debounceTemplate(function() { |
+ this._render(); |
+ if (this._itemsRendered && this._physicalItems && this._isVisible) { |
+ this._resetAverage(); |
+ this.updateViewportBoundaries(); |
+ this.scrollToIndex(this.firstVisibleIndex); |
} |
+ }); |
+ }, |
- }, |
- |
- _DEFAULT_ICONSET: 'icons', |
- |
- _iconChanged: function(icon) { |
- var parts = (icon || '').split(':'); |
- this._iconName = parts.pop(); |
- this._iconsetName = parts.pop() || this._DEFAULT_ICONSET; |
- this._updateIcon(); |
- }, |
- |
- _srcChanged: function(src) { |
- this._updateIcon(); |
- }, |
+ _getModelFromItem: function(item) { |
+ var key = this._collection.getKey(item); |
+ var pidx = this._physicalIndexForKey[key]; |
- _usesIconset: function() { |
- return this.icon || !this.src; |
- }, |
+ if (pidx !== undefined) { |
+ return this._physicalItems[pidx]._templateInstance; |
+ } |
+ return null; |
+ }, |
- /** @suppress {visibility} */ |
- _updateIcon: function() { |
- if (this._usesIconset()) { |
- if (this._iconsetName) { |
- this._iconset = /** @type {?Polymer.Iconset} */ ( |
- this._meta.byKey(this._iconsetName)); |
- if (this._iconset) { |
- this._iconset.applyIcon(this, this._iconName, this.theme); |
- this.unlisten(window, 'iron-iconset-added', '_updateIcon'); |
- } else { |
- this.listen(window, 'iron-iconset-added', '_updateIcon'); |
- } |
- } |
- } else { |
- if (!this._img) { |
- this._img = document.createElement('img'); |
- this._img.style.width = '100%'; |
- this._img.style.height = '100%'; |
- this._img.draggable = false; |
+ /** |
+ * Gets a valid item instance from its index or the object value. |
+ * |
+ * @param {(Object|number)} item The item object or its index |
+ */ |
+ _getNormalizedItem: function(item) { |
+ if (this._collection.getKey(item) === undefined) { |
+ if (typeof item === 'number') { |
+ item = this.items[item]; |
+ if (!item) { |
+ throw new RangeError('<item> not found'); |
} |
- this._img.src = this.src; |
- Polymer.dom(this.root).appendChild(this._img); |
+ return item; |
} |
+ throw new TypeError('<item> should be a valid item'); |
} |
+ return item; |
+ }, |
- }); |
-/** |
- * The `iron-iconset-svg` element allows users to define their own icon sets |
- * that contain svg icons. The svg icon elements should be children of the |
- * `iron-iconset-svg` element. Multiple icons should be given distinct id's. |
- * |
- * Using svg elements to create icons has a few advantages over traditional |
- * bitmap graphics like jpg or png. Icons that use svg are vector based so |
- * they are resolution independent and should look good on any device. They |
- * are stylable via css. Icons can be themed, colorized, and even animated. |
- * |
- * Example: |
- * |
- * <iron-iconset-svg name="my-svg-icons" size="24"> |
- * <svg> |
- * <defs> |
- * <g id="shape"> |
- * <rect x="12" y="0" width="12" height="24" /> |
- * <circle cx="12" cy="12" r="12" /> |
- * </g> |
- * </defs> |
- * </svg> |
- * </iron-iconset-svg> |
- * |
- * This will automatically register the icon set "my-svg-icons" to the iconset |
- * database. To use these icons from within another element, make a |
- * `iron-iconset` element and call the `byId` method |
- * to retrieve a given iconset. To apply a particular icon inside an |
- * element use the `applyIcon` method. For example: |
- * |
- * iconset.applyIcon(iconNode, 'car'); |
- * |
- * @element iron-iconset-svg |
- * @demo demo/index.html |
- * @implements {Polymer.Iconset} |
- */ |
- Polymer({ |
- is: 'iron-iconset-svg', |
- |
- properties: { |
- |
- /** |
- * The name of the iconset. |
- */ |
- name: { |
- type: String, |
- observer: '_nameChanged' |
- }, |
+ /** |
+ * Select the list item at the given index. |
+ * |
+ * @method selectItem |
+ * @param {(Object|number)} item The item object or its index |
+ */ |
+ selectItem: function(item) { |
+ item = this._getNormalizedItem(item); |
+ var model = this._getModelFromItem(item); |
- /** |
- * The size of an individual icon. Note that icons must be square. |
- */ |
- size: { |
- type: Number, |
- value: 24 |
+ if (!this.multiSelection && this.selectedItem) { |
+ this.deselectItem(this.selectedItem); |
} |
- |
- }, |
- |
- attached: function() { |
- this.style.display = 'none'; |
+ if (model) { |
+ model[this.selectedAs] = true; |
+ } |
+ this.$.selector.select(item); |
+ this.updateSizeForItem(item); |
}, |
/** |
- * Construct an array of all icon names in this iconset. |
+ * Deselects the given item list if it is already selected. |
* |
- * @return {!Array} Array of icon names. |
+ |
+ * @method deselect |
+ * @param {(Object|number)} item The item object or its index |
*/ |
- getIconNames: function() { |
- this._icons = this._createIconMap(); |
- return Object.keys(this._icons).map(function(n) { |
- return this.name + ':' + n; |
- }, this); |
+ deselectItem: function(item) { |
+ item = this._getNormalizedItem(item); |
+ var model = this._getModelFromItem(item); |
+ |
+ if (model) { |
+ model[this.selectedAs] = false; |
+ } |
+ this.$.selector.deselect(item); |
+ this.updateSizeForItem(item); |
}, |
/** |
- * Applies an icon to the given element. |
- * |
- * An svg icon is prepended to the element's shadowRoot if it exists, |
- * otherwise to the element itself. |
+ * Select or deselect a given item depending on whether the item |
+ * has already been selected. |
* |
- * @method applyIcon |
- * @param {Element} element Element to which the icon is applied. |
- * @param {string} iconName Name of the icon to apply. |
- * @return {?Element} The svg element which renders the icon. |
+ * @method toggleSelectionForItem |
+ * @param {(Object|number)} item The item object or its index |
*/ |
- applyIcon: function(element, iconName) { |
- // insert svg element into shadow root, if it exists |
- element = element.root || element; |
- // Remove old svg element |
- this.removeIcon(element); |
- // install new svg element |
- var svg = this._cloneIcon(iconName); |
- if (svg) { |
- var pde = Polymer.dom(element); |
- pde.insertBefore(svg, pde.childNodes[0]); |
- return element._svgIcon = svg; |
+ toggleSelectionForItem: function(item) { |
+ item = this._getNormalizedItem(item); |
+ if (/** @type {!ArraySelectorElement} */ (this.$.selector).isSelected(item)) { |
+ this.deselectItem(item); |
+ } else { |
+ this.selectItem(item); |
} |
- return null; |
}, |
/** |
- * Remove an icon from the given element by undoing the changes effected |
- * by `applyIcon`. |
+ * Clears the current selection state of the list. |
* |
- * @param {Element} element The element from which the icon is removed. |
+ * @method clearSelection |
*/ |
- removeIcon: function(element) { |
- // Remove old svg element |
- if (element._svgIcon) { |
- Polymer.dom(element).removeChild(element._svgIcon); |
- element._svgIcon = null; |
+ clearSelection: function() { |
+ function unselect(item) { |
+ var model = this._getModelFromItem(item); |
+ if (model) { |
+ model[this.selectedAs] = false; |
+ } |
+ } |
+ |
+ if (Array.isArray(this.selectedItems)) { |
+ this.selectedItems.forEach(unselect, this); |
+ } else if (this.selectedItem) { |
+ unselect.call(this, this.selectedItem); |
} |
+ |
+ /** @type {!ArraySelectorElement} */ (this.$.selector).clearSelection(); |
}, |
/** |
- * |
- * When name is changed, register iconset metadata |
- * |
+ * Add an event listener to `tap` if `selectionEnabled` is true, |
+ * it will remove the listener otherwise. |
*/ |
- _nameChanged: function() { |
- new Polymer.IronMeta({type: 'iconset', key: this.name, value: this}); |
- this.async(function() { |
- this.fire('iron-iconset-added', this, {node: window}); |
- }); |
+ _selectionEnabledChanged: function(selectionEnabled) { |
+ var handler = selectionEnabled ? this.listen : this.unlisten; |
+ handler.call(this, this, 'tap', '_selectionHandler'); |
}, |
/** |
- * Create a map of child SVG elements by id. |
- * |
- * @return {!Object} Map of id's to SVG elements. |
+ * Select an item from an event object. |
*/ |
- _createIconMap: function() { |
- // Objects chained to Object.prototype (`{}`) have members. Specifically, |
- // on FF there is a `watch` method that confuses the icon map, so we |
- // need to use a null-based object here. |
- var icons = Object.create(null); |
- Polymer.dom(this).querySelectorAll('[id]') |
- .forEach(function(icon) { |
- icons[icon.id] = icon; |
- }); |
- return icons; |
+ _selectionHandler: function(e) { |
+ if (this.selectionEnabled) { |
+ var model = this.modelForElement(e.target); |
+ if (model) { |
+ this.toggleSelectionForItem(model[this.as]); |
+ } |
+ } |
+ }, |
+ |
+ _multiSelectionChanged: function(multiSelection) { |
+ this.clearSelection(); |
+ this.$.selector.multi = multiSelection; |
}, |
/** |
- * Produce installable clone of the SVG element matching `id` in this |
- * iconset, or `undefined` if there is no matching element. |
+ * Updates the size of an item. |
* |
- * @return {Element} Returns an installable clone of the SVG element |
- * matching `id`. |
+ * @method updateSizeForItem |
+ * @param {(Object|number)} item The item object or its index |
*/ |
- _cloneIcon: function(id) { |
- // create the icon map on-demand, since the iconset itself has no discrete |
- // signal to know when it's children are fully parsed |
- this._icons = this._icons || this._createIconMap(); |
- return this._prepareSvgClone(this._icons[id], this.size); |
+ updateSizeForItem: function(item) { |
+ item = this._getNormalizedItem(item); |
+ var key = this._collection.getKey(item); |
+ var pidx = this._physicalIndexForKey[key]; |
+ |
+ if (pidx !== undefined) { |
+ this._updateMetrics([pidx]); |
+ this._positionItems(); |
+ } |
}, |
- /** |
- * @param {Element} sourceSvg |
- * @param {number} size |
- * @return {Element} |
- */ |
- _prepareSvgClone: function(sourceSvg, size) { |
- if (sourceSvg) { |
- var content = sourceSvg.cloneNode(true), |
- svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), |
- viewBox = content.getAttribute('viewBox') || '0 0 ' + size + ' ' + size; |
- svg.setAttribute('viewBox', viewBox); |
- svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); |
- // TODO(dfreedm): `pointer-events: none` works around https://crbug.com/370136 |
- // TODO(sjmiles): inline style may not be ideal, but avoids requiring a shadow-root |
- svg.style.cssText = 'pointer-events: none; display: block; width: 100%; height: 100%;'; |
- svg.appendChild(content).removeAttribute('id'); |
- return svg; |
+ _isIndexRendered: function(idx) { |
+ return idx >= this._virtualStart && idx <= this._virtualEnd; |
+ }, |
+ |
+ _getPhysicalItemForIndex: function(idx, force) { |
+ if (!this._collection) { |
+ return null; |
} |
- return null; |
- } |
+ if (!this._isIndexRendered(idx)) { |
+ if (force) { |
+ this.scrollToIndex(idx); |
+ return this._getPhysicalItemForIndex(idx, false); |
+ } |
+ return null; |
+ } |
+ var item = this._getNormalizedItem(idx); |
+ var physicalItem = this._physicalItems[this._physicalIndexForKey[this._collection.getKey(item)]]; |
- }); |
-(function() { |
- 'use strict'; |
+ return physicalItem || null; |
+ }, |
- /** |
- * Chrome uses an older version of DOM Level 3 Keyboard Events |
- * |
- * Most keys are labeled as text, but some are Unicode codepoints. |
- * Values taken from: http://www.w3.org/TR/2007/WD-DOM-Level-3-Events-20071221/keyset.html#KeySet-Set |
- */ |
- var KEY_IDENTIFIER = { |
- 'U+0008': 'backspace', |
- 'U+0009': 'tab', |
- 'U+001B': 'esc', |
- 'U+0020': 'space', |
- 'U+007F': 'del' |
- }; |
+ _focusPhysicalItem: function(idx) { |
+ this._restoreFocusedItem(); |
- /** |
- * Special table for KeyboardEvent.keyCode. |
- * KeyboardEvent.keyIdentifier is better, and KeyBoardEvent.key is even better |
- * than that. |
- * |
- * Values from: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent.keyCode#Value_of_keyCode |
- */ |
- var KEY_CODE = { |
- 8: 'backspace', |
- 9: 'tab', |
- 13: 'enter', |
- 27: 'esc', |
- 33: 'pageup', |
- 34: 'pagedown', |
- 35: 'end', |
- 36: 'home', |
- 32: 'space', |
- 37: 'left', |
- 38: 'up', |
- 39: 'right', |
- 40: 'down', |
- 46: 'del', |
- 106: '*' |
- }; |
+ var physicalItem = this._getPhysicalItemForIndex(idx, true); |
+ if (!physicalItem) { |
+ return; |
+ } |
+ var SECRET = ~(Math.random() * 100); |
+ var model = physicalItem._templateInstance; |
+ var focusable; |
- /** |
- * MODIFIER_KEYS maps the short name for modifier keys used in a key |
- * combo string to the property name that references those same keys |
- * in a KeyboardEvent instance. |
- */ |
- var MODIFIER_KEYS = { |
- 'shift': 'shiftKey', |
- 'ctrl': 'ctrlKey', |
- 'alt': 'altKey', |
- 'meta': 'metaKey' |
- }; |
+ model.tabIndex = SECRET; |
+ // the focusable element could be the entire physical item |
+ if (physicalItem.tabIndex === SECRET) { |
+ focusable = physicalItem; |
+ } |
+ // the focusable element could be somewhere within the physical item |
+ if (!focusable) { |
+ focusable = Polymer.dom(physicalItem).querySelector('[tabindex="' + SECRET + '"]'); |
+ } |
+ // restore the tab index |
+ model.tabIndex = 0; |
+ focusable && focusable.focus(); |
+ }, |
- /** |
- * KeyboardEvent.key is mostly represented by printable character made by |
- * the keyboard, with unprintable keys labeled nicely. |
- * |
- * However, on OS X, Alt+char can make a Unicode character that follows an |
- * Apple-specific mapping. In this case, we fall back to .keyCode. |
- */ |
- var KEY_CHAR = /[a-z0-9*]/; |
+ _restoreFocusedItem: function() { |
+ if (!this._offscreenFocusedItem) { |
+ return; |
+ } |
+ var item = this._getNormalizedItem(this._focusedIndex); |
+ var pidx = this._physicalIndexForKey[this._collection.getKey(item)]; |
- /** |
- * Matches a keyIdentifier string. |
- */ |
- var IDENT_CHAR = /U\+/; |
+ if (pidx !== undefined) { |
+ this.translate3d(0, HIDDEN_Y, 0, this._physicalItems[pidx]); |
+ this._physicalItems[pidx] = this._offscreenFocusedItem; |
+ } |
+ this._offscreenFocusedItem = null; |
+ }, |
- /** |
- * Matches arrow keys in Gecko 27.0+ |
- */ |
- var ARROW_KEY = /^arrow/; |
+ _removeFocusedItem: function() { |
+ if (!this._offscreenFocusedItem) { |
+ return; |
+ } |
+ Polymer.dom(this).removeChild(this._offscreenFocusedItem); |
+ this._offscreenFocusedItem = null; |
+ this._focusBackfillItem = null; |
+ }, |
- /** |
- * Matches space keys everywhere (notably including IE10's exceptional name |
- * `spacebar`). |
- */ |
- var SPACE_KEY = /^space(bar)?/; |
+ _createFocusBackfillItem: function() { |
+ if (this._offscreenFocusedItem) { |
+ return; |
+ } |
+ var item = this._getNormalizedItem(this._focusedIndex); |
+ var pidx = this._physicalIndexForKey[this._collection.getKey(item)]; |
- /** |
- * Transforms the key. |
- * @param {string} key The KeyBoardEvent.key |
- * @param {Boolean} [noSpecialChars] Limits the transformation to |
- * alpha-numeric characters. |
- */ |
- function transformKey(key, noSpecialChars) { |
- var validKey = ''; |
- if (key) { |
- var lKey = key.toLowerCase(); |
- if (lKey === ' ' || SPACE_KEY.test(lKey)) { |
- validKey = 'space'; |
- } else if (lKey.length == 1) { |
- if (!noSpecialChars || KEY_CHAR.test(lKey)) { |
- validKey = lKey; |
- } |
- } else if (ARROW_KEY.test(lKey)) { |
- validKey = lKey.replace('arrow', ''); |
- } else if (lKey == 'multiply') { |
- // numpad '*' can map to Multiply on IE/Windows |
- validKey = '*'; |
- } else { |
- validKey = lKey; |
- } |
+ this._offscreenFocusedItem = this._physicalItems[pidx]; |
+ this.translate3d(0, HIDDEN_Y, 0, this._offscreenFocusedItem); |
+ |
+ if (!this._focusBackfillItem) { |
+ var stampedTemplate = this.stamp(null); |
+ this._focusBackfillItem = stampedTemplate.root.querySelector('*'); |
+ Polymer.dom(this).appendChild(stampedTemplate.root); |
} |
- return validKey; |
- } |
+ this._physicalItems[pidx] = this._focusBackfillItem; |
+ }, |
- function transformKeyIdentifier(keyIdent) { |
- var validKey = ''; |
- if (keyIdent) { |
- if (keyIdent in KEY_IDENTIFIER) { |
- validKey = KEY_IDENTIFIER[keyIdent]; |
- } else if (IDENT_CHAR.test(keyIdent)) { |
- keyIdent = parseInt(keyIdent.replace('U+', '0x'), 16); |
- validKey = String.fromCharCode(keyIdent).toLowerCase(); |
- } else { |
- validKey = keyIdent.toLowerCase(); |
- } |
+ _didFocus: function(e) { |
+ var targetModel = this.modelForElement(e.target); |
+ var fidx = this._focusedIndex; |
+ |
+ if (!targetModel) { |
+ return; |
} |
- return validKey; |
- } |
+ this._restoreFocusedItem(); |
- function transformKeyCode(keyCode) { |
- var validKey = ''; |
- if (Number(keyCode)) { |
- if (keyCode >= 65 && keyCode <= 90) { |
- // ascii a-z |
- // lowercase is 32 offset from uppercase |
- validKey = String.fromCharCode(32 + keyCode); |
- } else if (keyCode >= 112 && keyCode <= 123) { |
- // function keys f1-f12 |
- validKey = 'f' + (keyCode - 112); |
- } else if (keyCode >= 48 && keyCode <= 57) { |
- // top 0-9 keys |
- validKey = String(48 - keyCode); |
- } else if (keyCode >= 96 && keyCode <= 105) { |
- // num pad 0-9 |
- validKey = String(96 - keyCode); |
+ if (this.modelForElement(this._offscreenFocusedItem) === targetModel) { |
+ this.scrollToIndex(fidx); |
+ } else { |
+ // restore tabIndex for the currently focused item |
+ this._getModelFromItem(this._getNormalizedItem(fidx)).tabIndex = -1; |
+ // set the tabIndex for the next focused item |
+ targetModel.tabIndex = 0; |
+ fidx = /** @type {{index: number}} */(targetModel).index; |
+ this._focusedIndex = fidx; |
+ // bring the item into view |
+ if (fidx < this.firstVisibleIndex || fidx > this.lastVisibleIndex) { |
+ this.scrollToIndex(fidx); |
} else { |
- validKey = KEY_CODE[keyCode]; |
+ this._update(); |
} |
} |
- return validKey; |
- } |
+ }, |
- /** |
- * Calculates the normalized key for a KeyboardEvent. |
- * @param {KeyboardEvent} keyEvent |
- * @param {Boolean} [noSpecialChars] Set to true to limit keyEvent.key |
- * transformation to alpha-numeric chars. This is useful with key |
- * combinations like shift + 2, which on FF for MacOS produces |
- * keyEvent.key = @ |
- * To get 2 returned, set noSpecialChars = true |
- * To get @ returned, set noSpecialChars = false |
- */ |
- function normalizedKeyForEvent(keyEvent, noSpecialChars) { |
- // Fall back from .key, to .keyIdentifier, to .keyCode, and then to |
- // .detail.key to support artificial keyboard events. |
- return transformKey(keyEvent.key, noSpecialChars) || |
- transformKeyIdentifier(keyEvent.keyIdentifier) || |
- transformKeyCode(keyEvent.keyCode) || |
- transformKey(keyEvent.detail.key, noSpecialChars) || ''; |
- } |
+ _didMoveUp: function() { |
+ this._focusPhysicalItem(Math.max(0, this._focusedIndex - 1)); |
+ }, |
- function keyComboMatchesEvent(keyCombo, event) { |
- // For combos with modifiers we support only alpha-numeric keys |
- var keyEvent = normalizedKeyForEvent(event, keyCombo.hasModifiers); |
- return keyEvent === keyCombo.key && |
- (!keyCombo.hasModifiers || ( |
- !!event.shiftKey === !!keyCombo.shiftKey && |
- !!event.ctrlKey === !!keyCombo.ctrlKey && |
- !!event.altKey === !!keyCombo.altKey && |
- !!event.metaKey === !!keyCombo.metaKey) |
- ); |
+ _didMoveDown: function() { |
+ this._focusPhysicalItem(Math.min(this._virtualCount, this._focusedIndex + 1)); |
+ }, |
+ |
+ _didEnter: function(e) { |
+ // focus the currently focused physical item |
+ this._focusPhysicalItem(this._focusedIndex); |
+ // toggle selection |
+ this._selectionHandler(/** @type {{keyboardEvent: Event}} */(e.detail).keyboardEvent); |
} |
+ }); |
- function parseKeyComboString(keyComboString) { |
- if (keyComboString.length === 1) { |
- return { |
- combo: keyComboString, |
- key: keyComboString, |
- event: 'keydown' |
- }; |
- } |
- return keyComboString.split('+').reduce(function(parsedKeyCombo, keyComboPart) { |
- var eventParts = keyComboPart.split(':'); |
- var keyName = eventParts[0]; |
- var event = eventParts[1]; |
+})(); |
+(function() { |
- if (keyName in MODIFIER_KEYS) { |
- parsedKeyCombo[MODIFIER_KEYS[keyName]] = true; |
- parsedKeyCombo.hasModifiers = true; |
- } else { |
- parsedKeyCombo.key = keyName; |
- parsedKeyCombo.event = event || 'keydown'; |
- } |
+ // monostate data |
+ var metaDatas = {}; |
+ var metaArrays = {}; |
+ var singleton = null; |
- return parsedKeyCombo; |
- }, { |
- combo: keyComboString.split(':').shift() |
- }); |
- } |
+ Polymer.IronMeta = Polymer({ |
- function parseEventString(eventString) { |
- return eventString.trim().split(' ').map(function(keyComboString) { |
- return parseKeyComboString(keyComboString); |
- }); |
- } |
+ is: 'iron-meta', |
- /** |
- * `Polymer.IronA11yKeysBehavior` provides a normalized interface for processing |
- * keyboard commands that pertain to [WAI-ARIA best practices](http://www.w3.org/TR/wai-aria-practices/#kbd_general_binding). |
- * The element takes care of browser differences with respect to Keyboard events |
- * and uses an expressive syntax to filter key presses. |
- * |
- * Use the `keyBindings` prototype property to express what combination of keys |
- * will trigger the event to fire. |
- * |
- * Use the `key-event-target` attribute to set up event handlers on a specific |
- * node. |
- * The `keys-pressed` event will fire when one of the key combinations set with the |
- * `keys` property is pressed. |
- * |
- * @demo demo/index.html |
- * @polymerBehavior |
- */ |
- Polymer.IronA11yKeysBehavior = { |
properties: { |
+ |
/** |
- * The HTMLElement that will be firing relevant KeyboardEvents. |
+ * The type of meta-data. All meta-data of the same type is stored |
+ * together. |
*/ |
- keyEventTarget: { |
+ type: { |
+ type: String, |
+ value: 'default', |
+ observer: '_typeChanged' |
+ }, |
+ |
+ /** |
+ * The key used to store `value` under the `type` namespace. |
+ */ |
+ key: { |
+ type: String, |
+ observer: '_keyChanged' |
+ }, |
+ |
+ /** |
+ * The meta-data to store or retrieve. |
+ */ |
+ value: { |
type: Object, |
- value: function() { |
- return this; |
- } |
+ notify: true, |
+ observer: '_valueChanged' |
}, |
/** |
- * If true, this property will cause the implementing element to |
- * automatically stop propagation on any handled KeyboardEvents. |
+ * If true, `value` is set to the iron-meta instance itself. |
*/ |
- stopKeyboardEventPropagation: { |
+ self: { |
type: Boolean, |
- value: false |
+ observer: '_selfChanged' |
}, |
- _boundKeyHandlers: { |
+ /** |
+ * Array of all meta-data values for the given type. |
+ */ |
+ list: { |
type: Array, |
- value: function() { |
- return []; |
- } |
- }, |
- |
- // We use this due to a limitation in IE10 where instances will have |
- // own properties of everything on the "prototype". |
- _imperativeKeyBindings: { |
- type: Object, |
- value: function() { |
- return {}; |
- } |
+ notify: true |
} |
+ |
}, |
- observers: [ |
- '_resetKeyEventListeners(keyEventTarget, _boundKeyHandlers)' |
- ], |
+ hostAttributes: { |
+ hidden: true |
+ }, |
- keyBindings: {}, |
+ /** |
+ * Only runs if someone invokes the factory/constructor directly |
+ * e.g. `new Polymer.IronMeta()` |
+ * |
+ * @param {{type: (string|undefined), key: (string|undefined), value}=} config |
+ */ |
+ factoryImpl: function(config) { |
+ if (config) { |
+ for (var n in config) { |
+ switch(n) { |
+ case 'type': |
+ case 'key': |
+ case 'value': |
+ this[n] = config[n]; |
+ break; |
+ } |
+ } |
+ } |
+ }, |
- registered: function() { |
- this._prepKeyBindings(); |
+ created: function() { |
+ // TODO(sjmiles): good for debugging? |
+ this._metaDatas = metaDatas; |
+ this._metaArrays = metaArrays; |
}, |
- attached: function() { |
- this._listenKeyEventListeners(); |
+ _keyChanged: function(key, old) { |
+ this._resetRegistration(old); |
}, |
- detached: function() { |
- this._unlistenKeyEventListeners(); |
+ _valueChanged: function(value) { |
+ this._resetRegistration(this.key); |
}, |
- /** |
- * Can be used to imperatively add a key binding to the implementing |
- * element. This is the imperative equivalent of declaring a keybinding |
- * in the `keyBindings` prototype property. |
- */ |
- addOwnKeyBinding: function(eventString, handlerName) { |
- this._imperativeKeyBindings[eventString] = handlerName; |
- this._prepKeyBindings(); |
- this._resetKeyEventListeners(); |
+ _selfChanged: function(self) { |
+ if (self) { |
+ this.value = this; |
+ } |
+ }, |
+ |
+ _typeChanged: function(type) { |
+ this._unregisterKey(this.key); |
+ if (!metaDatas[type]) { |
+ metaDatas[type] = {}; |
+ } |
+ this._metaData = metaDatas[type]; |
+ if (!metaArrays[type]) { |
+ metaArrays[type] = []; |
+ } |
+ this.list = metaArrays[type]; |
+ this._registerKeyValue(this.key, this.value); |
}, |
/** |
- * When called, will remove all imperatively-added key bindings. |
+ * Retrieves meta data value by key. |
+ * |
+ * @method byKey |
+ * @param {string} key The key of the meta-data to be returned. |
+ * @return {*} |
*/ |
- removeOwnKeyBindings: function() { |
- this._imperativeKeyBindings = {}; |
- this._prepKeyBindings(); |
- this._resetKeyEventListeners(); |
+ byKey: function(key) { |
+ return this._metaData && this._metaData[key]; |
}, |
- keyboardEventMatchesKeys: function(event, eventString) { |
- var keyCombos = parseEventString(eventString); |
- for (var i = 0; i < keyCombos.length; ++i) { |
- if (keyComboMatchesEvent(keyCombos[i], event)) { |
- return true; |
- } |
- } |
- return false; |
+ _resetRegistration: function(oldKey) { |
+ this._unregisterKey(oldKey); |
+ this._registerKeyValue(this.key, this.value); |
}, |
- _collectKeyBindings: function() { |
- var keyBindings = this.behaviors.map(function(behavior) { |
- return behavior.keyBindings; |
- }); |
- |
- if (keyBindings.indexOf(this.keyBindings) === -1) { |
- keyBindings.push(this.keyBindings); |
- } |
+ _unregisterKey: function(key) { |
+ this._unregister(key, this._metaData, this.list); |
+ }, |
- return keyBindings; |
+ _registerKeyValue: function(key, value) { |
+ this._register(key, value, this._metaData, this.list); |
}, |
- _prepKeyBindings: function() { |
- this._keyBindings = {}; |
+ _register: function(key, value, data, list) { |
+ if (key && data && value !== undefined) { |
+ data[key] = value; |
+ list.push(value); |
+ } |
+ }, |
- this._collectKeyBindings().forEach(function(keyBindings) { |
- for (var eventString in keyBindings) { |
- this._addKeyBinding(eventString, keyBindings[eventString]); |
+ _unregister: function(key, data, list) { |
+ if (key && data) { |
+ if (key in data) { |
+ var value = data[key]; |
+ delete data[key]; |
+ this.arrayDelete(list, value); |
} |
- }, this); |
- |
- for (var eventString in this._imperativeKeyBindings) { |
- this._addKeyBinding(eventString, this._imperativeKeyBindings[eventString]); |
} |
+ } |
- // Give precedence to combos with modifiers to be checked first. |
- for (var eventName in this._keyBindings) { |
- this._keyBindings[eventName].sort(function (kb1, kb2) { |
- var b1 = kb1[0].hasModifiers; |
- var b2 = kb2[0].hasModifiers; |
- return (b1 === b2) ? 0 : b1 ? -1 : 1; |
- }) |
- } |
- }, |
+ }); |
- _addKeyBinding: function(eventString, handlerName) { |
- parseEventString(eventString).forEach(function(keyCombo) { |
- this._keyBindings[keyCombo.event] = |
- this._keyBindings[keyCombo.event] || []; |
+ Polymer.IronMeta.getIronMeta = function getIronMeta() { |
+ if (singleton === null) { |
+ singleton = new Polymer.IronMeta(); |
+ } |
+ return singleton; |
+ }; |
- this._keyBindings[keyCombo.event].push([ |
- keyCombo, |
- handlerName |
- ]); |
- }, this); |
- }, |
+ /** |
+ `iron-meta-query` can be used to access infomation stored in `iron-meta`. |
- _resetKeyEventListeners: function() { |
- this._unlistenKeyEventListeners(); |
+ Examples: |
- if (this.isAttached) { |
- this._listenKeyEventListeners(); |
- } |
- }, |
+ If I create an instance like this: |
- _listenKeyEventListeners: function() { |
- Object.keys(this._keyBindings).forEach(function(eventName) { |
- var keyBindings = this._keyBindings[eventName]; |
- var boundKeyHandler = this._onKeyBindingEvent.bind(this, keyBindings); |
+ <iron-meta key="info" value="foo/bar"></iron-meta> |
- this._boundKeyHandlers.push([this.keyEventTarget, eventName, boundKeyHandler]); |
+ Note that value="foo/bar" is the metadata I've defined. I could define more |
+ attributes or use child nodes to define additional metadata. |
- this.keyEventTarget.addEventListener(eventName, boundKeyHandler); |
- }, this); |
- }, |
+ Now I can access that element (and it's metadata) from any `iron-meta-query` instance: |
- _unlistenKeyEventListeners: function() { |
- var keyHandlerTuple; |
- var keyEventTarget; |
- var eventName; |
- var boundKeyHandler; |
+ var value = new Polymer.IronMetaQuery({key: 'info'}).value; |
- while (this._boundKeyHandlers.length) { |
- // My kingdom for block-scope binding and destructuring assignment.. |
- keyHandlerTuple = this._boundKeyHandlers.pop(); |
- keyEventTarget = keyHandlerTuple[0]; |
- eventName = keyHandlerTuple[1]; |
- boundKeyHandler = keyHandlerTuple[2]; |
+ @group Polymer Iron Elements |
+ @element iron-meta-query |
+ */ |
+ Polymer.IronMetaQuery = Polymer({ |
- keyEventTarget.removeEventListener(eventName, boundKeyHandler); |
- } |
- }, |
+ is: 'iron-meta-query', |
- _onKeyBindingEvent: function(keyBindings, event) { |
- if (this.stopKeyboardEventPropagation) { |
- event.stopPropagation(); |
- } |
+ properties: { |
- // if event has been already prevented, don't do anything |
- if (event.defaultPrevented) { |
- return; |
- } |
+ /** |
+ * The type of meta-data. All meta-data of the same type is stored |
+ * together. |
+ */ |
+ type: { |
+ type: String, |
+ value: 'default', |
+ observer: '_typeChanged' |
+ }, |
- for (var i = 0; i < keyBindings.length; i++) { |
- var keyCombo = keyBindings[i][0]; |
- var handlerName = keyBindings[i][1]; |
- if (keyComboMatchesEvent(keyCombo, event)) { |
- this._triggerKeyHandler(keyCombo, handlerName, event); |
- // exit the loop if eventDefault was prevented |
- if (event.defaultPrevented) { |
- return; |
- } |
- } |
+ /** |
+ * Specifies a key to use for retrieving `value` from the `type` |
+ * namespace. |
+ */ |
+ key: { |
+ type: String, |
+ observer: '_keyChanged' |
+ }, |
+ |
+ /** |
+ * The meta-data to store or retrieve. |
+ */ |
+ value: { |
+ type: Object, |
+ notify: true, |
+ readOnly: true |
+ }, |
+ |
+ /** |
+ * Array of all meta-data values for the given type. |
+ */ |
+ list: { |
+ type: Array, |
+ notify: true |
} |
+ |
}, |
- _triggerKeyHandler: function(keyCombo, handlerName, keyboardEvent) { |
- var detail = Object.create(keyCombo); |
- detail.keyboardEvent = keyboardEvent; |
- var event = new CustomEvent(keyCombo.event, { |
- detail: detail, |
- cancelable: true |
- }); |
- this[handlerName].call(this, event); |
- if (event.defaultPrevented) { |
- keyboardEvent.preventDefault(); |
- } |
- } |
- }; |
+ /** |
+ * Actually a factory method, not a true constructor. Only runs if |
+ * someone invokes it directly (via `new Polymer.IronMeta()`); |
+ * |
+ * @param {{type: (string|undefined), key: (string|undefined)}=} config |
+ */ |
+ factoryImpl: function(config) { |
+ if (config) { |
+ for (var n in config) { |
+ switch(n) { |
+ case 'type': |
+ case 'key': |
+ this[n] = config[n]; |
+ break; |
+ } |
+ } |
+ } |
+ }, |
+ |
+ created: function() { |
+ // TODO(sjmiles): good for debugging? |
+ this._metaDatas = metaDatas; |
+ this._metaArrays = metaArrays; |
+ }, |
+ |
+ _keyChanged: function(key) { |
+ this._setValue(this._metaData && this._metaData[key]); |
+ }, |
+ |
+ _typeChanged: function(type) { |
+ this._metaData = metaDatas[type]; |
+ this.list = metaArrays[type]; |
+ if (this.key) { |
+ this._keyChanged(this.key); |
+ } |
+ }, |
+ |
+ /** |
+ * Retrieves meta data value by key. |
+ * @param {string} key The key of the meta-data to be returned. |
+ * @return {*} |
+ */ |
+ byKey: function(key) { |
+ return this._metaData && this._metaData[key]; |
+ } |
+ |
+ }); |
+ |
})(); |
+Polymer({ |
+ |
+ is: 'iron-icon', |
+ |
+ properties: { |
+ |
+ /** |
+ * The name of the icon to use. The name should be of the form: |
+ * `iconset_name:icon_name`. |
+ */ |
+ icon: { |
+ type: String, |
+ observer: '_iconChanged' |
+ }, |
+ |
+ /** |
+ * The name of the theme to used, if one is specified by the |
+ * iconset. |
+ */ |
+ theme: { |
+ type: String, |
+ observer: '_updateIcon' |
+ }, |
+ |
+ /** |
+ * If using iron-icon without an iconset, you can set the src to be |
+ * the URL of an individual icon image file. Note that this will take |
+ * precedence over a given icon attribute. |
+ */ |
+ src: { |
+ type: String, |
+ observer: '_srcChanged' |
+ }, |
+ |
+ /** |
+ * @type {!Polymer.IronMeta} |
+ */ |
+ _meta: { |
+ value: Polymer.Base.create('iron-meta', {type: 'iconset'}) |
+ } |
+ |
+ }, |
+ |
+ _DEFAULT_ICONSET: 'icons', |
+ |
+ _iconChanged: function(icon) { |
+ var parts = (icon || '').split(':'); |
+ this._iconName = parts.pop(); |
+ this._iconsetName = parts.pop() || this._DEFAULT_ICONSET; |
+ this._updateIcon(); |
+ }, |
+ |
+ _srcChanged: function(src) { |
+ this._updateIcon(); |
+ }, |
+ |
+ _usesIconset: function() { |
+ return this.icon || !this.src; |
+ }, |
+ |
+ /** @suppress {visibility} */ |
+ _updateIcon: function() { |
+ if (this._usesIconset()) { |
+ if (this._iconsetName) { |
+ this._iconset = /** @type {?Polymer.Iconset} */ ( |
+ this._meta.byKey(this._iconsetName)); |
+ if (this._iconset) { |
+ this._iconset.applyIcon(this, this._iconName, this.theme); |
+ this.unlisten(window, 'iron-iconset-added', '_updateIcon'); |
+ } else { |
+ this.listen(window, 'iron-iconset-added', '_updateIcon'); |
+ } |
+ } |
+ } else { |
+ if (!this._img) { |
+ this._img = document.createElement('img'); |
+ this._img.style.width = '100%'; |
+ this._img.style.height = '100%'; |
+ this._img.draggable = false; |
+ } |
+ this._img.src = this.src; |
+ Polymer.dom(this.root).appendChild(this._img); |
+ } |
+ } |
+ |
+ }); |
+/** |
+ * The `iron-iconset-svg` element allows users to define their own icon sets |
+ * that contain svg icons. The svg icon elements should be children of the |
+ * `iron-iconset-svg` element. Multiple icons should be given distinct id's. |
+ * |
+ * Using svg elements to create icons has a few advantages over traditional |
+ * bitmap graphics like jpg or png. Icons that use svg are vector based so |
+ * they are resolution independent and should look good on any device. They |
+ * are stylable via css. Icons can be themed, colorized, and even animated. |
+ * |
+ * Example: |
+ * |
+ * <iron-iconset-svg name="my-svg-icons" size="24"> |
+ * <svg> |
+ * <defs> |
+ * <g id="shape"> |
+ * <rect x="12" y="0" width="12" height="24" /> |
+ * <circle cx="12" cy="12" r="12" /> |
+ * </g> |
+ * </defs> |
+ * </svg> |
+ * </iron-iconset-svg> |
+ * |
+ * This will automatically register the icon set "my-svg-icons" to the iconset |
+ * database. To use these icons from within another element, make a |
+ * `iron-iconset` element and call the `byId` method |
+ * to retrieve a given iconset. To apply a particular icon inside an |
+ * element use the `applyIcon` method. For example: |
+ * |
+ * iconset.applyIcon(iconNode, 'car'); |
+ * |
+ * @element iron-iconset-svg |
+ * @demo demo/index.html |
+ * @implements {Polymer.Iconset} |
+ */ |
+ Polymer({ |
+ is: 'iron-iconset-svg', |
+ |
+ properties: { |
+ |
+ /** |
+ * The name of the iconset. |
+ */ |
+ name: { |
+ type: String, |
+ observer: '_nameChanged' |
+ }, |
+ |
+ /** |
+ * The size of an individual icon. Note that icons must be square. |
+ */ |
+ size: { |
+ type: Number, |
+ value: 24 |
+ } |
+ |
+ }, |
+ |
+ attached: function() { |
+ this.style.display = 'none'; |
+ }, |
+ |
+ /** |
+ * Construct an array of all icon names in this iconset. |
+ * |
+ * @return {!Array} Array of icon names. |
+ */ |
+ getIconNames: function() { |
+ this._icons = this._createIconMap(); |
+ return Object.keys(this._icons).map(function(n) { |
+ return this.name + ':' + n; |
+ }, this); |
+ }, |
+ |
+ /** |
+ * Applies an icon to the given element. |
+ * |
+ * An svg icon is prepended to the element's shadowRoot if it exists, |
+ * otherwise to the element itself. |
+ * |
+ * @method applyIcon |
+ * @param {Element} element Element to which the icon is applied. |
+ * @param {string} iconName Name of the icon to apply. |
+ * @return {?Element} The svg element which renders the icon. |
+ */ |
+ applyIcon: function(element, iconName) { |
+ // insert svg element into shadow root, if it exists |
+ element = element.root || element; |
+ // Remove old svg element |
+ this.removeIcon(element); |
+ // install new svg element |
+ var svg = this._cloneIcon(iconName); |
+ if (svg) { |
+ var pde = Polymer.dom(element); |
+ pde.insertBefore(svg, pde.childNodes[0]); |
+ return element._svgIcon = svg; |
+ } |
+ return null; |
+ }, |
+ |
+ /** |
+ * Remove an icon from the given element by undoing the changes effected |
+ * by `applyIcon`. |
+ * |
+ * @param {Element} element The element from which the icon is removed. |
+ */ |
+ removeIcon: function(element) { |
+ // Remove old svg element |
+ if (element._svgIcon) { |
+ Polymer.dom(element).removeChild(element._svgIcon); |
+ element._svgIcon = null; |
+ } |
+ }, |
+ |
+ /** |
+ * |
+ * When name is changed, register iconset metadata |
+ * |
+ */ |
+ _nameChanged: function() { |
+ new Polymer.IronMeta({type: 'iconset', key: this.name, value: this}); |
+ this.async(function() { |
+ this.fire('iron-iconset-added', this, {node: window}); |
+ }); |
+ }, |
+ |
+ /** |
+ * Create a map of child SVG elements by id. |
+ * |
+ * @return {!Object} Map of id's to SVG elements. |
+ */ |
+ _createIconMap: function() { |
+ // Objects chained to Object.prototype (`{}`) have members. Specifically, |
+ // on FF there is a `watch` method that confuses the icon map, so we |
+ // need to use a null-based object here. |
+ var icons = Object.create(null); |
+ Polymer.dom(this).querySelectorAll('[id]') |
+ .forEach(function(icon) { |
+ icons[icon.id] = icon; |
+ }); |
+ return icons; |
+ }, |
+ |
+ /** |
+ * Produce installable clone of the SVG element matching `id` in this |
+ * iconset, or `undefined` if there is no matching element. |
+ * |
+ * @return {Element} Returns an installable clone of the SVG element |
+ * matching `id`. |
+ */ |
+ _cloneIcon: function(id) { |
+ // create the icon map on-demand, since the iconset itself has no discrete |
+ // signal to know when it's children are fully parsed |
+ this._icons = this._icons || this._createIconMap(); |
+ return this._prepareSvgClone(this._icons[id], this.size); |
+ }, |
+ |
+ /** |
+ * @param {Element} sourceSvg |
+ * @param {number} size |
+ * @return {Element} |
+ */ |
+ _prepareSvgClone: function(sourceSvg, size) { |
+ if (sourceSvg) { |
+ var content = sourceSvg.cloneNode(true), |
+ svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), |
+ viewBox = content.getAttribute('viewBox') || '0 0 ' + size + ' ' + size; |
+ svg.setAttribute('viewBox', viewBox); |
+ svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); |
+ // TODO(dfreedm): `pointer-events: none` works around https://crbug.com/370136 |
+ // TODO(sjmiles): inline style may not be ideal, but avoids requiring a shadow-root |
+ svg.style.cssText = 'pointer-events: none; display: block; width: 100%; height: 100%;'; |
+ svg.appendChild(content).removeAttribute('id'); |
+ return svg; |
+ } |
+ return null; |
+ } |
+ |
+ }); |
/** |
* @demo demo/index.html |
* @polymerBehavior |