| 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 352b5bf7f50cd2f7d5ad20653ec41e7da06002c0..efa2abf0e1294cc9664a6e53acb14ad99bb8278f 100644
|
| --- a/chrome/browser/resources/md_downloads/crisper.js
|
| +++ b/chrome/browser/resources/md_downloads/crisper.js
|
| @@ -1431,10 +1431,19 @@ function createElementWithClassName(type, className) {
|
| * or when no paint happens during the animation). This function sets up
|
| * a timer and emulate the event if it is not fired when the timer expires.
|
| * @param {!HTMLElement} el The element to watch for webkitTransitionEnd.
|
| - * @param {number} timeOut The maximum wait time in milliseconds for the
|
| - * webkitTransitionEnd to happen.
|
| + * @param {number=} opt_timeOut The maximum wait time in milliseconds for the
|
| + * webkitTransitionEnd to happen. If not specified, it is fetched from |el|
|
| + * using the transitionDuration style value.
|
| */
|
| -function ensureTransitionEndEvent(el, timeOut) {
|
| +function ensureTransitionEndEvent(el, opt_timeOut) {
|
| + if (opt_timeOut === undefined) {
|
| + var style = getComputedStyle(el);
|
| + opt_timeOut = parseFloat(style.transitionDuration) * 1000;
|
| +
|
| + // Give an additional 50ms buffer for the animation to complete.
|
| + opt_timeOut += 50;
|
| + }
|
| +
|
| var fired = false;
|
| el.addEventListener('webkitTransitionEnd', function f(e) {
|
| el.removeEventListener('webkitTransitionEnd', f);
|
| @@ -1443,7 +1452,7 @@ function ensureTransitionEndEvent(el, timeOut) {
|
| window.setTimeout(function() {
|
| if (!fired)
|
| cr.dispatchSimpleEvent(el, 'webkitTransitionEnd', true);
|
| - }, timeOut);
|
| + }, opt_timeOut);
|
| }
|
|
|
| /**
|
| @@ -1522,2583 +1531,2592 @@ function elide(original, maxLength) {
|
| function quoteString(str) {
|
| return str.replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1');
|
| };
|
| -// Copyright (c) 2013 The Chromium Authors. All rights reserved.
|
| -// Use of this source code is governed by a BSD-style license that can be
|
| -// found in the LICENSE file.
|
| -
|
| -/**
|
| - * @fileoverview Assertion support.
|
| - */
|
| -
|
| -/**
|
| - * Verify |condition| is truthy and return |condition| if so.
|
| - * @template T
|
| - * @param {T} condition A condition to check for truthiness. Note that this
|
| - * may be used to test whether a value is defined or not, and we don't want
|
| - * to force a cast to Boolean.
|
| - * @param {string=} opt_message A message to show on failure.
|
| - * @return {T} A non-null |condition|.
|
| - */
|
| -function assert(condition, opt_message) {
|
| - if (!condition) {
|
| - var message = 'Assertion failed';
|
| - if (opt_message)
|
| - message = message + ': ' + opt_message;
|
| - var error = new Error(message);
|
| - var global = function() { return this; }();
|
| - if (global.traceAssertionsForTesting)
|
| - console.warn(error.stack);
|
| - throw error;
|
| - }
|
| - return condition;
|
| -}
|
| -
|
| -/**
|
| - * Call this from places in the code that should never be reached.
|
| - *
|
| - * For example, handling all the values of enum with a switch() like this:
|
| - *
|
| - * function getValueFromEnum(enum) {
|
| - * switch (enum) {
|
| - * case ENUM_FIRST_OF_TWO:
|
| - * return first
|
| - * case ENUM_LAST_OF_TWO:
|
| - * return last;
|
| - * }
|
| - * assertNotReached();
|
| - * return document;
|
| - * }
|
| - *
|
| - * This code should only be hit in the case of serious programmer error or
|
| - * unexpected input.
|
| - *
|
| - * @param {string=} opt_message A message to show when this is hit.
|
| - */
|
| -function assertNotReached(opt_message) {
|
| - assert(false, opt_message || 'Unreachable code hit');
|
| -}
|
| -
|
| /**
|
| - * @param {*} value The value to check.
|
| - * @param {function(new: T, ...)} type A user-defined constructor.
|
| - * @param {string=} opt_message A message to show when this is hit.
|
| - * @return {T}
|
| - * @template T
|
| - */
|
| -function assertInstanceof(value, type, opt_message) {
|
| - // 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.
|
| -// Use of this source code is governed by a BSD-style license that can be
|
| -// found in the LICENSE file.
|
| -
|
| -cr.define('downloads', function() {
|
| - /**
|
| - * @param {string} chromeSendName
|
| - * @return {function(string):void} A chrome.send() callback with curried name.
|
| - */
|
| - function chromeSendWithId(chromeSendName) {
|
| - return function(id) { chrome.send(chromeSendName, [id]); };
|
| - }
|
| -
|
| - /** @constructor */
|
| - function ActionService() {
|
| - /** @private {Array<string>} */
|
| - this.searchTerms_ = [];
|
| - }
|
| + * `IronResizableBehavior` is a behavior that can be used in Polymer elements to
|
| + * coordinate the flow of resize events between "resizers" (elements that control the
|
| + * size or hidden state of their children) and "resizables" (elements that need to be
|
| + * notified when they are resized or un-hidden by their parents in order to take
|
| + * action on their new measurements).
|
| + *
|
| + * Elements that perform measurement should add the `IronResizableBehavior` behavior to
|
| + * their element definition and listen for the `iron-resize` event on themselves.
|
| + * This event will be fired when they become showing after having been hidden,
|
| + * when they are resized explicitly by another resizable, or when the window has been
|
| + * resized.
|
| + *
|
| + * Note, the `iron-resize` event is non-bubbling.
|
| + *
|
| + * @polymerBehavior Polymer.IronResizableBehavior
|
| + * @demo demo/index.html
|
| + **/
|
| + Polymer.IronResizableBehavior = {
|
| + properties: {
|
| + /**
|
| + * The closest ancestor element that implements `IronResizableBehavior`.
|
| + */
|
| + _parentResizable: {
|
| + type: Object,
|
| + observer: '_parentResizableChanged'
|
| + },
|
|
|
| - /**
|
| - * @param {string} s
|
| - * @return {string} |s| without whitespace at the beginning or end.
|
| - */
|
| - function trim(s) { return s.trim(); }
|
| + /**
|
| + * True if this element is currently notifying its descedant elements of
|
| + * resize.
|
| + */
|
| + _notifyingDescendant: {
|
| + type: Boolean,
|
| + value: false
|
| + }
|
| + },
|
|
|
| - /**
|
| - * @param {string|undefined} value
|
| - * @return {boolean} Whether |value| is truthy.
|
| - */
|
| - function truthy(value) { return !!value; }
|
| + listeners: {
|
| + 'iron-request-resize-notifications': '_onIronRequestResizeNotifications'
|
| + },
|
|
|
| - /**
|
| - * @param {string} searchText Input typed by the user into a search box.
|
| - * @return {Array<string>} A list of terms extracted from |searchText|.
|
| - */
|
| - ActionService.splitTerms = function(searchText) {
|
| - // Split quoted terms (e.g., 'The "lazy" dog' => ['The', 'lazy', 'dog']).
|
| - return searchText.split(/"([^"]*)"/).map(trim).filter(truthy);
|
| - };
|
| + created: function() {
|
| + // We don't really need property effects on these, and also we want them
|
| + // to be created before the `_parentResizable` observer fires:
|
| + this._interestedResizables = [];
|
| + this._boundNotifyResize = this.notifyResize.bind(this);
|
| + },
|
|
|
| - ActionService.prototype = {
|
| - /** @param {string} id ID of the download to cancel. */
|
| - cancel: chromeSendWithId('cancel'),
|
| + attached: function() {
|
| + this.fire('iron-request-resize-notifications', null, {
|
| + node: this,
|
| + bubbles: true,
|
| + cancelable: true
|
| + });
|
|
|
| - /** Instructs the browser to clear all finished downloads. */
|
| - clearAll: function() {
|
| - if (loadTimeData.getBoolean('allowDeletingHistory')) {
|
| - chrome.send('clearAll');
|
| - this.search('');
|
| + if (!this._parentResizable) {
|
| + window.addEventListener('resize', this._boundNotifyResize);
|
| + this.notifyResize();
|
| }
|
| },
|
|
|
| - /** @param {string} id ID of the dangerous download to discard. */
|
| - discardDangerous: chromeSendWithId('discardDangerous'),
|
| + detached: function() {
|
| + if (this._parentResizable) {
|
| + this._parentResizable.stopResizeNotificationsFor(this);
|
| + } else {
|
| + window.removeEventListener('resize', this._boundNotifyResize);
|
| + }
|
|
|
| - /** @param {string} url URL of a file to download. */
|
| - download: function(url) {
|
| - var a = document.createElement('a');
|
| - a.href = url;
|
| - a.setAttribute('download', '');
|
| - a.click();
|
| + this._parentResizable = null;
|
| },
|
|
|
| - /** @param {string} id ID of the download that the user started dragging. */
|
| - drag: chromeSendWithId('drag'),
|
| + /**
|
| + * Can be called to manually notify a resizable and its descendant
|
| + * resizables of a resize change.
|
| + */
|
| + notifyResize: function() {
|
| + if (!this.isAttached) {
|
| + return;
|
| + }
|
|
|
| - /** Loads more downloads with the current search terms. */
|
| - loadMore: function() {
|
| - chrome.send('getDownloads', this.searchTerms_);
|
| + this._interestedResizables.forEach(function(resizable) {
|
| + if (this.resizerShouldNotify(resizable)) {
|
| + this._notifyDescendant(resizable);
|
| + }
|
| + }, this);
|
| +
|
| + this._fireResize();
|
| },
|
|
|
| /**
|
| - * @return {boolean} Whether the user is currently searching for downloads
|
| - * (i.e. has a non-empty search term).
|
| + * Used to assign the closest resizable ancestor to this resizable
|
| + * if the ancestor detects a request for notifications.
|
| */
|
| - isSearching: function() {
|
| - return this.searchTerms_.length > 0;
|
| + assignParentResizable: function(parentResizable) {
|
| + this._parentResizable = parentResizable;
|
| },
|
|
|
| - /** Opens the current local destination for downloads. */
|
| - openDownloadsFolder: chrome.send.bind(chrome, 'openDownloadsFolder'),
|
| -
|
| /**
|
| - * @param {string} id ID of the download to run locally on the user's box.
|
| + * Used to remove a resizable descendant from the list of descendants
|
| + * that should be notified of a resize change.
|
| */
|
| - openFile: chromeSendWithId('openFile'),
|
| -
|
| - /** @param {string} id ID the of the progressing download to pause. */
|
| - pause: chromeSendWithId('pause'),
|
| -
|
| - /** @param {string} id ID of the finished download to remove. */
|
| - remove: chromeSendWithId('remove'),
|
| + stopResizeNotificationsFor: function(target) {
|
| + var index = this._interestedResizables.indexOf(target);
|
|
|
| - /** @param {string} id ID of the paused download to resume. */
|
| - resume: chromeSendWithId('resume'),
|
| + if (index > -1) {
|
| + this._interestedResizables.splice(index, 1);
|
| + this.unlisten(target, 'iron-resize', '_onDescendantIronResize');
|
| + }
|
| + },
|
|
|
| /**
|
| - * @param {string} id ID of the dangerous download to save despite
|
| - * warnings.
|
| + * This method can be overridden to filter nested elements that should or
|
| + * should not be notified by the current element. Return true if an element
|
| + * should be notified, or false if it should not be notified.
|
| + *
|
| + * @param {HTMLElement} element A candidate descendant element that
|
| + * implements `IronResizableBehavior`.
|
| + * @return {boolean} True if the `element` should be notified of resize.
|
| */
|
| - saveDangerous: chromeSendWithId('saveDangerous'),
|
| + resizerShouldNotify: function(element) { return true; },
|
|
|
| - /** @param {string} searchText What to search for. */
|
| - search: function(searchText) {
|
| - var searchTerms = ActionService.splitTerms(searchText);
|
| - var sameTerms = searchTerms.length == this.searchTerms_.length;
|
| + _onDescendantIronResize: function(event) {
|
| + if (this._notifyingDescendant) {
|
| + event.stopPropagation();
|
| + return;
|
| + }
|
|
|
| - for (var i = 0; sameTerms && i < searchTerms.length; ++i) {
|
| - if (searchTerms[i] != this.searchTerms_[i])
|
| - sameTerms = false;
|
| + // NOTE(cdata): In ShadowDOM, event retargetting makes echoing of the
|
| + // otherwise non-bubbling event "just work." We do it manually here for
|
| + // the case where Polymer is not using shadow roots for whatever reason:
|
| + if (!Polymer.Settings.useShadow) {
|
| + this._fireResize();
|
| }
|
| + },
|
|
|
| - if (sameTerms)
|
| + _fireResize: function() {
|
| + this.fire('iron-resize', null, {
|
| + node: this,
|
| + bubbles: false
|
| + });
|
| + },
|
| +
|
| + _onIronRequestResizeNotifications: function(event) {
|
| + var target = event.path ? event.path[0] : event.target;
|
| +
|
| + if (target === this) {
|
| return;
|
| + }
|
|
|
| - this.searchTerms_ = searchTerms;
|
| - this.loadMore();
|
| + if (this._interestedResizables.indexOf(target) === -1) {
|
| + this._interestedResizables.push(target);
|
| + this.listen(target, 'iron-resize', '_onDescendantIronResize');
|
| + }
|
| +
|
| + target.assignParentResizable(this);
|
| + this._notifyDescendant(target);
|
| +
|
| + event.stopPropagation();
|
| + },
|
| +
|
| + _parentResizableChanged: function(parentResizable) {
|
| + if (parentResizable) {
|
| + window.removeEventListener('resize', this._boundNotifyResize);
|
| + }
|
| },
|
|
|
| + _notifyDescendant: function(descendant) {
|
| + // NOTE(cdata): In IE10, attached is fired on children first, so it's
|
| + // important not to notify them if the parent is not attached yet (or
|
| + // else they will get redundantly notified when the parent attaches).
|
| + if (!this.isAttached) {
|
| + return;
|
| + }
|
| +
|
| + this._notifyingDescendant = true;
|
| + descendant.notifyResize();
|
| + this._notifyingDescendant = false;
|
| + }
|
| + };
|
| +(function() {
|
| + 'use strict';
|
| +
|
| /**
|
| - * Shows the local folder a finished download resides in.
|
| - * @param {string} id ID of the download to show.
|
| + * 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
|
| */
|
| - show: chromeSendWithId('show'),
|
| + var KEY_IDENTIFIER = {
|
| + 'U+0008': 'backspace',
|
| + 'U+0009': 'tab',
|
| + 'U+001B': 'esc',
|
| + 'U+0020': 'space',
|
| + 'U+007F': 'del'
|
| + };
|
|
|
| - /** Undo download removal. */
|
| - undo: chrome.send.bind(chrome, 'undo'),
|
| - };
|
| + /**
|
| + * 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: '*'
|
| + };
|
|
|
| - cr.addSingletonGetter(ActionService);
|
| + /**
|
| + * 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'
|
| + };
|
|
|
| - return {ActionService: ActionService};
|
| -});
|
| -// Copyright 2015 The Chromium Authors. All rights reserved.
|
| -// Use of this source code is governed by a BSD-style license that can be
|
| -// found in the LICENSE file.
|
| + /**
|
| + * 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*]/;
|
|
|
| -cr.define('downloads', function() {
|
| - /**
|
| - * Explains why a download is in DANGEROUS state.
|
| - * @enum {string}
|
| - */
|
| - var DangerType = {
|
| - NOT_DANGEROUS: 'NOT_DANGEROUS',
|
| - DANGEROUS_FILE: 'DANGEROUS_FILE',
|
| - DANGEROUS_URL: 'DANGEROUS_URL',
|
| - DANGEROUS_CONTENT: 'DANGEROUS_CONTENT',
|
| - UNCOMMON_CONTENT: 'UNCOMMON_CONTENT',
|
| - DANGEROUS_HOST: 'DANGEROUS_HOST',
|
| - POTENTIALLY_UNWANTED: 'POTENTIALLY_UNWANTED',
|
| - };
|
| + /**
|
| + * Matches a keyIdentifier string.
|
| + */
|
| + var IDENT_CHAR = /U\+/;
|
|
|
| - /**
|
| - * The states a download can be in. These correspond to states defined in
|
| - * DownloadsDOMHandler::CreateDownloadItemValue
|
| - * @enum {string}
|
| - */
|
| - var States = {
|
| - IN_PROGRESS: 'IN_PROGRESS',
|
| - CANCELLED: 'CANCELLED',
|
| - COMPLETE: 'COMPLETE',
|
| - PAUSED: 'PAUSED',
|
| - DANGEROUS: 'DANGEROUS',
|
| - INTERRUPTED: 'INTERRUPTED',
|
| - };
|
| + /**
|
| + * Matches arrow keys in Gecko 27.0+
|
| + */
|
| + var ARROW_KEY = /^arrow/;
|
|
|
| - return {
|
| - DangerType: DangerType,
|
| - States: States,
|
| - };
|
| -});
|
| -// Copyright 2014 The Chromium Authors. All rights reserved.
|
| -// Use of this source code is governed by a BSD-style license that can be
|
| -// found in the LICENSE file.
|
| + /**
|
| + * Matches space keys everywhere (notably including IE10's exceptional name
|
| + * `spacebar`).
|
| + */
|
| + var SPACE_KEY = /^space(bar)?/;
|
|
|
| -// Action links are elements that are used to perform an in-page navigation or
|
| -// action (e.g. showing a dialog).
|
| -//
|
| -// They look like normal anchor (<a>) tags as their text color is blue. However,
|
| -// they're subtly different as they're not initially underlined (giving users a
|
| -// clue that underlined links navigate while action links don't).
|
| -//
|
| -// Action links look very similar to normal links when hovered (hand cursor,
|
| -// underlined). This gives the user an idea that clicking this link will do
|
| -// something similar to navigation but in the same page.
|
| -//
|
| -// They can be created in JavaScript like this:
|
| -//
|
| -// var link = document.createElement('a', 'action-link'); // Note second arg.
|
| -//
|
| -// or with a constructor like this:
|
| -//
|
| -// var link = new ActionLink();
|
| -//
|
| -// They can be used easily from HTML as well, like so:
|
| -//
|
| -// <a is="action-link">Click me!</a>
|
| -//
|
| -// NOTE: <action-link> and document.createElement('action-link') don't work.
|
| + /**
|
| + * Matches ESC key.
|
| + *
|
| + * Value from: http://w3c.github.io/uievents-key/#key-Escape
|
| + */
|
| + var ESC_KEY = /^escape$/;
|
|
|
| -/**
|
| - * @constructor
|
| - * @extends {HTMLAnchorElement}
|
| - */
|
| -var ActionLink = document.registerElement('action-link', {
|
| - prototype: {
|
| - __proto__: HTMLAnchorElement.prototype,
|
| + /**
|
| + * 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 (ESC_KEY.test(lKey)) {
|
| + validKey = 'esc';
|
| + } 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;
|
| + }
|
|
|
| - /** @this {ActionLink} */
|
| - createdCallback: function() {
|
| - // Action links can start disabled (e.g. <a is="action-link" disabled>).
|
| - this.tabIndex = this.disabled ? -1 : 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;
|
| + }
|
| +
|
| + 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(keyCode - 48);
|
| + } else if (keyCode >= 96 && keyCode <= 105) {
|
| + // num pad 0-9
|
| + validKey = String(keyCode - 96);
|
| + } else {
|
| + validKey = KEY_CODE[keyCode];
|
| + }
|
| + }
|
| + 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) || '';
|
| + }
|
|
|
| - if (!this.hasAttribute('role'))
|
| - this.setAttribute('role', 'link');
|
| + 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)
|
| + );
|
| + }
|
|
|
| - this.addEventListener('keydown', function(e) {
|
| - if (!this.disabled && e.keyIdentifier == 'Enter' && !this.href) {
|
| - // Schedule a click asynchronously because other 'keydown' handlers
|
| - // may still run later (e.g. document.addEventListener('keydown')).
|
| - // Specifically options dialogs break when this timeout isn't here.
|
| - // NOTE: this affects the "trusted" state of the ensuing click. I
|
| - // haven't found anything that breaks because of this (yet).
|
| - window.setTimeout(this.click.bind(this), 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];
|
| +
|
| + if (keyName in MODIFIER_KEYS) {
|
| + parsedKeyCombo[MODIFIER_KEYS[keyName]] = true;
|
| + parsedKeyCombo.hasModifiers = true;
|
| + } else {
|
| + parsedKeyCombo.key = keyName;
|
| + parsedKeyCombo.event = event || 'keydown';
|
| }
|
| +
|
| + return parsedKeyCombo;
|
| + }, {
|
| + combo: keyComboString.split(':').shift()
|
| });
|
| + }
|
|
|
| - function preventDefault(e) {
|
| - e.preventDefault();
|
| - }
|
| + function parseEventString(eventString) {
|
| + return eventString.trim().split(' ').map(function(keyComboString) {
|
| + return parseKeyComboString(keyComboString);
|
| + });
|
| + }
|
|
|
| - function removePreventDefault() {
|
| - document.removeEventListener('selectstart', preventDefault);
|
| - document.removeEventListener('mouseup', removePreventDefault);
|
| - }
|
| + /**
|
| + * `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.
|
| + */
|
| + keyEventTarget: {
|
| + type: Object,
|
| + value: function() {
|
| + return this;
|
| + }
|
| + },
|
|
|
| - this.addEventListener('mousedown', function() {
|
| - // This handlers strives to match the behavior of <a href="...">.
|
| + /**
|
| + * If true, this property will cause the implementing element to
|
| + * automatically stop propagation on any handled KeyboardEvents.
|
| + */
|
| + stopKeyboardEventPropagation: {
|
| + type: Boolean,
|
| + value: false
|
| + },
|
|
|
| - // While the mouse is down, prevent text selection from dragging.
|
| - document.addEventListener('selectstart', preventDefault);
|
| - document.addEventListener('mouseup', removePreventDefault);
|
| + _boundKeyHandlers: {
|
| + type: Array,
|
| + value: function() {
|
| + return [];
|
| + }
|
| + },
|
|
|
| - // If focus started via mouse press, don't show an outline.
|
| - if (document.activeElement != this)
|
| - this.classList.add('no-outline');
|
| - });
|
| + // 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 {};
|
| + }
|
| + }
|
| + },
|
|
|
| - this.addEventListener('blur', function() {
|
| - this.classList.remove('no-outline');
|
| - });
|
| - },
|
| + observers: [
|
| + '_resetKeyEventListeners(keyEventTarget, _boundKeyHandlers)'
|
| + ],
|
|
|
| - /** @type {boolean} */
|
| - set disabled(disabled) {
|
| - if (disabled)
|
| - HTMLAnchorElement.prototype.setAttribute.call(this, 'disabled', '');
|
| - else
|
| - HTMLAnchorElement.prototype.removeAttribute.call(this, 'disabled');
|
| - this.tabIndex = disabled ? -1 : 0;
|
| - },
|
| - get disabled() {
|
| - return this.hasAttribute('disabled');
|
| - },
|
| + keyBindings: {},
|
|
|
| - /** @override */
|
| - setAttribute: function(attr, val) {
|
| - if (attr.toLowerCase() == 'disabled')
|
| - this.disabled = true;
|
| - else
|
| - HTMLAnchorElement.prototype.setAttribute.apply(this, arguments);
|
| - },
|
| + registered: function() {
|
| + this._prepKeyBindings();
|
| + },
|
|
|
| - /** @override */
|
| - removeAttribute: function(attr) {
|
| - if (attr.toLowerCase() == 'disabled')
|
| - this.disabled = false;
|
| - else
|
| - HTMLAnchorElement.prototype.removeAttribute.apply(this, arguments);
|
| - },
|
| - },
|
| + attached: function() {
|
| + this._listenKeyEventListeners();
|
| + },
|
|
|
| - extends: 'a',
|
| -});
|
| -// Copyright (c) 2012 The Chromium Authors. All rights reserved.
|
| -// Use of this source code is governed by a BSD-style license that can be
|
| -// found in the LICENSE file.
|
| + detached: function() {
|
| + this._unlistenKeyEventListeners();
|
| + },
|
|
|
| -// <include src="../../../../ui/webui/resources/js/i18n_template_no_process.js">
|
| + /**
|
| + * 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();
|
| + },
|
|
|
| -i18nTemplate.process(document, loadTimeData);
|
| -/**
|
| - * `IronResizableBehavior` is a behavior that can be used in Polymer elements to
|
| - * coordinate the flow of resize events between "resizers" (elements that control the
|
| - * size or hidden state of their children) and "resizables" (elements that need to be
|
| - * notified when they are resized or un-hidden by their parents in order to take
|
| - * action on their new measurements).
|
| - *
|
| - * Elements that perform measurement should add the `IronResizableBehavior` behavior to
|
| - * their element definition and listen for the `iron-resize` event on themselves.
|
| - * This event will be fired when they become showing after having been hidden,
|
| - * when they are resized explicitly by another resizable, or when the window has been
|
| - * resized.
|
| - *
|
| - * Note, the `iron-resize` event is non-bubbling.
|
| - *
|
| - * @polymerBehavior Polymer.IronResizableBehavior
|
| - * @demo demo/index.html
|
| - **/
|
| - Polymer.IronResizableBehavior = {
|
| - properties: {
|
| /**
|
| - * The closest ancestor element that implements `IronResizableBehavior`.
|
| + * When called, will remove all imperatively-added key bindings.
|
| */
|
| - _parentResizable: {
|
| - type: Object,
|
| - observer: '_parentResizableChanged'
|
| + removeOwnKeyBindings: function() {
|
| + this._imperativeKeyBindings = {};
|
| + this._prepKeyBindings();
|
| + this._resetKeyEventListeners();
|
| },
|
|
|
| /**
|
| - * True if this element is currently notifying its descedant elements of
|
| - * resize.
|
| + * Returns true if a keyboard event matches `eventString`.
|
| + *
|
| + * @param {KeyboardEvent} event
|
| + * @param {string} eventString
|
| + * @return {boolean}
|
| */
|
| - _notifyingDescendant: {
|
| - type: Boolean,
|
| - value: false
|
| - }
|
| - },
|
| + 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;
|
| + },
|
|
|
| - listeners: {
|
| - 'iron-request-resize-notifications': '_onIronRequestResizeNotifications'
|
| - },
|
| + _collectKeyBindings: function() {
|
| + var keyBindings = this.behaviors.map(function(behavior) {
|
| + return behavior.keyBindings;
|
| + });
|
|
|
| - created: function() {
|
| - // We don't really need property effects on these, and also we want them
|
| - // to be created before the `_parentResizable` observer fires:
|
| - this._interestedResizables = [];
|
| - this._boundNotifyResize = this.notifyResize.bind(this);
|
| - },
|
| + if (keyBindings.indexOf(this.keyBindings) === -1) {
|
| + keyBindings.push(this.keyBindings);
|
| + }
|
|
|
| - attached: function() {
|
| - this.fire('iron-request-resize-notifications', null, {
|
| - node: this,
|
| - bubbles: true,
|
| - cancelable: true
|
| - });
|
| + return keyBindings;
|
| + },
|
| +
|
| + _prepKeyBindings: function() {
|
| + this._keyBindings = {};
|
| +
|
| + this._collectKeyBindings().forEach(function(keyBindings) {
|
| + for (var eventString in keyBindings) {
|
| + this._addKeyBinding(eventString, keyBindings[eventString]);
|
| + }
|
| + }, this);
|
|
|
| - if (!this._parentResizable) {
|
| - window.addEventListener('resize', this._boundNotifyResize);
|
| - this.notifyResize();
|
| - }
|
| - },
|
| + for (var eventString in this._imperativeKeyBindings) {
|
| + this._addKeyBinding(eventString, this._imperativeKeyBindings[eventString]);
|
| + }
|
|
|
| - detached: function() {
|
| - if (this._parentResizable) {
|
| - this._parentResizable.stopResizeNotificationsFor(this);
|
| - } else {
|
| - window.removeEventListener('resize', this._boundNotifyResize);
|
| - }
|
| + // 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;
|
| + })
|
| + }
|
| + },
|
|
|
| - this._parentResizable = null;
|
| - },
|
| + _addKeyBinding: function(eventString, handlerName) {
|
| + parseEventString(eventString).forEach(function(keyCombo) {
|
| + this._keyBindings[keyCombo.event] =
|
| + this._keyBindings[keyCombo.event] || [];
|
|
|
| - /**
|
| - * Can be called to manually notify a resizable and its descendant
|
| - * resizables of a resize change.
|
| - */
|
| - notifyResize: function() {
|
| - if (!this.isAttached) {
|
| - return;
|
| - }
|
| + this._keyBindings[keyCombo.event].push([
|
| + keyCombo,
|
| + handlerName
|
| + ]);
|
| + }, this);
|
| + },
|
|
|
| - this._interestedResizables.forEach(function(resizable) {
|
| - if (this.resizerShouldNotify(resizable)) {
|
| - this._notifyDescendant(resizable);
|
| - }
|
| - }, this);
|
| + _resetKeyEventListeners: function() {
|
| + this._unlistenKeyEventListeners();
|
|
|
| - this._fireResize();
|
| - },
|
| + if (this.isAttached) {
|
| + this._listenKeyEventListeners();
|
| + }
|
| + },
|
|
|
| - /**
|
| - * Used to assign the closest resizable ancestor to this resizable
|
| - * if the ancestor detects a request for notifications.
|
| - */
|
| - assignParentResizable: function(parentResizable) {
|
| - this._parentResizable = parentResizable;
|
| - },
|
| + _listenKeyEventListeners: function() {
|
| + Object.keys(this._keyBindings).forEach(function(eventName) {
|
| + var keyBindings = this._keyBindings[eventName];
|
| + var boundKeyHandler = this._onKeyBindingEvent.bind(this, keyBindings);
|
|
|
| - /**
|
| - * Used to remove a resizable descendant from the list of descendants
|
| - * that should be notified of a resize change.
|
| - */
|
| - stopResizeNotificationsFor: function(target) {
|
| - var index = this._interestedResizables.indexOf(target);
|
| + this._boundKeyHandlers.push([this.keyEventTarget, eventName, boundKeyHandler]);
|
|
|
| - if (index > -1) {
|
| - this._interestedResizables.splice(index, 1);
|
| - this.unlisten(target, 'iron-resize', '_onDescendantIronResize');
|
| - }
|
| - },
|
| + this.keyEventTarget.addEventListener(eventName, boundKeyHandler);
|
| + }, this);
|
| + },
|
|
|
| - /**
|
| - * This method can be overridden to filter nested elements that should or
|
| - * should not be notified by the current element. Return true if an element
|
| - * should be notified, or false if it should not be notified.
|
| - *
|
| - * @param {HTMLElement} element A candidate descendant element that
|
| - * implements `IronResizableBehavior`.
|
| - * @return {boolean} True if the `element` should be notified of resize.
|
| - */
|
| - resizerShouldNotify: function(element) { return true; },
|
| + _unlistenKeyEventListeners: function() {
|
| + var keyHandlerTuple;
|
| + var keyEventTarget;
|
| + var eventName;
|
| + var boundKeyHandler;
|
|
|
| - _onDescendantIronResize: function(event) {
|
| - if (this._notifyingDescendant) {
|
| - event.stopPropagation();
|
| - return;
|
| - }
|
| + 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];
|
|
|
| - // NOTE(cdata): In ShadowDOM, event retargetting makes echoing of the
|
| - // otherwise non-bubbling event "just work." We do it manually here for
|
| - // the case where Polymer is not using shadow roots for whatever reason:
|
| - if (!Polymer.Settings.useShadow) {
|
| - this._fireResize();
|
| - }
|
| - },
|
| + keyEventTarget.removeEventListener(eventName, boundKeyHandler);
|
| + }
|
| + },
|
|
|
| - _fireResize: function() {
|
| - this.fire('iron-resize', null, {
|
| - node: this,
|
| - bubbles: false
|
| - });
|
| - },
|
| + _onKeyBindingEvent: function(keyBindings, event) {
|
| + if (this.stopKeyboardEventPropagation) {
|
| + event.stopPropagation();
|
| + }
|
|
|
| - _onIronRequestResizeNotifications: function(event) {
|
| - var target = event.path ? event.path[0] : event.target;
|
| + // if event has been already prevented, don't do anything
|
| + if (event.defaultPrevented) {
|
| + return;
|
| + }
|
|
|
| - if (target === this) {
|
| - 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;
|
| + }
|
| + }
|
| + }
|
| + },
|
|
|
| - if (this._interestedResizables.indexOf(target) === -1) {
|
| - this._interestedResizables.push(target);
|
| - this.listen(target, 'iron-resize', '_onDescendantIronResize');
|
| + _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 = {
|
|
|
| - target.assignParentResizable(this);
|
| - this._notifyDescendant(target);
|
| -
|
| - event.stopPropagation();
|
| - },
|
| + properties: {
|
|
|
| - _parentResizableChanged: function(parentResizable) {
|
| - if (parentResizable) {
|
| - window.removeEventListener('resize', this._boundNotifyResize);
|
| + /**
|
| + * 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;
|
| + }
|
| }
|
| },
|
|
|
| - _notifyDescendant: function(descendant) {
|
| - // NOTE(cdata): In IE10, attached is fired on children first, so it's
|
| - // important not to notify them if the parent is not attached yet (or
|
| - // else they will get redundantly notified when the parent attaches).
|
| - if (!this.isAttached) {
|
| - return;
|
| + 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') {
|
|
|
| - this._notifyingDescendant = true;
|
| - descendant.notifyResize();
|
| - this._notifyingDescendant = false;
|
| - }
|
| - };
|
| -(function() {
|
| - 'use strict';
|
| + var host = this.domHost;
|
| + this.scrollTarget = host && host.$ ? host.$[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;
|
| + }
|
| + }
|
| + },
|
|
|
| /**
|
| - * Chrome uses an older version of DOM Level 3 Keyboard Events
|
| + * Runs on every scroll event. Consumer of this behavior may want to override this method.
|
| *
|
| - * 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
|
| + * @protected
|
| */
|
| - var KEY_IDENTIFIER = {
|
| - 'U+0008': 'backspace',
|
| - 'U+0009': 'tab',
|
| - 'U+001B': 'esc',
|
| - 'U+0020': 'space',
|
| - 'U+007F': 'del'
|
| - };
|
| + _scrollHandler: function scrollHandler() {},
|
|
|
| /**
|
| - * Special table for KeyboardEvent.keyCode.
|
| - * KeyboardEvent.keyIdentifier is better, and KeyBoardEvent.key is even better
|
| - * than that.
|
| + * The default scroll target. Consumers of this behavior may want to customize
|
| + * the default scroll target.
|
| *
|
| - * Values from: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent.keyCode#Value_of_keyCode
|
| + * @type {Element}
|
| */
|
| - 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: '*'
|
| - };
|
| + get _defaultScrollTarget() {
|
| + return this._doc;
|
| + },
|
|
|
| /**
|
| - * 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.
|
| + * Shortcut for the document element
|
| + *
|
| + * @type {Element}
|
| */
|
| - var MODIFIER_KEYS = {
|
| - 'shift': 'shiftKey',
|
| - 'ctrl': 'ctrlKey',
|
| - 'alt': 'altKey',
|
| - 'meta': 'metaKey'
|
| - };
|
| + get _doc() {
|
| + return this.ownerDocument.documentElement;
|
| + },
|
|
|
| /**
|
| - * KeyboardEvent.key is mostly represented by printable character made by
|
| - * the keyboard, with unprintable keys labeled nicely.
|
| + * Gets the number of pixels that the content of an element is scrolled upward.
|
| *
|
| - * 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.
|
| + * @type {number}
|
| */
|
| - var KEY_CHAR = /[a-z0-9*]/;
|
| + get _scrollTop() {
|
| + if (this._isValidScrollTarget()) {
|
| + return this.scrollTarget === this._doc ? window.pageYOffset : this.scrollTarget.scrollTop;
|
| + }
|
| + return 0;
|
| + },
|
|
|
| /**
|
| - * Matches a keyIdentifier string.
|
| + * Gets the number of pixels that the content of an element is scrolled to the left.
|
| + *
|
| + * @type {number}
|
| */
|
| - var IDENT_CHAR = /U\+/;
|
| + get _scrollLeft() {
|
| + if (this._isValidScrollTarget()) {
|
| + return this.scrollTarget === this._doc ? window.pageXOffset : this.scrollTarget.scrollLeft;
|
| + }
|
| + return 0;
|
| + },
|
|
|
| /**
|
| - * Matches arrow keys in Gecko 27.0+
|
| + * Sets the number of pixels that the content of an element is scrolled upward.
|
| + *
|
| + * @type {number}
|
| */
|
| - var ARROW_KEY = /^arrow/;
|
| + set _scrollTop(top) {
|
| + if (this.scrollTarget === this._doc) {
|
| + window.scrollTo(window.pageXOffset, top);
|
| + } else if (this._isValidScrollTarget()) {
|
| + this.scrollTarget.scrollTop = top;
|
| + }
|
| + },
|
|
|
| /**
|
| - * Matches space keys everywhere (notably including IE10's exceptional name
|
| - * `spacebar`).
|
| + * Sets the number of pixels that the content of an element is scrolled to the left.
|
| + *
|
| + * @type {number}
|
| */
|
| - var SPACE_KEY = /^space(bar)?/;
|
| + set _scrollLeft(left) {
|
| + if (this.scrollTarget === this._doc) {
|
| + window.scrollTo(left, window.pageYOffset);
|
| + } else if (this._isValidScrollTarget()) {
|
| + this.scrollTarget.scrollLeft = left;
|
| + }
|
| + },
|
|
|
| /**
|
| - * Transforms the key.
|
| - * @param {string} key The KeyBoardEvent.key
|
| - * @param {Boolean} [noSpecialChars] Limits the transformation to
|
| - * alpha-numeric characters.
|
| + * Scrolls the content to a particular place.
|
| + *
|
| + * @method scroll
|
| + * @param {number} left The left position
|
| + * @param {number} top The top position
|
| */
|
| - 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;
|
| - }
|
| -
|
| - 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;
|
| - }
|
| -
|
| - 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];
|
| - }
|
| + scroll: function(left, top) {
|
| + if (this.scrollTarget === this._doc) {
|
| + window.scrollTo(left, top);
|
| + } else if (this._isValidScrollTarget()) {
|
| + this.scrollTarget.scrollLeft = left;
|
| + this.scrollTarget.scrollTop = top;
|
| }
|
| - 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
|
| + * Gets the width of the scroll target.
|
| + *
|
| + * @type {number}
|
| */
|
| - 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) || '';
|
| - }
|
| -
|
| - 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)
|
| - );
|
| - }
|
| -
|
| - function parseKeyComboString(keyComboString) {
|
| - if (keyComboString.length === 1) {
|
| - return {
|
| - combo: keyComboString,
|
| - key: keyComboString,
|
| - event: 'keydown'
|
| - };
|
| + get _scrollTargetWidth() {
|
| + if (this._isValidScrollTarget()) {
|
| + return this.scrollTarget === this._doc ? window.innerWidth : this.scrollTarget.offsetWidth;
|
| }
|
| - return keyComboString.split('+').reduce(function(parsedKeyCombo, keyComboPart) {
|
| - var eventParts = keyComboPart.split(':');
|
| - var keyName = eventParts[0];
|
| - var event = eventParts[1];
|
| -
|
| - if (keyName in MODIFIER_KEYS) {
|
| - parsedKeyCombo[MODIFIER_KEYS[keyName]] = true;
|
| - parsedKeyCombo.hasModifiers = true;
|
| - } else {
|
| - parsedKeyCombo.key = keyName;
|
| - parsedKeyCombo.event = event || 'keydown';
|
| - }
|
| -
|
| - return parsedKeyCombo;
|
| - }, {
|
| - combo: keyComboString.split(':').shift()
|
| - });
|
| - }
|
| + return 0;
|
| + },
|
|
|
| - function parseEventString(eventString) {
|
| - return eventString.trim().split(' ').map(function(keyComboString) {
|
| - return parseKeyComboString(keyComboString);
|
| - });
|
| - }
|
| + /**
|
| + * Gets the height of the scroll target.
|
| + *
|
| + * @type {number}
|
| + */
|
| + get _scrollTargetHeight() {
|
| + if (this._isValidScrollTarget()) {
|
| + return this.scrollTarget === this._doc ? window.innerHeight : this.scrollTarget.offsetHeight;
|
| + }
|
| + return 0;
|
| + },
|
|
|
| /**
|
| - * `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.
|
| + * Returns true if the scroll target is a valid HTMLElement.
|
| *
|
| - * @demo demo/index.html
|
| - * @polymerBehavior
|
| + * @return {boolean}
|
| */
|
| - Polymer.IronA11yKeysBehavior = {
|
| - properties: {
|
| - /**
|
| - * The HTMLElement that will be firing relevant KeyboardEvents.
|
| - */
|
| - keyEventTarget: {
|
| - type: Object,
|
| - value: function() {
|
| - return this;
|
| - }
|
| - },
|
| -
|
| - /**
|
| - * If true, this property will cause the implementing element to
|
| - * automatically stop propagation on any handled KeyboardEvents.
|
| - */
|
| - stopKeyboardEventPropagation: {
|
| - type: Boolean,
|
| - value: false
|
| - },
|
| + _isValidScrollTarget: function() {
|
| + return this.scrollTarget instanceof HTMLElement;
|
| + }
|
| + };
|
| +(function() {
|
|
|
| - _boundKeyHandlers: {
|
| - type: Array,
|
| - value: function() {
|
| - return [];
|
| - }
|
| - },
|
| + 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';
|
|
|
| - // 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 {};
|
| - }
|
| - }
|
| - },
|
| + Polymer({
|
|
|
| - observers: [
|
| - '_resetKeyEventListeners(keyEventTarget, _boundKeyHandlers)'
|
| - ],
|
| + is: 'iron-list',
|
|
|
| - keyBindings: {},
|
| + properties: {
|
|
|
| - registered: function() {
|
| - this._prepKeyBindings();
|
| + /**
|
| + * 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
|
| },
|
|
|
| - attached: function() {
|
| - this._listenKeyEventListeners();
|
| + /**
|
| + * 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'
|
| },
|
|
|
| - detached: function() {
|
| - this._unlistenKeyEventListeners();
|
| + /**
|
| + * The name of the variable to add to the binding scope with the index
|
| + * for the row.
|
| + */
|
| + indexAs: {
|
| + type: String,
|
| + value: 'index'
|
| },
|
|
|
| /**
|
| - * 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.
|
| + * The name of the variable to add to the binding scope to indicate
|
| + * if the row is selected.
|
| */
|
| - addOwnKeyBinding: function(eventString, handlerName) {
|
| - this._imperativeKeyBindings[eventString] = handlerName;
|
| - this._prepKeyBindings();
|
| - this._resetKeyEventListeners();
|
| + selectedAs: {
|
| + type: String,
|
| + value: 'selected'
|
| },
|
|
|
| /**
|
| - * When called, will remove all imperatively-added key bindings.
|
| + * 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.
|
| */
|
| - removeOwnKeyBindings: function() {
|
| - this._imperativeKeyBindings = {};
|
| - this._prepKeyBindings();
|
| - this._resetKeyEventListeners();
|
| + selectionEnabled: {
|
| + type: Boolean,
|
| + value: false
|
| },
|
|
|
| - 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;
|
| + /**
|
| + * When `multiSelection` is false, this is the currently selected item, or `null`
|
| + * if no item is selected.
|
| + */
|
| + selectedItem: {
|
| + type: Object,
|
| + notify: true
|
| },
|
|
|
| - _collectKeyBindings: function() {
|
| - var keyBindings = this.behaviors.map(function(behavior) {
|
| - return behavior.keyBindings;
|
| - });
|
| -
|
| - if (keyBindings.indexOf(this.keyBindings) === -1) {
|
| - keyBindings.push(this.keyBindings);
|
| - }
|
| -
|
| - return keyBindings;
|
| + /**
|
| + * When `multiSelection` is true, this is an array that contains the selected items.
|
| + */
|
| + selectedItems: {
|
| + type: Object,
|
| + notify: true
|
| },
|
|
|
| - _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]);
|
| - }
|
| + /**
|
| + * 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
|
| + }
|
| + },
|
|
|
| - // 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;
|
| - })
|
| - }
|
| - },
|
| + observers: [
|
| + '_itemsChanged(items.*)',
|
| + '_selectionEnabledChanged(selectionEnabled)',
|
| + '_multiSelectionChanged(multiSelection)',
|
| + '_setOverflow(scrollTarget)'
|
| + ],
|
|
|
| - _addKeyBinding: function(eventString, handlerName) {
|
| - parseEventString(eventString).forEach(function(keyCombo) {
|
| - this._keyBindings[keyCombo.event] =
|
| - this._keyBindings[keyCombo.event] || [];
|
| + behaviors: [
|
| + Polymer.Templatizer,
|
| + Polymer.IronResizableBehavior,
|
| + Polymer.IronA11yKeysBehavior,
|
| + Polymer.IronScrollTargetBehavior
|
| + ],
|
|
|
| - this._keyBindings[keyCombo.event].push([
|
| - keyCombo,
|
| - handlerName
|
| - ]);
|
| - }, this);
|
| - },
|
| + listeners: {
|
| + 'iron-resize': '_resizeHandler'
|
| + },
|
|
|
| - _resetKeyEventListeners: function() {
|
| - this._unlistenKeyEventListeners();
|
| + keyBindings: {
|
| + 'up': '_didMoveUp',
|
| + 'down': '_didMoveDown',
|
| + 'enter': '_didEnter'
|
| + },
|
|
|
| - if (this.isAttached) {
|
| - this._listenKeyEventListeners();
|
| - }
|
| - },
|
| + /**
|
| + * 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,
|
|
|
| - _listenKeyEventListeners: function() {
|
| - Object.keys(this._keyBindings).forEach(function(eventName) {
|
| - var keyBindings = this._keyBindings[eventName];
|
| - var boundKeyHandler = this._onKeyBindingEvent.bind(this, keyBindings);
|
| + /**
|
| + * The padding-top value for the list.
|
| + */
|
| + _scrollerPaddingTop: 0,
|
|
|
| - this._boundKeyHandlers.push([this.keyEventTarget, eventName, boundKeyHandler]);
|
| + /**
|
| + * This value is the same as `scrollTop`.
|
| + */
|
| + _scrollPosition: 0,
|
|
|
| - this.keyEventTarget.addEventListener(eventName, boundKeyHandler);
|
| - }, this);
|
| - },
|
| + /**
|
| + * The sum of the heights of all the tiles in the DOM.
|
| + */
|
| + _physicalSize: 0,
|
|
|
| - _unlistenKeyEventListeners: function() {
|
| - var keyHandlerTuple;
|
| - var keyEventTarget;
|
| - var eventName;
|
| - var boundKeyHandler;
|
| + /**
|
| + * The average `F` of the tiles observed till now.
|
| + */
|
| + _physicalAverage: 0,
|
|
|
| - 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];
|
| + /**
|
| + * The number of tiles which `offsetHeight` > 0 observed until now.
|
| + */
|
| + _physicalAverageCount: 0,
|
|
|
| - keyEventTarget.removeEventListener(eventName, boundKeyHandler);
|
| - }
|
| - },
|
| + /**
|
| + * The Y position of the item rendered in the `_physicalStart`
|
| + * tile relative to the scrolling list.
|
| + */
|
| + _physicalTop: 0,
|
|
|
| - _onKeyBindingEvent: function(keyBindings, event) {
|
| - if (this.stopKeyboardEventPropagation) {
|
| - event.stopPropagation();
|
| - }
|
| + /**
|
| + * The number of items in the list.
|
| + */
|
| + _virtualCount: 0,
|
|
|
| - // if event has been already prevented, don't do anything
|
| - if (event.defaultPrevented) {
|
| - return;
|
| - }
|
| + /**
|
| + * A map between an item key and its physical item index
|
| + */
|
| + _physicalIndexForKey: null,
|
|
|
| - 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;
|
| - }
|
| - }
|
| - }
|
| - },
|
| + /**
|
| + * The estimated scroll height based on `_physicalAverage`
|
| + */
|
| + _estScrollHeight: 0,
|
|
|
| - _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 = {
|
| + /**
|
| + * The scroll height of the dom node
|
| + */
|
| + _scrollHeight: 0,
|
|
|
| - properties: {
|
| + /**
|
| + * The height of the list. This is referred as the viewport in the context of list.
|
| + */
|
| + _viewportSize: 0,
|
|
|
| - /**
|
| - * 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;
|
| - }
|
| - }
|
| - },
|
| + /**
|
| + * An array of DOM nodes that are currently in the tree
|
| + * @type {?Array<!TemplatizerNode>}
|
| + */
|
| + _physicalItems: null,
|
|
|
| - observers: [
|
| - '_scrollTargetChanged(scrollTarget, isAttached)'
|
| - ],
|
| + /**
|
| + * An array of heights for each item in `_physicalItems`
|
| + * @type {?Array<number>}
|
| + */
|
| + _physicalSizes: null,
|
|
|
| - _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') {
|
| + /**
|
| + * A cached value for the first visible index.
|
| + * See `firstVisibleIndex`
|
| + * @type {?number}
|
| + */
|
| + _firstVisibleIndexVal: null,
|
|
|
| - var host = this.domHost;
|
| - this.scrollTarget = host && host.$ ? host.$[scrollTarget] :
|
| - Polymer.dom(this.ownerDocument).querySelector('#' + scrollTarget);
|
| + /**
|
| + * A cached value for the last visible index.
|
| + * See `lastVisibleIndex`
|
| + * @type {?number}
|
| + */
|
| + _lastVisibleIndexVal: null,
|
|
|
| - } else if (this._scrollHandler) {
|
| + /**
|
| + * A Polymer collection for the items.
|
| + * @type {?Polymer.Collection}
|
| + */
|
| + _collection: null,
|
|
|
| - 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;
|
| - }
|
| - }
|
| - },
|
| + /**
|
| + * True if the current item list was rendered for the first time
|
| + * after attached.
|
| + */
|
| + _itemsRendered: false,
|
|
|
| /**
|
| - * Runs on every scroll event. Consumer of this behavior may want to override this method.
|
| - *
|
| - * @protected
|
| + * The page that is currently rendered.
|
| */
|
| - _scrollHandler: function scrollHandler() {},
|
| + _lastPage: null,
|
|
|
| /**
|
| - * The default scroll target. Consumers of this behavior may want to customize
|
| - * the default scroll target.
|
| - *
|
| - * @type {Element}
|
| + * The max number of pages to render. One page is equivalent to the height of the list.
|
| */
|
| - get _defaultScrollTarget() {
|
| - return this._doc;
|
| - },
|
| + _maxPages: 3,
|
|
|
| /**
|
| - * Shortcut for the document element
|
| - *
|
| - * @type {Element}
|
| + * The currently focused physical item.
|
| */
|
| - get _doc() {
|
| - return this.ownerDocument.documentElement;
|
| - },
|
| + _focusedItem: null,
|
|
|
| /**
|
| - * Gets the number of pixels that the content of an element is scrolled upward.
|
| - *
|
| - * @type {number}
|
| + * The index of the `_focusedItem`.
|
| */
|
| - get _scrollTop() {
|
| - if (this._isValidScrollTarget()) {
|
| - return this.scrollTarget === this._doc ? window.pageYOffset : this.scrollTarget.scrollTop;
|
| - }
|
| - return 0;
|
| - },
|
| + _focusedIndex: -1,
|
|
|
| /**
|
| - * Gets the number of pixels that the content of an element is scrolled to the left.
|
| - *
|
| - * @type {number}
|
| + * The the item that is focused if it is moved offscreen.
|
| + * @private {?TemplatizerNode}
|
| */
|
| - get _scrollLeft() {
|
| - if (this._isValidScrollTarget()) {
|
| - return this.scrollTarget === this._doc ? window.pageXOffset : this.scrollTarget.scrollLeft;
|
| - }
|
| - return 0;
|
| - },
|
| + _offscreenFocusedItem: null,
|
|
|
| /**
|
| - * Sets the number of pixels that the content of an element is scrolled upward.
|
| - *
|
| - * @type {number}
|
| + * The item that backfills the `_offscreenFocusedItem` in the physical items
|
| + * list when that item is moved offscreen.
|
| */
|
| - set _scrollTop(top) {
|
| - if (this.scrollTarget === this._doc) {
|
| - window.scrollTo(window.pageXOffset, top);
|
| - } else if (this._isValidScrollTarget()) {
|
| - this.scrollTarget.scrollTop = top;
|
| - }
|
| - },
|
| + _focusBackfillItem: null,
|
|
|
| /**
|
| - * Sets the number of pixels that the content of an element is scrolled to the left.
|
| - *
|
| - * @type {number}
|
| + * The bottom of the physical content.
|
| */
|
| - set _scrollLeft(left) {
|
| - if (this.scrollTarget === this._doc) {
|
| - window.scrollTo(left, window.pageYOffset);
|
| - } else if (this._isValidScrollTarget()) {
|
| - this.scrollTarget.scrollLeft = left;
|
| - }
|
| + get _physicalBottom() {
|
| + return this._physicalTop + this._physicalSize;
|
| + },
|
| +
|
| + /**
|
| + * The bottom of the scroll.
|
| + */
|
| + get _scrollBottom() {
|
| + return this._scrollPosition + this._viewportSize;
|
| },
|
|
|
| /**
|
| - * Scrolls the content to a particular place.
|
| - *
|
| - * @method scroll
|
| - * @param {number} left The left position
|
| - * @param {number} top The top position
|
| + * The n-th item rendered in the last physical item.
|
| */
|
| - scroll: function(left, top) {
|
| - if (this.scrollTarget === this._doc) {
|
| - window.scrollTo(left, top);
|
| - } else if (this._isValidScrollTarget()) {
|
| - this.scrollTarget.scrollLeft = left;
|
| - this.scrollTarget.scrollTop = top;
|
| - }
|
| + get _virtualEnd() {
|
| + return this._virtualStart + this._physicalCount - 1;
|
| },
|
|
|
| /**
|
| - * Gets the width of the scroll target.
|
| - *
|
| - * @type {number}
|
| + * The height of the physical content that isn't on the screen.
|
| */
|
| - get _scrollTargetWidth() {
|
| - if (this._isValidScrollTarget()) {
|
| - return this.scrollTarget === this._doc ? window.innerWidth : this.scrollTarget.offsetWidth;
|
| - }
|
| - return 0;
|
| + get _hiddenContentSize() {
|
| + return this._physicalSize - this._viewportSize;
|
| },
|
|
|
| /**
|
| - * Gets the height of the scroll target.
|
| - *
|
| - * @type {number}
|
| + * The maximum scroll top value.
|
| */
|
| - get _scrollTargetHeight() {
|
| - if (this._isValidScrollTarget()) {
|
| - return this.scrollTarget === this._doc ? window.innerHeight : this.scrollTarget.offsetHeight;
|
| - }
|
| - return 0;
|
| + get _maxScrollTop() {
|
| + return this._estScrollHeight - this._viewportSize + this._scrollerPaddingTop;
|
| },
|
|
|
| /**
|
| - * Returns true if the scroll target is a valid HTMLElement.
|
| - *
|
| - * @return {boolean}
|
| + * The lowest n-th value for an item such that it can be rendered in `_physicalStart`.
|
| */
|
| - _isValidScrollTarget: function() {
|
| - return this.scrollTarget instanceof HTMLElement;
|
| - }
|
| - };
|
| -(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;
|
| - var HIDDEN_Y = '-10000px';
|
| -
|
| - 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'
|
| - },
|
| + _minVirtualStart: 0,
|
|
|
| - /**
|
| - * The name of the variable to add to the binding scope with the index
|
| - * for the row.
|
| - */
|
| - indexAs: {
|
| - type: String,
|
| - value: 'index'
|
| - },
|
| + /**
|
| + * 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);
|
| + },
|
|
|
| - /**
|
| - * The name of the variable to add to the binding scope to indicate
|
| - * if the row is selected.
|
| - */
|
| - selectedAs: {
|
| - type: String,
|
| - value: 'selected'
|
| - },
|
| + /**
|
| + * The n-th item rendered in the `_physicalStart` tile.
|
| + */
|
| + _virtualStartVal: 0,
|
|
|
| - /**
|
| - * 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
|
| - },
|
| + set _virtualStart(val) {
|
| + this._virtualStartVal = Math.min(this._maxVirtualStart, Math.max(this._minVirtualStart, val));
|
| + },
|
|
|
| - /**
|
| - * When `multiSelection` is false, this is the currently selected item, or `null`
|
| - * if no item is selected.
|
| - */
|
| - selectedItem: {
|
| - type: Object,
|
| - notify: true
|
| - },
|
| + get _virtualStart() {
|
| + return this._virtualStartVal || 0;
|
| + },
|
|
|
| - /**
|
| - * When `multiSelection` is true, this is an array that contains the selected items.
|
| - */
|
| - selectedItems: {
|
| - type: Object,
|
| - notify: true
|
| - },
|
| + /**
|
| + * The k-th tile that is at the top of the scrolling list.
|
| + */
|
| + _physicalStartVal: 0,
|
|
|
| - /**
|
| - * 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
|
| + set _physicalStart(val) {
|
| + this._physicalStartVal = val % this._physicalCount;
|
| + if (this._physicalStartVal < 0) {
|
| + this._physicalStartVal = this._physicalCount + this._physicalStartVal;
|
| }
|
| + this._physicalEnd = (this._physicalStart + this._physicalCount - 1) % this._physicalCount;
|
| },
|
|
|
| - observers: [
|
| - '_itemsChanged(items.*)',
|
| - '_selectionEnabledChanged(selectionEnabled)',
|
| - '_multiSelectionChanged(multiSelection)',
|
| - '_setOverflow(scrollTarget)'
|
| - ],
|
| + get _physicalStart() {
|
| + return this._physicalStartVal || 0;
|
| + },
|
|
|
| - behaviors: [
|
| - Polymer.Templatizer,
|
| - Polymer.IronResizableBehavior,
|
| - Polymer.IronA11yKeysBehavior,
|
| - Polymer.IronScrollTargetBehavior
|
| - ],
|
| + /**
|
| + * The number of tiles in the DOM.
|
| + */
|
| + _physicalCountVal: 0,
|
|
|
| - listeners: {
|
| - 'iron-resize': '_resizeHandler'
|
| + set _physicalCount(val) {
|
| + this._physicalCountVal = val;
|
| + this._physicalEnd = (this._physicalStart + this._physicalCount - 1) % this._physicalCount;
|
| },
|
|
|
| - keyBindings: {
|
| - 'up': '_didMoveUp',
|
| - 'down': '_didMoveDown',
|
| - 'enter': '_didEnter'
|
| + get _physicalCount() {
|
| + return this._physicalCountVal;
|
| },
|
|
|
| /**
|
| - * 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.
|
| + * The k-th tile that is at the bottom of the scrolling list.
|
| */
|
| - _ratio: 0.5,
|
| + _physicalEnd: 0,
|
|
|
| /**
|
| - * The padding-top value for the list.
|
| + * An optimal physical size such that we will have enough physical items
|
| + * to fill up the viewport and recycle when the user scrolls.
|
| + *
|
| + * 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.
|
| */
|
| - _scrollerPaddingTop: 0,
|
| + get _optPhysicalSize() {
|
| + return this._viewportSize * this._maxPages;
|
| + },
|
|
|
| - /**
|
| - * This value is the same as `scrollTop`.
|
| - */
|
| - _scrollPosition: 0,
|
| + /**
|
| + * True if the current list is visible.
|
| + */
|
| + get _isVisible() {
|
| + return this.scrollTarget && Boolean(this.scrollTarget.offsetWidth || this.scrollTarget.offsetHeight);
|
| + },
|
|
|
| /**
|
| - * The sum of the heights of all the tiles in the DOM.
|
| + * Gets the index of the first visible item in the viewport.
|
| + *
|
| + * @type {number}
|
| */
|
| - _physicalSize: 0,
|
| + get firstVisibleIndex() {
|
| + if (this._firstVisibleIndexVal === null) {
|
| + var physicalOffset = this._physicalTop + this._scrollerPaddingTop;
|
| +
|
| + this._firstVisibleIndexVal = this._iterateItems(
|
| + function(pidx, vidx) {
|
| + physicalOffset += this._physicalSizes[pidx];
|
| + if (physicalOffset > this._scrollPosition) {
|
| + return vidx;
|
| + }
|
| + }) || 0;
|
| + }
|
| + return this._firstVisibleIndexVal;
|
| + },
|
|
|
| /**
|
| - * The average `F` of the tiles observed till now.
|
| + * Gets the index of the last visible item in the viewport.
|
| + *
|
| + * @type {number}
|
| */
|
| - _physicalAverage: 0,
|
| + get lastVisibleIndex() {
|
| + if (this._lastVisibleIndexVal === null) {
|
| + var physicalOffset = this._physicalTop;
|
| +
|
| + this._iterateItems(function(pidx, vidx) {
|
| + physicalOffset += this._physicalSizes[pidx];
|
| +
|
| + if (physicalOffset <= this._scrollBottom) {
|
| + this._lastVisibleIndexVal = vidx;
|
| + }
|
| + });
|
| + }
|
| + return this._lastVisibleIndexVal;
|
| + },
|
| +
|
| + get _defaultScrollTarget() {
|
| + return this;
|
| + },
|
| +
|
| + ready: function() {
|
| + this.addEventListener('focus', this._didFocus.bind(this), true);
|
| + },
|
| +
|
| + attached: function() {
|
| + this.updateViewportBoundaries();
|
| + this._render();
|
| + },
|
|
|
| - /**
|
| - * The number of tiles which `offsetHeight` > 0 observed until now.
|
| - */
|
| - _physicalAverageCount: 0,
|
| + detached: function() {
|
| + this._itemsRendered = false;
|
| + },
|
|
|
| /**
|
| - * The Y position of the item rendered in the `_physicalStart`
|
| - * tile relative to the scrolling list.
|
| + * Set the overflow property if this element has its own scrolling region
|
| */
|
| - _physicalTop: 0,
|
| + _setOverflow: function(scrollTarget) {
|
| + this.style.webkitOverflowScrolling = scrollTarget === this ? 'touch' : '';
|
| + this.style.overflow = scrollTarget === this ? 'auto' : '';
|
| + },
|
|
|
| /**
|
| - * The number of items in the list.
|
| + * Invoke this method if you dynamically update the viewport's
|
| + * size or CSS padding.
|
| + *
|
| + * @method updateViewportBoundaries
|
| */
|
| - _virtualCount: 0,
|
| + updateViewportBoundaries: function() {
|
| + this._scrollerPaddingTop = this.scrollTarget === this ? 0 :
|
| + parseInt(window.getComputedStyle(this)['padding-top'], 10);
|
|
|
| - /**
|
| - * A map between an item key and its physical item index
|
| - */
|
| - _physicalIndexForKey: null,
|
| + this._viewportSize = this._scrollTargetHeight;
|
| + },
|
|
|
| /**
|
| - * The estimated scroll height based on `_physicalAverage`
|
| + * Update the models, the position of the
|
| + * items in the viewport and recycle tiles as needed.
|
| */
|
| - _estScrollHeight: 0,
|
| + _scrollHandler: function() {
|
| + // clamp the `scrollTop` value
|
| + var scrollTop = Math.max(0, Math.min(this._maxScrollTop, this._scrollTop));
|
| + var delta = scrollTop - this._scrollPosition;
|
| + var tileHeight, tileTop, kth, recycledTileSet, scrollBottom, physicalBottom;
|
| + var ratio = this._ratio;
|
| + var recycledTiles = 0;
|
| + var hiddenContentSize = this._hiddenContentSize;
|
| + var currentRatio = ratio;
|
| + var movingUp = [];
|
|
|
| - /**
|
| - * The scroll height of the dom node
|
| - */
|
| - _scrollHeight: 0,
|
| + // track the last `scrollTop`
|
| + this._scrollPosition = scrollTop;
|
|
|
| - /**
|
| - * The height of the list. This is referred as the viewport in the context of list.
|
| - */
|
| - _viewportSize: 0,
|
| + // clear cached visible indexes
|
| + this._firstVisibleIndexVal = null;
|
| + this._lastVisibleIndexVal = null;
|
|
|
| - /**
|
| - * An array of DOM nodes that are currently in the tree
|
| - * @type {?Array<!TemplatizerNode>}
|
| - */
|
| - _physicalItems: null,
|
| + scrollBottom = this._scrollBottom;
|
| + physicalBottom = this._physicalBottom;
|
|
|
| - /**
|
| - * An array of heights for each item in `_physicalItems`
|
| - * @type {?Array<number>}
|
| - */
|
| - _physicalSizes: null,
|
| + // 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;
|
|
|
| - /**
|
| - * A cached value for the first visible index.
|
| - * See `firstVisibleIndex`
|
| - * @type {?number}
|
| - */
|
| - _firstVisibleIndexVal: null,
|
| + recycledTileSet = [];
|
|
|
| - /**
|
| - * A cached value for the last visible index.
|
| - * See `lastVisibleIndex`
|
| - * @type {?number}
|
| - */
|
| - _lastVisibleIndexVal: null,
|
| + kth = this._physicalEnd;
|
| + currentRatio = topSpace / hiddenContentSize;
|
|
|
| - /**
|
| - * A Polymer collection for the items.
|
| - * @type {?Polymer.Collection}
|
| - */
|
| - _collection: null,
|
| + // 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
|
| + ) {
|
|
|
| - /**
|
| - * True if the current item list was rendered for the first time
|
| - * after attached.
|
| - */
|
| - _itemsRendered: false,
|
| + tileHeight = this._physicalSizes[kth];
|
| + currentRatio += tileHeight / hiddenContentSize;
|
| + physicalBottom -= tileHeight;
|
| + recycledTileSet.push(kth);
|
| + recycledTiles++;
|
| + kth = (kth === 0) ? this._physicalCount - 1 : kth - 1;
|
| + }
|
|
|
| - /**
|
| - * The page that is currently rendered.
|
| - */
|
| - _lastPage: null,
|
| + movingUp = recycledTileSet;
|
| + recycledTiles = -recycledTiles;
|
| + }
|
| + // scroll down
|
| + else if (delta > 0) {
|
| + var bottomSpace = physicalBottom - scrollBottom;
|
| + var virtualEnd = this._virtualEnd;
|
| + var lastVirtualItemIndex = this._virtualCount-1;
|
|
|
| - /**
|
| - * The max number of pages to render. One page is equivalent to the height of the list.
|
| - */
|
| - _maxPages: 3,
|
| + recycledTileSet = [];
|
|
|
| - /**
|
| - * The currently focused physical item.
|
| - */
|
| - _focusedItem: null,
|
| + kth = this._physicalStart;
|
| + currentRatio = bottomSpace / hiddenContentSize;
|
|
|
| - /**
|
| - * The index of the `_focusedItem`.
|
| - */
|
| - _focusedIndex: -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
|
| + ) {
|
|
|
| - /**
|
| - * The the item that is focused if it is moved offscreen.
|
| - * @private {?TemplatizerNode}
|
| - */
|
| - _offscreenFocusedItem: null,
|
| + tileHeight = this._physicalSizes[kth];
|
| + currentRatio += tileHeight / hiddenContentSize;
|
|
|
| - /**
|
| - * The item that backfills the `_offscreenFocusedItem` in the physical items
|
| - * list when that item is moved offscreen.
|
| - */
|
| - _focusBackfillItem: null,
|
| + this._physicalTop += tileHeight;
|
| + recycledTileSet.push(kth);
|
| + recycledTiles++;
|
| + kth = (kth + 1) % this._physicalCount;
|
| + }
|
| + }
|
|
|
| - /**
|
| - * The bottom of the physical content.
|
| - */
|
| - get _physicalBottom() {
|
| - return this._physicalTop + this._physicalSize;
|
| + 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._physicalStart = this._physicalStart + recycledTiles;
|
| + this._update(recycledTileSet, movingUp);
|
| + }
|
| },
|
|
|
| /**
|
| - * The bottom of the scroll.
|
| + * Update the list of items, starting from the `_virtualStart` item.
|
| + * @param {!Array<number>=} itemSet
|
| + * @param {!Array<number>=} movingUp
|
| */
|
| - get _scrollBottom() {
|
| - return this._scrollPosition + this._viewportSize;
|
| + _update: function(itemSet, movingUp) {
|
| + // manage focus
|
| + this._manageFocus();
|
| + // 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();
|
| },
|
|
|
| /**
|
| - * The n-th item rendered in the last physical item.
|
| + * Creates a pool of DOM elements and attaches them to the local dom.
|
| */
|
| - get _virtualEnd() {
|
| - return this._virtualStart + this._physicalCount - 1;
|
| - },
|
| + _createPool: function(size) {
|
| + var physicalItems = new Array(size);
|
|
|
| - /**
|
| - * The height of the physical content that isn't on the screen.
|
| - */
|
| - get _hiddenContentSize() {
|
| - return this._physicalSize - this._viewportSize;
|
| + this._ensureTemplatized();
|
| +
|
| + 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 physicalItems;
|
| },
|
|
|
| /**
|
| - * The maximum scroll top value.
|
| + * Increases the pool of physical items only if needed.
|
| + * This function will allocate additional physical items
|
| + * if the physical size is shorter than `_optPhysicalSize`
|
| */
|
| - get _maxScrollTop() {
|
| - return this._estScrollHeight - this._viewportSize + this._scrollerPaddingTop;
|
| + _increasePoolIfNeeded: function() {
|
| + if (this._viewportSize === 0 || this._physicalSize >= this._optPhysicalSize) {
|
| + return false;
|
| + }
|
| + // 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;
|
| },
|
|
|
| /**
|
| - * The lowest n-th value for an item such that it can be rendered in `_physicalStart`.
|
| + * Increases the pool size.
|
| */
|
| - _minVirtualStart: 0,
|
| + _increasePool: function(missingItems) {
|
| + var nextPhysicalCount = Math.min(
|
| + this._physicalCount + missingItems,
|
| + this._virtualCount - this._virtualStart,
|
| + MAX_PHYSICAL_COUNT
|
| + );
|
| + var prevPhysicalCount = this._physicalCount;
|
| + var delta = nextPhysicalCount - prevPhysicalCount;
|
|
|
| - /**
|
| - * 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);
|
| - },
|
| + if (delta <= 0) {
|
| + return;
|
| + }
|
|
|
| - /**
|
| - * The n-th item rendered in the `_physicalStart` tile.
|
| - */
|
| - _virtualStartVal: 0,
|
| + [].push.apply(this._physicalItems, this._createPool(delta));
|
| + [].push.apply(this._physicalSizes, new Array(delta));
|
|
|
| - set _virtualStart(val) {
|
| - this._virtualStartVal = Math.min(this._maxVirtualStart, Math.max(this._minVirtualStart, val));
|
| - },
|
| + this._physicalCount = prevPhysicalCount + delta;
|
|
|
| - get _virtualStart() {
|
| - return this._virtualStartVal || 0;
|
| + // update the physical start if we need to preserve the model of the focused item.
|
| + // In this situation, the focused item is currently rendered and its model would
|
| + // have changed after increasing the pool if the physical start remained unchanged.
|
| + if (this._physicalStart > this._physicalEnd &&
|
| + this._isIndexRendered(this._focusedIndex) &&
|
| + this._getPhysicalIndex(this._focusedIndex) < this._physicalEnd) {
|
| + this._physicalStart = this._physicalStart + delta;
|
| + }
|
| + this._update();
|
| },
|
|
|
| /**
|
| - * The k-th tile that is at the top of the scrolling list.
|
| + * 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.
|
| */
|
| - _physicalStartVal: 0,
|
| + _render: function() {
|
| + var requiresUpdate = this._virtualCount > 0 || this._physicalCount > 0;
|
|
|
| - set _physicalStart(val) {
|
| - this._physicalStartVal = val % this._physicalCount;
|
| - if (this._physicalStartVal < 0) {
|
| - this._physicalStartVal = this._physicalCount + this._physicalStartVal;
|
| + if (this.isAttached && !this._itemsRendered && this._isVisible && requiresUpdate) {
|
| + this._lastPage = 0;
|
| + this._update();
|
| + this._scrollHandler();
|
| + this._itemsRendered = true;
|
| }
|
| - this._physicalEnd = (this._physicalStart + this._physicalCount - 1) % this._physicalCount;
|
| - },
|
| -
|
| - get _physicalStart() {
|
| - return this._physicalStartVal || 0;
|
| },
|
|
|
| /**
|
| - * The number of tiles in the DOM.
|
| + * Templetizes the user template.
|
| */
|
| - _physicalCountVal: 0,
|
| + _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;
|
|
|
| - set _physicalCount(val) {
|
| - this._physicalCountVal = val;
|
| - this._physicalEnd = (this._physicalStart + this._physicalCount - 1) % this._physicalCount;
|
| - },
|
| + this._instanceProps = props;
|
| + this._userTemplate = Polymer.dom(this).querySelector('template');
|
|
|
| - get _physicalCount() {
|
| - return this._physicalCountVal;
|
| + if (this._userTemplate) {
|
| + this.templatize(this._userTemplate);
|
| + } else {
|
| + console.warn('iron-list requires a template to be provided in light-dom');
|
| + }
|
| + }
|
| },
|
|
|
| /**
|
| - * The k-th tile that is at the bottom of the scrolling list.
|
| - */
|
| - _physicalEnd: 0,
|
| -
|
| - /**
|
| - * An optimal physical size such that we will have enough physical items
|
| - * to fill up the viewport and recycle when the user scrolls.
|
| - *
|
| - * 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.
|
| + * Implements extension point from Templatizer mixin.
|
| */
|
| - get _optPhysicalSize() {
|
| - return this._viewportSize * this._maxPages;
|
| - },
|
| -
|
| - /**
|
| - * True if the current list is visible.
|
| - */
|
| - get _isVisible() {
|
| - return this.scrollTarget && Boolean(this.scrollTarget.offsetWidth || this.scrollTarget.offsetHeight);
|
| + _getStampedChildren: function() {
|
| + return this._physicalItems;
|
| },
|
|
|
| /**
|
| - * Gets the index of the first visible item in the viewport.
|
| - *
|
| - * @type {number}
|
| + * 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.
|
| */
|
| - get firstVisibleIndex() {
|
| - if (this._firstVisibleIndexVal === null) {
|
| - var physicalOffset = this._physicalTop + this._scrollerPaddingTop;
|
| -
|
| - this._firstVisibleIndexVal = this._iterateItems(
|
| - function(pidx, vidx) {
|
| - physicalOffset += this._physicalSizes[pidx];
|
| - if (physicalOffset > this._scrollPosition) {
|
| - return vidx;
|
| - }
|
| - }) || 0;
|
| + _forwardInstancePath: function(inst, path, value) {
|
| + if (path.indexOf(this.as + '.') === 0) {
|
| + this.notifyPath('items.' + inst.__key__ + '.' +
|
| + path.slice(this.as.length + 1), value);
|
| }
|
| - return this._firstVisibleIndexVal;
|
| },
|
|
|
| /**
|
| - * Gets the index of the last visible item in the viewport.
|
| - *
|
| - * @type {number}
|
| + * Implements extension point from Templatizer mixin
|
| + * Called as side-effect of a host property change, responsible for
|
| + * notifying parent path change on each row.
|
| */
|
| - get lastVisibleIndex() {
|
| - if (this._lastVisibleIndexVal === null) {
|
| - var physicalOffset = this._physicalTop;
|
| -
|
| - this._iterateItems(function(pidx, vidx) {
|
| - physicalOffset += this._physicalSizes[pidx];
|
| -
|
| - if (physicalOffset <= this._scrollBottom) {
|
| - this._lastVisibleIndexVal = vidx;
|
| - }
|
| - });
|
| + _forwardParentProp: function(prop, value) {
|
| + if (this._physicalItems) {
|
| + this._physicalItems.forEach(function(item) {
|
| + item._templateInstance[prop] = value;
|
| + }, this);
|
| }
|
| - return this._lastVisibleIndexVal;
|
| - },
|
| -
|
| - get _defaultScrollTarget() {
|
| - return this;
|
| - },
|
| -
|
| - ready: function() {
|
| - this.addEventListener('focus', this._didFocus.bind(this), true);
|
| - },
|
| -
|
| - attached: function() {
|
| - this.updateViewportBoundaries();
|
| - this._render();
|
| - },
|
| -
|
| - detached: function() {
|
| - this._itemsRendered = false;
|
| },
|
|
|
| /**
|
| - * Set the overflow property if this element has its own scrolling region
|
| + * Implements extension point from Templatizer
|
| + * Called as side-effect of a host path change, responsible for
|
| + * notifying parent.<path> path change on each row.
|
| */
|
| - _setOverflow: function(scrollTarget) {
|
| - this.style.webkitOverflowScrolling = scrollTarget === this ? 'touch' : '';
|
| - this.style.overflow = scrollTarget === this ? 'auto' : '';
|
| + _forwardParentPath: function(path, value) {
|
| + if (this._physicalItems) {
|
| + this._physicalItems.forEach(function(item) {
|
| + item._templateInstance.notifyPath(path, value, true);
|
| + }, this);
|
| + }
|
| },
|
|
|
| /**
|
| - * Invoke this method if you dynamically update the viewport's
|
| - * size or CSS padding.
|
| - *
|
| - * @method updateViewportBoundaries
|
| + * Called as a side effect of a host items.<key>.<path> path change,
|
| + * responsible for notifying item.<path> changes.
|
| */
|
| - updateViewportBoundaries: function() {
|
| - this._scrollerPaddingTop = this.scrollTarget === this ? 0 :
|
| - parseInt(window.getComputedStyle(this)['padding-top'], 10);
|
| + _forwardItemPath: function(path, value) {
|
| + if (!this._physicalIndexForKey) {
|
| + return;
|
| + }
|
| + var inst;
|
| + var dot = path.indexOf('.');
|
| + var key = path.substring(0, dot < 0 ? path.length : dot);
|
| + var idx = this._physicalIndexForKey[key];
|
| + var el = this._physicalItems[idx];
|
|
|
| - this._viewportSize = this._scrollTargetHeight;
|
| +
|
| + if (idx === this._focusedIndex && this._offscreenFocusedItem) {
|
| + el = this._offscreenFocusedItem;
|
| + }
|
| + if (!el) {
|
| + return;
|
| + }
|
| +
|
| + inst = el._templateInstance;
|
| +
|
| + if (inst.__key__ !== key) {
|
| + return;
|
| + }
|
| + if (dot >= 0) {
|
| + path = this.as + '.' + path.substring(dot+1);
|
| + inst.notifyPath(path, value, true);
|
| + } else {
|
| + inst[this.as] = value;
|
| + }
|
| },
|
|
|
| /**
|
| - * Update the models, the position of the
|
| - * items in the viewport and recycle tiles as needed.
|
| + * Called when the items have changed. That is, ressignments
|
| + * to `items`, splices or updates to a single item.
|
| */
|
| - _scrollHandler: function() {
|
| - // clamp the `scrollTop` value
|
| - var scrollTop = Math.max(0, Math.min(this._maxScrollTop, this._scrollTop));
|
| - var delta = scrollTop - this._scrollPosition;
|
| - var tileHeight, tileTop, kth, recycledTileSet, scrollBottom, physicalBottom;
|
| - var ratio = this._ratio;
|
| - var recycledTiles = 0;
|
| - var hiddenContentSize = this._hiddenContentSize;
|
| - var currentRatio = ratio;
|
| - var movingUp = [];
|
| + _itemsChanged: function(change) {
|
| + if (change.path === 'items') {
|
| + // reset items
|
| + this._virtualStart = 0;
|
| + this._physicalTop = 0;
|
| + this._virtualCount = this.items ? this.items.length : 0;
|
| + this._collection = this.items ? Polymer.Collection.get(this.items) : null;
|
| + this._physicalIndexForKey = {};
|
|
|
| - // track the last `scrollTop`
|
| - this._scrollPosition = scrollTop;
|
| + this._resetScrollPosition(0);
|
| + this._removeFocusedItem();
|
|
|
| - // clear cached visible indexes
|
| - this._firstVisibleIndexVal = null;
|
| - this._lastVisibleIndexVal = 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);
|
| + }
|
|
|
| - scrollBottom = this._scrollBottom;
|
| - physicalBottom = this._physicalBottom;
|
| + this._physicalStart = 0;
|
|
|
| - // random access
|
| - if (Math.abs(delta) > this._physicalSize) {
|
| - this._physicalTop += delta;
|
| - recycledTiles = Math.round(delta / this._physicalAverage);
|
| + } else if (change.path === 'items.splices') {
|
| + this._adjustVirtualIndex(change.value.indexSplices);
|
| + this._virtualCount = this.items ? this.items.length : 0;
|
| +
|
| + } else {
|
| + // update a single item
|
| + this._forwardItemPath(change.path.split('.').slice(1).join('.'), change.value);
|
| + return;
|
| }
|
| - // scroll up
|
| - else if (delta < 0) {
|
| - var topSpace = scrollTop - this._physicalTop;
|
| - var virtualStart = this._virtualStart;
|
|
|
| - recycledTileSet = [];
|
| + this._itemsRendered = false;
|
| + this._debounceTemplate(this._render);
|
| + },
|
|
|
| - kth = this._physicalEnd;
|
| - currentRatio = topSpace / hiddenContentSize;
|
| + /**
|
| + * @param {!Array<!PolymerSplice>} splices
|
| + */
|
| + _adjustVirtualIndex: function(splices) {
|
| + splices.forEach(function(splice) {
|
| + // deselect removed items
|
| + splice.removed.forEach(this._removeItem, this);
|
| + // We only need to care about changes happening above the current position
|
| + if (splice.index < this._virtualStart) {
|
| + var delta = Math.max(
|
| + splice.addedCount - splice.removed.length,
|
| + splice.index - this._virtualStart);
|
|
|
| - // 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
|
| - ) {
|
| + this._virtualStart = this._virtualStart + delta;
|
|
|
| - tileHeight = this._physicalSizes[kth];
|
| - currentRatio += tileHeight / hiddenContentSize;
|
| - physicalBottom -= tileHeight;
|
| - recycledTileSet.push(kth);
|
| - recycledTiles++;
|
| - kth = (kth === 0) ? this._physicalCount - 1 : kth - 1;
|
| + if (this._focusedIndex >= 0) {
|
| + this._focusedIndex = this._focusedIndex + delta;
|
| + }
|
| }
|
| + }, this);
|
| + },
|
|
|
| - movingUp = recycledTileSet;
|
| - recycledTiles = -recycledTiles;
|
| + _removeItem: function(item) {
|
| + this.$.selector.deselect(item);
|
| + // remove the current focused item
|
| + if (this._focusedItem && this._focusedItem._templateInstance[this.as] === item) {
|
| + this._removeFocusedItem();
|
| }
|
| - // scroll down
|
| - else if (delta > 0) {
|
| - var bottomSpace = physicalBottom - scrollBottom;
|
| - var virtualEnd = this._virtualEnd;
|
| - var lastVirtualItemIndex = this._virtualCount-1;
|
| -
|
| - recycledTileSet = [];
|
| -
|
| - kth = this._physicalStart;
|
| - currentRatio = bottomSpace / hiddenContentSize;
|
| -
|
| - // 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
|
| - ) {
|
| + },
|
|
|
| - tileHeight = this._physicalSizes[kth];
|
| - currentRatio += tileHeight / hiddenContentSize;
|
| + /**
|
| + * 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;
|
|
|
| - this._physicalTop += tileHeight;
|
| - recycledTileSet.push(kth);
|
| - recycledTiles++;
|
| - kth = (kth + 1) % this._physicalCount;
|
| + 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;
|
|
|
| - 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));
|
| + for (; pidx < this._physicalCount; pidx++, vidx++) {
|
| + if ((rtn = fn.call(this, pidx, vidx)) != null) {
|
| + return rtn;
|
| + }
|
| + }
|
| + for (pidx = 0; pidx < this._physicalStart; pidx++, vidx++) {
|
| + if ((rtn = fn.call(this, pidx, vidx)) != null) {
|
| + return rtn;
|
| + }
|
| }
|
| - } else {
|
| - this._virtualStart = this._virtualStart + recycledTiles;
|
| - this._physicalStart = this._physicalStart + recycledTiles;
|
| - this._update(recycledTileSet, movingUp);
|
| }
|
| },
|
|
|
| /**
|
| - * Update the list of items, starting from the `_virtualStart` item.
|
| + * Assigns the data models to a given set of items.
|
| * @param {!Array<number>=} itemSet
|
| - * @param {!Array<number>=} movingUp
|
| */
|
| - _update: function(itemSet, movingUp) {
|
| - // manage focus
|
| - this._manageFocus();
|
| - // 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()];
|
| + _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 != 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 = this._focusedIndex === vidx ? 0 : -1;
|
| + this._physicalIndexForKey[inst.__key__] = pidx;
|
| + el.removeAttribute('hidden');
|
| + } else {
|
| + inst.__key__ = null;
|
| + el.setAttribute('hidden', '');
|
| }
|
| - }
|
| - // update the position of the items
|
| - this._positionItems();
|
| - // set the scroller size
|
| - this._updateScrollerSize();
|
| - // increase the pool of physical items
|
| - this._increasePoolIfNeeded();
|
| + }, itemSet);
|
| },
|
|
|
| /**
|
| - * Creates a pool of DOM elements and attaches them to the local dom.
|
| + * Updates the height for a given set of items.
|
| + *
|
| + * @param {!Array<number>=} itemSet
|
| */
|
| - _createPool: function(size) {
|
| - var physicalItems = new Array(size);
|
| + _updateMetrics: function(itemSet) {
|
| + // Make sure we distributed all the physical items
|
| + // so we can measure them
|
| + Polymer.dom.flush();
|
|
|
| - this._ensureTemplatized();
|
| + var newPhysicalSize = 0;
|
| + var oldPhysicalSize = 0;
|
| + var prevAvgCount = this._physicalAverageCount;
|
| + var prevPhysicalAvg = this._physicalAverage;
|
|
|
| - 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._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);
|
| +
|
| + this._physicalSize = this._physicalSize + newPhysicalSize - oldPhysicalSize;
|
| + this._viewportSize = this._scrollTargetHeight;
|
| +
|
| + // update the average if we measured something
|
| + if (this._physicalAverageCount !== prevAvgCount) {
|
| + this._physicalAverage = Math.round(
|
| + ((prevPhysicalAvg * prevAvgCount) + newPhysicalSize) /
|
| + this._physicalAverageCount);
|
| }
|
| - return physicalItems;
|
| },
|
|
|
| /**
|
| - * Increases the pool of physical items only if needed.
|
| - * This function will allocate additional physical items
|
| - * if the physical size is shorter than `_optPhysicalSize`
|
| + * Updates the position of the physical items.
|
| */
|
| - _increasePoolIfNeeded: function() {
|
| - if (this._viewportSize === 0 || this._physicalSize >= this._optPhysicalSize) {
|
| - return false;
|
| - }
|
| - // 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;
|
| + _positionItems: function() {
|
| + this._adjustScrollPosition();
|
| +
|
| + var y = this._physicalTop;
|
| +
|
| + this._iterateItems(function(pidx) {
|
| + this.translate3d(0, y + 'px', 0, this._physicalItems[pidx]);
|
| + y += this._physicalSizes[pidx];
|
| + });
|
| },
|
|
|
| /**
|
| - * Increases the pool size.
|
| + * Adjusts the scroll position when it was overestimated.
|
| */
|
| - _increasePool: function(missingItems) {
|
| - var nextPhysicalCount = Math.min(
|
| - this._physicalCount + missingItems,
|
| - this._virtualCount - this._virtualStart,
|
| - MAX_PHYSICAL_COUNT
|
| - );
|
| - var prevPhysicalCount = this._physicalCount;
|
| - var delta = nextPhysicalCount - prevPhysicalCount;
|
| + _adjustScrollPosition: function() {
|
| + var deltaHeight = this._virtualStart === 0 ? this._physicalTop :
|
| + Math.min(this._scrollPosition + this._physicalTop, 0);
|
|
|
| - if (delta <= 0) {
|
| - return;
|
| + 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);
|
| + }
|
| }
|
| + },
|
|
|
| - [].push.apply(this._physicalItems, this._createPool(delta));
|
| - [].push.apply(this._physicalSizes, new Array(delta));
|
| -
|
| - this._physicalCount = prevPhysicalCount + delta;
|
| -
|
| - // update the physical start if we need to preserve the model of the focused item.
|
| - // In this situation, the focused item is currently rendered and its model would
|
| - // have changed after increasing the pool if the physical start remained unchanged.
|
| - if (this._physicalStart > this._physicalEnd &&
|
| - this._isIndexRendered(this._focusedIndex) &&
|
| - this._getPhysicalIndex(this._focusedIndex) < this._physicalEnd) {
|
| - this._physicalStart = this._physicalStart + delta;
|
| + /**
|
| + * Sets the position of the scroll.
|
| + */
|
| + _resetScrollPosition: function(pos) {
|
| + if (this.scrollTarget) {
|
| + this._scrollTop = pos;
|
| + this._scrollPosition = this._scrollTop;
|
| }
|
| - this._update();
|
| },
|
|
|
| /**
|
| - * 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.
|
| + * Sets the scroll height, that's the height of the content,
|
| + *
|
| + * @param {boolean=} forceUpdate If true, updates the height no matter what.
|
| */
|
| - _render: function() {
|
| - var requiresUpdate = this._virtualCount > 0 || this._physicalCount > 0;
|
| + _updateScrollerSize: function(forceUpdate) {
|
| + this._estScrollHeight = (this._physicalBottom +
|
| + Math.max(this._virtualCount - this._physicalCount - this._virtualStart, 0) * this._physicalAverage);
|
|
|
| - if (this.isAttached && !this._itemsRendered && this._isVisible && requiresUpdate) {
|
| - this._lastPage = 0;
|
| - this._update();
|
| - this._scrollHandler();
|
| - this._itemsRendered = true;
|
| + 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;
|
| }
|
| },
|
| -
|
| /**
|
| - * Templetizes the user template.
|
| + * 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
|
| */
|
| - _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;
|
| + scrollToIndex: function(idx) {
|
| + if (typeof idx !== 'number') {
|
| + return;
|
| + }
|
|
|
| - this._instanceProps = props;
|
| - this._userTemplate = Polymer.dom(this).querySelector('template');
|
| + Polymer.dom.flush();
|
|
|
| - if (this._userTemplate) {
|
| - this.templatize(this._userTemplate);
|
| - } else {
|
| - console.warn('iron-list requires a template to be provided in light-dom');
|
| - }
|
| + idx = Math.min(Math.max(idx, 0), this._virtualCount-1);
|
| + // update the virtual start only when needed
|
| + if (!this._isIndexRendered(idx) || idx >= this._maxVirtualStart) {
|
| + this._virtualStart = idx - 1;
|
| + }
|
| + // manage focus
|
| + this._manageFocus();
|
| + // assign new models
|
| + this._assignModels();
|
| + // measure the new sizes
|
| + this._updateMetrics();
|
| + // estimate new physical offset
|
| + this._physicalTop = this._virtualStart * this._physicalAverage;
|
| +
|
| + var currentTopItem = this._physicalStart;
|
| + var currentVirtualItem = this._virtualStart;
|
| + var targetOffsetTop = 0;
|
| + var hiddenContentSize = this._hiddenContentSize;
|
| +
|
| + // 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;
|
| },
|
|
|
| /**
|
| - * Implements extension point from Templatizer mixin.
|
| + * Reset the physical average and the average count.
|
| */
|
| - _getStampedChildren: function() {
|
| - return this._physicalItems;
|
| + _resetAverage: function() {
|
| + this._physicalAverage = 0;
|
| + this._physicalAverageCount = 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.
|
| + * A handler for the `iron-resize` event triggered by `IronResizableBehavior`
|
| + * when the element is resized.
|
| */
|
| - _forwardInstancePath: function(inst, path, value) {
|
| - if (path.indexOf(this.as + '.') === 0) {
|
| - this.notifyPath('items.' + inst.__key__ + '.' +
|
| - path.slice(this.as.length + 1), value);
|
| + _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);
|
| + }
|
| + });
|
| },
|
|
|
| - /**
|
| - * Implements extension point from Templatizer mixin
|
| - * Called as side-effect of a host property change, responsible for
|
| - * notifying parent path change on each row.
|
| - */
|
| - _forwardParentProp: function(prop, value) {
|
| - if (this._physicalItems) {
|
| - this._physicalItems.forEach(function(item) {
|
| - item._templateInstance[prop] = value;
|
| - }, this);
|
| + _getModelFromItem: function(item) {
|
| + var key = this._collection.getKey(item);
|
| + var pidx = this._physicalIndexForKey[key];
|
| +
|
| + if (pidx != null) {
|
| + return this._physicalItems[pidx]._templateInstance;
|
| }
|
| + return 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.
|
| + * Gets a valid item instance from its index or the object value.
|
| + *
|
| + * @param {(Object|number)} item The item object or its index
|
| */
|
| - _forwardParentPath: function(path, value) {
|
| - if (this._physicalItems) {
|
| - this._physicalItems.forEach(function(item) {
|
| - item._templateInstance.notifyPath(path, value, true);
|
| - }, this);
|
| + _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');
|
| + }
|
| + return item;
|
| + }
|
| + throw new TypeError('<item> should be a valid item');
|
| }
|
| + return item;
|
| },
|
|
|
| /**
|
| - * Called as a side effect of a host items.<key>.<path> path change,
|
| - * responsible for notifying item.<path> changes.
|
| + * Select the list item at the given index.
|
| + *
|
| + * @method selectItem
|
| + * @param {(Object|number)} item The item object or its index
|
| */
|
| - _forwardItemPath: function(path, value) {
|
| - if (!this._physicalIndexForKey) {
|
| - return;
|
| - }
|
| - var inst;
|
| - var dot = path.indexOf('.');
|
| - var key = path.substring(0, dot < 0 ? path.length : dot);
|
| - var idx = this._physicalIndexForKey[key];
|
| - var el = this._physicalItems[idx];
|
| -
|
| + selectItem: function(item) {
|
| + item = this._getNormalizedItem(item);
|
| + var model = this._getModelFromItem(item);
|
|
|
| - if (idx === this._focusedIndex && this._offscreenFocusedItem) {
|
| - el = this._offscreenFocusedItem;
|
| + if (!this.multiSelection && this.selectedItem) {
|
| + this.deselectItem(this.selectedItem);
|
| }
|
| - if (!el) {
|
| - return;
|
| + if (model) {
|
| + model[this.selectedAs] = true;
|
| }
|
| -
|
| - inst = el._templateInstance;
|
| + this.$.selector.select(item);
|
| + this.updateSizeForItem(item);
|
| + },
|
|
|
| - if (inst.__key__ !== key) {
|
| - return;
|
| + /**
|
| + * Deselects the given item list if it is already selected.
|
| + *
|
| +
|
| + * @method deselect
|
| + * @param {(Object|number)} item The item object or its index
|
| + */
|
| + deselectItem: function(item) {
|
| + item = this._getNormalizedItem(item);
|
| + var model = this._getModelFromItem(item);
|
| +
|
| + if (model) {
|
| + model[this.selectedAs] = false;
|
| }
|
| - if (dot >= 0) {
|
| - path = this.as + '.' + path.substring(dot+1);
|
| - inst.notifyPath(path, value, true);
|
| + this.$.selector.deselect(item);
|
| + this.updateSizeForItem(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
|
| + */
|
| + toggleSelectionForItem: function(item) {
|
| + item = this._getNormalizedItem(item);
|
| + if (/** @type {!ArraySelectorElement} */ (this.$.selector).isSelected(item)) {
|
| + this.deselectItem(item);
|
| } else {
|
| - inst[this.as] = value;
|
| + this.selectItem(item);
|
| }
|
| },
|
|
|
| /**
|
| - * Called when the items have changed. That is, ressignments
|
| - * to `items`, splices or updates to a single item.
|
| + * Clears the current selection state of the list.
|
| + *
|
| + * @method clearSelection
|
| */
|
| - _itemsChanged: function(change) {
|
| - if (change.path === 'items') {
|
| - // reset items
|
| - this._virtualStart = 0;
|
| - this._physicalTop = 0;
|
| - this._virtualCount = this.items ? this.items.length : 0;
|
| - this._collection = this.items ? Polymer.Collection.get(this.items) : null;
|
| - this._physicalIndexForKey = {};
|
| -
|
| - this._resetScrollPosition(0);
|
| - this._removeFocusedItem();
|
| -
|
| - // 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);
|
| + clearSelection: function() {
|
| + function unselect(item) {
|
| + var model = this._getModelFromItem(item);
|
| + if (model) {
|
| + model[this.selectedAs] = false;
|
| }
|
| + }
|
|
|
| - this._physicalStart = 0;
|
| -
|
| - } else if (change.path === 'items.splices') {
|
| - this._adjustVirtualIndex(change.value.indexSplices);
|
| - this._virtualCount = this.items ? this.items.length : 0;
|
| -
|
| - } else {
|
| - // update a single item
|
| - this._forwardItemPath(change.path.split('.').slice(1).join('.'), change.value);
|
| - return;
|
| + if (Array.isArray(this.selectedItems)) {
|
| + this.selectedItems.forEach(unselect, this);
|
| + } else if (this.selectedItem) {
|
| + unselect.call(this, this.selectedItem);
|
| }
|
|
|
| - this._itemsRendered = false;
|
| - this._debounceTemplate(this._render);
|
| + /** @type {!ArraySelectorElement} */ (this.$.selector).clearSelection();
|
| },
|
|
|
| /**
|
| - * @param {!Array<!PolymerSplice>} splices
|
| + * Add an event listener to `tap` if `selectionEnabled` is true,
|
| + * it will remove the listener otherwise.
|
| */
|
| - _adjustVirtualIndex: function(splices) {
|
| - splices.forEach(function(splice) {
|
| - // deselect removed items
|
| - splice.removed.forEach(this._removeItem, this);
|
| - // We only need to care about changes happening above the current position
|
| - if (splice.index < this._virtualStart) {
|
| - var delta = Math.max(
|
| - splice.addedCount - splice.removed.length,
|
| - splice.index - this._virtualStart);
|
| -
|
| - this._virtualStart = this._virtualStart + delta;
|
| + _selectionEnabledChanged: function(selectionEnabled) {
|
| + var handler = selectionEnabled ? this.listen : this.unlisten;
|
| + handler.call(this, this, 'tap', '_selectionHandler');
|
| + },
|
|
|
| - if (this._focusedIndex >= 0) {
|
| - this._focusedIndex = this._focusedIndex + delta;
|
| - }
|
| + /**
|
| + * Select an item from an event object.
|
| + */
|
| + _selectionHandler: function(e) {
|
| + if (this.selectionEnabled) {
|
| + var model = this.modelForElement(e.target);
|
| + if (model) {
|
| + this.toggleSelectionForItem(model[this.as]);
|
| }
|
| - }, this);
|
| + }
|
| },
|
|
|
| - _removeItem: function(item) {
|
| - this.$.selector.deselect(item);
|
| - // remove the current focused item
|
| - if (this._focusedItem && this._focusedItem._templateInstance[this.as] === item) {
|
| - this._removeFocusedItem();
|
| - }
|
| + _multiSelectionChanged: function(multiSelection) {
|
| + this.clearSelection();
|
| + this.$.selector.multi = multiSelection;
|
| },
|
|
|
| /**
|
| - * Executes a provided function per every physical index in `itemSet`
|
| - * `itemSet` default value is equivalent to the entire set of physical indexes.
|
| + * Updates the size of an item.
|
| *
|
| - * @param {!function(number, number)} fn
|
| - * @param {!Array<number>=} itemSet
|
| + * @method updateSizeForItem
|
| + * @param {(Object|number)} item The item object or its index
|
| */
|
| - _iterateItems: function(fn, itemSet) {
|
| - var pidx, vidx, rtn, i;
|
| -
|
| - 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;
|
| + updateSizeForItem: function(item) {
|
| + item = this._getNormalizedItem(item);
|
| + var key = this._collection.getKey(item);
|
| + var pidx = this._physicalIndexForKey[key];
|
|
|
| - for (; pidx < this._physicalCount; pidx++, vidx++) {
|
| - if ((rtn = fn.call(this, pidx, vidx)) != null) {
|
| - return rtn;
|
| - }
|
| - }
|
| - for (pidx = 0; pidx < this._physicalStart; pidx++, vidx++) {
|
| - if ((rtn = fn.call(this, pidx, vidx)) != null) {
|
| - return rtn;
|
| - }
|
| - }
|
| + if (pidx != null) {
|
| + this._updateMetrics([pidx]);
|
| + this._positionItems();
|
| }
|
| },
|
|
|
| /**
|
| - * Assigns the data models to a given set of items.
|
| - * @param {!Array<number>=} itemSet
|
| + * Creates a temporary backfill item in the rendered pool of physical items
|
| + * to replace the main focused item. The focused item has tabIndex = 0
|
| + * and might be currently focused by the user.
|
| + *
|
| + * This dynamic replacement helps to preserve the focus state.
|
| */
|
| - _assignModels: function(itemSet) {
|
| - this._iterateItems(function(pidx, vidx) {
|
| - var el = this._physicalItems[pidx];
|
| - var inst = el._templateInstance;
|
| - var item = this.items && this.items[vidx];
|
| + _manageFocus: function() {
|
| + var fidx = this._focusedIndex;
|
|
|
| - if (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 = this._focusedIndex === vidx ? 0 : -1;
|
| - this._physicalIndexForKey[inst.__key__] = pidx;
|
| - el.removeAttribute('hidden');
|
| + if (fidx >= 0 && fidx < this._virtualCount) {
|
| + // if it's a valid index, check if that index is rendered
|
| + // in a physical item.
|
| + if (this._isIndexRendered(fidx)) {
|
| + this._restoreFocusedItem();
|
| } else {
|
| - inst.__key__ = null;
|
| - el.setAttribute('hidden', '');
|
| + this._createFocusBackfillItem();
|
| }
|
| - }, itemSet);
|
| + } else if (this._virtualCount > 0 && this._physicalCount > 0) {
|
| + // otherwise, assign the initial focused index.
|
| + this._focusedIndex = this._virtualStart;
|
| + this._focusedItem = this._physicalItems[this._physicalStart];
|
| + }
|
| },
|
|
|
| - /**
|
| - * 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();
|
| + _isIndexRendered: function(idx) {
|
| + return idx >= this._virtualStart && idx <= this._virtualEnd;
|
| + },
|
|
|
| - var newPhysicalSize = 0;
|
| - var oldPhysicalSize = 0;
|
| - var prevAvgCount = this._physicalAverageCount;
|
| - var prevPhysicalAvg = this._physicalAverage;
|
| + _isIndexVisible: function(idx) {
|
| + return idx >= this.firstVisibleIndex && idx <= this.lastVisibleIndex;
|
| + },
|
|
|
| - this._iterateItems(function(pidx, vidx) {
|
| + _getPhysicalIndex: function(idx) {
|
| + return this._physicalIndexForKey[this._collection.getKey(this._getNormalizedItem(idx))];
|
| + },
|
|
|
| - oldPhysicalSize += this._physicalSizes[pidx] || 0;
|
| - this._physicalSizes[pidx] = this._physicalItems[pidx].offsetHeight;
|
| - newPhysicalSize += this._physicalSizes[pidx];
|
| - this._physicalAverageCount += this._physicalSizes[pidx] ? 1 : 0;
|
| + _focusPhysicalItem: function(idx) {
|
| + if (idx < 0 || idx >= this._virtualCount) {
|
| + return;
|
| + }
|
| + this._restoreFocusedItem();
|
| + // scroll to index to make sure it's rendered
|
| + if (!this._isIndexRendered(idx)) {
|
| + this.scrollToIndex(idx);
|
| + }
|
|
|
| - }, itemSet);
|
| + var physicalItem = this._physicalItems[this._getPhysicalIndex(idx)];
|
| + var SECRET = ~(Math.random() * 100);
|
| + var model = physicalItem._templateInstance;
|
| + var focusable;
|
|
|
| - this._physicalSize = this._physicalSize + newPhysicalSize - oldPhysicalSize;
|
| - this._viewportSize = this._scrollTargetHeight;
|
| + // set a secret tab index
|
| + model.tabIndex = SECRET;
|
| + // check if focusable element is the physical item
|
| + if (physicalItem.tabIndex === SECRET) {
|
| + focusable = physicalItem;
|
| + }
|
| + // search for the element which tabindex is bound to the secret tab index
|
| + if (!focusable) {
|
| + focusable = Polymer.dom(physicalItem).querySelector('[tabindex="' + SECRET + '"]');
|
| + }
|
| + // restore the tab index
|
| + model.tabIndex = 0;
|
| + // focus the focusable element
|
| + this._focusedIndex = idx;
|
| + focusable && focusable.focus();
|
| + },
|
| +
|
| + _removeFocusedItem: function() {
|
| + if (this._offscreenFocusedItem) {
|
| + Polymer.dom(this).removeChild(this._offscreenFocusedItem);
|
| + }
|
| + this._offscreenFocusedItem = null;
|
| + this._focusBackfillItem = null;
|
| + this._focusedItem = null;
|
| + this._focusedIndex = -1;
|
| + },
|
| +
|
| + _createFocusBackfillItem: function() {
|
| + var pidx, fidx = this._focusedIndex;
|
| + if (this._offscreenFocusedItem || fidx < 0) {
|
| + return;
|
| + }
|
| + if (!this._focusBackfillItem) {
|
| + // create a physical item, so that it backfills the focused item.
|
| + var stampedTemplate = this.stamp(null);
|
| + this._focusBackfillItem = stampedTemplate.root.querySelector('*');
|
| + Polymer.dom(this).appendChild(stampedTemplate.root);
|
| + }
|
| + // get the physical index for the focused index
|
| + pidx = this._getPhysicalIndex(fidx);
|
|
|
| - // update the average if we measured something
|
| - if (this._physicalAverageCount !== prevAvgCount) {
|
| - this._physicalAverage = Math.round(
|
| - ((prevPhysicalAvg * prevAvgCount) + newPhysicalSize) /
|
| - this._physicalAverageCount);
|
| + if (pidx != null) {
|
| + // set the offcreen focused physical item
|
| + this._offscreenFocusedItem = this._physicalItems[pidx];
|
| + // backfill the focused physical item
|
| + this._physicalItems[pidx] = this._focusBackfillItem;
|
| + // hide the focused physical
|
| + this.translate3d(0, HIDDEN_Y, 0, this._offscreenFocusedItem);
|
| }
|
| },
|
|
|
| - /**
|
| - * Updates the position of the physical items.
|
| - */
|
| - _positionItems: function() {
|
| - this._adjustScrollPosition();
|
| + _restoreFocusedItem: function() {
|
| + var pidx, fidx = this._focusedIndex;
|
|
|
| - var y = this._physicalTop;
|
| + if (!this._offscreenFocusedItem || this._focusedIndex < 0) {
|
| + return;
|
| + }
|
| + // assign models to the focused index
|
| + this._assignModels();
|
| + // get the new physical index for the focused index
|
| + pidx = this._getPhysicalIndex(fidx);
|
|
|
| - this._iterateItems(function(pidx) {
|
| - this.translate3d(0, y + 'px', 0, this._physicalItems[pidx]);
|
| - y += this._physicalSizes[pidx];
|
| - });
|
| + if (pidx != null) {
|
| + // flip the focus backfill
|
| + this._focusBackfillItem = this._physicalItems[pidx];
|
| + // restore the focused physical item
|
| + this._physicalItems[pidx] = this._offscreenFocusedItem;
|
| + // reset the offscreen focused item
|
| + this._offscreenFocusedItem = null;
|
| + // hide the physical item that backfills
|
| + this.translate3d(0, HIDDEN_Y, 0, this._focusBackfillItem);
|
| + }
|
| },
|
|
|
| - /**
|
| - * Adjusts the scroll position when it was overestimated.
|
| - */
|
| - _adjustScrollPosition: function() {
|
| - var deltaHeight = this._virtualStart === 0 ? this._physicalTop :
|
| - Math.min(this._scrollPosition + this._physicalTop, 0);
|
| + _didFocus: function(e) {
|
| + var targetModel = this.modelForElement(e.target);
|
| + var focusedModel = this._focusedItem ? this._focusedItem._templateInstance : null;
|
| + var hasOffscreenFocusedItem = this._offscreenFocusedItem !== null;
|
| + var fidx = this._focusedIndex;
|
|
|
| - 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);
|
| + if (!targetModel || !focusedModel) {
|
| + return;
|
| + }
|
| + if (focusedModel === targetModel) {
|
| + // if the user focused the same item, then bring it into view if it's not visible
|
| + if (!this._isIndexVisible(fidx)) {
|
| + this.scrollToIndex(fidx);
|
| + }
|
| + } else {
|
| + this._restoreFocusedItem();
|
| + // restore tabIndex for the currently focused item
|
| + focusedModel.tabIndex = -1;
|
| + // set the tabIndex for the next focused item
|
| + targetModel.tabIndex = 0;
|
| + fidx = targetModel[this.indexAs];
|
| + this._focusedIndex = fidx;
|
| + this._focusedItem = this._physicalItems[this._getPhysicalIndex(fidx)];
|
| +
|
| + if (hasOffscreenFocusedItem && !this._offscreenFocusedItem) {
|
| + this._update();
|
| }
|
| }
|
| },
|
|
|
| - /**
|
| - * Sets the position of the scroll.
|
| - */
|
| - _resetScrollPosition: function(pos) {
|
| - if (this.scrollTarget) {
|
| - this._scrollTop = pos;
|
| - this._scrollPosition = this._scrollTop;
|
| - }
|
| + _didMoveUp: function() {
|
| + this._focusPhysicalItem(this._focusedIndex - 1);
|
| },
|
|
|
| - /**
|
| - * 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);
|
| + _didMoveDown: function() {
|
| + this._focusPhysicalItem(this._focusedIndex + 1);
|
| + },
|
|
|
| - forceUpdate = forceUpdate || this._scrollHeight === 0;
|
| - forceUpdate = forceUpdate || this._scrollPosition >= this._estScrollHeight - this._physicalSize;
|
| + _didEnter: function(e) {
|
| + this._focusPhysicalItem(this._focusedIndex);
|
| + this._selectionHandler(e.detail.keyboardEvent);
|
| + }
|
| + });
|
|
|
| - // 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;
|
| - }
|
| +})();
|
| +// Copyright (c) 2013 The Chromium Authors. All rights reserved.
|
| +// Use of this source code is governed by a BSD-style license that can be
|
| +// found in the LICENSE file.
|
|
|
| - Polymer.dom.flush();
|
| +/**
|
| + * @fileoverview Assertion support.
|
| + */
|
|
|
| - idx = Math.min(Math.max(idx, 0), this._virtualCount-1);
|
| - // update the virtual start only when needed
|
| - if (!this._isIndexRendered(idx) || idx >= this._maxVirtualStart) {
|
| - this._virtualStart = idx - 1;
|
| - }
|
| - // manage focus
|
| - this._manageFocus();
|
| - // assign new models
|
| - this._assignModels();
|
| - // measure the new sizes
|
| - this._updateMetrics();
|
| - // estimate new physical offset
|
| - this._physicalTop = this._virtualStart * this._physicalAverage;
|
| +/**
|
| + * Verify |condition| is truthy and return |condition| if so.
|
| + * @template T
|
| + * @param {T} condition A condition to check for truthiness. Note that this
|
| + * may be used to test whether a value is defined or not, and we don't want
|
| + * to force a cast to Boolean.
|
| + * @param {string=} opt_message A message to show on failure.
|
| + * @return {T} A non-null |condition|.
|
| + */
|
| +function assert(condition, opt_message) {
|
| + if (!condition) {
|
| + var message = 'Assertion failed';
|
| + if (opt_message)
|
| + message = message + ': ' + opt_message;
|
| + var error = new Error(message);
|
| + var global = function() { return this; }();
|
| + if (global.traceAssertionsForTesting)
|
| + console.warn(error.stack);
|
| + throw error;
|
| + }
|
| + return condition;
|
| +}
|
|
|
| - var currentTopItem = this._physicalStart;
|
| - var currentVirtualItem = this._virtualStart;
|
| - var targetOffsetTop = 0;
|
| - var hiddenContentSize = this._hiddenContentSize;
|
| +/**
|
| + * Call this from places in the code that should never be reached.
|
| + *
|
| + * For example, handling all the values of enum with a switch() like this:
|
| + *
|
| + * function getValueFromEnum(enum) {
|
| + * switch (enum) {
|
| + * case ENUM_FIRST_OF_TWO:
|
| + * return first
|
| + * case ENUM_LAST_OF_TWO:
|
| + * return last;
|
| + * }
|
| + * assertNotReached();
|
| + * return document;
|
| + * }
|
| + *
|
| + * This code should only be hit in the case of serious programmer error or
|
| + * unexpected input.
|
| + *
|
| + * @param {string=} opt_message A message to show when this is hit.
|
| + */
|
| +function assertNotReached(opt_message) {
|
| + assert(false, opt_message || 'Unreachable code hit');
|
| +}
|
|
|
| - // 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;
|
| - },
|
| +/**
|
| + * @param {*} value The value to check.
|
| + * @param {function(new: T, ...)} type A user-defined constructor.
|
| + * @param {string=} opt_message A message to show when this is hit.
|
| + * @return {T}
|
| + * @template T
|
| + */
|
| +function assertInstanceof(value, type, opt_message) {
|
| + // 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.
|
| +// Use of this source code is governed by a BSD-style license that can be
|
| +// found in the LICENSE file.
|
|
|
| - /**
|
| - * Reset the physical average and the average count.
|
| - */
|
| - _resetAverage: function() {
|
| - this._physicalAverage = 0;
|
| - this._physicalAverageCount = 0;
|
| - },
|
| +cr.define('downloads', function() {
|
| + /**
|
| + * @param {string} chromeSendName
|
| + * @return {function(string):void} A chrome.send() callback with curried name.
|
| + */
|
| + function chromeSendWithId(chromeSendName) {
|
| + return function(id) { chrome.send(chromeSendName, [id]); };
|
| + }
|
| +
|
| + /** @constructor */
|
| + function ActionService() {
|
| + /** @private {Array<string>} */
|
| + this.searchTerms_ = [];
|
| + }
|
| +
|
| + /**
|
| + * @param {string} s
|
| + * @return {string} |s| without whitespace at the beginning or end.
|
| + */
|
| + function trim(s) { return s.trim(); }
|
|
|
| - /**
|
| - * 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);
|
| - }
|
| - });
|
| - },
|
| + /**
|
| + * @param {string|undefined} value
|
| + * @return {boolean} Whether |value| is truthy.
|
| + */
|
| + function truthy(value) { return !!value; }
|
|
|
| - _getModelFromItem: function(item) {
|
| - var key = this._collection.getKey(item);
|
| - var pidx = this._physicalIndexForKey[key];
|
| + /**
|
| + * @param {string} searchText Input typed by the user into a search box.
|
| + * @return {Array<string>} A list of terms extracted from |searchText|.
|
| + */
|
| + ActionService.splitTerms = function(searchText) {
|
| + // Split quoted terms (e.g., 'The "lazy" dog' => ['The', 'lazy', 'dog']).
|
| + return searchText.split(/"([^"]*)"/).map(trim).filter(truthy);
|
| + };
|
|
|
| - if (pidx != null) {
|
| - return this._physicalItems[pidx]._templateInstance;
|
| - }
|
| - return null;
|
| - },
|
| + ActionService.prototype = {
|
| + /** @param {string} id ID of the download to cancel. */
|
| + cancel: chromeSendWithId('cancel'),
|
|
|
| - /**
|
| - * 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');
|
| - }
|
| - return item;
|
| - }
|
| - throw new TypeError('<item> should be a valid item');
|
| + /** Instructs the browser to clear all finished downloads. */
|
| + clearAll: function() {
|
| + if (loadTimeData.getBoolean('allowDeletingHistory')) {
|
| + chrome.send('clearAll');
|
| + this.search('');
|
| }
|
| - return item;
|
| },
|
|
|
| - /**
|
| - * 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);
|
| + /** @param {string} id ID of the dangerous download to discard. */
|
| + discardDangerous: chromeSendWithId('discardDangerous'),
|
|
|
| - if (!this.multiSelection && this.selectedItem) {
|
| - this.deselectItem(this.selectedItem);
|
| - }
|
| - if (model) {
|
| - model[this.selectedAs] = true;
|
| - }
|
| - this.$.selector.select(item);
|
| - this.updateSizeForItem(item);
|
| + /** @param {string} url URL of a file to download. */
|
| + download: function(url) {
|
| + var a = document.createElement('a');
|
| + a.href = url;
|
| + a.setAttribute('download', '');
|
| + a.click();
|
| },
|
|
|
| - /**
|
| - * Deselects the given item list if it is already selected.
|
| - *
|
| -
|
| - * @method deselect
|
| - * @param {(Object|number)} item The item object or its index
|
| - */
|
| - deselectItem: function(item) {
|
| - item = this._getNormalizedItem(item);
|
| - var model = this._getModelFromItem(item);
|
| + /** @param {string} id ID of the download that the user started dragging. */
|
| + drag: chromeSendWithId('drag'),
|
|
|
| - if (model) {
|
| - model[this.selectedAs] = false;
|
| - }
|
| - this.$.selector.deselect(item);
|
| - this.updateSizeForItem(item);
|
| + /** Loads more downloads with the current search terms. */
|
| + loadMore: function() {
|
| + chrome.send('getDownloads', this.searchTerms_);
|
| },
|
|
|
| /**
|
| - * 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
|
| + * @return {boolean} Whether the user is currently searching for downloads
|
| + * (i.e. has a non-empty search term).
|
| */
|
| - toggleSelectionForItem: function(item) {
|
| - item = this._getNormalizedItem(item);
|
| - if (/** @type {!ArraySelectorElement} */ (this.$.selector).isSelected(item)) {
|
| - this.deselectItem(item);
|
| - } else {
|
| - this.selectItem(item);
|
| - }
|
| + isSearching: function() {
|
| + return this.searchTerms_.length > 0;
|
| },
|
|
|
| + /** Opens the current local destination for downloads. */
|
| + openDownloadsFolder: chrome.send.bind(chrome, 'openDownloadsFolder'),
|
| +
|
| /**
|
| - * Clears the current selection state of the list.
|
| - *
|
| - * @method clearSelection
|
| + * @param {string} id ID of the download to run locally on the user's box.
|
| */
|
| - clearSelection: function() {
|
| - function unselect(item) {
|
| - var model = this._getModelFromItem(item);
|
| - if (model) {
|
| - model[this.selectedAs] = false;
|
| - }
|
| - }
|
| + openFile: chromeSendWithId('openFile'),
|
|
|
| - if (Array.isArray(this.selectedItems)) {
|
| - this.selectedItems.forEach(unselect, this);
|
| - } else if (this.selectedItem) {
|
| - unselect.call(this, this.selectedItem);
|
| - }
|
| + /** @param {string} id ID the of the progressing download to pause. */
|
| + pause: chromeSendWithId('pause'),
|
|
|
| - /** @type {!ArraySelectorElement} */ (this.$.selector).clearSelection();
|
| - },
|
| + /** @param {string} id ID of the finished download to remove. */
|
| + remove: chromeSendWithId('remove'),
|
|
|
| - /**
|
| - * Add an event listener to `tap` if `selectionEnabled` is true,
|
| - * it will remove the listener otherwise.
|
| - */
|
| - _selectionEnabledChanged: function(selectionEnabled) {
|
| - var handler = selectionEnabled ? this.listen : this.unlisten;
|
| - handler.call(this, this, 'tap', '_selectionHandler');
|
| - },
|
| + /** @param {string} id ID of the paused download to resume. */
|
| + resume: chromeSendWithId('resume'),
|
|
|
| /**
|
| - * Select an item from an event object.
|
| + * @param {string} id ID of the dangerous download to save despite
|
| + * warnings.
|
| */
|
| - _selectionHandler: function(e) {
|
| - if (this.selectionEnabled) {
|
| - var model = this.modelForElement(e.target);
|
| - if (model) {
|
| - this.toggleSelectionForItem(model[this.as]);
|
| - }
|
| + saveDangerous: chromeSendWithId('saveDangerous'),
|
| +
|
| + /** @param {string} searchText What to search for. */
|
| + search: function(searchText) {
|
| + var searchTerms = ActionService.splitTerms(searchText);
|
| + var sameTerms = searchTerms.length == this.searchTerms_.length;
|
| +
|
| + for (var i = 0; sameTerms && i < searchTerms.length; ++i) {
|
| + if (searchTerms[i] != this.searchTerms_[i])
|
| + sameTerms = false;
|
| }
|
| - },
|
|
|
| - _multiSelectionChanged: function(multiSelection) {
|
| - this.clearSelection();
|
| - this.$.selector.multi = multiSelection;
|
| + if (sameTerms)
|
| + return;
|
| +
|
| + this.searchTerms_ = searchTerms;
|
| + this.loadMore();
|
| },
|
|
|
| /**
|
| - * Updates the size of an item.
|
| - *
|
| - * @method updateSizeForItem
|
| - * @param {(Object|number)} item The item object or its index
|
| + * Shows the local folder a finished download resides in.
|
| + * @param {string} id ID of the download to show.
|
| */
|
| - updateSizeForItem: function(item) {
|
| - item = this._getNormalizedItem(item);
|
| - var key = this._collection.getKey(item);
|
| - var pidx = this._physicalIndexForKey[key];
|
| + show: chromeSendWithId('show'),
|
|
|
| - if (pidx != null) {
|
| - this._updateMetrics([pidx]);
|
| - this._positionItems();
|
| - }
|
| - },
|
| + /** Undo download removal. */
|
| + undo: chrome.send.bind(chrome, 'undo'),
|
| + };
|
|
|
| - /**
|
| - * Creates a temporary backfill item in the rendered pool of physical items
|
| - * to replace the main focused item. The focused item has tabIndex = 0
|
| - * and might be currently focused by the user.
|
| - *
|
| - * This dynamic replacement helps to preserve the focus state.
|
| - */
|
| - _manageFocus: function() {
|
| - var fidx = this._focusedIndex;
|
| + cr.addSingletonGetter(ActionService);
|
|
|
| - if (fidx >= 0 && fidx < this._virtualCount) {
|
| - // if it's a valid index, check if that index is rendered
|
| - // in a physical item.
|
| - if (this._isIndexRendered(fidx)) {
|
| - this._restoreFocusedItem();
|
| - } else {
|
| - this._createFocusBackfillItem();
|
| - }
|
| - } else if (this._virtualCount > 0 && this._physicalCount > 0) {
|
| - // otherwise, assign the initial focused index.
|
| - this._focusedIndex = this._virtualStart;
|
| - this._focusedItem = this._physicalItems[this._physicalStart];
|
| - }
|
| - },
|
| + return {ActionService: ActionService};
|
| +});
|
| +// Copyright 2015 The Chromium Authors. All rights reserved.
|
| +// Use of this source code is governed by a BSD-style license that can be
|
| +// found in the LICENSE file.
|
|
|
| - _isIndexRendered: function(idx) {
|
| - return idx >= this._virtualStart && idx <= this._virtualEnd;
|
| - },
|
| +cr.define('downloads', function() {
|
| + /**
|
| + * Explains why a download is in DANGEROUS state.
|
| + * @enum {string}
|
| + */
|
| + var DangerType = {
|
| + NOT_DANGEROUS: 'NOT_DANGEROUS',
|
| + DANGEROUS_FILE: 'DANGEROUS_FILE',
|
| + DANGEROUS_URL: 'DANGEROUS_URL',
|
| + DANGEROUS_CONTENT: 'DANGEROUS_CONTENT',
|
| + UNCOMMON_CONTENT: 'UNCOMMON_CONTENT',
|
| + DANGEROUS_HOST: 'DANGEROUS_HOST',
|
| + POTENTIALLY_UNWANTED: 'POTENTIALLY_UNWANTED',
|
| + };
|
|
|
| - _isIndexVisible: function(idx) {
|
| - return idx >= this.firstVisibleIndex && idx <= this.lastVisibleIndex;
|
| - },
|
| + /**
|
| + * The states a download can be in. These correspond to states defined in
|
| + * DownloadsDOMHandler::CreateDownloadItemValue
|
| + * @enum {string}
|
| + */
|
| + var States = {
|
| + IN_PROGRESS: 'IN_PROGRESS',
|
| + CANCELLED: 'CANCELLED',
|
| + COMPLETE: 'COMPLETE',
|
| + PAUSED: 'PAUSED',
|
| + DANGEROUS: 'DANGEROUS',
|
| + INTERRUPTED: 'INTERRUPTED',
|
| + };
|
|
|
| - _getPhysicalIndex: function(idx) {
|
| - return this._physicalIndexForKey[this._collection.getKey(this._getNormalizedItem(idx))];
|
| - },
|
| + return {
|
| + DangerType: DangerType,
|
| + States: States,
|
| + };
|
| +});
|
| +// Copyright 2014 The Chromium Authors. All rights reserved.
|
| +// Use of this source code is governed by a BSD-style license that can be
|
| +// found in the LICENSE file.
|
|
|
| - _focusPhysicalItem: function(idx) {
|
| - if (idx < 0 || idx >= this._virtualCount) {
|
| - return;
|
| - }
|
| - this._restoreFocusedItem();
|
| - // scroll to index to make sure it's rendered
|
| - if (!this._isIndexRendered(idx)) {
|
| - this.scrollToIndex(idx);
|
| - }
|
| +// Action links are elements that are used to perform an in-page navigation or
|
| +// action (e.g. showing a dialog).
|
| +//
|
| +// They look like normal anchor (<a>) tags as their text color is blue. However,
|
| +// they're subtly different as they're not initially underlined (giving users a
|
| +// clue that underlined links navigate while action links don't).
|
| +//
|
| +// Action links look very similar to normal links when hovered (hand cursor,
|
| +// underlined). This gives the user an idea that clicking this link will do
|
| +// something similar to navigation but in the same page.
|
| +//
|
| +// They can be created in JavaScript like this:
|
| +//
|
| +// var link = document.createElement('a', 'action-link'); // Note second arg.
|
| +//
|
| +// or with a constructor like this:
|
| +//
|
| +// var link = new ActionLink();
|
| +//
|
| +// They can be used easily from HTML as well, like so:
|
| +//
|
| +// <a is="action-link">Click me!</a>
|
| +//
|
| +// NOTE: <action-link> and document.createElement('action-link') don't work.
|
|
|
| - var physicalItem = this._physicalItems[this._getPhysicalIndex(idx)];
|
| - var SECRET = ~(Math.random() * 100);
|
| - var model = physicalItem._templateInstance;
|
| - var focusable;
|
| +/**
|
| + * @constructor
|
| + * @extends {HTMLAnchorElement}
|
| + */
|
| +var ActionLink = document.registerElement('action-link', {
|
| + prototype: {
|
| + __proto__: HTMLAnchorElement.prototype,
|
|
|
| - // set a secret tab index
|
| - model.tabIndex = SECRET;
|
| - // check if focusable element is the physical item
|
| - if (physicalItem.tabIndex === SECRET) {
|
| - focusable = physicalItem;
|
| - }
|
| - // search for the element which tabindex is bound to the secret tab index
|
| - if (!focusable) {
|
| - focusable = Polymer.dom(physicalItem).querySelector('[tabindex="' + SECRET + '"]');
|
| - }
|
| - // restore the tab index
|
| - model.tabIndex = 0;
|
| - // focus the focusable element
|
| - this._focusedIndex = idx;
|
| - focusable && focusable.focus();
|
| - },
|
| + /** @this {ActionLink} */
|
| + createdCallback: function() {
|
| + // Action links can start disabled (e.g. <a is="action-link" disabled>).
|
| + this.tabIndex = this.disabled ? -1 : 0;
|
|
|
| - _removeFocusedItem: function() {
|
| - if (this._offscreenFocusedItem) {
|
| - Polymer.dom(this).removeChild(this._offscreenFocusedItem);
|
| - }
|
| - this._offscreenFocusedItem = null;
|
| - this._focusBackfillItem = null;
|
| - this._focusedItem = null;
|
| - this._focusedIndex = -1;
|
| - },
|
| + if (!this.hasAttribute('role'))
|
| + this.setAttribute('role', 'link');
|
|
|
| - _createFocusBackfillItem: function() {
|
| - var pidx, fidx = this._focusedIndex;
|
| - if (this._offscreenFocusedItem || fidx < 0) {
|
| - return;
|
| - }
|
| - if (!this._focusBackfillItem) {
|
| - // create a physical item, so that it backfills the focused item.
|
| - var stampedTemplate = this.stamp(null);
|
| - this._focusBackfillItem = stampedTemplate.root.querySelector('*');
|
| - Polymer.dom(this).appendChild(stampedTemplate.root);
|
| - }
|
| - // get the physical index for the focused index
|
| - pidx = this._getPhysicalIndex(fidx);
|
| + this.addEventListener('keydown', function(e) {
|
| + if (!this.disabled && e.keyIdentifier == 'Enter' && !this.href) {
|
| + // Schedule a click asynchronously because other 'keydown' handlers
|
| + // may still run later (e.g. document.addEventListener('keydown')).
|
| + // Specifically options dialogs break when this timeout isn't here.
|
| + // NOTE: this affects the "trusted" state of the ensuing click. I
|
| + // haven't found anything that breaks because of this (yet).
|
| + window.setTimeout(this.click.bind(this), 0);
|
| + }
|
| + });
|
|
|
| - if (pidx != null) {
|
| - // set the offcreen focused physical item
|
| - this._offscreenFocusedItem = this._physicalItems[pidx];
|
| - // backfill the focused physical item
|
| - this._physicalItems[pidx] = this._focusBackfillItem;
|
| - // hide the focused physical
|
| - this.translate3d(0, HIDDEN_Y, 0, this._offscreenFocusedItem);
|
| + function preventDefault(e) {
|
| + e.preventDefault();
|
| }
|
| - },
|
|
|
| - _restoreFocusedItem: function() {
|
| - var pidx, fidx = this._focusedIndex;
|
| -
|
| - if (!this._offscreenFocusedItem || this._focusedIndex < 0) {
|
| - return;
|
| + function removePreventDefault() {
|
| + document.removeEventListener('selectstart', preventDefault);
|
| + document.removeEventListener('mouseup', removePreventDefault);
|
| }
|
| - // assign models to the focused index
|
| - this._assignModels();
|
| - // get the new physical index for the focused index
|
| - pidx = this._getPhysicalIndex(fidx);
|
|
|
| - if (pidx != null) {
|
| - // flip the focus backfill
|
| - this._focusBackfillItem = this._physicalItems[pidx];
|
| - // restore the focused physical item
|
| - this._physicalItems[pidx] = this._offscreenFocusedItem;
|
| - // reset the offscreen focused item
|
| - this._offscreenFocusedItem = null;
|
| - // hide the physical item that backfills
|
| - this.translate3d(0, HIDDEN_Y, 0, this._focusBackfillItem);
|
| - }
|
| - },
|
| + this.addEventListener('mousedown', function() {
|
| + // This handlers strives to match the behavior of <a href="...">.
|
|
|
| - _didFocus: function(e) {
|
| - var targetModel = this.modelForElement(e.target);
|
| - var focusedModel = this._focusedItem ? this._focusedItem._templateInstance : null;
|
| - var hasOffscreenFocusedItem = this._offscreenFocusedItem !== null;
|
| - var fidx = this._focusedIndex;
|
| + // While the mouse is down, prevent text selection from dragging.
|
| + document.addEventListener('selectstart', preventDefault);
|
| + document.addEventListener('mouseup', removePreventDefault);
|
|
|
| - if (!targetModel || !focusedModel) {
|
| - return;
|
| - }
|
| - if (focusedModel === targetModel) {
|
| - // if the user focused the same item, then bring it into view if it's not visible
|
| - if (!this._isIndexVisible(fidx)) {
|
| - this.scrollToIndex(fidx);
|
| - }
|
| - } else {
|
| - this._restoreFocusedItem();
|
| - // restore tabIndex for the currently focused item
|
| - focusedModel.tabIndex = -1;
|
| - // set the tabIndex for the next focused item
|
| - targetModel.tabIndex = 0;
|
| - fidx = targetModel[this.indexAs];
|
| - this._focusedIndex = fidx;
|
| - this._focusedItem = this._physicalItems[this._getPhysicalIndex(fidx)];
|
| + // If focus started via mouse press, don't show an outline.
|
| + if (document.activeElement != this)
|
| + this.classList.add('no-outline');
|
| + });
|
|
|
| - if (hasOffscreenFocusedItem && !this._offscreenFocusedItem) {
|
| - this._update();
|
| - }
|
| - }
|
| + this.addEventListener('blur', function() {
|
| + this.classList.remove('no-outline');
|
| + });
|
| },
|
|
|
| - _didMoveUp: function() {
|
| - this._focusPhysicalItem(this._focusedIndex - 1);
|
| + /** @type {boolean} */
|
| + set disabled(disabled) {
|
| + if (disabled)
|
| + HTMLAnchorElement.prototype.setAttribute.call(this, 'disabled', '');
|
| + else
|
| + HTMLAnchorElement.prototype.removeAttribute.call(this, 'disabled');
|
| + this.tabIndex = disabled ? -1 : 0;
|
| + },
|
| + get disabled() {
|
| + return this.hasAttribute('disabled');
|
| },
|
|
|
| - _didMoveDown: function() {
|
| - this._focusPhysicalItem(this._focusedIndex + 1);
|
| + /** @override */
|
| + setAttribute: function(attr, val) {
|
| + if (attr.toLowerCase() == 'disabled')
|
| + this.disabled = true;
|
| + else
|
| + HTMLAnchorElement.prototype.setAttribute.apply(this, arguments);
|
| },
|
|
|
| - _didEnter: function(e) {
|
| - this._focusPhysicalItem(this._focusedIndex);
|
| - this._selectionHandler(e.detail.keyboardEvent);
|
| - }
|
| - });
|
| + /** @override */
|
| + removeAttribute: function(attr) {
|
| + if (attr.toLowerCase() == 'disabled')
|
| + this.disabled = false;
|
| + else
|
| + HTMLAnchorElement.prototype.removeAttribute.apply(this, arguments);
|
| + },
|
| + },
|
|
|
| -})();
|
| + extends: 'a',
|
| +});
|
| (function() {
|
|
|
| // monostate data
|
| @@ -9947,7 +9965,7 @@ It may be desirable to only allow users to enter certain characters. You can use
|
| `prevent-invalid-input` and `allowed-pattern` attributes together to accomplish this. This feature
|
| is separate from validation, and `allowed-pattern` does not affect how the input is validated.
|
|
|
| - <!-- only allow characters that match [0-9] -->
|
| + \x3c!-- only allow characters that match [0-9] --\x3e
|
| <input is="iron-input" prevent-invalid-input allowed-pattern="[0-9]">
|
|
|
| @hero hero.svg
|
| @@ -10477,26 +10495,42 @@ var SearchField = Polymer({
|
| return searchInput ? searchInput.value : '';
|
| },
|
|
|
| + /**
|
| + * Sets the value of the search field, if it exists.
|
| + * @param {string} value
|
| + */
|
| + setValue: function(value) {
|
| + var searchInput = this.getSearchInput_();
|
| + if (searchInput)
|
| + searchInput.value = value;
|
| + },
|
| +
|
| /** @param {SearchFieldDelegate} delegate */
|
| setDelegate: function(delegate) {
|
| this.delegate_ = delegate;
|
| },
|
|
|
| + /** @return {Promise<boolean>} */
|
| showAndFocus: function() {
|
| this.showingSearch_ = true;
|
| - this.focus_();
|
| + return this.focus_();
|
| },
|
|
|
| - /** @private */
|
| + /**
|
| + * @return {Promise<boolean>}
|
| + * @private
|
| + */
|
| focus_: function() {
|
| - this.async(function() {
|
| - if (!this.showingSearch_)
|
| - return;
|
| -
|
| - var searchInput = this.getSearchInput_();
|
| - if (searchInput)
|
| - searchInput.focus();
|
| - });
|
| + return new Promise(function(resolve) {
|
| + this.async(function() {
|
| + if (this.showingSearch_) {
|
| + var searchInput = this.getSearchInput_();
|
| + if (searchInput)
|
| + searchInput.focus();
|
| + }
|
| + resolve(this.showingSearch_);
|
| + });
|
| + }.bind(this));
|
| },
|
|
|
| /**
|
| @@ -10823,6 +10857,13 @@ cr.define('downloads', function() {
|
|
|
| return {Manager: Manager};
|
| });
|
| +// Copyright (c) 2012 The Chromium Authors. All rights reserved.
|
| +// Use of this source code is governed by a BSD-style license that can be
|
| +// found in the LICENSE file.
|
| +
|
| +// <include src="../../../../ui/webui/resources/js/i18n_template_no_process.js">
|
| +
|
| +i18nTemplate.process(document, loadTimeData);
|
| // Copyright 2015 The Chromium Authors. All rights reserved.
|
| // Use of this source code is governed by a BSD-style license that can be
|
| // found in the LICENSE file.
|
|
|