Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(3316)

Unified Diff: chrome/browser/resources/md_history/app.crisper.js

Issue 2224003003: Vulcanize MD History to improve page-load performance (Closed) Base URL: https://chromium.googlesource.com/chromium/src.git@master
Patch Set: Change to keyword arguments Created 4 years, 4 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View side-by-side diff with in-line comments
Download patch
Index: chrome/browser/resources/md_history/app.crisper.js
diff --git a/chrome/browser/resources/md_downloads/crisper.js b/chrome/browser/resources/md_history/app.crisper.js
similarity index 68%
copy from chrome/browser/resources/md_downloads/crisper.js
copy to chrome/browser/resources/md_history/app.crisper.js
index 2d17f3a17ee86921ff069832066d8986932d700a..a807f959fc0aad1f1dafe19be558ab3e2a78fe1c 100644
--- a/chrome/browser/resources/md_downloads/crisper.js
+++ b/chrome/browser/resources/md_history/app.crisper.js
@@ -1,75 +1,3 @@
-// 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 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
@@ -1146,4949 +1074,4813 @@ cr.define('cr.ui', function() {
CanExecuteEvent: CanExecuteEvent
};
});
-// 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.
+Polymer({
+ is: 'app-drawer',
-// <include src="../../../../ui/webui/resources/js/assert.js">
+ properties: {
+ /**
+ * The opened state of the drawer.
+ */
+ opened: {
+ type: Boolean,
+ value: false,
+ notify: true,
+ reflectToAttribute: true
+ },
-/**
- * Alias for document.getElementById. Found elements must be HTMLElements.
- * @param {string} id The ID of the element to find.
- * @return {HTMLElement} The found element or null if not found.
- */
-function $(id) {
- var el = document.getElementById(id);
- return el ? assertInstanceof(el, HTMLElement) : null;
-}
+ /**
+ * The drawer does not have a scrim and cannot be swiped close.
+ */
+ persistent: {
+ type: Boolean,
+ value: false,
+ reflectToAttribute: true
+ },
-// TODO(devlin): This should return SVGElement, but closure compiler is missing
-// those externs.
-/**
- * Alias for document.getElementById. Found elements must be SVGElements.
- * @param {string} id The ID of the element to find.
- * @return {Element} The found element or null if not found.
- */
-function getSVGElement(id) {
- var el = document.getElementById(id);
- return el ? assertInstanceof(el, Element) : null;
-}
+ /**
+ * The alignment of the drawer on the screen ('left', 'right', 'start' or 'end').
+ * 'start' computes to left and 'end' to right in LTR layout and vice versa in RTL
+ * layout.
+ */
+ align: {
+ type: String,
+ value: 'left'
+ },
-/**
- * Add an accessible message to the page that will be announced to
- * users who have spoken feedback on, but will be invisible to all
- * other users. It's removed right away so it doesn't clutter the DOM.
- * @param {string} msg The text to be pronounced.
- */
-function announceAccessibleMessage(msg) {
- var element = document.createElement('div');
- element.setAttribute('aria-live', 'polite');
- element.style.position = 'relative';
- element.style.left = '-9999px';
- element.style.height = '0px';
- element.innerText = msg;
- document.body.appendChild(element);
- window.setTimeout(function() {
- document.body.removeChild(element);
- }, 0);
-}
+ /**
+ * The computed, read-only position of the drawer on the screen ('left' or 'right').
+ */
+ position: {
+ type: String,
+ readOnly: true,
+ value: 'left',
+ reflectToAttribute: true
+ },
-/**
- * Generates a CSS url string.
- * @param {string} s The URL to generate the CSS url for.
- * @return {string} The CSS url string.
- */
-function url(s) {
- // http://www.w3.org/TR/css3-values/#uris
- // Parentheses, commas, whitespace characters, single quotes (') and double
- // quotes (") appearing in a URI must be escaped with a backslash
- var s2 = s.replace(/(\(|\)|\,|\s|\'|\"|\\)/g, '\\$1');
- // WebKit has a bug when it comes to URLs that end with \
- // https://bugs.webkit.org/show_bug.cgi?id=28885
- if (/\\\\$/.test(s2)) {
- // Add a space to work around the WebKit bug.
- s2 += ' ';
- }
- return 'url("' + s2 + '")';
-}
+ /**
+ * Create an area at the edge of the screen to swipe open the drawer.
+ */
+ swipeOpen: {
+ type: Boolean,
+ value: false,
+ reflectToAttribute: true
+ },
-/**
- * Parses query parameters from Location.
- * @param {Location} location The URL to generate the CSS url for.
- * @return {Object} Dictionary containing name value pairs for URL
- */
-function parseQueryParams(location) {
- var params = {};
- var query = unescape(location.search.substring(1));
- var vars = query.split('&');
- for (var i = 0; i < vars.length; i++) {
- var pair = vars[i].split('=');
- params[pair[0]] = pair[1];
- }
- return params;
-}
+ /**
+ * Trap keyboard focus when the drawer is opened and not persistent.
+ */
+ noFocusTrap: {
+ type: Boolean,
+ value: false
+ }
+ },
-/**
- * Creates a new URL by appending or replacing the given query key and value.
- * Not supporting URL with username and password.
- * @param {Location} location The original URL.
- * @param {string} key The query parameter name.
- * @param {string} value The query parameter value.
- * @return {string} The constructed new URL.
- */
-function setQueryParam(location, key, value) {
- var query = parseQueryParams(location);
- query[encodeURIComponent(key)] = encodeURIComponent(value);
+ observers: [
+ 'resetLayout(position)',
+ '_resetPosition(align, isAttached)'
+ ],
- var newQuery = '';
- for (var q in query) {
- newQuery += (newQuery ? '&' : '?') + q + '=' + query[q];
- }
+ _translateOffset: 0,
- return location.origin + location.pathname + newQuery + location.hash;
-}
+ _trackDetails: null,
-/**
- * @param {Node} el A node to search for ancestors with |className|.
- * @param {string} className A class to search for.
- * @return {Element} A node with class of |className| or null if none is found.
- */
-function findAncestorByClass(el, className) {
- return /** @type {Element} */(findAncestor(el, function(el) {
- return el.classList && el.classList.contains(className);
- }));
-}
+ _drawerState: 0,
-/**
- * Return the first ancestor for which the {@code predicate} returns true.
- * @param {Node} node The node to check.
- * @param {function(Node):boolean} predicate The function that tests the
- * nodes.
- * @return {Node} The found ancestor or null if not found.
- */
-function findAncestor(node, predicate) {
- var last = false;
- while (node != null && !(last = predicate(node))) {
- node = node.parentNode;
- }
- return last ? node : null;
-}
+ _boundEscKeydownHandler: null,
-function swapDomNodes(a, b) {
- var afterA = a.nextSibling;
- if (afterA == b) {
- swapDomNodes(b, a);
- return;
- }
- var aParent = a.parentNode;
- b.parentNode.replaceChild(a, b);
- aParent.insertBefore(b, afterA);
-}
+ _firstTabStop: null,
-/**
- * Disables text selection and dragging, with optional whitelist callbacks.
- * @param {function(Event):boolean=} opt_allowSelectStart Unless this function
- * is defined and returns true, the onselectionstart event will be
- * surpressed.
- * @param {function(Event):boolean=} opt_allowDragStart Unless this function
- * is defined and returns true, the ondragstart event will be surpressed.
- */
-function disableTextSelectAndDrag(opt_allowSelectStart, opt_allowDragStart) {
- // Disable text selection.
- document.onselectstart = function(e) {
- if (!(opt_allowSelectStart && opt_allowSelectStart.call(this, e)))
- e.preventDefault();
- };
+ _lastTabStop: null,
- // Disable dragging.
- document.ondragstart = function(e) {
- if (!(opt_allowDragStart && opt_allowDragStart.call(this, e)))
- e.preventDefault();
- };
-}
+ ready: function() {
+ // Set the scroll direction so you can vertically scroll inside the drawer.
+ this.setScrollDirection('y');
-/**
- * TODO(dbeam): DO NOT USE. THIS IS DEPRECATED. Use an action-link instead.
- * Call this to stop clicks on <a href="#"> links from scrolling to the top of
- * the page (and possibly showing a # in the link).
- */
-function preventDefaultOnPoundLinkClicks() {
- document.addEventListener('click', function(e) {
- var anchor = findAncestor(/** @type {Node} */(e.target), function(el) {
- return el.tagName == 'A';
- });
- // Use getAttribute() to prevent URL normalization.
- if (anchor && anchor.getAttribute('href') == '#')
- e.preventDefault();
- });
-}
+ // Only transition the drawer after its first render (e.g. app-drawer-layout
+ // may need to set the initial opened state which should not be transitioned).
+ this._setTransitionDuration('0s');
+ },
-/**
- * Check the directionality of the page.
- * @return {boolean} True if Chrome is running an RTL UI.
- */
-function isRTL() {
- return document.documentElement.dir == 'rtl';
-}
+ attached: function() {
+ // Only transition the drawer after its first render (e.g. app-drawer-layout
+ // may need to set the initial opened state which should not be transitioned).
+ Polymer.RenderStatus.afterNextRender(this, function() {
+ this._setTransitionDuration('');
+ this._boundEscKeydownHandler = this._escKeydownHandler.bind(this);
+ this._resetDrawerState();
+
+ this.listen(this, 'track', '_track');
+ this.addEventListener('transitionend', this._transitionend.bind(this));
+ this.addEventListener('keydown', this._tabKeydownHandler.bind(this))
+ });
+ },
-/**
- * Get an element that's known to exist by its ID. We use this instead of just
- * calling getElementById and not checking the result because this lets us
- * satisfy the JSCompiler type system.
- * @param {string} id The identifier name.
- * @return {!HTMLElement} the Element.
- */
-function getRequiredElement(id) {
- return assertInstanceof($(id), HTMLElement,
- 'Missing required element: ' + id);
-}
+ detached: function() {
+ document.removeEventListener('keydown', this._boundEscKeydownHandler);
+ },
-/**
- * Query an element that's known to exist by a selector. We use this instead of
- * just calling querySelector and not checking the result because this lets us
- * satisfy the JSCompiler type system.
- * @param {string} selectors CSS selectors to query the element.
- * @param {(!Document|!DocumentFragment|!Element)=} opt_context An optional
- * context object for querySelector.
- * @return {!HTMLElement} the Element.
- */
-function queryRequiredElement(selectors, opt_context) {
- var element = (opt_context || document).querySelector(selectors);
- return assertInstanceof(element, HTMLElement,
- 'Missing required element: ' + selectors);
-}
+ /**
+ * Opens the drawer.
+ */
+ open: function() {
+ this.opened = true;
+ },
-// Handle click on a link. If the link points to a chrome: or file: url, then
-// call into the browser to do the navigation.
-document.addEventListener('click', function(e) {
- if (e.defaultPrevented)
- return;
-
- var el = e.target;
- if (el.nodeType == Node.ELEMENT_NODE &&
- el.webkitMatchesSelector('A, A *')) {
- while (el.tagName != 'A') {
- el = el.parentElement;
- }
+ /**
+ * Closes the drawer.
+ */
+ close: function() {
+ this.opened = false;
+ },
- if ((el.protocol == 'file:' || el.protocol == 'about:') &&
- (e.button == 0 || e.button == 1)) {
- chrome.send('navigateToUrl', [
- el.href,
- el.target,
- e.button,
- e.altKey,
- e.ctrlKey,
- e.metaKey,
- e.shiftKey
- ]);
- e.preventDefault();
- }
- }
-});
+ /**
+ * Toggles the drawer open and close.
+ */
+ toggle: function() {
+ this.opened = !this.opened;
+ },
-/**
- * Creates a new URL which is the old URL with a GET param of key=value.
- * @param {string} url The base URL. There is not sanity checking on the URL so
- * it must be passed in a proper format.
- * @param {string} key The key of the param.
- * @param {string} value The value of the param.
- * @return {string} The new URL.
- */
-function appendParam(url, key, value) {
- var param = encodeURIComponent(key) + '=' + encodeURIComponent(value);
+ /**
+ * Gets the width of the drawer.
+ *
+ * @return {number} The width of the drawer in pixels.
+ */
+ getWidth: function() {
+ return this.$.contentContainer.offsetWidth;
+ },
- if (url.indexOf('?') == -1)
- return url + '?' + param;
- return url + '&' + param;
-}
+ /**
+ * Resets the layout. If you changed the size of app-header via CSS
+ * you can notify the changes by either firing the `iron-resize` event
+ * or calling `resetLayout` directly.
+ *
+ * @method resetLayout
+ */
+ resetLayout: function() {
+ this.debounce('_resetLayout', function() {
+ this.fire('app-drawer-reset-layout');
+ }, 1);
+ },
-/**
- * Creates an element of a specified type with a specified class name.
- * @param {string} type The node type.
- * @param {string} className The class name to use.
- * @return {Element} The created element.
- */
-function createElementWithClassName(type, className) {
- var elm = document.createElement(type);
- elm.className = className;
- return elm;
-}
+ _isRTL: function() {
+ return window.getComputedStyle(this).direction === 'rtl';
+ },
-/**
- * webkitTransitionEnd does not always fire (e.g. when animation is aborted
- * 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=} 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, opt_timeOut) {
- if (opt_timeOut === undefined) {
- var style = getComputedStyle(el);
- opt_timeOut = parseFloat(style.transitionDuration) * 1000;
+ _resetPosition: function() {
+ switch (this.align) {
+ case 'start':
+ this._setPosition(this._isRTL() ? 'right' : 'left');
+ return;
+ case 'end':
+ this._setPosition(this._isRTL() ? 'left' : 'right');
+ return;
+ }
+ this._setPosition(this.align);
+ },
- // Give an additional 50ms buffer for the animation to complete.
- opt_timeOut += 50;
- }
+ _escKeydownHandler: function(event) {
+ var ESC_KEYCODE = 27;
+ if (event.keyCode === ESC_KEYCODE) {
+ // Prevent any side effects if app-drawer closes.
+ event.preventDefault();
+ this.close();
+ }
+ },
- var fired = false;
- el.addEventListener('webkitTransitionEnd', function f(e) {
- el.removeEventListener('webkitTransitionEnd', f);
- fired = true;
- });
- window.setTimeout(function() {
- if (!fired)
- cr.dispatchSimpleEvent(el, 'webkitTransitionEnd', true);
- }, opt_timeOut);
-}
+ _track: function(event) {
+ if (this.persistent) {
+ return;
+ }
-/**
- * Alias for document.scrollTop getter.
- * @param {!HTMLDocument} doc The document node where information will be
- * queried from.
- * @return {number} The Y document scroll offset.
- */
-function scrollTopForDocument(doc) {
- return doc.documentElement.scrollTop || doc.body.scrollTop;
-}
+ // Disable user selection on desktop.
+ event.preventDefault();
-/**
- * Alias for document.scrollTop setter.
- * @param {!HTMLDocument} doc The document node where information will be
- * queried from.
- * @param {number} value The target Y scroll offset.
- */
-function setScrollTopForDocument(doc, value) {
- doc.documentElement.scrollTop = doc.body.scrollTop = value;
-}
+ switch (event.detail.state) {
+ case 'start':
+ this._trackStart(event);
+ break;
+ case 'track':
+ this._trackMove(event);
+ break;
+ case 'end':
+ this._trackEnd(event);
+ break;
+ }
+ },
-/**
- * Alias for document.scrollLeft getter.
- * @param {!HTMLDocument} doc The document node where information will be
- * queried from.
- * @return {number} The X document scroll offset.
- */
-function scrollLeftForDocument(doc) {
- return doc.documentElement.scrollLeft || doc.body.scrollLeft;
-}
+ _trackStart: function(event) {
+ this._drawerState = this._DRAWER_STATE.TRACKING;
-/**
- * Alias for document.scrollLeft setter.
- * @param {!HTMLDocument} doc The document node where information will be
- * queried from.
- * @param {number} value The target X scroll offset.
- */
-function setScrollLeftForDocument(doc, value) {
- doc.documentElement.scrollLeft = doc.body.scrollLeft = value;
-}
+ // Disable transitions since style attributes will reflect user track events.
+ this._setTransitionDuration('0s');
+ this.style.visibility = 'visible';
-/**
- * Replaces '&', '<', '>', '"', and ''' characters with their HTML encoding.
- * @param {string} original The original string.
- * @return {string} The string with all the characters mentioned above replaced.
- */
-function HTMLEscape(original) {
- return original.replace(/&/g, '&amp;')
- .replace(/</g, '&lt;')
- .replace(/>/g, '&gt;')
- .replace(/"/g, '&quot;')
- .replace(/'/g, '&#39;');
-}
+ var rect = this.$.contentContainer.getBoundingClientRect();
+ if (this.position === 'left') {
+ this._translateOffset = rect.left;
+ } else {
+ this._translateOffset = rect.right - window.innerWidth;
+ }
-/**
- * Shortens the provided string (if necessary) to a string of length at most
- * |maxLength|.
- * @param {string} original The original string.
- * @param {number} maxLength The maximum length allowed for the string.
- * @return {string} The original string if its length does not exceed
- * |maxLength|. Otherwise the first |maxLength| - 1 characters with '...'
- * appended.
- */
-function elide(original, maxLength) {
- if (original.length <= maxLength)
- return original;
- return original.substring(0, maxLength - 1) + '\u2026';
-}
+ this._trackDetails = [];
+ },
-/**
- * Quote a string so it can be used in a regular expression.
- * @param {string} str The source string.
- * @return {string} The escaped string.
- */
-function quoteString(str) {
- return str.replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1');
-}
+ _trackMove: function(event) {
+ this._translateDrawer(event.detail.dx + this._translateOffset);
-// <if expr="is_ios">
-// Polyfill 'key' in KeyboardEvent for iOS.
-// This function is not intended to be complete but should
-// be sufficient enough to have iOS work correctly while
-// it does not support key yet.
-if (!('key' in KeyboardEvent.prototype)) {
- Object.defineProperty(KeyboardEvent.prototype, 'key', {
- /** @this {KeyboardEvent} */
- get: function () {
- // 0-9
- if (this.keyCode >= 0x30 && this.keyCode <= 0x39)
- return String.fromCharCode(this.keyCode);
-
- // A-Z
- if (this.keyCode >= 0x41 && this.keyCode <= 0x5a) {
- var result = String.fromCharCode(this.keyCode).toLowerCase();
- if (this.shiftKey)
- result = result.toUpperCase();
- return result;
- }
-
- // Special characters
- switch(this.keyCode) {
- case 0x08: return 'Backspace';
- case 0x09: return 'Tab';
- case 0x0d: return 'Enter';
- case 0x10: return 'Shift';
- case 0x11: return 'Control';
- case 0x12: return 'Alt';
- case 0x1b: return 'Escape';
- case 0x20: return ' ';
- case 0x21: return 'PageUp';
- case 0x22: return 'PageDown';
- case 0x23: return 'End';
- case 0x24: return 'Home';
- case 0x25: return 'ArrowLeft';
- case 0x26: return 'ArrowUp';
- case 0x27: return 'ArrowRight';
- case 0x28: return 'ArrowDown';
- case 0x2d: return 'Insert';
- case 0x2e: return 'Delete';
- case 0x5b: return 'Meta';
- case 0x70: return 'F1';
- case 0x71: return 'F2';
- case 0x72: return 'F3';
- case 0x73: return 'F4';
- case 0x74: return 'F5';
- case 0x75: return 'F6';
- case 0x76: return 'F7';
- case 0x77: return 'F8';
- case 0x78: return 'F9';
- case 0x79: return 'F10';
- case 0x7a: return 'F11';
- case 0x7b: return 'F12';
- case 0xbb: return '=';
- case 0xbd: return '-';
- case 0xdb: return '[';
- case 0xdd: return ']';
- }
- return 'Unidentified';
- }
- });
-} else {
- window.console.log("KeyboardEvent.Key polyfill not required");
-}
-// </if> /* is_ios */
-/**
- * `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'
+ // Use Date.now() since event.timeStamp is inconsistent across browsers (e.g. most
+ // browsers use milliseconds but FF 44 uses microseconds).
+ this._trackDetails.push({
+ dx: event.detail.dx,
+ timeStamp: Date.now()
+ });
},
- /**
- * True if this element is currently notifying its descedant elements of
- * resize.
- */
- _notifyingDescendant: {
- type: Boolean,
- value: false
- }
- },
+ _trackEnd: function(event) {
+ var x = event.detail.dx + this._translateOffset;
+ var drawerWidth = this.getWidth();
+ var isPositionLeft = this.position === 'left';
+ var isInEndState = isPositionLeft ? (x >= 0 || x <= -drawerWidth) :
+ (x <= 0 || x >= drawerWidth);
- listeners: {
- 'iron-request-resize-notifications': '_onIronRequestResizeNotifications'
- },
+ if (!isInEndState) {
+ // No longer need the track events after this method returns - allow them to be GC'd.
+ var trackDetails = this._trackDetails;
+ this._trackDetails = null;
- 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);
- },
+ this._flingDrawer(event, trackDetails);
+ if (this._drawerState === this._DRAWER_STATE.FLINGING) {
+ return;
+ }
+ }
- attached: function() {
- this.fire('iron-request-resize-notifications', null, {
- node: this,
- bubbles: true,
- cancelable: true
- });
+ // If the drawer is not flinging, toggle the opened state based on the position of
+ // the drawer.
+ var halfWidth = drawerWidth / 2;
+ if (event.detail.dx < -halfWidth) {
+ this.opened = this.position === 'right';
+ } else if (event.detail.dx > halfWidth) {
+ this.opened = this.position === 'left';
+ }
- if (!this._parentResizable) {
- window.addEventListener('resize', this._boundNotifyResize);
- this.notifyResize();
- }
- },
-
- detached: function() {
- if (this._parentResizable) {
- this._parentResizable.stopResizeNotificationsFor(this);
- } else {
- window.removeEventListener('resize', this._boundNotifyResize);
- }
+ // Trigger app-drawer-transitioned now since there will be no transitionend event.
+ if (isInEndState) {
+ this._resetDrawerState();
+ }
- this._parentResizable = null;
- },
+ this._setTransitionDuration('');
+ this._resetDrawerTranslate();
+ this.style.visibility = '';
+ },
- /**
- * Can be called to manually notify a resizable and its descendant
- * resizables of a resize change.
- */
- notifyResize: function() {
- if (!this.isAttached) {
- return;
- }
+ _calculateVelocity: function(event, trackDetails) {
+ // Find the oldest track event that is within 100ms using binary search.
+ var now = Date.now();
+ var timeLowerBound = now - 100;
+ var trackDetail;
+ var min = 0;
+ var max = trackDetails.length - 1;
- this._interestedResizables.forEach(function(resizable) {
- if (this.resizerShouldNotify(resizable)) {
- this._notifyDescendant(resizable);
+ while (min <= max) {
+ // Floor of average of min and max.
+ var mid = (min + max) >> 1;
+ var d = trackDetails[mid];
+ if (d.timeStamp >= timeLowerBound) {
+ trackDetail = d;
+ max = mid - 1;
+ } else {
+ min = mid + 1;
+ }
}
- }, this);
- this._fireResize();
- },
+ if (trackDetail) {
+ var dx = event.detail.dx - trackDetail.dx;
+ var dt = (now - trackDetail.timeStamp) || 1;
+ return dx / dt;
+ }
+ return 0;
+ },
- /**
- * Used to assign the closest resizable ancestor to this resizable
- * if the ancestor detects a request for notifications.
- */
- assignParentResizable: function(parentResizable) {
- this._parentResizable = parentResizable;
- },
+ _flingDrawer: function(event, trackDetails) {
+ var velocity = this._calculateVelocity(event, trackDetails);
- /**
- * 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);
+ // Do not fling if velocity is not above a threshold.
+ if (Math.abs(velocity) < this._MIN_FLING_THRESHOLD) {
+ return;
+ }
- if (index > -1) {
- this._interestedResizables.splice(index, 1);
- this.unlisten(target, 'iron-resize', '_onDescendantIronResize');
- }
- },
+ this._drawerState = this._DRAWER_STATE.FLINGING;
- /**
- * 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; },
+ var x = event.detail.dx + this._translateOffset;
+ var drawerWidth = this.getWidth();
+ var isPositionLeft = this.position === 'left';
+ var isVelocityPositive = velocity > 0;
+ var isClosingLeft = !isVelocityPositive && isPositionLeft;
+ var isClosingRight = isVelocityPositive && !isPositionLeft;
+ var dx;
+ if (isClosingLeft) {
+ dx = -(x + drawerWidth);
+ } else if (isClosingRight) {
+ dx = (drawerWidth - x);
+ } else {
+ dx = -x;
+ }
- _onDescendantIronResize: function(event) {
- if (this._notifyingDescendant) {
- event.stopPropagation();
- return;
- }
+ // Enforce a minimum transition velocity to make the drawer feel snappy.
+ if (isVelocityPositive) {
+ velocity = Math.max(velocity, this._MIN_TRANSITION_VELOCITY);
+ this.opened = this.position === 'left';
+ } else {
+ velocity = Math.min(velocity, -this._MIN_TRANSITION_VELOCITY);
+ this.opened = this.position === 'right';
+ }
- // 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();
- }
- },
+ // Calculate the amount of time needed to finish the transition based on the
+ // initial slope of the timing function.
+ this._setTransitionDuration((this._FLING_INITIAL_SLOPE * dx / velocity) + 'ms');
+ this._setTransitionTimingFunction(this._FLING_TIMING_FUNCTION);
- _fireResize: function() {
- this.fire('iron-resize', null, {
- node: this,
- bubbles: false
- });
- },
+ this._resetDrawerTranslate();
+ },
- _onIronRequestResizeNotifications: function(event) {
- var target = event.path ? event.path[0] : event.target;
+ _transitionend: function(event) {
+ // contentContainer will transition on opened state changed, and scrim will
+ // transition on persistent state changed when opened - these are the
+ // transitions we are interested in.
+ var target = Polymer.dom(event).rootTarget;
+ if (target === this.$.contentContainer || target === this.$.scrim) {
- if (target === this) {
- return;
- }
+ // If the drawer was flinging, we need to reset the style attributes.
+ if (this._drawerState === this._DRAWER_STATE.FLINGING) {
+ this._setTransitionDuration('');
+ this._setTransitionTimingFunction('');
+ this.style.visibility = '';
+ }
- if (this._interestedResizables.indexOf(target) === -1) {
- this._interestedResizables.push(target);
- this.listen(target, 'iron-resize', '_onDescendantIronResize');
- }
+ this._resetDrawerState();
+ }
+ },
- target.assignParentResizable(this);
- this._notifyDescendant(target);
+ _setTransitionDuration: function(duration) {
+ this.$.contentContainer.style.transitionDuration = duration;
+ this.$.scrim.style.transitionDuration = duration;
+ },
- event.stopPropagation();
- },
+ _setTransitionTimingFunction: function(timingFunction) {
+ this.$.contentContainer.style.transitionTimingFunction = timingFunction;
+ this.$.scrim.style.transitionTimingFunction = timingFunction;
+ },
- _parentResizableChanged: function(parentResizable) {
- if (parentResizable) {
- window.removeEventListener('resize', this._boundNotifyResize);
- }
- },
+ _translateDrawer: function(x) {
+ var drawerWidth = this.getWidth();
- _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;
- }
+ if (this.position === 'left') {
+ x = Math.max(-drawerWidth, Math.min(x, 0));
+ this.$.scrim.style.opacity = 1 + x / drawerWidth;
+ } else {
+ x = Math.max(0, Math.min(x, drawerWidth));
+ this.$.scrim.style.opacity = 1 - x / drawerWidth;
+ }
- this._notifyingDescendant = true;
- descendant.notifyResize();
- this._notifyingDescendant = false;
- }
- };
-(function() {
- 'use strict';
+ this.translate3d(x + 'px', '0', '0', this.$.contentContainer);
+ },
- /**
- * Chrome uses an older version of DOM Level 3 Keyboard Events
- *
- * Most keys are labeled as text, but some are Unicode codepoints.
- * Values taken from: http://www.w3.org/TR/2007/WD-DOM-Level-3-Events-20071221/keyset.html#KeySet-Set
- */
- var KEY_IDENTIFIER = {
- 'U+0008': 'backspace',
- 'U+0009': 'tab',
- 'U+001B': 'esc',
- 'U+0020': 'space',
- 'U+007F': 'del'
- };
+ _resetDrawerTranslate: function() {
+ this.$.scrim.style.opacity = '';
+ this.transform('', this.$.contentContainer);
+ },
- /**
- * 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: '*'
- };
+ _resetDrawerState: function() {
+ var oldState = this._drawerState;
+ if (this.opened) {
+ this._drawerState = this.persistent ?
+ this._DRAWER_STATE.OPENED_PERSISTENT : this._DRAWER_STATE.OPENED;
+ } else {
+ this._drawerState = this._DRAWER_STATE.CLOSED;
+ }
- /**
- * 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'
- };
+ if (oldState !== this._drawerState) {
+ if (this._drawerState === this._DRAWER_STATE.OPENED) {
+ this._setKeyboardFocusTrap();
+ document.addEventListener('keydown', this._boundEscKeydownHandler);
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.removeEventListener('keydown', this._boundEscKeydownHandler);
+ document.body.style.overflow = '';
+ }
- /**
- * 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*]/;
+ // Don't fire the event on initial load.
+ if (oldState !== this._DRAWER_STATE.INIT) {
+ this.fire('app-drawer-transitioned');
+ }
+ }
+ },
- /**
- * Matches a keyIdentifier string.
- */
- var IDENT_CHAR = /U\+/;
+ _setKeyboardFocusTrap: function() {
+ if (this.noFocusTrap) {
+ return;
+ }
- /**
- * Matches arrow keys in Gecko 27.0+
- */
- var ARROW_KEY = /^arrow/;
+ // NOTE: Unless we use /deep/ (which we shouldn't since it's deprecated), this will
+ // not select focusable elements inside shadow roots.
+ var focusableElementsSelector = [
+ 'a[href]:not([tabindex="-1"])',
+ 'area[href]:not([tabindex="-1"])',
+ 'input:not([disabled]):not([tabindex="-1"])',
+ 'select:not([disabled]):not([tabindex="-1"])',
+ 'textarea:not([disabled]):not([tabindex="-1"])',
+ 'button:not([disabled]):not([tabindex="-1"])',
+ 'iframe:not([tabindex="-1"])',
+ '[tabindex]:not([tabindex="-1"])',
+ '[contentEditable=true]:not([tabindex="-1"])'
+ ].join(',');
+ var focusableElements = Polymer.dom(this).querySelectorAll(focusableElementsSelector);
+
+ if (focusableElements.length > 0) {
+ this._firstTabStop = focusableElements[0];
+ this._lastTabStop = focusableElements[focusableElements.length - 1];
+ } else {
+ // Reset saved tab stops when there are no focusable elements in the drawer.
+ this._firstTabStop = null;
+ this._lastTabStop = null;
+ }
- /**
- * Matches space keys everywhere (notably including IE10's exceptional name
- * `spacebar`).
- */
- var SPACE_KEY = /^space(bar)?/;
+ // Focus on app-drawer if it has non-zero tabindex. Otherwise, focus the first focusable
+ // element in the drawer, if it exists. Use the tabindex attribute since the this.tabIndex
+ // property in IE/Edge returns 0 (instead of -1) when the attribute is not set.
+ var tabindex = this.getAttribute('tabindex');
+ if (tabindex && parseInt(tabindex, 10) > -1) {
+ this.focus();
+ } else if (this._firstTabStop) {
+ this._firstTabStop.focus();
+ }
+ },
- /**
- * Matches ESC key.
- *
- * Value from: http://w3c.github.io/uievents-key/#key-Escape
- */
- var ESC_KEY = /^escape$/;
+ _tabKeydownHandler: function(event) {
+ if (this.noFocusTrap) {
+ return;
+ }
- /**
- * 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;
+ var TAB_KEYCODE = 9;
+ if (this._drawerState === this._DRAWER_STATE.OPENED && event.keyCode === TAB_KEYCODE) {
+ if (event.shiftKey) {
+ if (this._firstTabStop && Polymer.dom(event).localTarget === this._firstTabStop) {
+ event.preventDefault();
+ this._lastTabStop.focus();
+ }
+ } else {
+ if (this._lastTabStop && Polymer.dom(event).localTarget === this._lastTabStop) {
+ event.preventDefault();
+ this._firstTabStop.focus();
+ }
}
- } 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;
}
+ },
+
+ _MIN_FLING_THRESHOLD: 0.2,
+
+ _MIN_TRANSITION_VELOCITY: 1.2,
+
+ _FLING_TIMING_FUNCTION: 'cubic-bezier(0.667, 1, 0.667, 1)',
+
+ _FLING_INITIAL_SLOPE: 1.5,
+
+ _DRAWER_STATE: {
+ INIT: 0,
+ OPENED: 1,
+ OPENED_PERSISTENT: 2,
+ CLOSED: 3,
+ TRACKING: 4,
+ FLINGING: 5
}
- 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(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 ? keyEvent.detail.key : keyEvent.detail, 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'
- };
- }
- 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()
- });
- }
+ /**
+ * Fired when the layout of app-drawer has changed.
+ *
+ * @event app-drawer-reset-layout
+ */
- function parseEventString(eventString) {
- return eventString.trim().split(' ').map(function(keyComboString) {
- return parseKeyComboString(keyComboString);
- });
- }
+ /**
+ * Fired when app-drawer has finished transitioning.
+ *
+ * @event app-drawer-transitioned
+ */
+ });
+(function() {
+ 'use strict';
- /**
- * `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 callback. A key binding has the format
- * `"KEY+MODIFIER:EVENT": "callback"` (`"KEY": "callback"` or
- * `"KEY:EVENT": "callback"` are valid as well). Some examples:
- *
- * keyBindings: {
- * 'space': '_onKeydown', // same as 'space:keydown'
- * 'shift+tab': '_onKeydown',
- * 'enter:keypress': '_onKeypress',
- * 'esc:keyup': '_onKeyup'
- * }
- *
- * The callback will receive with an event containing the following information in `event.detail`:
- *
- * _onKeydown: function(event) {
- * console.log(event.detail.combo); // KEY+MODIFIER, e.g. "shift+tab"
- * console.log(event.detail.key); // KEY only, e.g. "tab"
- * console.log(event.detail.event); // EVENT, e.g. "keydown"
- * console.log(event.detail.keyboardEvent); // the original KeyboardEvent
- * }
- *
- * Use the `keyEventTarget` attribute to set up event handlers on a specific
- * node.
- *
- * See the [demo source code](https://github.com/PolymerElements/iron-a11y-keys-behavior/blob/master/demo/x-key-aware.html)
- * for an example.
- *
- * @demo demo/index.html
- * @polymerBehavior
- */
- Polymer.IronA11yKeysBehavior = {
+ Polymer({
+ is: 'iron-location',
properties: {
/**
- * The EventTarget that will be firing relevant KeyboardEvents. Set it to
- * `null` to disable the listeners.
- * @type {?EventTarget}
+ * The pathname component of the URL.
*/
- keyEventTarget: {
- type: Object,
+ path: {
+ type: String,
+ notify: true,
value: function() {
- return this;
+ return window.decodeURIComponent(window.location.pathname);
}
},
-
/**
- * If true, this property will cause the implementing element to
- * automatically stop propagation on any handled KeyboardEvents.
+ * The query string portion of the URL.
*/
- stopKeyboardEventPropagation: {
- type: Boolean,
- value: false
- },
-
- _boundKeyHandlers: {
- type: Array,
+ query: {
+ type: String,
+ notify: true,
value: function() {
- return [];
+ return window.decodeURIComponent(window.location.search.slice(1));
}
},
-
- // We use this due to a limitation in IE10 where instances will have
- // own properties of everything on the "prototype".
- _imperativeKeyBindings: {
- type: Object,
+ /**
+ * The hash component of the URL.
+ */
+ hash: {
+ type: String,
+ notify: true,
value: function() {
- return {};
+ return window.decodeURIComponent(window.location.hash.slice(1));
}
- }
- },
+ },
+ /**
+ * If the user was on a URL for less than `dwellTime` milliseconds, it
+ * won't be added to the browser's history, but instead will be replaced
+ * by the next entry.
+ *
+ * This is to prevent large numbers of entries from clogging up the user's
+ * browser history. Disable by setting to a negative number.
+ */
+ dwellTime: {
+ type: Number,
+ value: 2000
+ },
- observers: [
- '_resetKeyEventListeners(keyEventTarget, _boundKeyHandlers)'
- ],
+ /**
+ * A regexp that defines the set of URLs that should be considered part
+ * of this web app.
+ *
+ * Clicking on a link that matches this regex won't result in a full page
+ * navigation, but will instead just update the URL state in place.
+ *
+ * This regexp is given everything after the origin in an absolute
+ * URL. So to match just URLs that start with /search/ do:
+ * url-space-regex="^/search/"
+ *
+ * @type {string|RegExp}
+ */
+ urlSpaceRegex: {
+ type: String,
+ value: ''
+ },
+ /**
+ * urlSpaceRegex, but coerced into a regexp.
+ *
+ * @type {RegExp}
+ */
+ _urlSpaceRegExp: {
+ computed: '_makeRegExp(urlSpaceRegex)'
+ },
- /**
- * To be used to express what combination of keys will trigger the relative
- * callback. e.g. `keyBindings: { 'esc': '_onEscPressed'}`
- * @type {Object}
- */
- keyBindings: {},
+ _lastChangedAt: {
+ type: Number
+ },
- registered: function() {
- this._prepKeyBindings();
+ _initialized: {
+ type: Boolean,
+ value: false
+ }
},
-
- attached: function() {
- this._listenKeyEventListeners();
+ hostAttributes: {
+ hidden: true
},
+ observers: [
+ '_updateUrl(path, query, hash)'
+ ],
+ attached: function() {
+ this.listen(window, 'hashchange', '_hashChanged');
+ this.listen(window, 'location-changed', '_urlChanged');
+ this.listen(window, 'popstate', '_urlChanged');
+ this.listen(/** @type {!HTMLBodyElement} */(document.body), 'click', '_globalOnClick');
+ // Give a 200ms grace period to make initial redirects without any
+ // additions to the user's history.
+ this._lastChangedAt = window.performance.now() - (this.dwellTime - 200);
+ this._initialized = true;
+ this._urlChanged();
+ },
detached: function() {
- this._unlistenKeyEventListeners();
+ this.unlisten(window, 'hashchange', '_hashChanged');
+ this.unlisten(window, 'location-changed', '_urlChanged');
+ this.unlisten(window, 'popstate', '_urlChanged');
+ this.unlisten(/** @type {!HTMLBodyElement} */(document.body), 'click', '_globalOnClick');
+ this._initialized = false;
+ },
+ _hashChanged: function() {
+ this.hash = window.decodeURIComponent(window.location.hash.substring(1));
+ },
+ _urlChanged: function() {
+ // We want to extract all info out of the updated URL before we
+ // try to write anything back into it.
+ //
+ // i.e. without _dontUpdateUrl we'd overwrite the new path with the old
+ // one when we set this.hash. Likewise for query.
+ this._dontUpdateUrl = true;
+ this._hashChanged();
+ this.path = window.decodeURIComponent(window.location.pathname);
+ this.query = window.decodeURIComponent(
+ window.location.search.substring(1));
+ this._dontUpdateUrl = false;
+ this._updateUrl();
+ },
+ _getUrl: function() {
+ var partiallyEncodedPath = window.encodeURI(
+ this.path).replace(/\#/g, '%23').replace(/\?/g, '%3F');
+ var partiallyEncodedQuery = '';
+ if (this.query) {
+ partiallyEncodedQuery = '?' + window.encodeURI(
+ this.query).replace(/\#/g, '%23');
+ }
+ var partiallyEncodedHash = '';
+ if (this.hash) {
+ partiallyEncodedHash = '#' + window.encodeURI(this.hash);
+ }
+ return (
+ partiallyEncodedPath + partiallyEncodedQuery + partiallyEncodedHash);
+ },
+ _updateUrl: function() {
+ if (this._dontUpdateUrl || !this._initialized) {
+ return;
+ }
+ if (this.path === window.decodeURIComponent(window.location.pathname) &&
+ this.query === window.decodeURIComponent(
+ window.location.search.substring(1)) &&
+ this.hash === window.decodeURIComponent(
+ window.location.hash.substring(1))) {
+ // Nothing to do, the current URL is a representation of our properties.
+ return;
+ }
+ var newUrl = this._getUrl();
+ // Need to use a full URL in case the containing page has a base URI.
+ var fullNewUrl = new URL(
+ newUrl, window.location.protocol + '//' + window.location.host).href;
+ var now = window.performance.now();
+ var shouldReplace =
+ this._lastChangedAt + this.dwellTime > now;
+ this._lastChangedAt = now;
+ if (shouldReplace) {
+ window.history.replaceState({}, '', fullNewUrl);
+ } else {
+ window.history.pushState({}, '', fullNewUrl);
+ }
+ this.fire('location-changed', {}, {node: window});
},
-
/**
- * 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.
+ * A necessary evil so that links work as expected. Does its best to
+ * bail out early if possible.
+ *
+ * @param {MouseEvent} event .
*/
- addOwnKeyBinding: function(eventString, handlerName) {
- this._imperativeKeyBindings[eventString] = handlerName;
- this._prepKeyBindings();
- this._resetKeyEventListeners();
+ _globalOnClick: function(event) {
+ // If another event handler has stopped this event then there's nothing
+ // for us to do. This can happen e.g. when there are multiple
+ // iron-location elements in a page.
+ if (event.defaultPrevented) {
+ return;
+ }
+ var href = this._getSameOriginLinkHref(event);
+ if (!href) {
+ return;
+ }
+ event.preventDefault();
+ // If the navigation is to the current page we shouldn't add a history
+ // entry or fire a change event.
+ if (href === window.location.href) {
+ return;
+ }
+ window.history.pushState({}, '', href);
+ this.fire('location-changed', {}, {node: window});
},
-
/**
- * When called, will remove all imperatively-added key bindings.
+ * Returns the absolute URL of the link (if any) that this click event
+ * is clicking on, if we can and should override the resulting full
+ * page navigation. Returns null otherwise.
+ *
+ * @param {MouseEvent} event .
+ * @return {string?} .
*/
- removeOwnKeyBindings: function() {
- this._imperativeKeyBindings = {};
- this._prepKeyBindings();
- this._resetKeyEventListeners();
- },
+ _getSameOriginLinkHref: function(event) {
+ // We only care about left-clicks.
+ if (event.button !== 0) {
+ return null;
+ }
+ // We don't want modified clicks, where the intent is to open the page
+ // in a new tab.
+ if (event.metaKey || event.ctrlKey) {
+ return null;
+ }
+ var eventPath = Polymer.dom(event).path;
+ var anchor = null;
+ for (var i = 0; i < eventPath.length; i++) {
+ var element = eventPath[i];
+ if (element.tagName === 'A' && element.href) {
+ anchor = element;
+ break;
+ }
+ }
- /**
- * Returns true if a keyboard event matches `eventString`.
- *
- * @param {KeyboardEvent} event
- * @param {string} eventString
- * @return {boolean}
- */
- keyboardEventMatchesKeys: function(event, eventString) {
- var keyCombos = parseEventString(eventString);
- for (var i = 0; i < keyCombos.length; ++i) {
- if (keyComboMatchesEvent(keyCombos[i], event)) {
- return true;
- }
+ // If there's no link there's nothing to do.
+ if (!anchor) {
+ return null;
}
- return false;
- },
- _collectKeyBindings: function() {
- var keyBindings = this.behaviors.map(function(behavior) {
- return behavior.keyBindings;
- });
+ // Target blank is a new tab, don't intercept.
+ if (anchor.target === '_blank') {
+ return null;
+ }
+ // If the link is for an existing parent frame, don't intercept.
+ if ((anchor.target === '_top' ||
+ anchor.target === '_parent') &&
+ window.top !== window) {
+ return null;
+ }
- if (keyBindings.indexOf(this.keyBindings) === -1) {
- keyBindings.push(this.keyBindings);
+ var href = anchor.href;
+
+ // It only makes sense for us to intercept same-origin navigations.
+ // pushState/replaceState don't work with cross-origin links.
+ var url;
+ if (document.baseURI != null) {
+ url = new URL(href, /** @type {string} */(document.baseURI));
+ } else {
+ url = new URL(href);
}
- return keyBindings;
- },
+ var origin;
- _prepKeyBindings: function() {
- this._keyBindings = {};
+ // IE Polyfill
+ if (window.location.origin) {
+ origin = window.location.origin;
+ } else {
+ origin = window.location.protocol + '//' + window.location.hostname;
- this._collectKeyBindings().forEach(function(keyBindings) {
- for (var eventString in keyBindings) {
- this._addKeyBinding(eventString, keyBindings[eventString]);
+ if (window.location.port) {
+ origin += ':' + window.location.port;
}
- }, this);
-
- for (var eventString in this._imperativeKeyBindings) {
- this._addKeyBinding(eventString, this._imperativeKeyBindings[eventString]);
}
- // Give precedence to combos with modifiers to be checked first.
- for (var eventName in this._keyBindings) {
- this._keyBindings[eventName].sort(function (kb1, kb2) {
- var b1 = kb1[0].hasModifiers;
- var b2 = kb2[0].hasModifiers;
- return (b1 === b2) ? 0 : b1 ? -1 : 1;
- })
+ if (url.origin !== origin) {
+ return null;
}
- },
-
- _addKeyBinding: function(eventString, handlerName) {
- parseEventString(eventString).forEach(function(keyCombo) {
- this._keyBindings[keyCombo.event] =
- this._keyBindings[keyCombo.event] || [];
+ var normalizedHref = url.pathname + url.search + url.hash;
- this._keyBindings[keyCombo.event].push([
- keyCombo,
- handlerName
- ]);
- }, this);
- },
-
- _resetKeyEventListeners: function() {
- this._unlistenKeyEventListeners();
-
- if (this.isAttached) {
- this._listenKeyEventListeners();
+ // If we've been configured not to handle this url... don't handle it!
+ if (this._urlSpaceRegExp &&
+ !this._urlSpaceRegExp.test(normalizedHref)) {
+ return null;
}
+ // Need to use a full URL in case the containing page has a base URI.
+ var fullNormalizedHref = new URL(
+ normalizedHref, window.location.href).href;
+ return fullNormalizedHref;
},
+ _makeRegExp: function(urlSpaceRegex) {
+ return RegExp(urlSpaceRegex);
+ }
+ });
+ })();
+'use strict';
- _listenKeyEventListeners: function() {
- if (!this.keyEventTarget) {
- return;
- }
- Object.keys(this._keyBindings).forEach(function(eventName) {
- var keyBindings = this._keyBindings[eventName];
- var boundKeyHandler = this._onKeyBindingEvent.bind(this, keyBindings);
-
- this._boundKeyHandlers.push([this.keyEventTarget, eventName, boundKeyHandler]);
-
- this.keyEventTarget.addEventListener(eventName, boundKeyHandler);
- }, this);
+ Polymer({
+ is: 'iron-query-params',
+ properties: {
+ paramsString: {
+ type: String,
+ notify: true,
+ observer: 'paramsStringChanged',
},
-
- _unlistenKeyEventListeners: function() {
- var keyHandlerTuple;
- var keyEventTarget;
- var eventName;
- var boundKeyHandler;
-
- while (this._boundKeyHandlers.length) {
- // My kingdom for block-scope binding and destructuring assignment..
- keyHandlerTuple = this._boundKeyHandlers.pop();
- keyEventTarget = keyHandlerTuple[0];
- eventName = keyHandlerTuple[1];
- boundKeyHandler = keyHandlerTuple[2];
-
- keyEventTarget.removeEventListener(eventName, boundKeyHandler);
+ paramsObject: {
+ type: Object,
+ notify: true,
+ value: function() {
+ return {};
}
},
-
- _onKeyBindingEvent: function(keyBindings, event) {
- if (this.stopKeyboardEventPropagation) {
- event.stopPropagation();
- }
-
- // if event has been already prevented, don't do anything
- if (event.defaultPrevented) {
- return;
+ _dontReact: {
+ type: Boolean,
+ value: false
+ }
+ },
+ hostAttributes: {
+ hidden: true
+ },
+ observers: [
+ 'paramsObjectChanged(paramsObject.*)'
+ ],
+ paramsStringChanged: function() {
+ this._dontReact = true;
+ this.paramsObject = this._decodeParams(this.paramsString);
+ this._dontReact = false;
+ },
+ paramsObjectChanged: function() {
+ if (this._dontReact) {
+ return;
+ }
+ this.paramsString = this._encodeParams(this.paramsObject);
+ },
+ _encodeParams: function(params) {
+ var encodedParams = [];
+ for (var key in params) {
+ var value = params[key];
+ if (value === '') {
+ encodedParams.push(encodeURIComponent(key));
+ } else if (value) {
+ encodedParams.push(
+ encodeURIComponent(key) +
+ '=' +
+ encodeURIComponent(value.toString())
+ );
}
+ }
+ return encodedParams.join('&');
+ },
+ _decodeParams: function(paramString) {
+ var params = {};
- 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;
- }
- }
- }
- },
+ // Work around a bug in decodeURIComponent where + is not
+ // converted to spaces:
+ paramString = (paramString || '').replace(/\+/g, '%20');
- _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();
+ var paramList = paramString.split('&');
+ for (var i = 0; i < paramList.length; i++) {
+ var param = paramList[i].split('=');
+ if (param[0]) {
+ params[decodeURIComponent(param[0])] =
+ decodeURIComponent(param[1] || '');
}
}
- };
- })();
-/**
- * `Polymer.IronScrollTargetBehavior` allows an element to respond to scroll events from a
- * designated scroll target.
+ return params;
+ }
+ });
+'use strict';
+
+ /**
+ * Provides bidirectional mapping between `path` and `queryParams` and a
+ * app-route compatible `route` object.
*
- * Elements that consume this behavior can override the `_scrollHandler`
- * method to add logic on the scroll event.
+ * For more information, see the docs for `app-route-converter`.
*
- * @demo demo/scrolling-region.html Scrolling Region
- * @demo demo/document.html Document Element
* @polymerBehavior
*/
- Polymer.IronScrollTargetBehavior = {
-
+ Polymer.AppRouteConverterBehavior = {
properties: {
-
/**
- * Specifies the element that will handle the scroll event
- * on the behalf of the current element. This is typically a reference to an element,
- * but there are a few more posibilities:
- *
- * ### Elements id
- *
- *```html
- * <div id="scrollable-element" style="overflow: auto;">
- * <x-element scroll-target="scrollable-element">
- * \x3c!-- Content--\x3e
- * </x-element>
- * </div>
- *```
- * In this case, the `scrollTarget` will point to the outer div element.
- *
- * ### Document scrolling
+ * A model representing the deserialized path through the route tree, as
+ * well as the current queryParams.
*
- * For document scrolling, you can use the reserved word `document`:
- *
- *```html
- * <x-element scroll-target="document">
- * \x3c!-- Content --\x3e
- * </x-element>
- *```
- *
- * ### Elements reference
+ * A route object is the kernel of the routing system. It is intended to
+ * be fed into consuming elements such as `app-route`.
*
- *```js
- * appHeader.scrollTarget = document.querySelector('#scrollable-element');
- *```
+ * @type {?Object}
+ */
+ route: {
+ type: Object,
+ notify: true
+ },
+
+ /**
+ * A set of key/value pairs that are universally accessible to branches of
+ * the route tree.
*
- * @type {HTMLElement}
+ * @type {?Object}
*/
- scrollTarget: {
- type: HTMLElement,
- value: function() {
- return this._defaultScrollTarget;
- }
+ queryParams: {
+ type: Object,
+ notify: true
+ },
+
+ /**
+ * The serialized path through the route tree. This corresponds to the
+ * `window.location.pathname` value, and will update to reflect changes
+ * to that value.
+ */
+ path: {
+ type: String,
+ notify: true,
}
},
observers: [
- '_scrollTargetChanged(scrollTarget, isAttached)'
+ '_locationChanged(path, queryParams)',
+ '_routeChanged(route.prefix, route.path)',
+ '_routeQueryParamsChanged(route.__queryParams)'
],
- _scrollTargetChanged: function(scrollTarget, isAttached) {
- var eventTarget;
+ created: function() {
+ this.linkPaths('route.__queryParams', 'queryParams');
+ this.linkPaths('queryParams', 'route.__queryParams');
+ },
- if (this._oldScrollTarget) {
- eventTarget = this._oldScrollTarget === this._doc ? window : this._oldScrollTarget;
- eventTarget.removeEventListener('scroll', this._boundScrollHandler);
- this._oldScrollTarget = null;
+ /**
+ * Handler called when the path or queryParams change.
+ */
+ _locationChanged: function() {
+ if (this.route &&
+ this.route.path === this.path &&
+ this.queryParams === this.route.__queryParams) {
+ return;
}
+ this.route = {
+ prefix: '',
+ path: this.path,
+ __queryParams: this.queryParams
+ };
+ },
- if (!isAttached) {
+ /**
+ * Handler called when the route prefix and route path change.
+ */
+ _routeChanged: function() {
+ if (!this.route) {
return;
}
- // Support element id references
- if (scrollTarget === 'document') {
-
- this.scrollTarget = this._doc;
-
- } else if (typeof scrollTarget === 'string') {
-
- this.scrollTarget = this.domHost ? this.domHost.$[scrollTarget] :
- Polymer.dom(this.ownerDocument).querySelector('#' + scrollTarget);
-
- } else if (this._isValidScrollTarget()) {
-
- eventTarget = scrollTarget === this._doc ? window : scrollTarget;
- this._boundScrollHandler = this._boundScrollHandler || this._scrollHandler.bind(this);
- this._oldScrollTarget = scrollTarget;
- eventTarget.addEventListener('scroll', this._boundScrollHandler);
- }
+ this.path = this.route.prefix + this.route.path;
},
/**
- * Runs on every scroll event. Consumer of this behavior may override this method.
+ * Handler called when the route queryParams change.
*
- * @protected
+ * @param {Object} queryParams A set of key/value pairs that are
+ * universally accessible to branches of the route tree.
*/
- _scrollHandler: function scrollHandler() {},
+ _routeQueryParamsChanged: function(queryParams) {
+ if (!this.route) {
+ return;
+ }
+ this.queryParams = queryParams;
+ }
+ };
+'use strict';
- /**
- * The default scroll target. Consumers of this behavior may want to customize
- * the default scroll target.
- *
- * @type {Element}
- */
- get _defaultScrollTarget() {
- return this._doc;
- },
-
- /**
- * Shortcut for the document element
- *
- * @type {Element}
- */
- get _doc() {
- return this.ownerDocument.documentElement;
- },
-
- /**
- * Gets the number of pixels that the content of an element is scrolled upward.
- *
- * @type {number}
- */
- get _scrollTop() {
- if (this._isValidScrollTarget()) {
- return this.scrollTarget === this._doc ? window.pageYOffset : this.scrollTarget.scrollTop;
- }
- return 0;
- },
+ Polymer({
+ is: 'app-location',
- /**
- * Gets the number of pixels that the content of an element is scrolled to the left.
- *
- * @type {number}
- */
- get _scrollLeft() {
- if (this._isValidScrollTarget()) {
- return this.scrollTarget === this._doc ? window.pageXOffset : this.scrollTarget.scrollLeft;
- }
- return 0;
- },
+ properties: {
+ /**
+ * A model representing the deserialized path through the route tree, as
+ * well as the current queryParams.
+ */
+ route: {
+ type: Object,
+ notify: true
+ },
- /**
- * Sets the number of pixels that the content of an element is scrolled upward.
- *
- * @type {number}
- */
- set _scrollTop(top) {
- if (this.scrollTarget === this._doc) {
- window.scrollTo(window.pageXOffset, top);
- } else if (this._isValidScrollTarget()) {
- this.scrollTarget.scrollTop = top;
- }
- },
+ /**
+ * In many scenarios, it is convenient to treat the `hash` as a stand-in
+ * alternative to the `path`. For example, if deploying an app to a static
+ * web server (e.g., Github Pages) - where one does not have control over
+ * server-side routing - it is usually a better experience to use the hash
+ * to represent paths through one's app.
+ *
+ * When this property is set to true, the `hash` will be used in place of
- /**
- * Sets the number of pixels that the content of an element is scrolled to the left.
- *
- * @type {number}
- */
- set _scrollLeft(left) {
- if (this.scrollTarget === this._doc) {
- window.scrollTo(left, window.pageYOffset);
- } else if (this._isValidScrollTarget()) {
- this.scrollTarget.scrollLeft = left;
- }
- },
+ * the `path` for generating a `route`.
+ */
+ useHashAsPath: {
+ type: Boolean,
+ value: false
+ },
- /**
- * Scrolls the content to a particular place.
- *
- * @method scroll
- * @param {number} left The left position
- * @param {number} top The top position
- */
- 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;
- }
- },
+ /**
+ * A regexp that defines the set of URLs that should be considered part
+ * of this web app.
+ *
+ * Clicking on a link that matches this regex won't result in a full page
+ * navigation, but will instead just update the URL state in place.
+ *
+ * This regexp is given everything after the origin in an absolute
+ * URL. So to match just URLs that start with /search/ do:
+ * url-space-regex="^/search/"
+ *
+ * @type {string|RegExp}
+ */
+ urlSpaceRegex: {
+ type: String,
+ notify: true
+ },
- /**
- * Gets the width of the scroll target.
- *
- * @type {number}
- */
- get _scrollTargetWidth() {
- if (this._isValidScrollTarget()) {
- return this.scrollTarget === this._doc ? window.innerWidth : this.scrollTarget.offsetWidth;
- }
- return 0;
- },
+ /**
+ * A set of key/value pairs that are universally accessible to branches
+ * of the route tree.
+ */
+ __queryParams: {
+ type: Object
+ },
- /**
- * 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;
- },
+ /**
+ * The pathname component of the current URL.
+ */
+ __path: {
+ type: String
+ },
- /**
- * Returns true if the scroll target is a valid HTMLElement.
- *
- * @return {boolean}
- */
- _isValidScrollTarget: function() {
- return this.scrollTarget instanceof HTMLElement;
- }
- };
-(function() {
+ /**
+ * The query string portion of the current URL.
+ */
+ __query: {
+ type: String
+ },
- 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 HIDDEN_Y = '-10000px';
- var DEFAULT_GRID_SIZE = 200;
- var SECRET_TABINDEX = -100;
+ /**
+ * The hash portion of the current URL.
+ */
+ __hash: {
+ type: String
+ },
- Polymer({
+ /**
+ * The route path, which will be either the hash or the path, depending
+ * on useHashAsPath.
+ */
+ path: {
+ type: String,
+ observer: '__onPathChanged'
+ }
+ },
- is: 'iron-list',
+ behaviors: [Polymer.AppRouteConverterBehavior],
- properties: {
+ observers: [
+ '__computeRoutePath(useHashAsPath, __hash, __path)'
+ ],
- /**
- * 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
+ __computeRoutePath: function() {
+ this.path = this.useHashAsPath ? this.__hash : this.__path;
},
- /**
- * The max count of physical items the pool can extend to.
- */
- maxPhysicalCount: {
- type: Number,
- value: 500
- },
+ __onPathChanged: function() {
+ if (!this._readied) {
+ return;
+ }
- /**
- * 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'
- },
+ if (this.useHashAsPath) {
+ this.__hash = this.path;
+ } else {
+ this.__path = this.path;
+ }
+ }
+ });
+'use strict';
- /**
- * The name of the variable to add to the binding scope with the index
- * for the row.
- */
- indexAs: {
- type: String,
- value: 'index'
- },
+ Polymer({
+ is: 'app-route',
+ properties: {
/**
- * The name of the variable to add to the binding scope to indicate
- * if the row is selected.
+ * The URL component managed by this element.
*/
- selectedAs: {
- type: String,
- value: 'selected'
+ route: {
+ type: Object,
+ notify: true
},
/**
- * When true, the list is rendered as a grid. Grid items must have
- * fixed width and height set via CSS. e.g.
+ * The pattern of slash-separated segments to match `path` against.
*
- * ```html
- * <iron-list grid>
- * <template>
- * <div style="width: 100px; height: 100px;"> 100x100 </div>
- * </template>
- * </iron-list>
- * ```
+ * For example the pattern "/foo" will match "/foo" or "/foo/bar"
+ * but not "/foobar".
+ *
+ * Path segments like `/:named` are mapped to properties on the `data` object.
*/
- grid: {
- type: Boolean,
- value: false,
- reflectToAttribute: true
+ pattern: {
+ type: String
},
/**
- * 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.
+ * The parameterized values that are extracted from the route as
+ * described by `pattern`.
*/
- selectionEnabled: {
- type: Boolean,
- value: false
+ data: {
+ type: Object,
+ value: function() {return {};},
+ notify: true
},
/**
- * When `multiSelection` is false, this is the currently selected item, or `null`
- * if no item is selected.
+ * @type {?Object}
*/
- selectedItem: {
+ queryParams: {
type: Object,
+ value: function() {
+ return {};
+ },
notify: true
},
/**
- * When `multiSelection` is true, this is an array that contains the selected items.
+ * The part of `path` NOT consumed by `pattern`.
*/
- selectedItems: {
+ tail: {
type: Object,
+ value: function() {return {path: null, prefix: null, __queryParams: null};},
notify: true
},
- /**
- * When `true`, multiple items may be selected at once (in this case,
- * `selected` is an array of currently selected items). When `false`,
- * only one item may be selected at a time.
- */
- multiSelection: {
+ active: {
+ type: Boolean,
+ notify: true,
+ readOnly: true
+ },
+
+ _queryParamsUpdating: {
type: Boolean,
value: false
+ },
+ /**
+ * @type {?string}
+ */
+ _matched: {
+ type: String,
+ value: ''
}
},
observers: [
- '_itemsChanged(items.*)',
- '_selectionEnabledChanged(selectionEnabled)',
- '_multiSelectionChanged(multiSelection)',
- '_setOverflow(scrollTarget)'
- ],
-
- behaviors: [
- Polymer.Templatizer,
- Polymer.IronResizableBehavior,
- Polymer.IronA11yKeysBehavior,
- Polymer.IronScrollTargetBehavior
+ '__tryToMatch(route.path, pattern)',
+ '__updatePathOnDataChange(data.*)',
+ '__tailPathChanged(tail.path)',
+ '__routeQueryParamsChanged(route.__queryParams)',
+ '__tailQueryParamsChanged(tail.__queryParams)',
+ '__queryParamsChanged(queryParams.*)'
],
- keyBindings: {
- 'up': '_didMoveUp',
- 'down': '_didMoveDown',
- 'enter': '_didEnter'
+ created: function() {
+ this.linkPaths('route.__queryParams', 'tail.__queryParams');
+ this.linkPaths('tail.__queryParams', 'route.__queryParams');
},
/**
- * 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.
+ * Deal with the query params object being assigned to wholesale.
+ * @export
*/
- _ratio: 0.5,
+ __routeQueryParamsChanged: function(queryParams) {
+ if (queryParams && this.tail) {
+ this.set('tail.__queryParams', queryParams);
- /**
- * The padding-top value for the list.
- */
- _scrollerPaddingTop: 0,
+ if (!this.active || this._queryParamsUpdating) {
+ return;
+ }
- /**
- * This value is the same as `scrollTop`.
- */
- _scrollPosition: 0,
+ // Copy queryParams and track whether there are any differences compared
+ // to the existing query params.
+ var copyOfQueryParams = {};
+ var anythingChanged = false;
+ for (var key in queryParams) {
+ copyOfQueryParams[key] = queryParams[key];
+ if (anythingChanged ||
+ !this.queryParams ||
+ queryParams[key] !== this.queryParams[key]) {
+ anythingChanged = true;
+ }
+ }
+ // Need to check whether any keys were deleted
+ for (var key in this.queryParams) {
+ if (anythingChanged || !(key in queryParams)) {
+ anythingChanged = true;
+ break;
+ }
+ }
- /**
- * The sum of the heights of all the tiles in the DOM.
- */
- _physicalSize: 0,
+ if (!anythingChanged) {
+ return;
+ }
+ this._queryParamsUpdating = true;
+ this.set('queryParams', copyOfQueryParams);
+ this._queryParamsUpdating = false;
+ }
+ },
/**
- * The average `offsetHeight` of the tiles observed till now.
+ * @export
*/
- _physicalAverage: 0,
+ __tailQueryParamsChanged: function(queryParams) {
+ if (queryParams && this.route) {
+ this.set('route.__queryParams', queryParams);
+ }
+ },
/**
- * The number of tiles which `offsetHeight` > 0 observed until now.
+ * @export
*/
- _physicalAverageCount: 0,
+ __queryParamsChanged: function(changes) {
+ if (!this.active || this._queryParamsUpdating) {
+ return;
+ }
- /**
- * The Y position of the item rendered in the `_physicalStart`
- * tile relative to the scrolling list.
- */
- _physicalTop: 0,
+ this.set('route.__' + changes.path, changes.value);
+ },
- /**
- * The number of items in the list.
- */
- _virtualCount: 0,
+ __resetProperties: function() {
+ this._setActive(false);
+ this._matched = null;
+ //this.tail = { path: null, prefix: null, queryParams: null };
+ //this.data = {};
+ },
/**
- * A map between an item key and its physical item index
+ * @export
*/
- _physicalIndexForKey: null,
+ __tryToMatch: function() {
+ if (!this.route) {
+ return;
+ }
+ var path = this.route.path;
+ var pattern = this.pattern;
+ if (!pattern) {
+ return;
+ }
- /**
- * The estimated scroll height based on `_physicalAverage`
- */
- _estScrollHeight: 0,
+ if (!path) {
+ this.__resetProperties();
+ return;
+ }
- /**
- * The scroll height of the dom node
- */
- _scrollHeight: 0,
+ var remainingPieces = path.split('/');
+ var patternPieces = pattern.split('/');
- /**
- * The height of the list. This is referred as the viewport in the context of list.
- */
- _viewportHeight: 0,
+ var matched = [];
+ var namedMatches = {};
- /**
- * The width of the list. This is referred as the viewport in the context of list.
- */
- _viewportWidth: 0,
+ for (var i=0; i < patternPieces.length; i++) {
+ var patternPiece = patternPieces[i];
+ if (!patternPiece && patternPiece !== '') {
+ break;
+ }
+ var pathPiece = remainingPieces.shift();
- /**
- * An array of DOM nodes that are currently in the tree
- * @type {?Array<!TemplatizerNode>}
- */
- _physicalItems: null,
+ // We don't match this path.
+ if (!pathPiece && pathPiece !== '') {
+ this.__resetProperties();
+ return;
+ }
+ matched.push(pathPiece);
- /**
- * An array of heights for each item in `_physicalItems`
- * @type {?Array<number>}
- */
- _physicalSizes: null,
+ if (patternPiece.charAt(0) == ':') {
+ namedMatches[patternPiece.slice(1)] = pathPiece;
+ } else if (patternPiece !== pathPiece) {
+ this.__resetProperties();
+ return;
+ }
+ }
- /**
- * A cached value for the first visible index.
- * See `firstVisibleIndex`
- * @type {?number}
- */
- _firstVisibleIndexVal: null,
+ this._matched = matched.join('/');
- /**
- * A cached value for the last visible index.
- * See `lastVisibleIndex`
- * @type {?number}
- */
- _lastVisibleIndexVal: null,
+ // Properties that must be updated atomically.
+ var propertyUpdates = {};
- /**
- * A Polymer collection for the items.
- * @type {?Polymer.Collection}
- */
- _collection: null,
+ //this.active
+ if (!this.active) {
+ propertyUpdates.active = true;
+ }
- /**
- * True if the current item list was rendered for the first time
- * after attached.
- */
- _itemsRendered: false,
+ // this.tail
+ var tailPrefix = this.route.prefix + this._matched;
+ var tailPath = remainingPieces.join('/');
+ if (remainingPieces.length > 0) {
+ tailPath = '/' + tailPath;
+ }
+ if (!this.tail ||
+ this.tail.prefix !== tailPrefix ||
+ this.tail.path !== tailPath) {
+ propertyUpdates.tail = {
+ prefix: tailPrefix,
+ path: tailPath,
+ __queryParams: this.route.__queryParams
+ };
+ }
- /**
- * The page that is currently rendered.
- */
- _lastPage: null,
+ // this.data
+ propertyUpdates.data = namedMatches;
+ this._dataInUrl = {};
+ for (var key in namedMatches) {
+ this._dataInUrl[key] = namedMatches[key];
+ }
- /**
- * The max number of pages to render. One page is equivalent to the height of the list.
- */
- _maxPages: 3,
+ this.__setMulti(propertyUpdates);
+ },
/**
- * The currently focused physical item.
+ * @export
*/
- _focusedItem: null,
+ __tailPathChanged: function() {
+ if (!this.active) {
+ return;
+ }
+ var tailPath = this.tail.path;
+ var newPath = this._matched;
+ if (tailPath) {
+ if (tailPath.charAt(0) !== '/') {
+ tailPath = '/' + tailPath;
+ }
+ newPath += tailPath;
+ }
+ this.set('route.path', newPath);
+ },
/**
- * The index of the `_focusedItem`.
+ * @export
*/
- _focusedIndex: -1,
+ __updatePathOnDataChange: function() {
+ if (!this.route || !this.active) {
+ return;
+ }
+ var newPath = this.__getLink({});
+ var oldPath = this.__getLink(this._dataInUrl);
+ if (newPath === oldPath) {
+ return;
+ }
+ this.set('route.path', newPath);
+ },
- /**
- * The the item that is focused if it is moved offscreen.
- * @private {?TemplatizerNode}
- */
- _offscreenFocusedItem: null,
+ __getLink: function(overrideValues) {
+ var values = {tail: null};
+ for (var key in this.data) {
+ values[key] = this.data[key];
+ }
+ for (var key in overrideValues) {
+ values[key] = overrideValues[key];
+ }
+ var patternPieces = this.pattern.split('/');
+ var interp = patternPieces.map(function(value) {
+ if (value[0] == ':') {
+ value = values[value.slice(1)];
+ }
+ return value;
+ }, this);
+ if (values.tail && values.tail.path) {
+ if (interp.length > 0 && values.tail.path.charAt(0) === '/') {
+ interp.push(values.tail.path.slice(1));
+ } else {
+ interp.push(values.tail.path);
+ }
+ }
+ return interp.join('/');
+ },
- /**
- * The item that backfills the `_offscreenFocusedItem` in the physical items
- * list when that item is moved offscreen.
- */
- _focusBackfillItem: null,
+ __setMulti: function(setObj) {
+ // HACK(rictic): skirting around 1.0's lack of a setMulti by poking at
+ // internal data structures. I would not advise that you copy this
+ // example.
+ //
+ // In the future this will be a feature of Polymer itself.
+ // See: https://github.com/Polymer/polymer/issues/3640
+ //
+ // Hacking around with private methods like this is juggling footguns,
+ // and is likely to have unexpected and unsupported rough edges.
+ //
+ // Be ye so warned.
+ for (var property in setObj) {
+ this._propertySetter(property, setObj[property]);
+ }
- /**
- * The maximum items per row
- */
- _itemsPerRow: 1,
+ for (var property in setObj) {
+ this._pathEffector(property, this[property]);
+ this._notifyPathUp(property, this[property]);
+ }
+ }
+ });
+Polymer({
- /**
- * The width of each grid item
- */
- _itemWidth: 0,
+ is: 'iron-media-query',
- /**
- * The height of the row in grid layout.
- */
- _rowHeight: 0,
+ properties: {
- /**
- * The bottom of the physical content.
- */
- get _physicalBottom() {
- return this._physicalTop + this._physicalSize;
+ /**
+ * The Boolean return value of the media query.
+ */
+ queryMatches: {
+ type: Boolean,
+ value: false,
+ readOnly: true,
+ notify: true
+ },
+
+ /**
+ * The CSS media query to evaluate.
+ */
+ query: {
+ type: String,
+ observer: 'queryChanged'
+ },
+
+ /**
+ * If true, the query attribute is assumed to be a complete media query
+ * string rather than a single media feature.
+ */
+ full: {
+ type: Boolean,
+ value: false
+ },
+
+ /**
+ * @type {function(MediaQueryList)}
+ */
+ _boundMQHandler: {
+ value: function() {
+ return this.queryHandler.bind(this);
+ }
+ },
+
+ /**
+ * @type {MediaQueryList}
+ */
+ _mq: {
+ value: null
+ }
},
- /**
- * The bottom of the scroll.
- */
- get _scrollBottom() {
- return this._scrollPosition + this._viewportHeight;
+ attached: function() {
+ this.style.display = 'none';
+ this.queryChanged();
},
- /**
- * The n-th item rendered in the last physical item.
- */
- get _virtualEnd() {
- return this._virtualStart + this._physicalCount - 1;
+ detached: function() {
+ this._remove();
},
- /**
- * The height of the physical content that isn't on the screen.
- */
- get _hiddenContentSize() {
- var size = this.grid ? this._physicalRows * this._rowHeight : this._physicalSize;
- return size - this._viewportHeight;
+ _add: function() {
+ if (this._mq) {
+ this._mq.addListener(this._boundMQHandler);
+ }
},
- /**
- * The maximum scroll top value.
- */
- get _maxScrollTop() {
- return this._estScrollHeight - this._viewportHeight + this._scrollerPaddingTop;
+ _remove: function() {
+ if (this._mq) {
+ this._mq.removeListener(this._boundMQHandler);
+ }
+ this._mq = null;
},
- /**
- * The lowest n-th value for an item such that it can be rendered in `_physicalStart`.
- */
- _minVirtualStart: 0,
+ queryChanged: function() {
+ this._remove();
+ var query = this.query;
+ if (!query) {
+ return;
+ }
+ if (!this.full && query[0] !== '(') {
+ query = '(' + query + ')';
+ }
+ this._mq = window.matchMedia(query);
+ this._add();
+ this.queryHandler(this._mq);
+ },
- /**
- * 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);
- },
+ queryHandler: function(mq) {
+ this._setQueryMatches(mq.matches);
+ }
- /**
- * The n-th item rendered in the `_physicalStart` tile.
- */
- _virtualStartVal: 0,
+ });
+/**
+ * `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'
+ },
- set _virtualStart(val) {
- this._virtualStartVal = Math.min(this._maxVirtualStart, Math.max(this._minVirtualStart, val));
+ /**
+ * True if this element is currently notifying its descedant elements of
+ * resize.
+ */
+ _notifyingDescendant: {
+ type: Boolean,
+ value: false
+ }
},
- get _virtualStart() {
- return this._virtualStartVal || 0;
+ listeners: {
+ 'iron-request-resize-notifications': '_onIronRequestResizeNotifications'
},
- /**
- * The k-th tile that is at the top of the scrolling list.
- */
- _physicalStartVal: 0,
+ 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);
+ },
- set _physicalStart(val) {
- this._physicalStartVal = val % this._physicalCount;
- if (this._physicalStartVal < 0) {
- this._physicalStartVal = this._physicalCount + this._physicalStartVal;
+ attached: function() {
+ this.fire('iron-request-resize-notifications', null, {
+ node: this,
+ bubbles: true,
+ cancelable: true
+ });
+
+ if (!this._parentResizable) {
+ window.addEventListener('resize', this._boundNotifyResize);
+ this.notifyResize();
}
- this._physicalEnd = (this._physicalStart + this._physicalCount - 1) % this._physicalCount;
},
- get _physicalStart() {
- return this._physicalStartVal || 0;
+ detached: function() {
+ if (this._parentResizable) {
+ this._parentResizable.stopResizeNotificationsFor(this);
+ } else {
+ window.removeEventListener('resize', this._boundNotifyResize);
+ }
+
+ this._parentResizable = null;
},
/**
- * The number of tiles in the DOM.
+ * Can be called to manually notify a resizable and its descendant
+ * resizables of a resize change.
*/
- _physicalCountVal: 0,
+ notifyResize: function() {
+ if (!this.isAttached) {
+ return;
+ }
- set _physicalCount(val) {
- this._physicalCountVal = val;
- this._physicalEnd = (this._physicalStart + this._physicalCount - 1) % this._physicalCount;
- },
+ this._interestedResizables.forEach(function(resizable) {
+ if (this.resizerShouldNotify(resizable)) {
+ this._notifyDescendant(resizable);
+ }
+ }, this);
- get _physicalCount() {
- return this._physicalCountVal;
+ this._fireResize();
},
/**
- * The k-th tile that is at the bottom of the scrolling list.
+ * Used to assign the closest resizable ancestor to this resizable
+ * if the ancestor detects a request for notifications.
*/
- _physicalEnd: 0,
+ assignParentResizable: function(parentResizable) {
+ this._parentResizable = parentResizable;
+ },
/**
- * 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.
+ * Used to remove a resizable descendant from the list of descendants
+ * that should be notified of a resize change.
*/
- get _optPhysicalSize() {
- if (this.grid) {
- return this._estRowsInView * this._rowHeight * this._maxPages;
- }
- return this._viewportHeight * this._maxPages;
- },
-
- get _optPhysicalCount() {
- return this._estRowsInView * this._itemsPerRow * this._maxPages;
- },
+ stopResizeNotificationsFor: function(target) {
+ var index = this._interestedResizables.indexOf(target);
- /**
- * True if the current list is visible.
- */
- get _isVisible() {
- return this.scrollTarget && Boolean(this.scrollTarget.offsetWidth || this.scrollTarget.offsetHeight);
+ if (index > -1) {
+ this._interestedResizables.splice(index, 1);
+ this.unlisten(target, 'iron-resize', '_onDescendantIronResize');
+ }
},
/**
- * Gets the index of the first visible item in the viewport.
+ * 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.
*
- * @type {number}
+ * @param {HTMLElement} element A candidate descendant element that
+ * implements `IronResizableBehavior`.
+ * @return {boolean} True if the `element` should be notified of resize.
*/
- get firstVisibleIndex() {
- if (this._firstVisibleIndexVal === null) {
- var physicalOffset = Math.floor(this._physicalTop + this._scrollerPaddingTop);
-
- this._firstVisibleIndexVal = this._iterateItems(
- function(pidx, vidx) {
- physicalOffset += this._getPhysicalSizeIncrement(pidx);
+ resizerShouldNotify: function(element) { return true; },
- if (physicalOffset > this._scrollPosition) {
- return this.grid ? vidx - (vidx % this._itemsPerRow) : vidx;
- }
- // Handle a partially rendered final row in grid mode
- if (this.grid && this._virtualCount - 1 === vidx) {
- return vidx - (vidx % this._itemsPerRow);
- }
- }) || 0;
+ _onDescendantIronResize: function(event) {
+ if (this._notifyingDescendant) {
+ event.stopPropagation();
+ return;
}
- return this._firstVisibleIndexVal;
- },
- /**
- * Gets the index of the last visible item in the viewport.
- *
- * @type {number}
- */
- get lastVisibleIndex() {
- if (this._lastVisibleIndexVal === null) {
- if (this.grid) {
- var lastIndex = this.firstVisibleIndex + this._estRowsInView * this._itemsPerRow - 1;
- this._lastVisibleIndexVal = Math.min(this._virtualCount, lastIndex);
- } else {
- var physicalOffset = this._physicalTop;
- this._iterateItems(function(pidx, vidx) {
- if (physicalOffset < this._scrollBottom) {
- this._lastVisibleIndexVal = vidx;
- } else {
- // Break _iterateItems
- return true;
- }
- physicalOffset += this._getPhysicalSizeIncrement(pidx);
- });
- }
+ // 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();
}
- return this._lastVisibleIndexVal;
},
- get _defaultScrollTarget() {
- return this;
- },
- get _virtualRowCount() {
- return Math.ceil(this._virtualCount / this._itemsPerRow);
+ _fireResize: function() {
+ this.fire('iron-resize', null, {
+ node: this,
+ bubbles: false
+ });
},
- get _estRowsInView() {
- return Math.ceil(this._viewportHeight / this._rowHeight);
- },
+ _onIronRequestResizeNotifications: function(event) {
+ var target = event.path ? event.path[0] : event.target;
- get _physicalRows() {
- return Math.ceil(this._physicalCount / this._itemsPerRow);
- },
+ if (target === this) {
+ return;
+ }
- ready: function() {
- this.addEventListener('focus', this._didFocus.bind(this), true);
- },
+ if (this._interestedResizables.indexOf(target) === -1) {
+ this._interestedResizables.push(target);
+ this.listen(target, 'iron-resize', '_onDescendantIronResize');
+ }
- attached: function() {
- this.updateViewportBoundaries();
- this._render();
- // `iron-resize` is fired when the list is attached if the event is added
- // before attached causing unnecessary work.
- this.listen(this, 'iron-resize', '_resizeHandler');
- },
+ target.assignParentResizable(this);
+ this._notifyDescendant(target);
- detached: function() {
- this._itemsRendered = false;
- this.unlisten(this, 'iron-resize', '_resizeHandler');
+ event.stopPropagation();
},
- /**
- * Set the overflow property if this element has its own scrolling region
- */
- _setOverflow: function(scrollTarget) {
- this.style.webkitOverflowScrolling = scrollTarget === this ? 'touch' : '';
- this.style.overflow = scrollTarget === this ? 'auto' : '';
+ _parentResizableChanged: function(parentResizable) {
+ if (parentResizable) {
+ window.removeEventListener('resize', this._boundNotifyResize);
+ }
},
- /**
- * Invoke this method if you dynamically update the viewport's
- * size or CSS padding.
- *
- * @method updateViewportBoundaries
- */
- updateViewportBoundaries: function() {
- this._scrollerPaddingTop = this.scrollTarget === this ? 0 :
- parseInt(window.getComputedStyle(this)['padding-top'], 10);
-
- this._viewportHeight = this._scrollTargetHeight;
- if (this.grid) {
- this._updateGridMetrics();
- }
- },
-
- /**
- * Update the models, the position of the
- * items in the viewport and recycle tiles as needed.
- */
- _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 = [];
-
- // track the last `scrollTop`
- this._scrollPosition = scrollTop;
-
- // clear cached visible indexes
- this._firstVisibleIndexVal = null;
- this._lastVisibleIndexVal = null;
-
- scrollBottom = this._scrollBottom;
- physicalBottom = this._physicalBottom;
-
- // random access
- if (Math.abs(delta) > this._physicalSize) {
- this._physicalTop += delta;
- recycledTiles = Math.round(delta / this._physicalAverage);
- }
- // scroll up
- else if (delta < 0) {
- var topSpace = scrollTop - this._physicalTop;
- var virtualStart = this._virtualStart;
-
- recycledTileSet = [];
-
- kth = this._physicalEnd;
- currentRatio = topSpace / hiddenContentSize;
-
- // move tiles from bottom to top
- while (
- // approximate `currentRatio` to `ratio`
- currentRatio < ratio &&
- // recycle less physical items than the total
- recycledTiles < this._physicalCount &&
- // ensure that these recycled tiles are needed
- virtualStart - recycledTiles > 0 &&
- // ensure that the tile is not visible
- physicalBottom - this._getPhysicalSizeIncrement(kth) > scrollBottom
- ) {
-
- tileHeight = this._getPhysicalSizeIncrement(kth);
- currentRatio += tileHeight / hiddenContentSize;
- physicalBottom -= tileHeight;
- recycledTileSet.push(kth);
- recycledTiles++;
- kth = (kth === 0) ? this._physicalCount - 1 : kth - 1;
- }
-
- movingUp = recycledTileSet;
- recycledTiles = -recycledTiles;
+ _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;
}
- // 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;
+ this._notifyingDescendant = true;
+ descendant.notifyResize();
+ this._notifyingDescendant = false;
+ }
+ };
+/**
+ * @param {!Function} selectCallback
+ * @constructor
+ */
+ Polymer.IronSelection = function(selectCallback) {
+ this.selection = [];
+ this.selectCallback = selectCallback;
+ };
- // 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._getPhysicalSizeIncrement(kth) < scrollTop
- ) {
+ Polymer.IronSelection.prototype = {
- tileHeight = this._getPhysicalSizeIncrement(kth);
- currentRatio += tileHeight / hiddenContentSize;
+ /**
+ * Retrieves the selected item(s).
+ *
+ * @method get
+ * @returns Returns the selected item(s). If the multi property is true,
+ * `get` will return an array, otherwise it will return
+ * the selected item or undefined if there is no selection.
+ */
+ get: function() {
+ return this.multi ? this.selection.slice() : this.selection[0];
+ },
- this._physicalTop += tileHeight;
- recycledTileSet.push(kth);
- recycledTiles++;
- kth = (kth + 1) % this._physicalCount;
+ /**
+ * Clears all the selection except the ones indicated.
+ *
+ * @method clear
+ * @param {Array} excludes items to be excluded.
+ */
+ clear: function(excludes) {
+ this.selection.slice().forEach(function(item) {
+ if (!excludes || excludes.indexOf(item) < 0) {
+ this.setItemSelected(item, false);
}
- }
+ }, this);
+ },
- if (recycledTiles === 0) {
- // Try to increase the pool if the list's client height isn't filled up with physical items
- if (physicalBottom < scrollBottom || this._physicalTop > scrollTop) {
- this._increasePoolIfNeeded();
- }
- } else {
- this._virtualStart = this._virtualStart + recycledTiles;
- this._physicalStart = this._physicalStart + recycledTiles;
- this._update(recycledTileSet, movingUp);
- }
+ /**
+ * Indicates if a given item is selected.
+ *
+ * @method isSelected
+ * @param {*} item The item whose selection state should be checked.
+ * @returns Returns true if `item` is selected.
+ */
+ isSelected: function(item) {
+ return this.selection.indexOf(item) >= 0;
},
/**
- * Update the list of items, starting from the `_virtualStart` item.
- * @param {!Array<number>=} itemSet
- * @param {!Array<number>=} movingUp
+ * Sets the selection state for a given item to either selected or deselected.
+ *
+ * @method setItemSelected
+ * @param {*} item The item to select.
+ * @param {boolean} isSelected True for selected, false for deselected.
*/
- _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) {
- var idx = movingUp.pop();
- this._physicalTop -= this._getPhysicalSizeIncrement(idx);
+ setItemSelected: function(item, isSelected) {
+ if (item != null) {
+ if (isSelected !== this.isSelected(item)) {
+ // proceed to update selection only if requested state differs from current
+ if (isSelected) {
+ this.selection.push(item);
+ } else {
+ var i = this.selection.indexOf(item);
+ if (i >= 0) {
+ this.selection.splice(i, 1);
+ }
+ }
+ if (this.selectCallback) {
+ this.selectCallback(item, isSelected);
+ }
}
}
- // update the position of the items
- this._positionItems();
- // set the scroller size
- this._updateScrollerSize();
- // increase the pool of physical items
- this._increasePoolIfNeeded();
},
/**
- * Creates a pool of DOM elements and attaches them to the local dom.
+ * Sets the selection state for a given item. If the `multi` property
+ * is true, then the selected state of `item` will be toggled; otherwise
+ * the `item` will be selected.
+ *
+ * @method select
+ * @param {*} item The item to select.
*/
- _createPool: function(size) {
- var physicalItems = new Array(size);
-
- 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);
+ select: function(item) {
+ if (this.multi) {
+ this.toggle(item);
+ } else if (this.get() !== item) {
+ this.setItemSelected(this.get(), false);
+ this.setItemSelected(item, true);
}
- return physicalItems;
},
/**
- * Increases the pool of physical items only if needed.
+ * Toggles the selection state for `item`.
*
- * @return {boolean} True if the pool was increased.
+ * @method toggle
+ * @param {*} item The item to toggle.
*/
- _increasePoolIfNeeded: function() {
- // Base case 1: the list has no height.
- if (this._viewportHeight === 0) {
- return false;
- }
- // Base case 2: If the physical size is optimal and the list's client height is full
- // with physical items, don't increase the pool.
- var isClientHeightFull = this._physicalBottom >= this._scrollBottom && this._physicalTop <= this._scrollPosition;
- if (this._physicalSize >= this._optPhysicalSize && isClientHeightFull) {
- return false;
- }
- // this value should range between [0 <= `currentPage` <= `_maxPages`]
- var currentPage = Math.floor(this._physicalSize / this._viewportHeight);
+ toggle: function(item) {
+ this.setItemSelected(item, !this.isSelected(item));
+ }
- if (currentPage === 0) {
- // fill the first page
- this._debounceTemplate(this._increasePool.bind(this, Math.round(this._physicalCount * 0.5)));
- } else if (this._lastPage !== currentPage && isClientHeightFull) {
- // 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, this._itemsPerRow), 16));
- } else {
- // fill the rest of the pages
- this._debounceTemplate(this._increasePool.bind(this, this._itemsPerRow));
- }
+ };
+/** @polymerBehavior */
+ Polymer.IronSelectableBehavior = {
- this._lastPage = currentPage;
+ /**
+ * Fired when iron-selector is activated (selected or deselected).
+ * It is fired before the selected items are changed.
+ * Cancel the event to abort selection.
+ *
+ * @event iron-activate
+ */
- return true;
- },
+ /**
+ * Fired when an item is selected
+ *
+ * @event iron-select
+ */
- /**
- * Increases the pool size.
- */
- _increasePool: function(missingItems) {
- var nextPhysicalCount = Math.min(
- this._physicalCount + missingItems,
- this._virtualCount - this._virtualStart,
- Math.max(this.maxPhysicalCount, DEFAULT_PHYSICAL_COUNT)
- );
- var prevPhysicalCount = this._physicalCount;
- var delta = nextPhysicalCount - prevPhysicalCount;
+ /**
+ * Fired when an item is deselected
+ *
+ * @event iron-deselect
+ */
- if (delta <= 0) {
- return;
- }
+ /**
+ * Fired when the list of selectable items changes (e.g., items are
+ * added or removed). The detail of the event is a mutation record that
+ * describes what changed.
+ *
+ * @event iron-items-changed
+ */
- [].push.apply(this._physicalItems, this._createPool(delta));
- [].push.apply(this._physicalSizes, new Array(delta));
+ properties: {
- this._physicalCount = prevPhysicalCount + delta;
+ /**
+ * If you want to use an attribute value or property of an element for
+ * `selected` instead of the index, set this to the name of the attribute
+ * or property. Hyphenated values are converted to camel case when used to
+ * look up the property of a selectable element. Camel cased values are
+ * *not* converted to hyphenated values for attribute lookup. It's
+ * recommended that you provide the hyphenated form of the name so that
+ * selection works in both cases. (Use `attr-or-property-name` instead of
+ * `attrOrPropertyName`.)
+ */
+ attrForSelected: {
+ type: String,
+ value: null
+ },
- // 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();
- },
+ /**
+ * Gets or sets the selected element. The default is to use the index of the item.
+ * @type {string|number}
+ */
+ selected: {
+ type: String,
+ notify: true
+ },
- /**
- * 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.
- */
- _render: function() {
- var requiresUpdate = this._virtualCount > 0 || this._physicalCount > 0;
+ /**
+ * Returns the currently selected item.
+ *
+ * @type {?Object}
+ */
+ selectedItem: {
+ type: Object,
+ readOnly: true,
+ notify: true
+ },
- if (this.isAttached && !this._itemsRendered && this._isVisible && requiresUpdate) {
- this._lastPage = 0;
- this._update();
- this._itemsRendered = true;
- }
- },
+ /**
+ * The event that fires from items when they are selected. Selectable
+ * will listen for this event from items and update the selection state.
+ * Set to empty string to listen to no events.
+ */
+ activateEvent: {
+ type: String,
+ value: 'tap',
+ observer: '_activateEventChanged'
+ },
- /**
- * Templetizes the user template.
- */
- _ensureTemplatized: function() {
- if (!this.ctor) {
- // Template instance props that should be excluded from forwarding
- var props = {};
- props.__key__ = true;
- props[this.as] = true;
- props[this.indexAs] = true;
- props[this.selectedAs] = true;
- props.tabIndex = true;
+ /**
+ * This is a CSS selector string. If this is set, only items that match the CSS selector
+ * are selectable.
+ */
+ selectable: String,
- this._instanceProps = props;
- this._userTemplate = Polymer.dom(this).querySelector('template');
+ /**
+ * The class to set on elements when selected.
+ */
+ selectedClass: {
+ type: String,
+ value: 'iron-selected'
+ },
- if (this._userTemplate) {
- this.templatize(this._userTemplate);
- } else {
- console.warn('iron-list requires a template to be provided in light-dom');
+ /**
+ * The attribute to set on elements when selected.
+ */
+ selectedAttribute: {
+ type: String,
+ value: null
+ },
+
+ /**
+ * Default fallback if the selection based on selected with `attrForSelected`
+ * is not found.
+ */
+ fallbackSelection: {
+ type: String,
+ value: null
+ },
+
+ /**
+ * The list of items from which a selection can be made.
+ */
+ items: {
+ type: Array,
+ readOnly: true,
+ notify: true,
+ value: function() {
+ return [];
+ }
+ },
+
+ /**
+ * The set of excluded elements where the key is the `localName`
+ * of the element that will be ignored from the item list.
+ *
+ * @default {template: 1}
+ */
+ _excludedLocalNames: {
+ type: Object,
+ value: function() {
+ return {
+ 'template': 1
+ };
}
}
},
- /**
- * Implements extension point from Templatizer mixin.
- */
- _getStampedChildren: function() {
- return this._physicalItems;
+ observers: [
+ '_updateAttrForSelected(attrForSelected)',
+ '_updateSelected(selected)',
+ '_checkFallback(fallbackSelection)'
+ ],
+
+ created: function() {
+ this._bindFilterItem = this._filterItem.bind(this);
+ this._selection = new Polymer.IronSelection(this._applySelection.bind(this));
},
- /**
- * 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.
- */
- _forwardInstancePath: function(inst, path, value) {
- if (path.indexOf(this.as + '.') === 0) {
- this.notifyPath('items.' + inst.__key__ + '.' +
- path.slice(this.as.length + 1), value);
+ attached: function() {
+ this._observer = this._observeItems(this);
+ this._updateItems();
+ if (!this._shouldUpdateSelection) {
+ this._updateSelected();
}
+ this._addListener(this.activateEvent);
},
- /**
- * 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);
+ detached: function() {
+ if (this._observer) {
+ Polymer.dom(this).unobserveNodes(this._observer);
}
+ this._removeListener(this.activateEvent);
},
/**
- * Implements extension point from Templatizer
- * Called as side-effect of a host path change, responsible for
- * notifying parent.<path> path change on each row.
+ * Returns the index of the given item.
+ *
+ * @method indexOf
+ * @param {Object} item
+ * @returns Returns the index of the item
*/
- _forwardParentPath: function(path, value) {
- if (this._physicalItems) {
- this._physicalItems.forEach(function(item) {
- item._templateInstance.notifyPath(path, value, true);
- }, this);
- }
+ indexOf: function(item) {
+ return this.items.indexOf(item);
},
/**
- * Called as a side effect of a host items.<key>.<path> path change,
- * responsible for notifying item.<path> changes.
+ * Selects the given value.
+ *
+ * @method select
+ * @param {string|number} value the value to select.
*/
- _forwardItemPath: function(path, value) {
- if (!this._physicalIndexForKey) {
- return;
- }
- var dot = path.indexOf('.');
- var key = path.substring(0, dot < 0 ? path.length : dot);
- var idx = this._physicalIndexForKey[key];
- var offscreenItem = this._offscreenFocusedItem;
- var el = offscreenItem && offscreenItem._templateInstance.__key__ === key ?
- offscreenItem : this._physicalItems[idx];
-
- if (!el || el._templateInstance.__key__ !== key) {
- return;
- }
- if (dot >= 0) {
- path = this.as + '.' + path.substring(dot+1);
- el._templateInstance.notifyPath(path, value, true);
- } else {
- // Update selection if needed
- var currentItem = el._templateInstance[this.as];
- if (Array.isArray(this.selectedItems)) {
- for (var i = 0; i < this.selectedItems.length; i++) {
- if (this.selectedItems[i] === currentItem) {
- this.set('selectedItems.' + i, value);
- break;
- }
- }
- } else if (this.selectedItem === currentItem) {
- this.set('selectedItem', value);
- }
- el._templateInstance[this.as] = value;
- }
+ select: function(value) {
+ this.selected = value;
},
/**
- * Called when the items have changed. That is, ressignments
- * to `items`, splices or updates to a single item.
+ * Selects the previous item.
+ *
+ * @method selectPrevious
*/
- _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._firstVisibleIndexVal = null;
- this._lastVisibleIndexVal = null;
+ selectPrevious: function() {
+ var length = this.items.length;
+ var index = (Number(this._valueToIndex(this.selected)) - 1 + length) % length;
+ this.selected = this._indexToValue(index);
+ },
- 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);
- }
+ /**
+ * Selects the next item.
+ *
+ * @method selectNext
+ */
+ selectNext: function() {
+ var index = (Number(this._valueToIndex(this.selected)) + 1) % this.items.length;
+ this.selected = this._indexToValue(index);
+ },
- this._physicalStart = 0;
+ /**
+ * Selects the item at the given index.
+ *
+ * @method selectIndex
+ */
+ selectIndex: function(index) {
+ this.select(this._indexToValue(index));
+ },
- } else if (change.path === 'items.splices') {
+ /**
+ * Force a synchronous update of the `items` property.
+ *
+ * NOTE: Consider listening for the `iron-items-changed` event to respond to
+ * updates to the set of selectable items after updates to the DOM list and
+ * selection state have been made.
+ *
+ * WARNING: If you are using this method, you should probably consider an
+ * alternate approach. Synchronously querying for items is potentially
+ * slow for many use cases. The `items` property will update asynchronously
+ * on its own to reflect selectable items in the DOM.
+ */
+ forceSynchronousItemUpdate: function() {
+ this._updateItems();
+ },
- this._adjustVirtualIndex(change.value.indexSplices);
- this._virtualCount = this.items ? this.items.length : 0;
+ get _shouldUpdateSelection() {
+ return this.selected != null;
+ },
- } else {
- // update a single item
- this._forwardItemPath(change.path.split('.').slice(1).join('.'), change.value);
- return;
+ _checkFallback: function() {
+ if (this._shouldUpdateSelection) {
+ this._updateSelected();
}
+ },
- this._itemsRendered = false;
- this._debounceTemplate(this._render);
+ _addListener: function(eventName) {
+ this.listen(this, eventName, '_activateHandler');
},
- /**
- * @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);
+ _removeListener: function(eventName) {
+ this.unlisten(this, eventName, '_activateHandler');
+ },
- this._virtualStart = this._virtualStart + delta;
+ _activateEventChanged: function(eventName, old) {
+ this._removeListener(old);
+ this._addListener(eventName);
+ },
- if (this._focusedIndex >= 0) {
- this._focusedIndex = this._focusedIndex + delta;
- }
- }
- }, this);
+ _updateItems: function() {
+ var nodes = Polymer.dom(this).queryDistributedElements(this.selectable || '*');
+ nodes = Array.prototype.filter.call(nodes, this._bindFilterItem);
+ this._setItems(nodes);
},
- _removeItem: function(item) {
- this.$.selector.deselect(item);
- // remove the current focused item
- if (this._focusedItem && this._focusedItem._templateInstance[this.as] === item) {
- this._removeFocusedItem();
+ _updateAttrForSelected: function() {
+ if (this._shouldUpdateSelection) {
+ this.selected = this._indexToValue(this.indexOf(this.selectedItem));
}
},
- /**
- * 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;
+ _updateSelected: function() {
+ this._selectSelected(this.selected);
+ },
- if (arguments.length === 2 && itemSet) {
- for (i = 0; i < itemSet.length; i++) {
- pidx = itemSet[i];
- vidx = this._computeVidx(pidx);
- if ((rtn = fn.call(this, pidx, vidx)) != null) {
- return rtn;
+ _selectSelected: function(selected) {
+ this._selection.select(this._valueToItem(this.selected));
+ // Check for items, since this array is populated only when attached
+ // Since Number(0) is falsy, explicitly check for undefined
+ if (this.fallbackSelection && this.items.length && (this._selection.get() === undefined)) {
+ this.selected = this.fallbackSelection;
+ }
+ },
+
+ _filterItem: function(node) {
+ return !this._excludedLocalNames[node.localName];
+ },
+
+ _valueToItem: function(value) {
+ return (value == null) ? null : this.items[this._valueToIndex(value)];
+ },
+
+ _valueToIndex: function(value) {
+ if (this.attrForSelected) {
+ for (var i = 0, item; item = this.items[i]; i++) {
+ if (this._valueForItem(item) == value) {
+ return i;
}
}
} else {
- pidx = this._physicalStart;
- vidx = this._virtualStart;
+ return Number(value);
+ }
+ },
- 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;
- }
+ _indexToValue: function(index) {
+ if (this.attrForSelected) {
+ var item = this.items[index];
+ if (item) {
+ return this._valueForItem(item);
}
+ } else {
+ return index;
}
},
- /**
- * Returns the virtual index for a given physical index
- *
- * @param {number} pidx Physical index
- * @return {number}
- */
- _computeVidx: function(pidx) {
- if (pidx >= this._physicalStart) {
- return this._virtualStart + (pidx - this._physicalStart);
- }
- return this._virtualStart + (this._physicalCount - this._physicalStart) + pidx;
+ _valueForItem: function(item) {
+ var propValue = item[Polymer.CaseMap.dashToCamelCase(this.attrForSelected)];
+ return propValue != undefined ? propValue : item.getAttribute(this.attrForSelected);
},
- /**
- * Assigns the data models to a given set of items.
- * @param {!Array<number>=} itemSet
- */
- _assignModels: function(itemSet) {
- this._iterateItems(function(pidx, vidx) {
- var el = this._physicalItems[pidx];
- var inst = el._templateInstance;
- var item = this.items && this.items[vidx];
+ _applySelection: function(item, isSelected) {
+ if (this.selectedClass) {
+ this.toggleClass(this.selectedClass, isSelected, item);
+ }
+ if (this.selectedAttribute) {
+ this.toggleAttribute(this.selectedAttribute, isSelected, item);
+ }
+ this._selectionChange();
+ this.fire('iron-' + (isSelected ? 'select' : 'deselect'), {item: item});
+ },
- 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', '');
- }
- }, itemSet);
+ _selectionChange: function() {
+ this._setSelectedItem(this._selection.get());
},
- /**
- * 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();
-
- var newPhysicalSize = 0;
- var oldPhysicalSize = 0;
- var prevAvgCount = this._physicalAverageCount;
- var prevPhysicalAvg = this._physicalAverage;
-
- 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);
+ // observe items change under the given node.
+ _observeItems: function(node) {
+ return Polymer.dom(node).observeNodes(function(mutation) {
+ this._updateItems();
- this._viewportHeight = this._scrollTargetHeight;
- if (this.grid) {
- this._updateGridMetrics();
- this._physicalSize = Math.ceil(this._physicalCount / this._itemsPerRow) * this._rowHeight;
- } else {
- this._physicalSize = this._physicalSize + newPhysicalSize - oldPhysicalSize;
- }
+ if (this._shouldUpdateSelection) {
+ this._updateSelected();
+ }
- // update the average if we measured something
- if (this._physicalAverageCount !== prevAvgCount) {
- this._physicalAverage = Math.round(
- ((prevPhysicalAvg * prevAvgCount) + newPhysicalSize) /
- this._physicalAverageCount);
- }
+ // Let other interested parties know about the change so that
+ // we don't have to recreate mutation observers everywhere.
+ this.fire('iron-items-changed', mutation, {
+ bubbles: false,
+ cancelable: false
+ });
+ });
},
- _updateGridMetrics: function() {
- this._viewportWidth = this.$.items.offsetWidth;
- // Set item width to the value of the _physicalItems offsetWidth
- this._itemWidth = this._physicalCount > 0 ? this._physicalItems[0].getBoundingClientRect().width : DEFAULT_GRID_SIZE;
- // Set row height to the value of the _physicalItems offsetHeight
- this._rowHeight = this._physicalCount > 0 ? this._physicalItems[0].offsetHeight : DEFAULT_GRID_SIZE;
- // If in grid mode compute how many items with exist in each row
- this._itemsPerRow = this._itemWidth ? Math.floor(this._viewportWidth / this._itemWidth) : this._itemsPerRow;
+ _activateHandler: function(e) {
+ var t = e.target;
+ var items = this.items;
+ while (t && t != this) {
+ var i = items.indexOf(t);
+ if (i >= 0) {
+ var value = this._indexToValue(i);
+ this._itemActivate(value, t);
+ return;
+ }
+ t = t.parentNode;
+ }
},
- /**
- * Updates the position of the physical items.
- */
- _positionItems: function() {
- this._adjustScrollPosition();
-
- var y = this._physicalTop;
-
- if (this.grid) {
- var totalItemWidth = this._itemsPerRow * this._itemWidth;
- var rowOffset = (this._viewportWidth - totalItemWidth) / 2;
+ _itemActivate: function(value, item) {
+ if (!this.fire('iron-activate',
+ {selected: value, item: item}, {cancelable: true}).defaultPrevented) {
+ this.select(value);
+ }
+ }
- this._iterateItems(function(pidx, vidx) {
+ };
+Polymer({
- var modulus = vidx % this._itemsPerRow;
- var x = Math.floor((modulus * this._itemWidth) + rowOffset);
+ is: 'iron-pages',
- this.translate3d(x + 'px', y + 'px', 0, this._physicalItems[pidx]);
+ behaviors: [
+ Polymer.IronResizableBehavior,
+ Polymer.IronSelectableBehavior
+ ],
- if (this._shouldRenderNextRow(vidx)) {
- y += this._rowHeight;
- }
+ properties: {
- });
- } else {
- this._iterateItems(function(pidx, vidx) {
+ // as the selected page is the only one visible, activateEvent
+ // is both non-sensical and problematic; e.g. in cases where a user
+ // handler attempts to change the page and the activateEvent
+ // handler immediately changes it back
+ activateEvent: {
+ type: String,
+ value: null
+ }
- this.translate3d(0, y + 'px', 0, this._physicalItems[pidx]);
- y += this._physicalSizes[pidx];
+ },
- });
- }
- },
+ observers: [
+ '_selectedPageChanged(selected)'
+ ],
- _getPhysicalSizeIncrement: function(pidx) {
- if (!this.grid) {
- return this._physicalSizes[pidx];
- }
- if (this._computeVidx(pidx) % this._itemsPerRow !== this._itemsPerRow - 1) {
- return 0;
+ _selectedPageChanged: function(selected, old) {
+ this.async(this.notifyResize);
}
- return this._rowHeight;
- },
+ });
+(function() {
+ 'use strict';
/**
- * Returns, based on the current index,
- * whether or not the next index will need
- * to be rendered on a new row.
+ * Chrome uses an older version of DOM Level 3 Keyboard Events
*
- * @param {number} vidx Virtual index
- * @return {boolean}
+ * 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
*/
- _shouldRenderNextRow: function(vidx) {
- return vidx % this._itemsPerRow === this._itemsPerRow - 1;
- },
+ var KEY_IDENTIFIER = {
+ 'U+0008': 'backspace',
+ 'U+0009': 'tab',
+ 'U+001B': 'esc',
+ 'U+0020': 'space',
+ 'U+007F': 'del'
+ };
/**
- * Adjusts the scroll position when it was overestimated.
+ * 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
*/
- _adjustScrollPosition: function() {
- var deltaHeight = this._virtualStart === 0 ? this._physicalTop :
- Math.min(this._scrollPosition + this._physicalTop, 0);
-
- if (deltaHeight) {
- this._physicalTop = this._physicalTop - deltaHeight;
- // juking scroll position during interial scrolling on iOS is no bueno
- if (!IOS_TOUCH_SCROLLING && this._physicalTop !== 0) {
- this._resetScrollPosition(this._scrollTop - deltaHeight);
- }
- }
- },
+ 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: '*'
+ };
/**
- * Sets the position of the scroll.
+ * 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.
*/
- _resetScrollPosition: function(pos) {
- if (this.scrollTarget) {
- this._scrollTop = pos;
- this._scrollPosition = this._scrollTop;
- }
- },
+ var MODIFIER_KEYS = {
+ 'shift': 'shiftKey',
+ 'ctrl': 'ctrlKey',
+ 'alt': 'altKey',
+ 'meta': 'metaKey'
+ };
/**
- * Sets the scroll height, that's the height of the content,
+ * KeyboardEvent.key is mostly represented by printable character made by
+ * the keyboard, with unprintable keys labeled nicely.
*
- * @param {boolean=} forceUpdate If true, updates the height no matter what.
+ * 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.
*/
- _updateScrollerSize: function(forceUpdate) {
- if (this.grid) {
- this._estScrollHeight = this._virtualRowCount * this._rowHeight;
- } else {
- this._estScrollHeight = (this._physicalBottom +
- Math.max(this._virtualCount - this._physicalCount - this._virtualStart, 0) * this._physicalAverage);
- }
+ var KEY_CHAR = /[a-z0-9*]/;
- forceUpdate = forceUpdate || this._scrollHeight === 0;
- forceUpdate = forceUpdate || this._scrollPosition >= this._estScrollHeight - this._physicalSize;
- forceUpdate = forceUpdate || this.grid && this.$.items.style.height < this._estScrollHeight;
+ /**
+ * Matches a keyIdentifier string.
+ */
+ var IDENT_CHAR = /U\+/;
- // 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;
- }
- },
+ /**
+ * Matches arrow keys in Gecko 27.0+
+ */
+ var ARROW_KEY = /^arrow/;
/**
- * Scroll to a specific item in the virtual list regardless
- * of the physical items in the DOM tree.
- *
- * @method scrollToItem
- * @param {(Object)} item The item to be scrolled to
+ * Matches space keys everywhere (notably including IE10's exceptional name
+ * `spacebar`).
*/
- scrollToItem: function(item){
- return this.scrollToIndex(this.items.indexOf(item));
- },
+ var SPACE_KEY = /^space(bar)?/;
/**
- * Scroll to a specific index in the virtual list regardless
- * of the physical items in the DOM tree.
+ * Matches ESC key.
*
- * @method scrollToIndex
- * @param {number} idx The index of the item
+ * Value from: http://w3c.github.io/uievents-key/#key-Escape
*/
- scrollToIndex: function(idx) {
- if (typeof idx !== 'number' || idx < 0 || idx > this.items.length - 1) {
- return;
- }
-
- Polymer.dom.flush();
-
- 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 = this.grid ? (idx - this._itemsPerRow * 2) : (idx - 1);
- }
- // manage focus
- this._manageFocus();
- // assign new models
- this._assignModels();
- // measure the new sizes
- this._updateMetrics();
-
- // estimate new physical offset
- var estPhysicalTop = Math.floor(this._virtualStart / this._itemsPerRow) * this._physicalAverage;
- this._physicalTop = estPhysicalTop;
-
- 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._getPhysicalSizeIncrement(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);
- // increase the pool of physical items if needed
- this._increasePoolIfNeeded();
- // clear cached visible index
- this._firstVisibleIndexVal = null;
- this._lastVisibleIndexVal = null;
- },
-
- /**
- * Reset the physical average and the average count.
- */
- _resetAverage: function() {
- this._physicalAverage = 0;
- this._physicalAverageCount = 0;
- },
+ var ESC_KEY = /^escape$/;
/**
- * A handler for the `iron-resize` event triggered by `IronResizableBehavior`
- * when the element is resized.
+ * Transforms the key.
+ * @param {string} key The KeyBoardEvent.key
+ * @param {Boolean} [noSpecialChars] Limits the transformation to
+ * alpha-numeric characters.
*/
- _resizeHandler: function() {
- // iOS fires the resize event when the address bar slides up
- if (IOS && Math.abs(this._viewportHeight - this._scrollTargetHeight) < 100) {
- return;
+ 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;
+ }
}
- // In Desktop Safari 9.0.3, if the scroll bars are always shown,
- // changing the scroll position from a resize handler would result in
- // the scroll position being reset. Waiting 1ms fixes the issue.
- Polymer.dom.addDebouncer(this.debounce('_debounceTemplate', function() {
- this.updateViewportBoundaries();
- this._render();
+ return validKey;
+ }
- if (this._itemsRendered && this._physicalItems && this._isVisible) {
- this._resetAverage();
- this.scrollToIndex(this.firstVisibleIndex);
+ 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();
}
- }.bind(this), 1));
- },
-
- _getModelFromItem: function(item) {
- var key = this._collection.getKey(item);
- var pidx = this._physicalIndexForKey[key];
-
- if (pidx != null) {
- return this._physicalItems[pidx]._templateInstance;
}
- return null;
- },
+ return validKey;
+ }
- /**
- * 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;
+ 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];
}
- throw new TypeError('<item> should be a valid item');
}
- return item;
- },
+ return validKey;
+ }
/**
- * Select the list item at the given index.
- *
- * @method selectItem
- * @param {(Object|number)} item The item object or its index
+ * Calculates the normalized key for a KeyboardEvent.
+ * @param {KeyboardEvent} keyEvent
+ * @param {Boolean} [noSpecialChars] Set to true to limit keyEvent.key
+ * transformation to alpha-numeric chars. This is useful with key
+ * combinations like shift + 2, which on FF for MacOS produces
+ * keyEvent.key = @
+ * To get 2 returned, set noSpecialChars = true
+ * To get @ returned, set noSpecialChars = false
*/
- selectItem: function(item) {
- item = this._getNormalizedItem(item);
- var model = this._getModelFromItem(item);
+ 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 ? keyEvent.detail.key : keyEvent.detail, noSpecialChars) || '';
+ }
- if (!this.multiSelection && this.selectedItem) {
- this.deselectItem(this.selectedItem);
- }
- if (model) {
- model[this.selectedAs] = true;
+ 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'
+ };
}
- this.$.selector.select(item);
- this.updateSizeForItem(item);
- },
+ return keyComboString.split('+').reduce(function(parsedKeyCombo, keyComboPart) {
+ var eventParts = keyComboPart.split(':');
+ var keyName = eventParts[0];
+ var event = eventParts[1];
- /**
- * Deselects the given item list if it is already selected.
- *
+ if (keyName in MODIFIER_KEYS) {
+ parsedKeyCombo[MODIFIER_KEYS[keyName]] = true;
+ parsedKeyCombo.hasModifiers = true;
+ } else {
+ parsedKeyCombo.key = keyName;
+ parsedKeyCombo.event = event || 'keydown';
+ }
- * @method deselect
- * @param {(Object|number)} item The item object or its index
- */
- deselectItem: function(item) {
- item = this._getNormalizedItem(item);
- var model = this._getModelFromItem(item);
+ return parsedKeyCombo;
+ }, {
+ combo: keyComboString.split(':').shift()
+ });
+ }
- if (model) {
- model[this.selectedAs] = false;
- }
- this.$.selector.deselect(item);
- this.updateSizeForItem(item);
- },
+ function parseEventString(eventString) {
+ return eventString.trim().split(' ').map(function(keyComboString) {
+ return parseKeyComboString(keyComboString);
+ });
+ }
/**
- * Select or deselect a given item depending on whether the item
- * has already been selected.
+ * `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.
*
- * @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 {
- this.selectItem(item);
- }
- },
-
- /**
- * Clears the current selection state of the list.
+ * Use the `keyBindings` prototype property to express what combination of keys
+ * will trigger the callback. A key binding has the format
+ * `"KEY+MODIFIER:EVENT": "callback"` (`"KEY": "callback"` or
+ * `"KEY:EVENT": "callback"` are valid as well). Some examples:
*
- * @method clearSelection
- */
- clearSelection: function() {
- function unselect(item) {
- var model = this._getModelFromItem(item);
- if (model) {
- model[this.selectedAs] = false;
+ * keyBindings: {
+ * 'space': '_onKeydown', // same as 'space:keydown'
+ * 'shift+tab': '_onKeydown',
+ * 'enter:keypress': '_onKeypress',
+ * 'esc:keyup': '_onKeyup'
+ * }
+ *
+ * The callback will receive with an event containing the following information in `event.detail`:
+ *
+ * _onKeydown: function(event) {
+ * console.log(event.detail.combo); // KEY+MODIFIER, e.g. "shift+tab"
+ * console.log(event.detail.key); // KEY only, e.g. "tab"
+ * console.log(event.detail.event); // EVENT, e.g. "keydown"
+ * console.log(event.detail.keyboardEvent); // the original KeyboardEvent
+ * }
+ *
+ * Use the `keyEventTarget` attribute to set up event handlers on a specific
+ * node.
+ *
+ * See the [demo source code](https://github.com/PolymerElements/iron-a11y-keys-behavior/blob/master/demo/x-key-aware.html)
+ * for an example.
+ *
+ * @demo demo/index.html
+ * @polymerBehavior
+ */
+ Polymer.IronA11yKeysBehavior = {
+ properties: {
+ /**
+ * The EventTarget that will be firing relevant KeyboardEvents. Set it to
+ * `null` to disable the listeners.
+ * @type {?EventTarget}
+ */
+ 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
+ },
+
+ _boundKeyHandlers: {
+ type: Array,
+ value: function() {
+ return [];
+ }
+ },
+
+ // We use this due to a limitation in IE10 where instances will have
+ // own properties of everything on the "prototype".
+ _imperativeKeyBindings: {
+ type: Object,
+ value: function() {
+ return {};
+ }
}
- }
+ },
- if (Array.isArray(this.selectedItems)) {
- this.selectedItems.forEach(unselect, this);
- } else if (this.selectedItem) {
- unselect.call(this, this.selectedItem);
- }
+ observers: [
+ '_resetKeyEventListeners(keyEventTarget, _boundKeyHandlers)'
+ ],
- /** @type {!ArraySelectorElement} */ (this.$.selector).clearSelection();
- },
- /**
- * 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');
- },
+ /**
+ * To be used to express what combination of keys will trigger the relative
+ * callback. e.g. `keyBindings: { 'esc': '_onEscPressed'}`
+ * @type {Object}
+ */
+ keyBindings: {},
- /**
- * Select an item from an event object.
- */
- _selectionHandler: function(e) {
- var model = this.modelForElement(e.target);
- if (!model) {
- return;
- }
- var modelTabIndex, activeElTabIndex;
- var target = Polymer.dom(e).path[0];
- var activeEl = Polymer.dom(this.domHost ? this.domHost.root : document).activeElement;
- var physicalItem = this._physicalItems[this._getPhysicalIndex(model[this.indexAs])];
- // Safari does not focus certain form controls via mouse
- // https://bugs.webkit.org/show_bug.cgi?id=118043
- if (target.localName === 'input' ||
- target.localName === 'button' ||
- target.localName === 'select') {
- return;
- }
- // Set a temporary tabindex
- modelTabIndex = model.tabIndex;
- model.tabIndex = SECRET_TABINDEX;
- activeElTabIndex = activeEl ? activeEl.tabIndex : -1;
- model.tabIndex = modelTabIndex;
- // Only select the item if the tap wasn't on a focusable child
- // or the element bound to `tabIndex`
- if (activeEl && physicalItem.contains(activeEl) && activeElTabIndex !== SECRET_TABINDEX) {
- return;
- }
- this.toggleSelectionForItem(model[this.as]);
- },
+ registered: function() {
+ this._prepKeyBindings();
+ },
- _multiSelectionChanged: function(multiSelection) {
- this.clearSelection();
- this.$.selector.multi = multiSelection;
- },
+ attached: function() {
+ this._listenKeyEventListeners();
+ },
- /**
- * Updates the size of an item.
- *
- * @method updateSizeForItem
- * @param {(Object|number)} item The item object or its index
- */
- updateSizeForItem: function(item) {
- item = this._getNormalizedItem(item);
- var key = this._collection.getKey(item);
- var pidx = this._physicalIndexForKey[key];
+ detached: function() {
+ this._unlistenKeyEventListeners();
+ },
- if (pidx != null) {
- this._updateMetrics([pidx]);
- this._positionItems();
- }
- },
+ /**
+ * 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();
+ },
- /**
- * 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;
+ /**
+ * When called, will remove all imperatively-added key bindings.
+ */
+ removeOwnKeyBindings: function() {
+ this._imperativeKeyBindings = {};
+ this._prepKeyBindings();
+ this._resetKeyEventListeners();
+ },
- 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();
+ /**
+ * Returns true if a keyboard event matches `eventString`.
+ *
+ * @param {KeyboardEvent} event
+ * @param {string} eventString
+ * @return {boolean}
+ */
+ keyboardEventMatchesKeys: function(event, eventString) {
+ var keyCombos = parseEventString(eventString);
+ for (var i = 0; i < keyCombos.length; ++i) {
+ if (keyComboMatchesEvent(keyCombos[i], event)) {
+ return true;
+ }
}
- } else if (this._virtualCount > 0 && this._physicalCount > 0) {
- // otherwise, assign the initial focused index.
- this._focusedIndex = this._virtualStart;
- this._focusedItem = this._physicalItems[this._physicalStart];
- }
- },
-
- _isIndexRendered: function(idx) {
- return idx >= this._virtualStart && idx <= this._virtualEnd;
- },
+ return false;
+ },
- _isIndexVisible: function(idx) {
- return idx >= this.firstVisibleIndex && idx <= this.lastVisibleIndex;
- },
+ _collectKeyBindings: function() {
+ var keyBindings = this.behaviors.map(function(behavior) {
+ return behavior.keyBindings;
+ });
- _getPhysicalIndex: function(idx) {
- return this._physicalIndexForKey[this._collection.getKey(this._getNormalizedItem(idx))];
- },
+ if (keyBindings.indexOf(this.keyBindings) === -1) {
+ keyBindings.push(this.keyBindings);
+ }
- _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);
- }
+ return keyBindings;
+ },
- var physicalItem = this._physicalItems[this._getPhysicalIndex(idx)];
- var model = physicalItem._templateInstance;
- var focusable;
+ _prepKeyBindings: function() {
+ this._keyBindings = {};
- // set a secret tab index
- model.tabIndex = SECRET_TABINDEX;
- // check if focusable element is the physical item
- if (physicalItem.tabIndex === SECRET_TABINDEX) {
- focusable = physicalItem;
- }
- // search for the element which tabindex is bound to the secret tab index
- if (!focusable) {
- focusable = Polymer.dom(physicalItem).querySelector('[tabindex="' + SECRET_TABINDEX + '"]');
- }
- // restore the tab index
- model.tabIndex = 0;
- // focus the focusable element
- this._focusedIndex = idx;
- focusable && focusable.focus();
- },
+ this._collectKeyBindings().forEach(function(keyBindings) {
+ for (var eventString in keyBindings) {
+ this._addKeyBinding(eventString, keyBindings[eventString]);
+ }
+ }, this);
- _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);
-
- 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);
- }
- },
+ for (var eventString in this._imperativeKeyBindings) {
+ this._addKeyBinding(eventString, this._imperativeKeyBindings[eventString]);
+ }
- _restoreFocusedItem: function() {
- var pidx, fidx = this._focusedIndex;
+ // 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;
+ })
+ }
+ },
- 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);
+ _addKeyBinding: function(eventString, handlerName) {
+ parseEventString(eventString).forEach(function(keyCombo) {
+ this._keyBindings[keyCombo.event] =
+ this._keyBindings[keyCombo.event] || [];
- 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._keyBindings[keyCombo.event].push([
+ keyCombo,
+ handlerName
+ ]);
+ }, this);
+ },
- _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;
+ _resetKeyEventListeners: function() {
+ this._unlistenKeyEventListeners();
- 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);
+ if (this.isAttached) {
+ this._listenKeyEventListeners();
}
- } 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();
+ _listenKeyEventListeners: function() {
+ if (!this.keyEventTarget) {
+ return;
}
- }
- },
+ Object.keys(this._keyBindings).forEach(function(eventName) {
+ var keyBindings = this._keyBindings[eventName];
+ var boundKeyHandler = this._onKeyBindingEvent.bind(this, keyBindings);
- _didMoveUp: function() {
- this._focusPhysicalItem(this._focusedIndex - 1);
- },
+ this._boundKeyHandlers.push([this.keyEventTarget, eventName, boundKeyHandler]);
- _didMoveDown: function(e) {
- // disable scroll when pressing the down key
- e.detail.keyboardEvent.preventDefault();
- this._focusPhysicalItem(this._focusedIndex + 1);
- },
+ this.keyEventTarget.addEventListener(eventName, boundKeyHandler);
+ }, this);
+ },
- _didEnter: function(e) {
- this._focusPhysicalItem(this._focusedIndex);
- this._selectionHandler(e.detail.keyboardEvent);
- }
- });
+ _unlistenKeyEventListeners: function() {
+ var keyHandlerTuple;
+ var keyEventTarget;
+ var eventName;
+ var boundKeyHandler;
-})();
-// 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.
+ 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];
-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]); };
- }
+ keyEventTarget.removeEventListener(eventName, boundKeyHandler);
+ }
+ },
- /** @constructor */
- function ActionService() {
- /** @private {Array<string>} */
- this.searchTerms_ = [];
- }
+ _onKeyBindingEvent: function(keyBindings, event) {
+ if (this.stopKeyboardEventPropagation) {
+ event.stopPropagation();
+ }
- /**
- * @param {string} s
- * @return {string} |s| without whitespace at the beginning or end.
- */
- function trim(s) { return s.trim(); }
+ // if event has been already prevented, don't do anything
+ if (event.defaultPrevented) {
+ return;
+ }
- /**
- * @param {string|undefined} value
- * @return {boolean} Whether |value| is truthy.
- */
- function truthy(value) { return !!value; }
+ 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;
+ }
+ }
+ }
+ },
- /**
- * @param {string} searchText Input typed by the user into a search box.
- * @return {Array<string>} A list of terms extracted from |searchText|.
+ _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();
+ }
+ }
+ };
+ })();
+/**
+ * @demo demo/index.html
+ * @polymerBehavior
*/
- ActionService.splitTerms = function(searchText) {
- // Split quoted terms (e.g., 'The "lazy" dog' => ['The', 'lazy', 'dog']).
- return searchText.split(/"([^"]*)"/).map(trim).filter(truthy);
- };
-
- ActionService.prototype = {
- /** @param {string} id ID of the download to cancel. */
- cancel: chromeSendWithId('cancel'),
+ Polymer.IronControlState = {
- /** Instructs the browser to clear all finished downloads. */
- clearAll: function() {
- if (loadTimeData.getBoolean('allowDeletingHistory')) {
- chrome.send('clearAll');
- this.search('');
- }
- },
+ properties: {
- /** @param {string} id ID of the dangerous download to discard. */
- discardDangerous: chromeSendWithId('discardDangerous'),
+ /**
+ * If true, the element currently has focus.
+ */
+ focused: {
+ type: Boolean,
+ value: false,
+ notify: true,
+ readOnly: true,
+ reflectToAttribute: true
+ },
- /** @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();
- },
+ /**
+ * If true, the user cannot interact with this element.
+ */
+ disabled: {
+ type: Boolean,
+ value: false,
+ notify: true,
+ observer: '_disabledChanged',
+ reflectToAttribute: true
+ },
- /** @param {string} id ID of the download that the user started dragging. */
- drag: chromeSendWithId('drag'),
+ _oldTabIndex: {
+ type: Number
+ },
- /** Loads more downloads with the current search terms. */
- loadMore: function() {
- chrome.send('getDownloads', this.searchTerms_);
- },
+ _boundFocusBlurHandler: {
+ type: Function,
+ value: function() {
+ return this._focusBlurHandler.bind(this);
+ }
+ }
- /**
- * @return {boolean} Whether the user is currently searching for downloads
- * (i.e. has a non-empty search term).
- */
- isSearching: function() {
- return this.searchTerms_.length > 0;
},
- /** 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.
- */
- 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'),
-
- /** @param {string} id ID of the paused download to resume. */
- resume: chromeSendWithId('resume'),
-
- /**
- * @param {string} id ID of the dangerous download to save despite
- * warnings.
- */
- 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;
- }
-
- if (sameTerms)
- return;
+ observers: [
+ '_changedControlState(focused, disabled)'
+ ],
- this.searchTerms_ = searchTerms;
- this.loadMore();
+ ready: function() {
+ this.addEventListener('focus', this._boundFocusBlurHandler, true);
+ this.addEventListener('blur', this._boundFocusBlurHandler, true);
},
- /**
- * Shows the local folder a finished download resides in.
- * @param {string} id ID of the download to show.
- */
- show: chromeSendWithId('show'),
-
- /** Undo download removal. */
- undo: chrome.send.bind(chrome, 'undo'),
- };
+ _focusBlurHandler: function(event) {
+ // NOTE(cdata): if we are in ShadowDOM land, `event.target` will
+ // eventually become `this` due to retargeting; if we are not in
+ // ShadowDOM land, `event.target` will eventually become `this` due
+ // to the second conditional which fires a synthetic event (that is also
+ // handled). In either case, we can disregard `event.path`.
- cr.addSingletonGetter(ActionService);
+ if (event.target === this) {
+ this._setFocused(event.type === 'focus');
+ } else if (!this.shadowRoot) {
+ var target = /** @type {Node} */(Polymer.dom(event).localTarget);
+ if (!this.isLightDescendant(target)) {
+ this.fire(event.type, {sourceEvent: event}, {
+ node: this,
+ bubbles: event.bubbles,
+ cancelable: event.cancelable
+ });
+ }
+ }
+ },
- 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.
+ _disabledChanged: function(disabled, old) {
+ this.setAttribute('aria-disabled', disabled ? 'true' : 'false');
+ this.style.pointerEvents = disabled ? 'none' : '';
+ if (disabled) {
+ this._oldTabIndex = this.tabIndex;
+ this._setFocused(false);
+ this.tabIndex = -1;
+ this.blur();
+ } else if (this._oldTabIndex !== undefined) {
+ this.tabIndex = this._oldTabIndex;
+ }
+ },
-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',
- };
+ _changedControlState: function() {
+ // _controlStateChanged is abstract, follow-on behaviors may implement it
+ if (this._controlStateChanged) {
+ this._controlStateChanged();
+ }
+ }
- /**
- * 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',
};
+/**
+ * @demo demo/index.html
+ * @polymerBehavior Polymer.IronButtonState
+ */
+ Polymer.IronButtonStateImpl = {
- 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.
+ properties: {
+
+ /**
+ * If true, the user is currently holding down the button.
+ */
+ pressed: {
+ type: Boolean,
+ readOnly: true,
+ value: false,
+ reflectToAttribute: true,
+ observer: '_pressedChanged'
+ },
-// 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.
+ /**
+ * If true, the button toggles the active state with each tap or press
+ * of the spacebar.
+ */
+ toggles: {
+ type: Boolean,
+ value: false,
+ reflectToAttribute: true
+ },
-/**
- * @constructor
- * @extends {HTMLAnchorElement}
- */
-var ActionLink = document.registerElement('action-link', {
- prototype: {
- __proto__: HTMLAnchorElement.prototype,
-
- /** @this {ActionLink} */
- createdCallback: function() {
- // Action links can start disabled (e.g. <a is="action-link" disabled>).
- this.tabIndex = this.disabled ? -1 : 0;
-
- if (!this.hasAttribute('role'))
- this.setAttribute('role', 'link');
-
- this.addEventListener('keydown', function(e) {
- if (!this.disabled && e.key == '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 true, the button is a toggle and is currently in the active state.
+ */
+ active: {
+ type: Boolean,
+ value: false,
+ notify: true,
+ reflectToAttribute: true
+ },
- function preventDefault(e) {
- e.preventDefault();
- }
+ /**
+ * True if the element is currently being pressed by a "pointer," which
+ * is loosely defined as mouse or touch input (but specifically excluding
+ * keyboard input).
+ */
+ pointerDown: {
+ type: Boolean,
+ readOnly: true,
+ value: false
+ },
+
+ /**
+ * True if the input device that caused the element to receive focus
+ * was a keyboard.
+ */
+ receivedFocusFromKeyboard: {
+ type: Boolean,
+ readOnly: true
+ },
- function removePreventDefault() {
- document.removeEventListener('selectstart', preventDefault);
- document.removeEventListener('mouseup', removePreventDefault);
+ /**
+ * The aria attribute to be set if the button is a toggle and in the
+ * active state.
+ */
+ ariaActiveAttribute: {
+ type: String,
+ value: 'aria-pressed',
+ observer: '_ariaActiveAttributeChanged'
}
+ },
+
+ listeners: {
+ down: '_downHandler',
+ up: '_upHandler',
+ tap: '_tapHandler'
+ },
- this.addEventListener('mousedown', function() {
- // This handlers strives to match the behavior of <a href="...">.
+ observers: [
+ '_detectKeyboardFocus(focused)',
+ '_activeChanged(active, ariaActiveAttribute)'
+ ],
- // While the mouse is down, prevent text selection from dragging.
- document.addEventListener('selectstart', preventDefault);
- document.addEventListener('mouseup', removePreventDefault);
+ keyBindings: {
+ 'enter:keydown': '_asyncClick',
+ 'space:keydown': '_spaceKeyDownHandler',
+ 'space:keyup': '_spaceKeyUpHandler',
+ },
- // If focus started via mouse press, don't show an outline.
- if (document.activeElement != this)
- this.classList.add('no-outline');
- });
+ _mouseEventRe: /^mouse/,
- this.addEventListener('blur', function() {
- this.classList.remove('no-outline');
- });
+ _tapHandler: function() {
+ if (this.toggles) {
+ // a tap is needed to toggle the active state
+ this._userActivate(!this.active);
+ } else {
+ this.active = false;
+ }
},
- /** @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;
+ _detectKeyboardFocus: function(focused) {
+ this._setReceivedFocusFromKeyboard(!this.pointerDown && focused);
},
- get disabled() {
- return this.hasAttribute('disabled');
+
+ // to emulate native checkbox, (de-)activations from a user interaction fire
+ // 'change' events
+ _userActivate: function(active) {
+ if (this.active !== active) {
+ this.active = active;
+ this.fire('change');
+ }
},
- /** @override */
- setAttribute: function(attr, val) {
- if (attr.toLowerCase() == 'disabled')
- this.disabled = true;
- else
- HTMLAnchorElement.prototype.setAttribute.apply(this, arguments);
+ _downHandler: function(event) {
+ this._setPointerDown(true);
+ this._setPressed(true);
+ this._setReceivedFocusFromKeyboard(false);
},
- /** @override */
- removeAttribute: function(attr) {
- if (attr.toLowerCase() == 'disabled')
- this.disabled = false;
- else
- HTMLAnchorElement.prototype.removeAttribute.apply(this, arguments);
+ _upHandler: function() {
+ this._setPointerDown(false);
+ this._setPressed(false);
},
- },
- extends: 'a',
-});
-(function() {
+ /**
+ * @param {!KeyboardEvent} event .
+ */
+ _spaceKeyDownHandler: function(event) {
+ var keyboardEvent = event.detail.keyboardEvent;
+ var target = Polymer.dom(keyboardEvent).localTarget;
- // monostate data
- var metaDatas = {};
- var metaArrays = {};
- var singleton = null;
+ // Ignore the event if this is coming from a focused light child, since that
+ // element will deal with it.
+ if (this.isLightDescendant(/** @type {Node} */(target)))
+ return;
- Polymer.IronMeta = Polymer({
+ keyboardEvent.preventDefault();
+ keyboardEvent.stopImmediatePropagation();
+ this._setPressed(true);
+ },
- is: 'iron-meta',
+ /**
+ * @param {!KeyboardEvent} event .
+ */
+ _spaceKeyUpHandler: function(event) {
+ var keyboardEvent = event.detail.keyboardEvent;
+ var target = Polymer.dom(keyboardEvent).localTarget;
- properties: {
+ // Ignore the event if this is coming from a focused light child, since that
+ // element will deal with it.
+ if (this.isLightDescendant(/** @type {Node} */(target)))
+ return;
- /**
- * The type of meta-data. All meta-data of the same type is stored
- * together.
- */
- type: {
- type: String,
- value: 'default',
- observer: '_typeChanged'
- },
+ if (this.pressed) {
+ this._asyncClick();
+ }
+ this._setPressed(false);
+ },
- /**
- * The key used to store `value` under the `type` namespace.
- */
- key: {
- type: String,
- observer: '_keyChanged'
- },
+ // trigger click asynchronously, the asynchrony is useful to allow one
+ // event handler to unwind before triggering another event
+ _asyncClick: function() {
+ this.async(function() {
+ this.click();
+ }, 1);
+ },
- /**
- * The meta-data to store or retrieve.
- */
- value: {
- type: Object,
- notify: true,
- observer: '_valueChanged'
- },
+ // any of these changes are considered a change to button state
- /**
- * If true, `value` is set to the iron-meta instance itself.
- */
- self: {
- type: Boolean,
- observer: '_selfChanged'
- },
+ _pressedChanged: function(pressed) {
+ this._changedButtonState();
+ },
- /**
- * Array of all meta-data values for the given type.
- */
- list: {
- type: Array,
- notify: true
- }
+ _ariaActiveAttributeChanged: function(value, oldValue) {
+ if (oldValue && oldValue != value && this.hasAttribute(oldValue)) {
+ this.removeAttribute(oldValue);
+ }
+ },
- },
+ _activeChanged: function(active, ariaActiveAttribute) {
+ if (this.toggles) {
+ this.setAttribute(this.ariaActiveAttribute,
+ active ? 'true' : 'false');
+ } else {
+ this.removeAttribute(this.ariaActiveAttribute);
+ }
+ this._changedButtonState();
+ },
- hostAttributes: {
- hidden: true
- },
+ _controlStateChanged: function() {
+ if (this.disabled) {
+ this._setPressed(false);
+ } else {
+ this._changedButtonState();
+ }
+ },
- /**
- * Only runs if someone invokes the factory/constructor directly
- * e.g. `new Polymer.IronMeta()`
- *
- * @param {{type: (string|undefined), key: (string|undefined), value}=} config
- */
- factoryImpl: function(config) {
- if (config) {
- for (var n in config) {
- switch(n) {
- case 'type':
- case 'key':
- case 'value':
- this[n] = config[n];
- break;
- }
- }
- }
- },
+ // provide hook for follow-on behaviors to react to button-state
- created: function() {
- // TODO(sjmiles): good for debugging?
- this._metaDatas = metaDatas;
- this._metaArrays = metaArrays;
+ _changedButtonState: function() {
+ if (this._buttonStateChanged) {
+ this._buttonStateChanged(); // abstract
+ }
+ }
+
+ };
+
+ /** @polymerBehavior */
+ Polymer.IronButtonState = [
+ Polymer.IronA11yKeysBehavior,
+ Polymer.IronButtonStateImpl
+ ];
+(function() {
+ var Utility = {
+ distance: function(x1, y1, x2, y2) {
+ var xDelta = (x1 - x2);
+ var yDelta = (y1 - y2);
+
+ return Math.sqrt(xDelta * xDelta + yDelta * yDelta);
},
- _keyChanged: function(key, old) {
- this._resetRegistration(old);
+ now: window.performance && window.performance.now ?
+ window.performance.now.bind(window.performance) : Date.now
+ };
+
+ /**
+ * @param {HTMLElement} element
+ * @constructor
+ */
+ function ElementMetrics(element) {
+ this.element = element;
+ this.width = this.boundingRect.width;
+ this.height = this.boundingRect.height;
+
+ this.size = Math.max(this.width, this.height);
+ }
+
+ ElementMetrics.prototype = {
+ get boundingRect () {
+ return this.element.getBoundingClientRect();
},
- _valueChanged: function(value) {
- this._resetRegistration(this.key);
+ furthestCornerDistanceFrom: function(x, y) {
+ var topLeft = Utility.distance(x, y, 0, 0);
+ var topRight = Utility.distance(x, y, this.width, 0);
+ var bottomLeft = Utility.distance(x, y, 0, this.height);
+ var bottomRight = Utility.distance(x, y, this.width, this.height);
+
+ return Math.max(topLeft, topRight, bottomLeft, bottomRight);
+ }
+ };
+
+ /**
+ * @param {HTMLElement} element
+ * @constructor
+ */
+ function Ripple(element) {
+ this.element = element;
+ this.color = window.getComputedStyle(element).color;
+
+ this.wave = document.createElement('div');
+ this.waveContainer = document.createElement('div');
+ this.wave.style.backgroundColor = this.color;
+ this.wave.classList.add('wave');
+ this.waveContainer.classList.add('wave-container');
+ Polymer.dom(this.waveContainer).appendChild(this.wave);
+
+ this.resetInteractionState();
+ }
+
+ Ripple.MAX_RADIUS = 300;
+
+ Ripple.prototype = {
+ get recenters() {
+ return this.element.recenters;
},
- _selfChanged: function(self) {
- if (self) {
- this.value = this;
- }
+ get center() {
+ return this.element.center;
},
- _typeChanged: function(type) {
- this._unregisterKey(this.key);
- if (!metaDatas[type]) {
- metaDatas[type] = {};
+ get mouseDownElapsed() {
+ var elapsed;
+
+ if (!this.mouseDownStart) {
+ return 0;
}
- this._metaData = metaDatas[type];
- if (!metaArrays[type]) {
- metaArrays[type] = [];
+
+ elapsed = Utility.now() - this.mouseDownStart;
+
+ if (this.mouseUpStart) {
+ elapsed -= this.mouseUpElapsed;
}
- this.list = metaArrays[type];
- this._registerKeyValue(this.key, this.value);
+
+ return elapsed;
},
- /**
- * Retrieves meta data value by key.
- *
- * @method byKey
- * @param {string} key The key of the meta-data to be returned.
- * @return {*}
- */
- byKey: function(key) {
- return this._metaData && this._metaData[key];
+ get mouseUpElapsed() {
+ return this.mouseUpStart ?
+ Utility.now () - this.mouseUpStart : 0;
},
- _resetRegistration: function(oldKey) {
- this._unregisterKey(oldKey);
- this._registerKeyValue(this.key, this.value);
+ get mouseDownElapsedSeconds() {
+ return this.mouseDownElapsed / 1000;
},
- _unregisterKey: function(key) {
- this._unregister(key, this._metaData, this.list);
+ get mouseUpElapsedSeconds() {
+ return this.mouseUpElapsed / 1000;
},
- _registerKeyValue: function(key, value) {
- this._register(key, value, this._metaData, this.list);
+ get mouseInteractionSeconds() {
+ return this.mouseDownElapsedSeconds + this.mouseUpElapsedSeconds;
},
- _register: function(key, value, data, list) {
- if (key && data && value !== undefined) {
- data[key] = value;
- list.push(value);
- }
+ get initialOpacity() {
+ return this.element.initialOpacity;
},
- _unregister: function(key, data, list) {
- if (key && data) {
- if (key in data) {
- var value = data[key];
- delete data[key];
- this.arrayDelete(list, value);
- }
- }
- }
+ get opacityDecayVelocity() {
+ return this.element.opacityDecayVelocity;
+ },
- });
+ get radius() {
+ var width2 = this.containerMetrics.width * this.containerMetrics.width;
+ var height2 = this.containerMetrics.height * this.containerMetrics.height;
+ var waveRadius = Math.min(
+ Math.sqrt(width2 + height2),
+ Ripple.MAX_RADIUS
+ ) * 1.1 + 5;
- Polymer.IronMeta.getIronMeta = function getIronMeta() {
- if (singleton === null) {
- singleton = new Polymer.IronMeta();
- }
- return singleton;
- };
+ var duration = 1.1 - 0.2 * (waveRadius / Ripple.MAX_RADIUS);
+ var timeNow = this.mouseInteractionSeconds / duration;
+ var size = waveRadius * (1 - Math.pow(80, -timeNow));
- /**
- `iron-meta-query` can be used to access infomation stored in `iron-meta`.
+ return Math.abs(size);
+ },
- Examples:
+ get opacity() {
+ if (!this.mouseUpStart) {
+ return this.initialOpacity;
+ }
- If I create an instance like this:
+ return Math.max(
+ 0,
+ this.initialOpacity - this.mouseUpElapsedSeconds * this.opacityDecayVelocity
+ );
+ },
- <iron-meta key="info" value="foo/bar"></iron-meta>
+ get outerOpacity() {
+ // Linear increase in background opacity, capped at the opacity
+ // of the wavefront (waveOpacity).
+ var outerOpacity = this.mouseUpElapsedSeconds * 0.3;
+ var waveOpacity = this.opacity;
- Note that value="foo/bar" is the metadata I've defined. I could define more
- attributes or use child nodes to define additional metadata.
+ return Math.max(
+ 0,
+ Math.min(outerOpacity, waveOpacity)
+ );
+ },
- Now I can access that element (and it's metadata) from any `iron-meta-query` instance:
+ get isOpacityFullyDecayed() {
+ return this.opacity < 0.01 &&
+ this.radius >= Math.min(this.maxRadius, Ripple.MAX_RADIUS);
+ },
- var value = new Polymer.IronMetaQuery({key: 'info'}).value;
+ get isRestingAtMaxRadius() {
+ return this.opacity >= this.initialOpacity &&
+ this.radius >= Math.min(this.maxRadius, Ripple.MAX_RADIUS);
+ },
- @group Polymer Iron Elements
- @element iron-meta-query
- */
- Polymer.IronMetaQuery = Polymer({
-
- is: 'iron-meta-query',
-
- properties: {
+ get isAnimationComplete() {
+ return this.mouseUpStart ?
+ this.isOpacityFullyDecayed : this.isRestingAtMaxRadius;
+ },
- /**
- * The type of meta-data. All meta-data of the same type is stored
- * together.
- */
- type: {
- type: String,
- value: 'default',
- observer: '_typeChanged'
- },
+ get translationFraction() {
+ return Math.min(
+ 1,
+ this.radius / this.containerMetrics.size * 2 / Math.sqrt(2)
+ );
+ },
- /**
- * Specifies a key to use for retrieving `value` from the `type`
- * namespace.
- */
- key: {
- type: String,
- observer: '_keyChanged'
- },
+ get xNow() {
+ if (this.xEnd) {
+ return this.xStart + this.translationFraction * (this.xEnd - this.xStart);
+ }
- /**
- * The meta-data to store or retrieve.
- */
- value: {
- type: Object,
- notify: true,
- readOnly: true
- },
+ return this.xStart;
+ },
- /**
- * Array of all meta-data values for the given type.
- */
- list: {
- type: Array,
- notify: true
+ get yNow() {
+ if (this.yEnd) {
+ return this.yStart + this.translationFraction * (this.yEnd - this.yStart);
}
+ return this.yStart;
},
- /**
- * Actually a factory method, not a true constructor. Only runs if
- * someone invokes it directly (via `new Polymer.IronMeta()`);
- *
- * @param {{type: (string|undefined), key: (string|undefined)}=} config
- */
- factoryImpl: function(config) {
- if (config) {
- for (var n in config) {
- switch(n) {
- case 'type':
- case 'key':
- this[n] = config[n];
- break;
- }
- }
- }
+ get isMouseDown() {
+ return this.mouseDownStart && !this.mouseUpStart;
},
- created: function() {
- // TODO(sjmiles): good for debugging?
- this._metaDatas = metaDatas;
- this._metaArrays = metaArrays;
+ resetInteractionState: function() {
+ this.maxRadius = 0;
+ this.mouseDownStart = 0;
+ this.mouseUpStart = 0;
+
+ this.xStart = 0;
+ this.yStart = 0;
+ this.xEnd = 0;
+ this.yEnd = 0;
+ this.slideDistance = 0;
+
+ this.containerMetrics = new ElementMetrics(this.element);
},
- _keyChanged: function(key) {
- this._setValue(this._metaData && this._metaData[key]);
+ draw: function() {
+ var scale;
+ var translateString;
+ var dx;
+ var dy;
+
+ this.wave.style.opacity = this.opacity;
+
+ scale = this.radius / (this.containerMetrics.size / 2);
+ dx = this.xNow - (this.containerMetrics.width / 2);
+ dy = this.yNow - (this.containerMetrics.height / 2);
+
+
+ // 2d transform for safari because of border-radius and overflow:hidden clipping bug.
+ // https://bugs.webkit.org/show_bug.cgi?id=98538
+ this.waveContainer.style.webkitTransform = 'translate(' + dx + 'px, ' + dy + 'px)';
+ this.waveContainer.style.transform = 'translate3d(' + dx + 'px, ' + dy + 'px, 0)';
+ this.wave.style.webkitTransform = 'scale(' + scale + ',' + scale + ')';
+ this.wave.style.transform = 'scale3d(' + scale + ',' + scale + ',1)';
},
- _typeChanged: function(type) {
- this._metaData = metaDatas[type];
- this.list = metaArrays[type];
- if (this.key) {
- this._keyChanged(this.key);
+ /** @param {Event=} event */
+ downAction: function(event) {
+ var xCenter = this.containerMetrics.width / 2;
+ var yCenter = this.containerMetrics.height / 2;
+
+ this.resetInteractionState();
+ this.mouseDownStart = Utility.now();
+
+ if (this.center) {
+ this.xStart = xCenter;
+ this.yStart = yCenter;
+ this.slideDistance = Utility.distance(
+ this.xStart, this.yStart, this.xEnd, this.yEnd
+ );
+ } else {
+ this.xStart = event ?
+ event.detail.x - this.containerMetrics.boundingRect.left :
+ this.containerMetrics.width / 2;
+ this.yStart = event ?
+ event.detail.y - this.containerMetrics.boundingRect.top :
+ this.containerMetrics.height / 2;
+ }
+
+ if (this.recenters) {
+ this.xEnd = xCenter;
+ this.yEnd = yCenter;
+ this.slideDistance = Utility.distance(
+ this.xStart, this.yStart, this.xEnd, this.yEnd
+ );
}
+
+ this.maxRadius = this.containerMetrics.furthestCornerDistanceFrom(
+ this.xStart,
+ this.yStart
+ );
+
+ this.waveContainer.style.top =
+ (this.containerMetrics.height - this.containerMetrics.size) / 2 + 'px';
+ this.waveContainer.style.left =
+ (this.containerMetrics.width - this.containerMetrics.size) / 2 + 'px';
+
+ this.waveContainer.style.width = this.containerMetrics.size + 'px';
+ this.waveContainer.style.height = this.containerMetrics.size + 'px';
},
- /**
- * Retrieves meta data value by key.
- * @param {string} key The key of the meta-data to be returned.
- * @return {*}
- */
- byKey: function(key) {
- return this._metaData && this._metaData[key];
- }
+ /** @param {Event=} event */
+ upAction: function(event) {
+ if (!this.isMouseDown) {
+ return;
+ }
- });
+ this.mouseUpStart = Utility.now();
+ },
- })();
-Polymer({
+ remove: function() {
+ Polymer.dom(this.waveContainer.parentNode).removeChild(
+ this.waveContainer
+ );
+ }
+ };
- is: 'iron-icon',
+ Polymer({
+ is: 'paper-ripple',
- properties: {
+ behaviors: [
+ Polymer.IronA11yKeysBehavior
+ ],
+ properties: {
/**
- * The name of the icon to use. The name should be of the form:
- * `iconset_name:icon_name`.
+ * The initial opacity set on the wave.
+ *
+ * @attribute initialOpacity
+ * @type number
+ * @default 0.25
*/
- icon: {
- type: String,
- observer: '_iconChanged'
+ initialOpacity: {
+ type: Number,
+ value: 0.25
},
/**
- * The name of the theme to used, if one is specified by the
- * iconset.
+ * How fast (opacity per second) the wave fades out.
+ *
+ * @attribute opacityDecayVelocity
+ * @type number
+ * @default 0.8
*/
- theme: {
- type: String,
- observer: '_updateIcon'
+ opacityDecayVelocity: {
+ type: Number,
+ value: 0.8
},
/**
- * If using iron-icon without an iconset, you can set the src to be
- * the URL of an individual icon image file. Note that this will take
- * precedence over a given icon attribute.
+ * If true, ripples will exhibit a gravitational pull towards
+ * the center of their container as they fade away.
+ *
+ * @attribute recenters
+ * @type boolean
+ * @default false
*/
- src: {
- type: String,
- observer: '_srcChanged'
+ recenters: {
+ type: Boolean,
+ value: false
},
/**
- * @type {!Polymer.IronMeta}
+ * If true, ripples will center inside its container
+ *
+ * @attribute recenters
+ * @type boolean
+ * @default false
*/
- _meta: {
- value: Polymer.Base.create('iron-meta', {type: 'iconset'}),
- observer: '_updateIcon'
- }
-
- },
-
- _DEFAULT_ICONSET: 'icons',
-
- _iconChanged: function(icon) {
- var parts = (icon || '').split(':');
- this._iconName = parts.pop();
- this._iconsetName = parts.pop() || this._DEFAULT_ICONSET;
- this._updateIcon();
- },
+ center: {
+ type: Boolean,
+ value: false
+ },
- _srcChanged: function(src) {
- this._updateIcon();
- },
+ /**
+ * A list of the visual ripples.
+ *
+ * @attribute ripples
+ * @type Array
+ * @default []
+ */
+ ripples: {
+ type: Array,
+ value: function() {
+ return [];
+ }
+ },
- _usesIconset: function() {
- return this.icon || !this.src;
- },
+ /**
+ * True when there are visible ripples animating within the
+ * element.
+ */
+ animating: {
+ type: Boolean,
+ readOnly: true,
+ reflectToAttribute: true,
+ value: false
+ },
- /** @suppress {visibility} */
- _updateIcon: function() {
- if (this._usesIconset()) {
- if (this._img && this._img.parentNode) {
- Polymer.dom(this.root).removeChild(this._img);
- }
- if (this._iconName === "") {
- if (this._iconset) {
- this._iconset.removeIcon(this);
- }
- } else if (this._iconsetName && this._meta) {
- this._iconset = /** @type {?Polymer.Iconset} */ (
- this._meta.byKey(this._iconsetName));
- if (this._iconset) {
- this._iconset.applyIcon(this, this._iconName, this.theme);
- this.unlisten(window, 'iron-iconset-added', '_updateIcon');
- } else {
- this.listen(window, 'iron-iconset-added', '_updateIcon');
- }
- }
- } else {
- if (this._iconset) {
- this._iconset.removeIcon(this);
- }
- if (!this._img) {
- this._img = document.createElement('img');
- this._img.style.width = '100%';
- this._img.style.height = '100%';
- this._img.draggable = false;
- }
- this._img.src = this.src;
- Polymer.dom(this.root).appendChild(this._img);
- }
- }
+ /**
+ * If true, the ripple will remain in the "down" state until `holdDown`
+ * is set to false again.
+ */
+ holdDown: {
+ type: Boolean,
+ value: false,
+ observer: '_holdDownChanged'
+ },
- });
-/**
- * @demo demo/index.html
- * @polymerBehavior
- */
- Polymer.IronControlState = {
+ /**
+ * If true, the ripple will not generate a ripple effect
+ * via pointer interaction.
+ * Calling ripple's imperative api like `simulatedRipple` will
+ * still generate the ripple effect.
+ */
+ noink: {
+ type: Boolean,
+ value: false
+ },
- properties: {
+ _animating: {
+ type: Boolean
+ },
- /**
- * If true, the element currently has focus.
- */
- focused: {
- type: Boolean,
- value: false,
- notify: true,
- readOnly: true,
- reflectToAttribute: true
+ _boundAnimate: {
+ type: Function,
+ value: function() {
+ return this.animate.bind(this);
+ }
+ }
},
- /**
- * If true, the user cannot interact with this element.
- */
- disabled: {
- type: Boolean,
- value: false,
- notify: true,
- observer: '_disabledChanged',
- reflectToAttribute: true
+ get target () {
+ return this.keyEventTarget;
},
- _oldTabIndex: {
- type: Number
+ keyBindings: {
+ 'enter:keydown': '_onEnterKeydown',
+ 'space:keydown': '_onSpaceKeydown',
+ 'space:keyup': '_onSpaceKeyup'
},
- _boundFocusBlurHandler: {
- type: Function,
- value: function() {
- return this._focusBlurHandler.bind(this);
+ attached: function() {
+ // Set up a11yKeysBehavior to listen to key events on the target,
+ // so that space and enter activate the ripple even if the target doesn't
+ // handle key events. The key handlers deal with `noink` themselves.
+ if (this.parentNode.nodeType == 11) { // DOCUMENT_FRAGMENT_NODE
+ this.keyEventTarget = Polymer.dom(this).getOwnerRoot().host;
+ } else {
+ this.keyEventTarget = this.parentNode;
}
- }
-
- },
-
- observers: [
- '_changedControlState(focused, disabled)'
- ],
-
- ready: function() {
- this.addEventListener('focus', this._boundFocusBlurHandler, true);
- this.addEventListener('blur', this._boundFocusBlurHandler, true);
- },
+ var keyEventTarget = /** @type {!EventTarget} */ (this.keyEventTarget);
+ this.listen(keyEventTarget, 'up', 'uiUpAction');
+ this.listen(keyEventTarget, 'down', 'uiDownAction');
+ },
- _focusBlurHandler: function(event) {
- // NOTE(cdata): if we are in ShadowDOM land, `event.target` will
- // eventually become `this` due to retargeting; if we are not in
- // ShadowDOM land, `event.target` will eventually become `this` due
- // to the second conditional which fires a synthetic event (that is also
- // handled). In either case, we can disregard `event.path`.
+ detached: function() {
+ this.unlisten(this.keyEventTarget, 'up', 'uiUpAction');
+ this.unlisten(this.keyEventTarget, 'down', 'uiDownAction');
+ this.keyEventTarget = null;
+ },
- if (event.target === this) {
- this._setFocused(event.type === 'focus');
- } else if (!this.shadowRoot) {
- var target = /** @type {Node} */(Polymer.dom(event).localTarget);
- if (!this.isLightDescendant(target)) {
- this.fire(event.type, {sourceEvent: event}, {
- node: this,
- bubbles: event.bubbles,
- cancelable: event.cancelable
- });
+ get shouldKeepAnimating () {
+ for (var index = 0; index < this.ripples.length; ++index) {
+ if (!this.ripples[index].isAnimationComplete) {
+ return true;
+ }
}
- }
- },
-
- _disabledChanged: function(disabled, old) {
- this.setAttribute('aria-disabled', disabled ? 'true' : 'false');
- this.style.pointerEvents = disabled ? 'none' : '';
- if (disabled) {
- this._oldTabIndex = this.tabIndex;
- this._setFocused(false);
- this.tabIndex = -1;
- this.blur();
- } else if (this._oldTabIndex !== undefined) {
- this.tabIndex = this._oldTabIndex;
- }
- },
- _changedControlState: function() {
- // _controlStateChanged is abstract, follow-on behaviors may implement it
- if (this._controlStateChanged) {
- this._controlStateChanged();
- }
- }
+ return false;
+ },
- };
-/**
- * @demo demo/index.html
- * @polymerBehavior Polymer.IronButtonState
- */
- Polymer.IronButtonStateImpl = {
+ simulatedRipple: function() {
+ this.downAction(null);
- properties: {
+ // Please see polymer/polymer#1305
+ this.async(function() {
+ this.upAction();
+ }, 1);
+ },
/**
- * If true, the user is currently holding down the button.
+ * Provokes a ripple down effect via a UI event,
+ * respecting the `noink` property.
+ * @param {Event=} event
*/
- pressed: {
- type: Boolean,
- readOnly: true,
- value: false,
- reflectToAttribute: true,
- observer: '_pressedChanged'
+ uiDownAction: function(event) {
+ if (!this.noink) {
+ this.downAction(event);
+ }
},
/**
- * If true, the button toggles the active state with each tap or press
- * of the spacebar.
+ * Provokes a ripple down effect via a UI event,
+ * *not* respecting the `noink` property.
+ * @param {Event=} event
*/
- toggles: {
- type: Boolean,
- value: false,
- reflectToAttribute: true
+ downAction: function(event) {
+ if (this.holdDown && this.ripples.length > 0) {
+ return;
+ }
+
+ var ripple = this.addRipple();
+
+ ripple.downAction(event);
+
+ if (!this._animating) {
+ this._animating = true;
+ this.animate();
+ }
},
/**
- * If true, the button is a toggle and is currently in the active state.
+ * Provokes a ripple up effect via a UI event,
+ * respecting the `noink` property.
+ * @param {Event=} event
*/
- active: {
- type: Boolean,
- value: false,
- notify: true,
- reflectToAttribute: true
+ uiUpAction: function(event) {
+ if (!this.noink) {
+ this.upAction(event);
+ }
},
/**
- * True if the element is currently being pressed by a "pointer," which
- * is loosely defined as mouse or touch input (but specifically excluding
- * keyboard input).
+ * Provokes a ripple up effect via a UI event,
+ * *not* respecting the `noink` property.
+ * @param {Event=} event
*/
- pointerDown: {
- type: Boolean,
- readOnly: true,
- value: false
+ upAction: function(event) {
+ if (this.holdDown) {
+ return;
+ }
+
+ this.ripples.forEach(function(ripple) {
+ ripple.upAction(event);
+ });
+
+ this._animating = true;
+ this.animate();
},
- /**
- * True if the input device that caused the element to receive focus
- * was a keyboard.
- */
- receivedFocusFromKeyboard: {
- type: Boolean,
- readOnly: true
+ onAnimationComplete: function() {
+ this._animating = false;
+ this.$.background.style.backgroundColor = null;
+ this.fire('transitionend');
},
- /**
- * The aria attribute to be set if the button is a toggle and in the
- * active state.
- */
- ariaActiveAttribute: {
- type: String,
- value: 'aria-pressed',
- observer: '_ariaActiveAttributeChanged'
- }
- },
+ addRipple: function() {
+ var ripple = new Ripple(this);
- listeners: {
- down: '_downHandler',
- up: '_upHandler',
- tap: '_tapHandler'
- },
+ Polymer.dom(this.$.waves).appendChild(ripple.waveContainer);
+ this.$.background.style.backgroundColor = ripple.color;
+ this.ripples.push(ripple);
- observers: [
- '_detectKeyboardFocus(focused)',
- '_activeChanged(active, ariaActiveAttribute)'
- ],
+ this._setAnimating(true);
- keyBindings: {
- 'enter:keydown': '_asyncClick',
- 'space:keydown': '_spaceKeyDownHandler',
- 'space:keyup': '_spaceKeyUpHandler',
- },
+ return ripple;
+ },
- _mouseEventRe: /^mouse/,
+ removeRipple: function(ripple) {
+ var rippleIndex = this.ripples.indexOf(ripple);
- _tapHandler: function() {
- if (this.toggles) {
- // a tap is needed to toggle the active state
- this._userActivate(!this.active);
- } else {
- this.active = false;
- }
- },
+ if (rippleIndex < 0) {
+ return;
+ }
- _detectKeyboardFocus: function(focused) {
- this._setReceivedFocusFromKeyboard(!this.pointerDown && focused);
- },
+ this.ripples.splice(rippleIndex, 1);
- // to emulate native checkbox, (de-)activations from a user interaction fire
- // 'change' events
- _userActivate: function(active) {
- if (this.active !== active) {
- this.active = active;
- this.fire('change');
- }
- },
+ ripple.remove();
- _downHandler: function(event) {
- this._setPointerDown(true);
- this._setPressed(true);
- this._setReceivedFocusFromKeyboard(false);
- },
+ if (!this.ripples.length) {
+ this._setAnimating(false);
+ }
+ },
- _upHandler: function() {
- this._setPointerDown(false);
- this._setPressed(false);
- },
+ animate: function() {
+ if (!this._animating) {
+ return;
+ }
+ var index;
+ var ripple;
- /**
- * @param {!KeyboardEvent} event .
- */
- _spaceKeyDownHandler: function(event) {
- var keyboardEvent = event.detail.keyboardEvent;
- var target = Polymer.dom(keyboardEvent).localTarget;
+ for (index = 0; index < this.ripples.length; ++index) {
+ ripple = this.ripples[index];
- // Ignore the event if this is coming from a focused light child, since that
- // element will deal with it.
- if (this.isLightDescendant(/** @type {Node} */(target)))
- return;
+ ripple.draw();
- keyboardEvent.preventDefault();
- keyboardEvent.stopImmediatePropagation();
- this._setPressed(true);
- },
+ this.$.background.style.opacity = ripple.outerOpacity;
- /**
- * @param {!KeyboardEvent} event .
- */
- _spaceKeyUpHandler: function(event) {
- var keyboardEvent = event.detail.keyboardEvent;
- var target = Polymer.dom(keyboardEvent).localTarget;
+ if (ripple.isOpacityFullyDecayed && !ripple.isRestingAtMaxRadius) {
+ this.removeRipple(ripple);
+ }
+ }
- // Ignore the event if this is coming from a focused light child, since that
- // element will deal with it.
- if (this.isLightDescendant(/** @type {Node} */(target)))
- return;
+ if (!this.shouldKeepAnimating && this.ripples.length === 0) {
+ this.onAnimationComplete();
+ } else {
+ window.requestAnimationFrame(this._boundAnimate);
+ }
+ },
- if (this.pressed) {
- this._asyncClick();
+ _onEnterKeydown: function() {
+ this.uiDownAction();
+ this.async(this.uiUpAction, 1);
+ },
+
+ _onSpaceKeydown: function() {
+ this.uiDownAction();
+ },
+
+ _onSpaceKeyup: function() {
+ this.uiUpAction();
+ },
+
+ // note: holdDown does not respect noink since it can be a focus based
+ // effect.
+ _holdDownChanged: function(newVal, oldVal) {
+ if (oldVal === undefined) {
+ return;
+ }
+ if (newVal) {
+ this.downAction();
+ } else {
+ this.upAction();
+ }
}
- this._setPressed(false);
- },
- // trigger click asynchronously, the asynchrony is useful to allow one
- // event handler to unwind before triggering another event
- _asyncClick: function() {
- this.async(function() {
- this.click();
- }, 1);
- },
+ /**
+ Fired when the animation finishes.
+ This is useful if you want to wait until
+ the ripple animation finishes to perform some action.
- // any of these changes are considered a change to button state
+ @event transitionend
+ @param {{node: Object}} detail Contains the animated node.
+ */
+ });
+ })();
+/**
+ * `Polymer.PaperRippleBehavior` dynamically implements a ripple
+ * when the element has focus via pointer or keyboard.
+ *
+ * NOTE: This behavior is intended to be used in conjunction with and after
+ * `Polymer.IronButtonState` and `Polymer.IronControlState`.
+ *
+ * @polymerBehavior Polymer.PaperRippleBehavior
+ */
+ Polymer.PaperRippleBehavior = {
+ properties: {
+ /**
+ * If true, the element will not produce a ripple effect when interacted
+ * with via the pointer.
+ */
+ noink: {
+ type: Boolean,
+ observer: '_noinkChanged'
+ },
- _pressedChanged: function(pressed) {
- this._changedButtonState();
+ /**
+ * @type {Element|undefined}
+ */
+ _rippleContainer: {
+ type: Object,
+ }
},
- _ariaActiveAttributeChanged: function(value, oldValue) {
- if (oldValue && oldValue != value && this.hasAttribute(oldValue)) {
- this.removeAttribute(oldValue);
+ /**
+ * Ensures a `<paper-ripple>` element is available when the element is
+ * focused.
+ */
+ _buttonStateChanged: function() {
+ if (this.focused) {
+ this.ensureRipple();
}
},
- _activeChanged: function(active, ariaActiveAttribute) {
- if (this.toggles) {
- this.setAttribute(this.ariaActiveAttribute,
- active ? 'true' : 'false');
- } else {
- this.removeAttribute(this.ariaActiveAttribute);
+ /**
+ * In addition to the functionality provided in `IronButtonState`, ensures
+ * a ripple effect is created when the element is in a `pressed` state.
+ */
+ _downHandler: function(event) {
+ Polymer.IronButtonStateImpl._downHandler.call(this, event);
+ if (this.pressed) {
+ this.ensureRipple(event);
}
- this._changedButtonState();
},
- _controlStateChanged: function() {
- if (this.disabled) {
- this._setPressed(false);
- } else {
- this._changedButtonState();
+ /**
+ * Ensures this element contains a ripple effect. For startup efficiency
+ * the ripple effect is dynamically on demand when needed.
+ * @param {!Event=} optTriggeringEvent (optional) event that triggered the
+ * ripple.
+ */
+ ensureRipple: function(optTriggeringEvent) {
+ if (!this.hasRipple()) {
+ this._ripple = this._createRipple();
+ this._ripple.noink = this.noink;
+ var rippleContainer = this._rippleContainer || this.root;
+ if (rippleContainer) {
+ Polymer.dom(rippleContainer).appendChild(this._ripple);
+ }
+ if (optTriggeringEvent) {
+ // Check if the event happened inside of the ripple container
+ // Fall back to host instead of the root because distributed text
+ // nodes are not valid event targets
+ var domContainer = Polymer.dom(this._rippleContainer || this);
+ var target = Polymer.dom(optTriggeringEvent).rootTarget;
+ if (domContainer.deepContains( /** @type {Node} */(target))) {
+ this._ripple.uiDownAction(optTriggeringEvent);
+ }
+ }
}
},
- // provide hook for follow-on behaviors to react to button-state
-
- _changedButtonState: function() {
- if (this._buttonStateChanged) {
- this._buttonStateChanged(); // abstract
- }
- }
-
- };
-
- /** @polymerBehavior */
- Polymer.IronButtonState = [
- Polymer.IronA11yKeysBehavior,
- Polymer.IronButtonStateImpl
- ];
-(function() {
- var Utility = {
- distance: function(x1, y1, x2, y2) {
- var xDelta = (x1 - x2);
- var yDelta = (y1 - y2);
-
- return Math.sqrt(xDelta * xDelta + yDelta * yDelta);
- },
+ /**
+ * Returns the `<paper-ripple>` element used by this element to create
+ * ripple effects. The element's ripple is created on demand, when
+ * necessary, and calling this method will force the
+ * ripple to be created.
+ */
+ getRipple: function() {
+ this.ensureRipple();
+ return this._ripple;
+ },
- now: window.performance && window.performance.now ?
- window.performance.now.bind(window.performance) : Date.now
- };
+ /**
+ * Returns true if this element currently contains a ripple effect.
+ * @return {boolean}
+ */
+ hasRipple: function() {
+ return Boolean(this._ripple);
+ },
/**
- * @param {HTMLElement} element
- * @constructor
+ * Create the element's ripple effect via creating a `<paper-ripple>`.
+ * Override this method to customize the ripple element.
+ * @return {!PaperRippleElement} Returns a `<paper-ripple>` element.
*/
- function ElementMetrics(element) {
- this.element = element;
- this.width = this.boundingRect.width;
- this.height = this.boundingRect.height;
+ _createRipple: function() {
+ return /** @type {!PaperRippleElement} */ (
+ document.createElement('paper-ripple'));
+ },
- this.size = Math.max(this.width, this.height);
+ _noinkChanged: function(noink) {
+ if (this.hasRipple()) {
+ this._ripple.noink = noink;
+ }
}
+ };
+/** @polymerBehavior Polymer.PaperButtonBehavior */
+ Polymer.PaperButtonBehaviorImpl = {
+ properties: {
+ /**
+ * The z-depth of this element, from 0-5. Setting to 0 will remove the
+ * shadow, and each increasing number greater than 0 will be "deeper"
+ * than the last.
+ *
+ * @attribute elevation
+ * @type number
+ * @default 1
+ */
+ elevation: {
+ type: Number,
+ reflectToAttribute: true,
+ readOnly: true
+ }
+ },
- ElementMetrics.prototype = {
- get boundingRect () {
- return this.element.getBoundingClientRect();
- },
+ observers: [
+ '_calculateElevation(focused, disabled, active, pressed, receivedFocusFromKeyboard)',
+ '_computeKeyboardClass(receivedFocusFromKeyboard)'
+ ],
- furthestCornerDistanceFrom: function(x, y) {
- var topLeft = Utility.distance(x, y, 0, 0);
- var topRight = Utility.distance(x, y, this.width, 0);
- var bottomLeft = Utility.distance(x, y, 0, this.height);
- var bottomRight = Utility.distance(x, y, this.width, this.height);
+ hostAttributes: {
+ role: 'button',
+ tabindex: '0',
+ animated: true
+ },
- return Math.max(topLeft, topRight, bottomLeft, bottomRight);
+ _calculateElevation: function() {
+ var e = 1;
+ if (this.disabled) {
+ e = 0;
+ } else if (this.active || this.pressed) {
+ e = 4;
+ } else if (this.receivedFocusFromKeyboard) {
+ e = 3;
}
- };
+ this._setElevation(e);
+ },
+
+ _computeKeyboardClass: function(receivedFocusFromKeyboard) {
+ this.toggleClass('keyboard-focus', receivedFocusFromKeyboard);
+ },
/**
- * @param {HTMLElement} element
- * @constructor
+ * In addition to `IronButtonState` behavior, when space key goes down,
+ * create a ripple down effect.
+ *
+ * @param {!KeyboardEvent} event .
*/
- function Ripple(element) {
- this.element = element;
- this.color = window.getComputedStyle(element).color;
-
- this.wave = document.createElement('div');
- this.waveContainer = document.createElement('div');
- this.wave.style.backgroundColor = this.color;
- this.wave.classList.add('wave');
- this.waveContainer.classList.add('wave-container');
- Polymer.dom(this.waveContainer).appendChild(this.wave);
+ _spaceKeyDownHandler: function(event) {
+ Polymer.IronButtonStateImpl._spaceKeyDownHandler.call(this, event);
+ // Ensure that there is at most one ripple when the space key is held down.
+ if (this.hasRipple() && this.getRipple().ripples.length < 1) {
+ this._ripple.uiDownAction();
+ }
+ },
- this.resetInteractionState();
+ /**
+ * In addition to `IronButtonState` behavior, when space key goes up,
+ * create a ripple up effect.
+ *
+ * @param {!KeyboardEvent} event .
+ */
+ _spaceKeyUpHandler: function(event) {
+ Polymer.IronButtonStateImpl._spaceKeyUpHandler.call(this, event);
+ if (this.hasRipple()) {
+ this._ripple.uiUpAction();
+ }
}
+ };
- Ripple.MAX_RADIUS = 300;
-
- Ripple.prototype = {
- get recenters() {
- return this.element.recenters;
- },
-
- get center() {
- return this.element.center;
- },
+ /** @polymerBehavior */
+ Polymer.PaperButtonBehavior = [
+ Polymer.IronButtonState,
+ Polymer.IronControlState,
+ Polymer.PaperRippleBehavior,
+ Polymer.PaperButtonBehaviorImpl
+ ];
+Polymer({
+ is: 'paper-button',
- get mouseDownElapsed() {
- var elapsed;
+ behaviors: [
+ Polymer.PaperButtonBehavior
+ ],
- if (!this.mouseDownStart) {
- return 0;
+ properties: {
+ /**
+ * If true, the button should be styled with a shadow.
+ */
+ raised: {
+ type: Boolean,
+ reflectToAttribute: true,
+ value: false,
+ observer: '_calculateElevation'
}
+ },
- elapsed = Utility.now() - this.mouseDownStart;
-
- if (this.mouseUpStart) {
- elapsed -= this.mouseUpElapsed;
+ _calculateElevation: function() {
+ if (!this.raised) {
+ this._setElevation(0);
+ } else {
+ Polymer.PaperButtonBehaviorImpl._calculateElevation.apply(this);
}
+ }
- return elapsed;
- },
+ /**
+ Fired when the animation finishes.
+ This is useful if you want to wait until
+ the ripple animation finishes to perform some action.
- get mouseUpElapsed() {
- return this.mouseUpStart ?
- Utility.now () - this.mouseUpStart : 0;
- },
+ @event transitionend
+ Event param: {{node: Object}} detail Contains the animated node.
+ */
+ });
+(function() {
- get mouseDownElapsedSeconds() {
- return this.mouseDownElapsed / 1000;
- },
+ // monostate data
+ var metaDatas = {};
+ var metaArrays = {};
+ var singleton = null;
- get mouseUpElapsedSeconds() {
- return this.mouseUpElapsed / 1000;
- },
+ Polymer.IronMeta = Polymer({
- get mouseInteractionSeconds() {
- return this.mouseDownElapsedSeconds + this.mouseUpElapsedSeconds;
- },
+ is: 'iron-meta',
- get initialOpacity() {
- return this.element.initialOpacity;
- },
+ properties: {
- get opacityDecayVelocity() {
- return this.element.opacityDecayVelocity;
- },
+ /**
+ * The type of meta-data. All meta-data of the same type is stored
+ * together.
+ */
+ type: {
+ type: String,
+ value: 'default',
+ observer: '_typeChanged'
+ },
- get radius() {
- var width2 = this.containerMetrics.width * this.containerMetrics.width;
- var height2 = this.containerMetrics.height * this.containerMetrics.height;
- var waveRadius = Math.min(
- Math.sqrt(width2 + height2),
- Ripple.MAX_RADIUS
- ) * 1.1 + 5;
+ /**
+ * The key used to store `value` under the `type` namespace.
+ */
+ key: {
+ type: String,
+ observer: '_keyChanged'
+ },
- var duration = 1.1 - 0.2 * (waveRadius / Ripple.MAX_RADIUS);
- var timeNow = this.mouseInteractionSeconds / duration;
- var size = waveRadius * (1 - Math.pow(80, -timeNow));
+ /**
+ * The meta-data to store or retrieve.
+ */
+ value: {
+ type: Object,
+ notify: true,
+ observer: '_valueChanged'
+ },
- return Math.abs(size);
- },
+ /**
+ * If true, `value` is set to the iron-meta instance itself.
+ */
+ self: {
+ type: Boolean,
+ observer: '_selfChanged'
+ },
- get opacity() {
- if (!this.mouseUpStart) {
- return this.initialOpacity;
+ /**
+ * Array of all meta-data values for the given type.
+ */
+ list: {
+ type: Array,
+ notify: true
}
- return Math.max(
- 0,
- this.initialOpacity - this.mouseUpElapsedSeconds * this.opacityDecayVelocity
- );
},
- get outerOpacity() {
- // Linear increase in background opacity, capped at the opacity
- // of the wavefront (waveOpacity).
- var outerOpacity = this.mouseUpElapsedSeconds * 0.3;
- var waveOpacity = this.opacity;
-
- return Math.max(
- 0,
- Math.min(outerOpacity, waveOpacity)
- );
+ hostAttributes: {
+ hidden: true
},
- get isOpacityFullyDecayed() {
- return this.opacity < 0.01 &&
- this.radius >= Math.min(this.maxRadius, Ripple.MAX_RADIUS);
+ /**
+ * Only runs if someone invokes the factory/constructor directly
+ * e.g. `new Polymer.IronMeta()`
+ *
+ * @param {{type: (string|undefined), key: (string|undefined), value}=} config
+ */
+ factoryImpl: function(config) {
+ if (config) {
+ for (var n in config) {
+ switch(n) {
+ case 'type':
+ case 'key':
+ case 'value':
+ this[n] = config[n];
+ break;
+ }
+ }
+ }
},
- get isRestingAtMaxRadius() {
- return this.opacity >= this.initialOpacity &&
- this.radius >= Math.min(this.maxRadius, Ripple.MAX_RADIUS);
+ created: function() {
+ // TODO(sjmiles): good for debugging?
+ this._metaDatas = metaDatas;
+ this._metaArrays = metaArrays;
},
- get isAnimationComplete() {
- return this.mouseUpStart ?
- this.isOpacityFullyDecayed : this.isRestingAtMaxRadius;
+ _keyChanged: function(key, old) {
+ this._resetRegistration(old);
},
- get translationFraction() {
- return Math.min(
- 1,
- this.radius / this.containerMetrics.size * 2 / Math.sqrt(2)
- );
+ _valueChanged: function(value) {
+ this._resetRegistration(this.key);
},
- get xNow() {
- if (this.xEnd) {
- return this.xStart + this.translationFraction * (this.xEnd - this.xStart);
+ _selfChanged: function(self) {
+ if (self) {
+ this.value = this;
}
-
- return this.xStart;
},
- get yNow() {
- if (this.yEnd) {
- return this.yStart + this.translationFraction * (this.yEnd - this.yStart);
+ _typeChanged: function(type) {
+ this._unregisterKey(this.key);
+ if (!metaDatas[type]) {
+ metaDatas[type] = {};
+ }
+ this._metaData = metaDatas[type];
+ if (!metaArrays[type]) {
+ metaArrays[type] = [];
}
+ this.list = metaArrays[type];
+ this._registerKeyValue(this.key, this.value);
+ },
- return this.yStart;
+ /**
+ * Retrieves meta data value by key.
+ *
+ * @method byKey
+ * @param {string} key The key of the meta-data to be returned.
+ * @return {*}
+ */
+ byKey: function(key) {
+ return this._metaData && this._metaData[key];
},
- get isMouseDown() {
- return this.mouseDownStart && !this.mouseUpStart;
+ _resetRegistration: function(oldKey) {
+ this._unregisterKey(oldKey);
+ this._registerKeyValue(this.key, this.value);
},
- resetInteractionState: function() {
- this.maxRadius = 0;
- this.mouseDownStart = 0;
- this.mouseUpStart = 0;
+ _unregisterKey: function(key) {
+ this._unregister(key, this._metaData, this.list);
+ },
- this.xStart = 0;
- this.yStart = 0;
- this.xEnd = 0;
- this.yEnd = 0;
- this.slideDistance = 0;
+ _registerKeyValue: function(key, value) {
+ this._register(key, value, this._metaData, this.list);
+ },
- this.containerMetrics = new ElementMetrics(this.element);
+ _register: function(key, value, data, list) {
+ if (key && data && value !== undefined) {
+ data[key] = value;
+ list.push(value);
+ }
},
- draw: function() {
- var scale;
- var translateString;
- var dx;
- var dy;
+ _unregister: function(key, data, list) {
+ if (key && data) {
+ if (key in data) {
+ var value = data[key];
+ delete data[key];
+ this.arrayDelete(list, value);
+ }
+ }
+ }
- this.wave.style.opacity = this.opacity;
+ });
- scale = this.radius / (this.containerMetrics.size / 2);
- dx = this.xNow - (this.containerMetrics.width / 2);
- dy = this.yNow - (this.containerMetrics.height / 2);
+ Polymer.IronMeta.getIronMeta = function getIronMeta() {
+ if (singleton === null) {
+ singleton = new Polymer.IronMeta();
+ }
+ return singleton;
+ };
+ /**
+ `iron-meta-query` can be used to access infomation stored in `iron-meta`.
- // 2d transform for safari because of border-radius and overflow:hidden clipping bug.
- // https://bugs.webkit.org/show_bug.cgi?id=98538
- this.waveContainer.style.webkitTransform = 'translate(' + dx + 'px, ' + dy + 'px)';
- this.waveContainer.style.transform = 'translate3d(' + dx + 'px, ' + dy + 'px, 0)';
- this.wave.style.webkitTransform = 'scale(' + scale + ',' + scale + ')';
- this.wave.style.transform = 'scale3d(' + scale + ',' + scale + ',1)';
- },
+ Examples:
- /** @param {Event=} event */
- downAction: function(event) {
- var xCenter = this.containerMetrics.width / 2;
- var yCenter = this.containerMetrics.height / 2;
+ If I create an instance like this:
- this.resetInteractionState();
- this.mouseDownStart = Utility.now();
+ <iron-meta key="info" value="foo/bar"></iron-meta>
- if (this.center) {
- this.xStart = xCenter;
- this.yStart = yCenter;
- this.slideDistance = Utility.distance(
- this.xStart, this.yStart, this.xEnd, this.yEnd
- );
- } else {
- this.xStart = event ?
- event.detail.x - this.containerMetrics.boundingRect.left :
- this.containerMetrics.width / 2;
- this.yStart = event ?
- event.detail.y - this.containerMetrics.boundingRect.top :
- this.containerMetrics.height / 2;
- }
+ Note that value="foo/bar" is the metadata I've defined. I could define more
+ attributes or use child nodes to define additional metadata.
- if (this.recenters) {
- this.xEnd = xCenter;
- this.yEnd = yCenter;
- this.slideDistance = Utility.distance(
- this.xStart, this.yStart, this.xEnd, this.yEnd
- );
- }
+ Now I can access that element (and it's metadata) from any `iron-meta-query` instance:
- this.maxRadius = this.containerMetrics.furthestCornerDistanceFrom(
- this.xStart,
- this.yStart
- );
+ var value = new Polymer.IronMetaQuery({key: 'info'}).value;
- this.waveContainer.style.top =
- (this.containerMetrics.height - this.containerMetrics.size) / 2 + 'px';
- this.waveContainer.style.left =
- (this.containerMetrics.width - this.containerMetrics.size) / 2 + 'px';
+ @group Polymer Iron Elements
+ @element iron-meta-query
+ */
+ Polymer.IronMetaQuery = Polymer({
- this.waveContainer.style.width = this.containerMetrics.size + 'px';
- this.waveContainer.style.height = this.containerMetrics.size + 'px';
- },
-
- /** @param {Event=} event */
- upAction: function(event) {
- if (!this.isMouseDown) {
- return;
- }
-
- this.mouseUpStart = Utility.now();
- },
-
- remove: function() {
- Polymer.dom(this.waveContainer.parentNode).removeChild(
- this.waveContainer
- );
- }
- };
-
- Polymer({
- is: 'paper-ripple',
-
- behaviors: [
- Polymer.IronA11yKeysBehavior
- ],
+ is: 'iron-meta-query',
properties: {
- /**
- * The initial opacity set on the wave.
- *
- * @attribute initialOpacity
- * @type number
- * @default 0.25
- */
- initialOpacity: {
- type: Number,
- value: 0.25
- },
/**
- * How fast (opacity per second) the wave fades out.
- *
- * @attribute opacityDecayVelocity
- * @type number
- * @default 0.8
+ * The type of meta-data. All meta-data of the same type is stored
+ * together.
*/
- opacityDecayVelocity: {
- type: Number,
- value: 0.8
+ type: {
+ type: String,
+ value: 'default',
+ observer: '_typeChanged'
},
/**
- * If true, ripples will exhibit a gravitational pull towards
- * the center of their container as they fade away.
- *
- * @attribute recenters
- * @type boolean
- * @default false
+ * Specifies a key to use for retrieving `value` from the `type`
+ * namespace.
*/
- recenters: {
- type: Boolean,
- value: false
+ key: {
+ type: String,
+ observer: '_keyChanged'
},
/**
- * If true, ripples will center inside its container
- *
- * @attribute recenters
- * @type boolean
- * @default false
+ * The meta-data to store or retrieve.
*/
- center: {
- type: Boolean,
- value: false
+ value: {
+ type: Object,
+ notify: true,
+ readOnly: true
},
/**
- * A list of the visual ripples.
- *
- * @attribute ripples
- * @type Array
- * @default []
+ * Array of all meta-data values for the given type.
*/
- ripples: {
+ list: {
type: Array,
- value: function() {
- return [];
+ notify: true
+ }
+
+ },
+
+ /**
+ * Actually a factory method, not a true constructor. Only runs if
+ * someone invokes it directly (via `new Polymer.IronMeta()`);
+ *
+ * @param {{type: (string|undefined), key: (string|undefined)}=} config
+ */
+ factoryImpl: function(config) {
+ if (config) {
+ for (var n in config) {
+ switch(n) {
+ case 'type':
+ case 'key':
+ this[n] = config[n];
+ break;
+ }
}
- },
+ }
+ },
+
+ created: function() {
+ // TODO(sjmiles): good for debugging?
+ this._metaDatas = metaDatas;
+ this._metaArrays = metaArrays;
+ },
+
+ _keyChanged: function(key) {
+ this._setValue(this._metaData && this._metaData[key]);
+ },
+
+ _typeChanged: function(type) {
+ this._metaData = metaDatas[type];
+ this.list = metaArrays[type];
+ if (this.key) {
+ this._keyChanged(this.key);
+ }
+ },
+
+ /**
+ * Retrieves meta data value by key.
+ * @param {string} key The key of the meta-data to be returned.
+ * @return {*}
+ */
+ byKey: function(key) {
+ return this._metaData && this._metaData[key];
+ }
+
+ });
+
+ })();
+Polymer({
+
+ is: 'iron-icon',
+
+ properties: {
/**
- * True when there are visible ripples animating within the
- * element.
+ * The name of the icon to use. The name should be of the form:
+ * `iconset_name:icon_name`.
*/
- animating: {
- type: Boolean,
- readOnly: true,
- reflectToAttribute: true,
- value: false
+ icon: {
+ type: String,
+ observer: '_iconChanged'
},
/**
- * If true, the ripple will remain in the "down" state until `holdDown`
- * is set to false again.
+ * The name of the theme to used, if one is specified by the
+ * iconset.
*/
- holdDown: {
- type: Boolean,
- value: false,
- observer: '_holdDownChanged'
+ theme: {
+ type: String,
+ observer: '_updateIcon'
},
/**
- * If true, the ripple will not generate a ripple effect
- * via pointer interaction.
- * Calling ripple's imperative api like `simulatedRipple` will
- * still generate the ripple effect.
+ * If using iron-icon without an iconset, you can set the src to be
+ * the URL of an individual icon image file. Note that this will take
+ * precedence over a given icon attribute.
*/
- noink: {
- type: Boolean,
- value: false
- },
-
- _animating: {
- type: Boolean
+ src: {
+ type: String,
+ observer: '_srcChanged'
},
- _boundAnimate: {
- type: Function,
- value: function() {
- return this.animate.bind(this);
- }
- }
- },
-
- get target () {
- return this.keyEventTarget;
- },
-
- keyBindings: {
- 'enter:keydown': '_onEnterKeydown',
- 'space:keydown': '_onSpaceKeydown',
- 'space:keyup': '_onSpaceKeyup'
- },
-
- attached: function() {
- // Set up a11yKeysBehavior to listen to key events on the target,
- // so that space and enter activate the ripple even if the target doesn't
- // handle key events. The key handlers deal with `noink` themselves.
- if (this.parentNode.nodeType == 11) { // DOCUMENT_FRAGMENT_NODE
- this.keyEventTarget = Polymer.dom(this).getOwnerRoot().host;
- } else {
- this.keyEventTarget = this.parentNode;
+ /**
+ * @type {!Polymer.IronMeta}
+ */
+ _meta: {
+ value: Polymer.Base.create('iron-meta', {type: 'iconset'}),
+ observer: '_updateIcon'
}
- var keyEventTarget = /** @type {!EventTarget} */ (this.keyEventTarget);
- this.listen(keyEventTarget, 'up', 'uiUpAction');
- this.listen(keyEventTarget, 'down', 'uiDownAction');
- },
- detached: function() {
- this.unlisten(this.keyEventTarget, 'up', 'uiUpAction');
- this.unlisten(this.keyEventTarget, 'down', 'uiDownAction');
- this.keyEventTarget = null;
},
- get shouldKeepAnimating () {
- for (var index = 0; index < this.ripples.length; ++index) {
- if (!this.ripples[index].isAnimationComplete) {
- return true;
- }
- }
+ _DEFAULT_ICONSET: 'icons',
- return false;
+ _iconChanged: function(icon) {
+ var parts = (icon || '').split(':');
+ this._iconName = parts.pop();
+ this._iconsetName = parts.pop() || this._DEFAULT_ICONSET;
+ this._updateIcon();
},
- simulatedRipple: function() {
- this.downAction(null);
-
- // Please see polymer/polymer#1305
- this.async(function() {
- this.upAction();
- }, 1);
+ _srcChanged: function(src) {
+ this._updateIcon();
},
- /**
- * Provokes a ripple down effect via a UI event,
- * respecting the `noink` property.
- * @param {Event=} event
- */
- uiDownAction: function(event) {
- if (!this.noink) {
- this.downAction(event);
- }
+ _usesIconset: function() {
+ return this.icon || !this.src;
},
- /**
- * Provokes a ripple down effect via a UI event,
- * *not* respecting the `noink` property.
- * @param {Event=} event
- */
- downAction: function(event) {
- if (this.holdDown && this.ripples.length > 0) {
- return;
- }
-
- var ripple = this.addRipple();
-
- ripple.downAction(event);
-
- if (!this._animating) {
- this._animating = true;
- this.animate();
+ /** @suppress {visibility} */
+ _updateIcon: function() {
+ if (this._usesIconset()) {
+ if (this._img && this._img.parentNode) {
+ Polymer.dom(this.root).removeChild(this._img);
+ }
+ if (this._iconName === "") {
+ if (this._iconset) {
+ this._iconset.removeIcon(this);
+ }
+ } else if (this._iconsetName && this._meta) {
+ this._iconset = /** @type {?Polymer.Iconset} */ (
+ this._meta.byKey(this._iconsetName));
+ if (this._iconset) {
+ this._iconset.applyIcon(this, this._iconName, this.theme);
+ this.unlisten(window, 'iron-iconset-added', '_updateIcon');
+ } else {
+ this.listen(window, 'iron-iconset-added', '_updateIcon');
+ }
+ }
+ } else {
+ if (this._iconset) {
+ this._iconset.removeIcon(this);
+ }
+ if (!this._img) {
+ this._img = document.createElement('img');
+ this._img.style.width = '100%';
+ this._img.style.height = '100%';
+ this._img.draggable = false;
+ }
+ this._img.src = this.src;
+ Polymer.dom(this.root).appendChild(this._img);
}
- },
+ }
- /**
- * Provokes a ripple up effect via a UI event,
- * respecting the `noink` property.
- * @param {Event=} event
- */
- uiUpAction: function(event) {
- if (!this.noink) {
- this.upAction(event);
- }
- },
+ });
+/**
+ * `Polymer.PaperInkyFocusBehavior` implements a ripple when the element has keyboard focus.
+ *
+ * @polymerBehavior Polymer.PaperInkyFocusBehavior
+ */
+ Polymer.PaperInkyFocusBehaviorImpl = {
+ observers: [
+ '_focusedChanged(receivedFocusFromKeyboard)'
+ ],
- /**
- * Provokes a ripple up effect via a UI event,
- * *not* respecting the `noink` property.
- * @param {Event=} event
- */
- upAction: function(event) {
- if (this.holdDown) {
- return;
- }
+ _focusedChanged: function(receivedFocusFromKeyboard) {
+ if (receivedFocusFromKeyboard) {
+ this.ensureRipple();
+ }
+ if (this.hasRipple()) {
+ this._ripple.holdDown = receivedFocusFromKeyboard;
+ }
+ },
- this.ripples.forEach(function(ripple) {
- ripple.upAction(event);
- });
+ _createRipple: function() {
+ var ripple = Polymer.PaperRippleBehavior._createRipple();
+ ripple.id = 'ink';
+ ripple.setAttribute('center', '');
+ ripple.classList.add('circle');
+ return ripple;
+ }
+ };
- this._animating = true;
- this.animate();
- },
+ /** @polymerBehavior Polymer.PaperInkyFocusBehavior */
+ Polymer.PaperInkyFocusBehavior = [
+ Polymer.IronButtonState,
+ Polymer.IronControlState,
+ Polymer.PaperRippleBehavior,
+ Polymer.PaperInkyFocusBehaviorImpl
+ ];
+Polymer({
+ is: 'paper-icon-button',
- onAnimationComplete: function() {
- this._animating = false;
- this.$.background.style.backgroundColor = null;
- this.fire('transitionend');
+ hostAttributes: {
+ role: 'button',
+ tabindex: '0'
},
- addRipple: function() {
- var ripple = new Ripple(this);
+ behaviors: [
+ Polymer.PaperInkyFocusBehavior
+ ],
- Polymer.dom(this.$.waves).appendChild(ripple.waveContainer);
- this.$.background.style.backgroundColor = ripple.color;
- this.ripples.push(ripple);
+ properties: {
+ /**
+ * The URL of an image for the icon. If the src property is specified,
+ * the icon property should not be.
+ */
+ src: {
+ type: String
+ },
- this._setAnimating(true);
+ /**
+ * Specifies the icon name or index in the set of icons available in
+ * the icon's icon set. If the icon property is specified,
+ * the src property should not be.
+ */
+ icon: {
+ type: String
+ },
- return ripple;
+ /**
+ * Specifies the alternate text for the button, for accessibility.
+ */
+ alt: {
+ type: String,
+ observer: "_altChanged"
+ }
},
- removeRipple: function(ripple) {
- var rippleIndex = this.ripples.indexOf(ripple);
+ _altChanged: function(newValue, oldValue) {
+ var label = this.getAttribute('aria-label');
- if (rippleIndex < 0) {
- return;
+ // Don't stomp over a user-set aria-label.
+ if (!label || oldValue == label) {
+ this.setAttribute('aria-label', newValue);
}
+ }
+ });
+Polymer({
+ is: 'paper-tab',
- this.ripples.splice(rippleIndex, 1);
-
- ripple.remove();
+ behaviors: [
+ Polymer.IronControlState,
+ Polymer.IronButtonState,
+ Polymer.PaperRippleBehavior
+ ],
- if (!this.ripples.length) {
- this._setAnimating(false);
- }
- },
+ properties: {
- animate: function() {
- if (!this._animating) {
- return;
+ /**
+ * If true, the tab will forward keyboard clicks (enter/space) to
+ * the first anchor element found in its descendants
+ */
+ link: {
+ type: Boolean,
+ value: false,
+ reflectToAttribute: true
}
- var index;
- var ripple;
-
- for (index = 0; index < this.ripples.length; ++index) {
- ripple = this.ripples[index];
- ripple.draw();
-
- this.$.background.style.opacity = ripple.outerOpacity;
+ },
- if (ripple.isOpacityFullyDecayed && !ripple.isRestingAtMaxRadius) {
- this.removeRipple(ripple);
- }
- }
+ hostAttributes: {
+ role: 'tab'
+ },
- if (!this.shouldKeepAnimating && this.ripples.length === 0) {
- this.onAnimationComplete();
- } else {
- window.requestAnimationFrame(this._boundAnimate);
- }
+ listeners: {
+ down: '_updateNoink',
+ tap: '_onTap'
},
- _onEnterKeydown: function() {
- this.uiDownAction();
- this.async(this.uiUpAction, 1);
+ attached: function() {
+ this._updateNoink();
},
- _onSpaceKeydown: function() {
- this.uiDownAction();
+ get _parentNoink () {
+ var parent = Polymer.dom(this).parentNode;
+ return !!parent && !!parent.noink;
},
- _onSpaceKeyup: function() {
- this.uiUpAction();
+ _updateNoink: function() {
+ this.noink = !!this.noink || !!this._parentNoink;
},
- // note: holdDown does not respect noink since it can be a focus based
- // effect.
- _holdDownChanged: function(newVal, oldVal) {
- if (oldVal === undefined) {
- return;
- }
- if (newVal) {
- this.downAction();
- } else {
- this.upAction();
+ _onTap: function(event) {
+ if (this.link) {
+ var anchor = this.queryEffectiveChildren('a');
+
+ if (!anchor) {
+ return;
+ }
+
+ // Don't get stuck in a loop delegating
+ // the listener from the child anchor
+ if (event.target === anchor) {
+ return;
+ }
+
+ anchor.click();
}
}
- /**
- Fired when the animation finishes.
- This is useful if you want to wait until
- the ripple animation finishes to perform some action.
-
- @event transitionend
- @param {{node: Object}} detail Contains the animated node.
- */
});
- })();
-/**
- * `Polymer.PaperRippleBehavior` dynamically implements a ripple
- * when the element has focus via pointer or keyboard.
- *
- * NOTE: This behavior is intended to be used in conjunction with and after
- * `Polymer.IronButtonState` and `Polymer.IronControlState`.
- *
- * @polymerBehavior Polymer.PaperRippleBehavior
- */
- Polymer.PaperRippleBehavior = {
+/** @polymerBehavior Polymer.IronMultiSelectableBehavior */
+ Polymer.IronMultiSelectableBehaviorImpl = {
properties: {
+
/**
- * If true, the element will not produce a ripple effect when interacted
- * with via the pointer.
+ * If true, multiple selections are allowed.
*/
- noink: {
+ multi: {
type: Boolean,
- observer: '_noinkChanged'
+ value: false,
+ observer: 'multiChanged'
},
/**
- * @type {Element|undefined}
+ * Gets or sets the selected elements. This is used instead of `selected` when `multi`
+ * is true.
*/
- _rippleContainer: {
- type: Object,
- }
+ selectedValues: {
+ type: Array,
+ notify: true
+ },
+
+ /**
+ * Returns an array of currently selected items.
+ */
+ selectedItems: {
+ type: Array,
+ readOnly: true,
+ notify: true
+ },
+
},
+ observers: [
+ '_updateSelected(selectedValues.splices)'
+ ],
+
/**
- * Ensures a `<paper-ripple>` element is available when the element is
- * focused.
+ * Selects the given value. If the `multi` property is true, then the selected state of the
+ * `value` will be toggled; otherwise the `value` will be selected.
+ *
+ * @method select
+ * @param {string|number} value the value to select.
*/
- _buttonStateChanged: function() {
- if (this.focused) {
- this.ensureRipple();
+ select: function(value) {
+ if (this.multi) {
+ if (this.selectedValues) {
+ this._toggleSelected(value);
+ } else {
+ this.selectedValues = [value];
+ }
+ } else {
+ this.selected = value;
}
},
- /**
- * In addition to the functionality provided in `IronButtonState`, ensures
- * a ripple effect is created when the element is in a `pressed` state.
- */
- _downHandler: function(event) {
- Polymer.IronButtonStateImpl._downHandler.call(this, event);
- if (this.pressed) {
- this.ensureRipple(event);
+ multiChanged: function(multi) {
+ this._selection.multi = multi;
+ },
+
+ get _shouldUpdateSelection() {
+ return this.selected != null ||
+ (this.selectedValues != null && this.selectedValues.length);
+ },
+
+ _updateAttrForSelected: function() {
+ if (!this.multi) {
+ Polymer.IronSelectableBehavior._updateAttrForSelected.apply(this);
+ } else if (this._shouldUpdateSelection) {
+ this.selectedValues = this.selectedItems.map(function(selectedItem) {
+ return this._indexToValue(this.indexOf(selectedItem));
+ }, this).filter(function(unfilteredValue) {
+ return unfilteredValue != null;
+ }, this);
}
},
- /**
- * Ensures this element contains a ripple effect. For startup efficiency
- * the ripple effect is dynamically on demand when needed.
- * @param {!Event=} optTriggeringEvent (optional) event that triggered the
- * ripple.
- */
- ensureRipple: function(optTriggeringEvent) {
- if (!this.hasRipple()) {
- this._ripple = this._createRipple();
- this._ripple.noink = this.noink;
- var rippleContainer = this._rippleContainer || this.root;
- if (rippleContainer) {
- Polymer.dom(rippleContainer).appendChild(this._ripple);
+ _updateSelected: function() {
+ if (this.multi) {
+ this._selectMulti(this.selectedValues);
+ } else {
+ this._selectSelected(this.selected);
+ }
+ },
+
+ _selectMulti: function(values) {
+ if (values) {
+ var selectedItems = this._valuesToItems(values);
+ // clear all but the current selected items
+ this._selection.clear(selectedItems);
+ // select only those not selected yet
+ for (var i = 0; i < selectedItems.length; i++) {
+ this._selection.setItemSelected(selectedItems[i], true);
}
- if (optTriggeringEvent) {
- // Check if the event happened inside of the ripple container
- // Fall back to host instead of the root because distributed text
- // nodes are not valid event targets
- var domContainer = Polymer.dom(this._rippleContainer || this);
- var target = Polymer.dom(optTriggeringEvent).rootTarget;
- if (domContainer.deepContains( /** @type {Node} */(target))) {
- this._ripple.uiDownAction(optTriggeringEvent);
+ // Check for items, since this array is populated only when attached
+ if (this.fallbackSelection && this.items.length && !this._selection.get().length) {
+ var fallback = this._valueToItem(this.fallbackSelection);
+ if (fallback) {
+ this.selectedValues = [this.fallbackSelection];
}
}
+ } else {
+ this._selection.clear();
}
},
- /**
- * Returns the `<paper-ripple>` element used by this element to create
- * ripple effects. The element's ripple is created on demand, when
- * necessary, and calling this method will force the
- * ripple to be created.
- */
- getRipple: function() {
- this.ensureRipple();
- return this._ripple;
- },
-
- /**
- * Returns true if this element currently contains a ripple effect.
- * @return {boolean}
- */
- hasRipple: function() {
- return Boolean(this._ripple);
+ _selectionChange: function() {
+ var s = this._selection.get();
+ if (this.multi) {
+ this._setSelectedItems(s);
+ } else {
+ this._setSelectedItems([s]);
+ this._setSelectedItem(s);
+ }
},
- /**
- * Create the element's ripple effect via creating a `<paper-ripple>`.
- * Override this method to customize the ripple element.
- * @return {!PaperRippleElement} Returns a `<paper-ripple>` element.
- */
- _createRipple: function() {
- return /** @type {!PaperRippleElement} */ (
- document.createElement('paper-ripple'));
+ _toggleSelected: function(value) {
+ var i = this.selectedValues.indexOf(value);
+ var unselected = i < 0;
+ if (unselected) {
+ this.push('selectedValues',value);
+ } else {
+ this.splice('selectedValues',i,1);
+ }
},
- _noinkChanged: function(noink) {
- if (this.hasRipple()) {
- this._ripple.noink = noink;
- }
+ _valuesToItems: function(values) {
+ return (values == null) ? null : values.map(function(value) {
+ return this._valueToItem(value);
+ }, this);
}
};
-/** @polymerBehavior Polymer.PaperButtonBehavior */
- Polymer.PaperButtonBehaviorImpl = {
+
+ /** @polymerBehavior */
+ Polymer.IronMultiSelectableBehavior = [
+ Polymer.IronSelectableBehavior,
+ Polymer.IronMultiSelectableBehaviorImpl
+ ];
+/**
+ * `Polymer.IronMenuBehavior` implements accessible menu behavior.
+ *
+ * @demo demo/index.html
+ * @polymerBehavior Polymer.IronMenuBehavior
+ */
+ Polymer.IronMenuBehaviorImpl = {
+
properties: {
+
/**
- * The z-depth of this element, from 0-5. Setting to 0 will remove the
- * shadow, and each increasing number greater than 0 will be "deeper"
- * than the last.
- *
- * @attribute elevation
- * @type number
- * @default 1
+ * Returns the currently focused item.
+ * @type {?Object}
*/
- elevation: {
- type: Number,
- reflectToAttribute: true,
- readOnly: true
+ focusedItem: {
+ observer: '_focusedItemChanged',
+ readOnly: true,
+ type: Object
+ },
+
+ /**
+ * The attribute to use on menu items to look up the item title. Typing the first
+ * letter of an item when the menu is open focuses that item. If unset, `textContent`
+ * will be used.
+ */
+ attrForItemTitle: {
+ type: String
}
},
+ hostAttributes: {
+ 'role': 'menu',
+ 'tabindex': '0'
+ },
+
observers: [
- '_calculateElevation(focused, disabled, active, pressed, receivedFocusFromKeyboard)',
- '_computeKeyboardClass(receivedFocusFromKeyboard)'
+ '_updateMultiselectable(multi)'
],
- hostAttributes: {
- role: 'button',
- tabindex: '0',
- animated: true
+ listeners: {
+ 'focus': '_onFocus',
+ 'keydown': '_onKeydown',
+ 'iron-items-changed': '_onIronItemsChanged'
},
- _calculateElevation: function() {
- var e = 1;
- if (this.disabled) {
- e = 0;
- } else if (this.active || this.pressed) {
- e = 4;
- } else if (this.receivedFocusFromKeyboard) {
- e = 3;
- }
- this._setElevation(e);
+ keyBindings: {
+ 'up': '_onUpKey',
+ 'down': '_onDownKey',
+ 'esc': '_onEscKey',
+ 'shift+tab:keydown': '_onShiftTabDown'
},
- _computeKeyboardClass: function(receivedFocusFromKeyboard) {
- this.toggleClass('keyboard-focus', receivedFocusFromKeyboard);
+ attached: function() {
+ this._resetTabindices();
},
/**
- * In addition to `IronButtonState` behavior, when space key goes down,
- * create a ripple down effect.
+ * Selects the given value. If the `multi` property is true, then the selected state of the
+ * `value` will be toggled; otherwise the `value` will be selected.
*
- * @param {!KeyboardEvent} event .
+ * @param {string|number} value the value to select.
*/
- _spaceKeyDownHandler: function(event) {
- Polymer.IronButtonStateImpl._spaceKeyDownHandler.call(this, event);
- // Ensure that there is at most one ripple when the space key is held down.
- if (this.hasRipple() && this.getRipple().ripples.length < 1) {
- this._ripple.uiDownAction();
+ select: function(value) {
+ // Cancel automatically focusing a default item if the menu received focus
+ // through a user action selecting a particular item.
+ if (this._defaultFocusAsync) {
+ this.cancelAsync(this._defaultFocusAsync);
+ this._defaultFocusAsync = null;
}
+ var item = this._valueToItem(value);
+ if (item && item.hasAttribute('disabled')) return;
+ this._setFocusedItem(item);
+ Polymer.IronMultiSelectableBehaviorImpl.select.apply(this, arguments);
},
/**
- * In addition to `IronButtonState` behavior, when space key goes up,
- * create a ripple up effect.
- *
- * @param {!KeyboardEvent} event .
+ * Resets all tabindex attributes to the appropriate value based on the
+ * current selection state. The appropriate value is `0` (focusable) for
+ * the default selected item, and `-1` (not keyboard focusable) for all
+ * other items.
*/
- _spaceKeyUpHandler: function(event) {
- Polymer.IronButtonStateImpl._spaceKeyUpHandler.call(this, event);
- if (this.hasRipple()) {
- this._ripple.uiUpAction();
- }
- }
- };
+ _resetTabindices: function() {
+ var selectedItem = this.multi ? (this.selectedItems && this.selectedItems[0]) : this.selectedItem;
- /** @polymerBehavior */
- Polymer.PaperButtonBehavior = [
- Polymer.IronButtonState,
- Polymer.IronControlState,
- Polymer.PaperRippleBehavior,
- Polymer.PaperButtonBehaviorImpl
- ];
-Polymer({
- is: 'paper-button',
+ this.items.forEach(function(item) {
+ item.setAttribute('tabindex', item === selectedItem ? '0' : '-1');
+ }, this);
+ },
- behaviors: [
- Polymer.PaperButtonBehavior
- ],
+ /**
+ * Sets appropriate ARIA based on whether or not the menu is meant to be
+ * multi-selectable.
+ *
+ * @param {boolean} multi True if the menu should be multi-selectable.
+ */
+ _updateMultiselectable: function(multi) {
+ if (multi) {
+ this.setAttribute('aria-multiselectable', 'true');
+ } else {
+ this.removeAttribute('aria-multiselectable');
+ }
+ },
- properties: {
- /**
- * If true, the button should be styled with a shadow.
- */
- raised: {
- type: Boolean,
- reflectToAttribute: true,
- value: false,
- observer: '_calculateElevation'
- }
- },
+ /**
+ * Given a KeyboardEvent, this method will focus the appropriate item in the
+ * menu (if there is a relevant item, and it is possible to focus it).
+ *
+ * @param {KeyboardEvent} event A KeyboardEvent.
+ */
+ _focusWithKeyboardEvent: function(event) {
+ for (var i = 0, item; item = this.items[i]; i++) {
+ var attr = this.attrForItemTitle || 'textContent';
+ var title = item[attr] || item.getAttribute(attr);
- _calculateElevation: function() {
- if (!this.raised) {
- this._setElevation(0);
- } else {
- Polymer.PaperButtonBehaviorImpl._calculateElevation.apply(this);
+ if (!item.hasAttribute('disabled') && title &&
+ title.trim().charAt(0).toLowerCase() === String.fromCharCode(event.keyCode).toLowerCase()) {
+ this._setFocusedItem(item);
+ break;
}
}
+ },
- /**
- Fired when the animation finishes.
- This is useful if you want to wait until
- the ripple animation finishes to perform some action.
-
- @event transitionend
- Event param: {{node: Object}} detail Contains the animated node.
- */
- });
-Polymer({
- is: 'paper-icon-button-light',
- extends: 'button',
-
- behaviors: [
- Polymer.PaperRippleBehavior
- ],
-
- listeners: {
- 'down': '_rippleDown',
- 'up': '_rippleUp',
- 'focus': '_rippleDown',
- 'blur': '_rippleUp',
- },
-
- _rippleDown: function() {
- this.getRipple().downAction();
- },
-
- _rippleUp: function() {
- this.getRipple().upAction();
- },
-
- /**
- * @param {...*} var_args
- */
- ensureRipple: function(var_args) {
- var lastRipple = this._ripple;
- Polymer.PaperRippleBehavior.ensureRipple.apply(this, arguments);
- if (this._ripple && this._ripple !== lastRipple) {
- this._ripple.center = true;
- this._ripple.classList.add('circle');
+ /**
+ * Focuses the previous item (relative to the currently focused item) in the
+ * menu, disabled items will be skipped.
+ * Loop until length + 1 to handle case of single item in menu.
+ */
+ _focusPrevious: function() {
+ var length = this.items.length;
+ var curFocusIndex = Number(this.indexOf(this.focusedItem));
+ for (var i = 1; i < length + 1; i++) {
+ var item = this.items[(curFocusIndex - i + length) % length];
+ if (!item.hasAttribute('disabled')) {
+ this._setFocusedItem(item);
+ return;
}
}
- });
-/**
- * `iron-range-behavior` provides the behavior for something with a minimum to maximum range.
- *
- * @demo demo/index.html
- * @polymerBehavior
- */
- Polymer.IronRangeBehavior = {
-
- properties: {
+ },
/**
- * The number that represents the current value.
+ * Focuses the next item (relative to the currently focused item) in the
+ * menu, disabled items will be skipped.
+ * Loop until length + 1 to handle case of single item in menu.
*/
- value: {
- type: Number,
- value: 0,
- notify: true,
- reflectToAttribute: true
+ _focusNext: function() {
+ var length = this.items.length;
+ var curFocusIndex = Number(this.indexOf(this.focusedItem));
+ for (var i = 1; i < length + 1; i++) {
+ var item = this.items[(curFocusIndex + i) % length];
+ if (!item.hasAttribute('disabled')) {
+ this._setFocusedItem(item);
+ return;
+ }
+ }
},
/**
- * The number that indicates the minimum value of the range.
+ * Mutates items in the menu based on provided selection details, so that
+ * all items correctly reflect selection state.
+ *
+ * @param {Element} item An item in the menu.
+ * @param {boolean} isSelected True if the item should be shown in a
+ * selected state, otherwise false.
*/
- min: {
- type: Number,
- value: 0,
- notify: true
+ _applySelection: function(item, isSelected) {
+ if (isSelected) {
+ item.setAttribute('aria-selected', 'true');
+ } else {
+ item.removeAttribute('aria-selected');
+ }
+ Polymer.IronSelectableBehavior._applySelection.apply(this, arguments);
},
/**
- * The number that indicates the maximum value of the range.
+ * Discretely updates tabindex values among menu items as the focused item
+ * changes.
+ *
+ * @param {Element} focusedItem The element that is currently focused.
+ * @param {?Element} old The last element that was considered focused, if
+ * applicable.
*/
- max: {
- type: Number,
- value: 100,
- notify: true
+ _focusedItemChanged: function(focusedItem, old) {
+ old && old.setAttribute('tabindex', '-1');
+ if (focusedItem) {
+ focusedItem.setAttribute('tabindex', '0');
+ focusedItem.focus();
+ }
},
/**
- * Specifies the value granularity of the range's value.
+ * A handler that responds to mutation changes related to the list of items
+ * in the menu.
+ *
+ * @param {CustomEvent} event An event containing mutation records as its
+ * detail.
*/
- step: {
- type: Number,
- value: 1,
- notify: true
+ _onIronItemsChanged: function(event) {
+ if (event.detail.addedNodes.length) {
+ this._resetTabindices();
+ }
},
/**
- * Returns the ratio of the value.
+ * Handler that is called when a shift+tab keypress is detected by the menu.
+ *
+ * @param {CustomEvent} event A key combination event.
*/
- ratio: {
- type: Number,
- value: 0,
- readOnly: true,
- notify: true
- },
- },
+ _onShiftTabDown: function(event) {
+ var oldTabIndex = this.getAttribute('tabindex');
- observers: [
- '_update(value, min, max, step)'
- ],
+ Polymer.IronMenuBehaviorImpl._shiftTabPressed = true;
- _calcRatio: function(value) {
- return (this._clampValue(value) - this.min) / (this.max - this.min);
- },
+ this._setFocusedItem(null);
- _clampValue: function(value) {
- return Math.min(this.max, Math.max(this.min, this._calcStep(value)));
- },
+ this.setAttribute('tabindex', '-1');
- _calcStep: function(value) {
- // polymer/issues/2493
- value = parseFloat(value);
+ this.async(function() {
+ this.setAttribute('tabindex', oldTabIndex);
+ Polymer.IronMenuBehaviorImpl._shiftTabPressed = false;
+ // NOTE(cdata): polymer/polymer#1305
+ }, 1);
+ },
- if (!this.step) {
- return value;
- }
+ /**
+ * Handler that is called when the menu receives focus.
+ *
+ * @param {FocusEvent} event A focus event.
+ */
+ _onFocus: function(event) {
+ if (Polymer.IronMenuBehaviorImpl._shiftTabPressed) {
+ // do not focus the menu itself
+ return;
+ }
- var numSteps = Math.round((value - this.min) / this.step);
- if (this.step < 1) {
- /**
- * For small values of this.step, if we calculate the step using
- * `Math.round(value / step) * step` we may hit a precision point issue
- * eg. 0.1 * 0.2 = 0.020000000000000004
- * http://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html
- *
- * as a work around we can divide by the reciprocal of `step`
- */
- return numSteps / (1 / this.step) + this.min;
- } else {
- return numSteps * this.step + this.min;
- }
- },
+ // Do not focus the selected tab if the deepest target is part of the
+ // menu element's local DOM and is focusable.
+ var rootTarget = /** @type {?HTMLElement} */(
+ Polymer.dom(event).rootTarget);
+ if (rootTarget !== this && typeof rootTarget.tabIndex !== "undefined" && !this.isLightDescendant(rootTarget)) {
+ return;
+ }
- _validateValue: function() {
- var v = this._clampValue(this.value);
- this.value = this.oldValue = isNaN(v) ? this.oldValue : v;
- return this.value !== v;
- },
+ // clear the cached focus item
+ this._defaultFocusAsync = this.async(function() {
+ // focus the selected item when the menu receives focus, or the first item
+ // if no item is selected
+ var selectedItem = this.multi ? (this.selectedItems && this.selectedItems[0]) : this.selectedItem;
- _update: function() {
- this._validateValue();
- this._setRatio(this._calcRatio(this.value) * 100);
- }
+ this._setFocusedItem(null);
-};
-Polymer({
- is: 'paper-progress',
-
- behaviors: [
- Polymer.IronRangeBehavior
- ],
+ if (selectedItem) {
+ this._setFocusedItem(selectedItem);
+ } else if (this.items[0]) {
+ // We find the first none-disabled item (if one exists)
+ this._focusNext();
+ }
+ });
+ },
- properties: {
- /**
- * The number that represents the current secondary progress.
- */
- secondaryProgress: {
- type: Number,
- value: 0
- },
+ /**
+ * Handler that is called when the up key is pressed.
+ *
+ * @param {CustomEvent} event A key combination event.
+ */
+ _onUpKey: function(event) {
+ // up and down arrows moves the focus
+ this._focusPrevious();
+ event.detail.keyboardEvent.preventDefault();
+ },
- /**
- * The secondary ratio
- */
- secondaryRatio: {
- type: Number,
- value: 0,
- readOnly: true
- },
+ /**
+ * Handler that is called when the down key is pressed.
+ *
+ * @param {CustomEvent} event A key combination event.
+ */
+ _onDownKey: function(event) {
+ this._focusNext();
+ event.detail.keyboardEvent.preventDefault();
+ },
- /**
- * Use an indeterminate progress indicator.
- */
- indeterminate: {
- type: Boolean,
- value: false,
- observer: '_toggleIndeterminate'
- },
+ /**
+ * Handler that is called when the esc key is pressed.
+ *
+ * @param {CustomEvent} event A key combination event.
+ */
+ _onEscKey: function(event) {
+ // esc blurs the control
+ this.focusedItem.blur();
+ },
- /**
- * True if the progress is disabled.
- */
- disabled: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- observer: '_disabledChanged'
+ /**
+ * Handler that is called when a keydown event is detected.
+ *
+ * @param {KeyboardEvent} event A keyboard event.
+ */
+ _onKeydown: function(event) {
+ if (!this.keyboardEventMatchesKeys(event, 'up down esc')) {
+ // all other keys focus the menu item starting with that character
+ this._focusWithKeyboardEvent(event);
}
+ event.stopPropagation();
},
- observers: [
- '_progressChanged(secondaryProgress, value, min, max)'
- ],
+ // override _activateHandler
+ _activateHandler: function(event) {
+ Polymer.IronSelectableBehavior._activateHandler.call(this, event);
+ event.stopPropagation();
+ }
+ };
+
+ Polymer.IronMenuBehaviorImpl._shiftTabPressed = false;
+
+ /** @polymerBehavior Polymer.IronMenuBehavior */
+ Polymer.IronMenuBehavior = [
+ Polymer.IronMultiSelectableBehavior,
+ Polymer.IronA11yKeysBehavior,
+ Polymer.IronMenuBehaviorImpl
+ ];
+/**
+ * `Polymer.IronMenubarBehavior` implements accessible menubar behavior.
+ *
+ * @polymerBehavior Polymer.IronMenubarBehavior
+ */
+ Polymer.IronMenubarBehaviorImpl = {
hostAttributes: {
- role: 'progressbar'
+ 'role': 'menubar'
},
- _toggleIndeterminate: function(indeterminate) {
- // If we use attribute/class binding, the animation sometimes doesn't translate properly
- // on Safari 7.1. So instead, we toggle the class here in the update method.
- this.toggleClass('indeterminate', indeterminate, this.$.primaryProgress);
+ keyBindings: {
+ 'left': '_onLeftKey',
+ 'right': '_onRightKey'
},
- _transformProgress: function(progress, ratio) {
- var transform = 'scaleX(' + (ratio / 100) + ')';
- progress.style.transform = progress.style.webkitTransform = transform;
+ _onUpKey: function(event) {
+ this.focusedItem.click();
+ event.detail.keyboardEvent.preventDefault();
},
- _mainRatioChanged: function(ratio) {
- this._transformProgress(this.$.primaryProgress, ratio);
+ _onDownKey: function(event) {
+ this.focusedItem.click();
+ event.detail.keyboardEvent.preventDefault();
},
- _progressChanged: function(secondaryProgress, value, min, max) {
- secondaryProgress = this._clampValue(secondaryProgress);
- value = this._clampValue(value);
-
- var secondaryRatio = this._calcRatio(secondaryProgress) * 100;
- var mainRatio = this._calcRatio(value) * 100;
-
- this._setSecondaryRatio(secondaryRatio);
- this._transformProgress(this.$.secondaryProgress, secondaryRatio);
- this._transformProgress(this.$.primaryProgress, mainRatio);
-
- this.secondaryProgress = secondaryProgress;
+ get _isRTL() {
+ return window.getComputedStyle(this)['direction'] === 'rtl';
+ },
- this.setAttribute('aria-valuenow', value);
- this.setAttribute('aria-valuemin', min);
- this.setAttribute('aria-valuemax', max);
+ _onLeftKey: function(event) {
+ if (this._isRTL) {
+ this._focusNext();
+ } else {
+ this._focusPrevious();
+ }
+ event.detail.keyboardEvent.preventDefault();
},
- _disabledChanged: function(disabled) {
- this.setAttribute('aria-disabled', disabled ? 'true' : 'false');
+ _onRightKey: function(event) {
+ if (this._isRTL) {
+ this._focusPrevious();
+ } else {
+ this._focusNext();
+ }
+ event.detail.keyboardEvent.preventDefault();
},
- _hideSecondaryProgress: function(secondaryRatio) {
- return secondaryRatio === 0;
+ _onKeydown: function(event) {
+ if (this.keyboardEventMatchesKeys(event, 'up down left right esc')) {
+ return;
+ }
+
+ // all other keys focus the menu item starting with that character
+ this._focusWithKeyboardEvent(event);
}
- });
+
+ };
+
+ /** @polymerBehavior Polymer.IronMenubarBehavior */
+ Polymer.IronMenubarBehavior = [
+ Polymer.IronMenuBehavior,
+ Polymer.IronMenubarBehaviorImpl
+ ];
/**
* The `iron-iconset-svg` element allows users to define their own icon sets
* that contain svg icons. The svg icon elements should be children of the
@@ -6268,5653 +6060,9071 @@ Polymer({
}
});
-// 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.
+Polymer({
+ is: 'paper-tabs',
-cr.define('downloads', function() {
- var Item = Polymer({
- is: 'downloads-item',
+ behaviors: [
+ Polymer.IronResizableBehavior,
+ Polymer.IronMenubarBehavior
+ ],
- properties: {
- data: {
- type: Object,
+ properties: {
+ /**
+ * If true, ink ripple effect is disabled. When this property is changed,
+ * all descendant `<paper-tab>` elements have their `noink` property
+ * changed to the new value as well.
+ */
+ noink: {
+ type: Boolean,
+ value: false,
+ observer: '_noinkChanged'
+ },
+
+ /**
+ * If true, the bottom bar to indicate the selected tab will not be shown.
+ */
+ noBar: {
+ type: Boolean,
+ value: false
+ },
+
+ /**
+ * If true, the slide effect for the bottom bar is disabled.
+ */
+ noSlide: {
+ type: Boolean,
+ value: false
+ },
+
+ /**
+ * If true, tabs are scrollable and the tab width is based on the label width.
+ */
+ scrollable: {
+ type: Boolean,
+ value: false
+ },
+
+ /**
+ * If true, tabs expand to fit their container. This currently only applies when
+ * scrollable is true.
+ */
+ fitContainer: {
+ type: Boolean,
+ value: false
+ },
+
+ /**
+ * If true, dragging on the tabs to scroll is disabled.
+ */
+ disableDrag: {
+ type: Boolean,
+ value: false
+ },
+
+ /**
+ * If true, scroll buttons (left/right arrow) will be hidden for scrollable tabs.
+ */
+ hideScrollButtons: {
+ type: Boolean,
+ value: false
+ },
+
+ /**
+ * If true, the tabs are aligned to bottom (the selection bar appears at the top).
+ */
+ alignBottom: {
+ type: Boolean,
+ value: false
+ },
+
+ selectable: {
+ type: String,
+ value: 'paper-tab'
+ },
+
+ /**
+ * If true, tabs are automatically selected when focused using the
+ * keyboard.
+ */
+ autoselect: {
+ type: Boolean,
+ value: false
+ },
+
+ /**
+ * The delay (in milliseconds) between when the user stops interacting
+ * with the tabs through the keyboard and when the focused item is
+ * automatically selected (if `autoselect` is true).
+ */
+ autoselectDelay: {
+ type: Number,
+ value: 0
+ },
+
+ _step: {
+ type: Number,
+ value: 10
+ },
+
+ _holdDelay: {
+ type: Number,
+ value: 1
+ },
+
+ _leftHidden: {
+ type: Boolean,
+ value: false
+ },
+
+ _rightHidden: {
+ type: Boolean,
+ value: false
+ },
+
+ _previousTab: {
+ type: Object
+ }
},
- completelyOnDisk_: {
- computed: 'computeCompletelyOnDisk_(' +
- 'data.state, data.file_externally_removed)',
- type: Boolean,
- value: true,
+ hostAttributes: {
+ role: 'tablist'
},
- controlledBy_: {
- computed: 'computeControlledBy_(data.by_ext_id, data.by_ext_name)',
- type: String,
- value: '',
+ listeners: {
+ 'iron-resize': '_onTabSizingChanged',
+ 'iron-items-changed': '_onTabSizingChanged',
+ 'iron-select': '_onIronSelect',
+ 'iron-deselect': '_onIronDeselect'
},
- isActive_: {
- computed: 'computeIsActive_(' +
- 'data.state, data.file_externally_removed)',
- type: Boolean,
- value: true,
+ keyBindings: {
+ 'left:keyup right:keyup': '_onArrowKeyup'
},
- isDangerous_: {
- computed: 'computeIsDangerous_(data.state)',
- type: Boolean,
- value: false,
+ created: function() {
+ this._holdJob = null;
+ this._pendingActivationItem = undefined;
+ this._pendingActivationTimeout = undefined;
+ this._bindDelayedActivationHandler = this._delayedActivationHandler.bind(this);
+ this.addEventListener('blur', this._onBlurCapture.bind(this), true);
},
- isMalware_: {
- computed: 'computeIsMalware_(isDangerous_, data.danger_type)',
- type: Boolean,
- value: false,
+ ready: function() {
+ this.setScrollDirection('y', this.$.tabsContainer);
},
- isInProgress_: {
- computed: 'computeIsInProgress_(data.state)',
- type: Boolean,
- value: false,
+ detached: function() {
+ this._cancelPendingActivation();
},
- pauseOrResumeText_: {
- computed: 'computePauseOrResumeText_(isInProgress_, data.resume)',
- type: String,
+ _noinkChanged: function(noink) {
+ var childTabs = Polymer.dom(this).querySelectorAll('paper-tab');
+ childTabs.forEach(noink ? this._setNoinkAttribute : this._removeNoinkAttribute);
},
- showCancel_: {
- computed: 'computeShowCancel_(data.state)',
- type: Boolean,
- value: false,
+ _setNoinkAttribute: function(element) {
+ element.setAttribute('noink', '');
},
- showProgress_: {
- computed: 'computeShowProgress_(showCancel_, data.percent)',
- type: Boolean,
- value: false,
+ _removeNoinkAttribute: function(element) {
+ element.removeAttribute('noink');
},
- },
- observers: [
- // TODO(dbeam): this gets called way more when I observe data.by_ext_id
- // and data.by_ext_name directly. Why?
- 'observeControlledBy_(controlledBy_)',
- 'observeIsDangerous_(isDangerous_, data)',
- ],
+ _computeScrollButtonClass: function(hideThisButton, scrollable, hideScrollButtons) {
+ if (!scrollable || hideScrollButtons) {
+ return 'hidden';
+ }
- ready: function() {
- this.content = this.$.content;
- },
+ if (hideThisButton) {
+ return 'not-visible';
+ }
- /** @private */
- computeClass_: function() {
- var classes = [];
+ return '';
+ },
- if (this.isActive_)
- classes.push('is-active');
+ _computeTabsContentClass: function(scrollable, fitContainer) {
+ return scrollable ? 'scrollable' + (fitContainer ? ' fit-container' : '') : ' fit-container';
+ },
- if (this.isDangerous_)
- classes.push('dangerous');
+ _computeSelectionBarClass: function(noBar, alignBottom) {
+ if (noBar) {
+ return 'hidden';
+ } else if (alignBottom) {
+ return 'align-bottom';
+ }
- if (this.showProgress_)
- classes.push('show-progress');
+ return '';
+ },
- return classes.join(' ');
- },
+ // TODO(cdata): Add `track` response back in when gesture lands.
- /** @private */
- computeCompletelyOnDisk_: function() {
- return this.data.state == downloads.States.COMPLETE &&
- !this.data.file_externally_removed;
- },
+ _onTabSizingChanged: function() {
+ this.debounce('_onTabSizingChanged', function() {
+ this._scroll();
+ this._tabChanged(this.selectedItem);
+ }, 10);
+ },
- /** @private */
- computeControlledBy_: function() {
- if (!this.data.by_ext_id || !this.data.by_ext_name)
- return '';
+ _onIronSelect: function(event) {
+ this._tabChanged(event.detail.item, this._previousTab);
+ this._previousTab = event.detail.item;
+ this.cancelDebouncer('tab-changed');
+ },
- var url = 'chrome://extensions#' + this.data.by_ext_id;
- var name = this.data.by_ext_name;
- return loadTimeData.getStringF('controlledByUrl', url, name);
- },
+ _onIronDeselect: function(event) {
+ this.debounce('tab-changed', function() {
+ this._tabChanged(null, this._previousTab);
+ this._previousTab = null;
+ // See polymer/polymer#1305
+ }, 1);
+ },
- /** @private */
- computeDangerIcon_: function() {
- if (!this.isDangerous_)
- return '';
+ _activateHandler: function() {
+ // Cancel item activations scheduled by keyboard events when any other
+ // action causes an item to be activated (e.g. clicks).
+ this._cancelPendingActivation();
- switch (this.data.danger_type) {
- case downloads.DangerType.DANGEROUS_CONTENT:
- case downloads.DangerType.DANGEROUS_HOST:
- case downloads.DangerType.DANGEROUS_URL:
- case downloads.DangerType.POTENTIALLY_UNWANTED:
- case downloads.DangerType.UNCOMMON_CONTENT:
- return 'downloads:remove-circle';
- default:
- return 'cr:warning';
- }
- },
-
- /** @private */
- computeDate_: function() {
- assert(typeof this.data.hideDate == 'boolean');
- if (this.data.hideDate)
- return '';
- return assert(this.data.since_string || this.data.date_string);
- },
+ Polymer.IronMenuBehaviorImpl._activateHandler.apply(this, arguments);
+ },
- /** @private */
- computeDescription_: function() {
- var data = this.data;
-
- switch (data.state) {
- case downloads.States.DANGEROUS:
- var fileName = data.file_name;
- switch (data.danger_type) {
- case downloads.DangerType.DANGEROUS_FILE:
- return loadTimeData.getStringF('dangerFileDesc', fileName);
- case downloads.DangerType.DANGEROUS_URL:
- return loadTimeData.getString('dangerUrlDesc');
- case downloads.DangerType.DANGEROUS_CONTENT: // Fall through.
- case downloads.DangerType.DANGEROUS_HOST:
- return loadTimeData.getStringF('dangerContentDesc', fileName);
- case downloads.DangerType.UNCOMMON_CONTENT:
- return loadTimeData.getStringF('dangerUncommonDesc', fileName);
- case downloads.DangerType.POTENTIALLY_UNWANTED:
- return loadTimeData.getStringF('dangerSettingsDesc', fileName);
- }
- break;
+ /**
+ * Activates an item after a delay (in milliseconds).
+ */
+ _scheduleActivation: function(item, delay) {
+ this._pendingActivationItem = item;
+ this._pendingActivationTimeout = this.async(
+ this._bindDelayedActivationHandler, delay);
+ },
- case downloads.States.IN_PROGRESS:
- case downloads.States.PAUSED: // Fallthrough.
- return data.progress_status_text;
- }
+ /**
+ * Activates the last item given to `_scheduleActivation`.
+ */
+ _delayedActivationHandler: function() {
+ var item = this._pendingActivationItem;
+ this._pendingActivationItem = undefined;
+ this._pendingActivationTimeout = undefined;
+ item.fire(this.activateEvent, null, {
+ bubbles: true,
+ cancelable: true
+ });
+ },
- return '';
- },
+ /**
+ * Cancels a previously scheduled item activation made with
+ * `_scheduleActivation`.
+ */
+ _cancelPendingActivation: function() {
+ if (this._pendingActivationTimeout !== undefined) {
+ this.cancelAsync(this._pendingActivationTimeout);
+ this._pendingActivationItem = undefined;
+ this._pendingActivationTimeout = undefined;
+ }
+ },
- /** @private */
- computeIsActive_: function() {
- return this.data.state != downloads.States.CANCELLED &&
- this.data.state != downloads.States.INTERRUPTED &&
- !this.data.file_externally_removed;
- },
+ _onArrowKeyup: function(event) {
+ if (this.autoselect) {
+ this._scheduleActivation(this.focusedItem, this.autoselectDelay);
+ }
+ },
- /** @private */
- computeIsDangerous_: function() {
- return this.data.state == downloads.States.DANGEROUS;
- },
+ _onBlurCapture: function(event) {
+ // Cancel a scheduled item activation (if any) when that item is
+ // blurred.
+ if (event.target === this._pendingActivationItem) {
+ this._cancelPendingActivation();
+ }
+ },
- /** @private */
- computeIsInProgress_: function() {
- return this.data.state == downloads.States.IN_PROGRESS;
- },
+ get _tabContainerScrollSize () {
+ return Math.max(
+ 0,
+ this.$.tabsContainer.scrollWidth -
+ this.$.tabsContainer.offsetWidth
+ );
+ },
- /** @private */
- computeIsMalware_: function() {
- return this.isDangerous_ &&
- (this.data.danger_type == downloads.DangerType.DANGEROUS_CONTENT ||
- this.data.danger_type == downloads.DangerType.DANGEROUS_HOST ||
- this.data.danger_type == downloads.DangerType.DANGEROUS_URL ||
- this.data.danger_type == downloads.DangerType.POTENTIALLY_UNWANTED);
- },
+ _scroll: function(e, detail) {
+ if (!this.scrollable) {
+ return;
+ }
- /** @private */
- computePauseOrResumeText_: function() {
- if (this.isInProgress_)
- return loadTimeData.getString('controlPause');
- if (this.data.resume)
- return loadTimeData.getString('controlResume');
- return '';
- },
+ var ddx = (detail && -detail.ddx) || 0;
+ this._affectScroll(ddx);
+ },
- /** @private */
- computeRemoveStyle_: function() {
- var canDelete = loadTimeData.getBoolean('allowDeletingHistory');
- var hideRemove = this.isDangerous_ || this.showCancel_ || !canDelete;
- return hideRemove ? 'visibility: hidden' : '';
- },
+ _down: function(e) {
+ // go one beat async to defeat IronMenuBehavior
+ // autorefocus-on-no-selection timeout
+ this.async(function() {
+ if (this._defaultFocusAsync) {
+ this.cancelAsync(this._defaultFocusAsync);
+ this._defaultFocusAsync = null;
+ }
+ }, 1);
+ },
- /** @private */
- computeShowCancel_: function() {
- return this.data.state == downloads.States.IN_PROGRESS ||
- this.data.state == downloads.States.PAUSED;
- },
+ _affectScroll: function(dx) {
+ this.$.tabsContainer.scrollLeft += dx;
- /** @private */
- computeShowProgress_: function() {
- return this.showCancel_ && this.data.percent >= -1;
- },
+ var scrollLeft = this.$.tabsContainer.scrollLeft;
- /** @private */
- computeTag_: function() {
- switch (this.data.state) {
- case downloads.States.CANCELLED:
- return loadTimeData.getString('statusCancelled');
+ this._leftHidden = scrollLeft === 0;
+ this._rightHidden = scrollLeft === this._tabContainerScrollSize;
+ },
- case downloads.States.INTERRUPTED:
- return this.data.last_reason_text;
+ _onLeftScrollButtonDown: function() {
+ this._scrollToLeft();
+ this._holdJob = setInterval(this._scrollToLeft.bind(this), this._holdDelay);
+ },
- case downloads.States.COMPLETE:
- return this.data.file_externally_removed ?
- loadTimeData.getString('statusRemoved') : '';
- }
+ _onRightScrollButtonDown: function() {
+ this._scrollToRight();
+ this._holdJob = setInterval(this._scrollToRight.bind(this), this._holdDelay);
+ },
- return '';
- },
+ _onScrollButtonUp: function() {
+ clearInterval(this._holdJob);
+ this._holdJob = null;
+ },
- /** @private */
- isIndeterminate_: function() {
- return this.data.percent == -1;
- },
+ _scrollToLeft: function() {
+ this._affectScroll(-this._step);
+ },
- /** @private */
- observeControlledBy_: function() {
- this.$['controlled-by'].innerHTML = this.controlledBy_;
- },
+ _scrollToRight: function() {
+ this._affectScroll(this._step);
+ },
- /** @private */
- observeIsDangerous_: function() {
- if (!this.data)
- return;
+ _tabChanged: function(tab, old) {
+ if (!tab) {
+ // Remove the bar without animation.
+ this.$.selectionBar.classList.remove('expand');
+ this.$.selectionBar.classList.remove('contract');
+ this._positionBar(0, 0);
+ return;
+ }
- if (this.isDangerous_) {
- this.$.url.removeAttribute('href');
- } else {
- this.$.url.href = assert(this.data.url);
- var filePath = encodeURIComponent(this.data.file_path);
- var scaleFactor = '?scale=' + window.devicePixelRatio + 'x';
- this.$['file-icon'].src = 'chrome://fileicon/' + filePath + scaleFactor;
- }
- },
+ var r = this.$.tabsContent.getBoundingClientRect();
+ var w = r.width;
+ var tabRect = tab.getBoundingClientRect();
+ var tabOffsetLeft = tabRect.left - r.left;
- /** @private */
- onCancelTap_: function() {
- downloads.ActionService.getInstance().cancel(this.data.id);
- },
+ this._pos = {
+ width: this._calcPercent(tabRect.width, w),
+ left: this._calcPercent(tabOffsetLeft, w)
+ };
- /** @private */
- onDiscardDangerousTap_: function() {
- downloads.ActionService.getInstance().discardDangerous(this.data.id);
- },
+ if (this.noSlide || old == null) {
+ // Position the bar without animation.
+ this.$.selectionBar.classList.remove('expand');
+ this.$.selectionBar.classList.remove('contract');
+ this._positionBar(this._pos.width, this._pos.left);
+ return;
+ }
- /**
- * @private
- * @param {Event} e
- */
- onDragStart_: function(e) {
- e.preventDefault();
- downloads.ActionService.getInstance().drag(this.data.id);
- },
+ var oldRect = old.getBoundingClientRect();
+ var oldIndex = this.items.indexOf(old);
+ var index = this.items.indexOf(tab);
+ var m = 5;
- /**
- * @param {Event} e
- * @private
- */
- onFileLinkTap_: function(e) {
- e.preventDefault();
- downloads.ActionService.getInstance().openFile(this.data.id);
- },
+ // bar animation: expand
+ this.$.selectionBar.classList.add('expand');
- /** @private */
- onPauseOrResumeTap_: function() {
- if (this.isInProgress_)
- downloads.ActionService.getInstance().pause(this.data.id);
- else
- downloads.ActionService.getInstance().resume(this.data.id);
- },
+ var moveRight = oldIndex < index;
+ var isRTL = this._isRTL;
+ if (isRTL) {
+ moveRight = !moveRight;
+ }
- /** @private */
- onRemoveTap_: function() {
- downloads.ActionService.getInstance().remove(this.data.id);
- },
+ if (moveRight) {
+ this._positionBar(this._calcPercent(tabRect.left + tabRect.width - oldRect.left, w) - m,
+ this._left);
+ } else {
+ this._positionBar(this._calcPercent(oldRect.left + oldRect.width - tabRect.left, w) - m,
+ this._calcPercent(tabOffsetLeft, w) + m);
+ }
- /** @private */
- onRetryTap_: function() {
- downloads.ActionService.getInstance().download(this.data.url);
- },
+ if (this.scrollable) {
+ this._scrollToSelectedIfNeeded(tabRect.width, tabOffsetLeft);
+ }
+ },
- /** @private */
- onSaveDangerousTap_: function() {
- downloads.ActionService.getInstance().saveDangerous(this.data.id);
- },
+ _scrollToSelectedIfNeeded: function(tabWidth, tabOffsetLeft) {
+ var l = tabOffsetLeft - this.$.tabsContainer.scrollLeft;
+ if (l < 0) {
+ this.$.tabsContainer.scrollLeft += l;
+ } else {
+ l += (tabWidth - this.$.tabsContainer.offsetWidth);
+ if (l > 0) {
+ this.$.tabsContainer.scrollLeft += l;
+ }
+ }
+ },
- /** @private */
- onShowTap_: function() {
- downloads.ActionService.getInstance().show(this.data.id);
- },
- });
+ _calcPercent: function(w, w0) {
+ return 100 * w / w0;
+ },
- return {Item: Item};
-});
-/** @polymerBehavior Polymer.PaperItemBehavior */
- Polymer.PaperItemBehaviorImpl = {
- hostAttributes: {
- role: 'option',
- tabindex: '0'
- }
- };
+ _positionBar: function(width, left) {
+ width = width || 0;
+ left = left || 0;
- /** @polymerBehavior */
- Polymer.PaperItemBehavior = [
- Polymer.IronButtonState,
- Polymer.IronControlState,
- Polymer.PaperItemBehaviorImpl
- ];
-Polymer({
- is: 'paper-item',
+ this._width = width;
+ this._left = left;
+ this.transform(
+ 'translateX(' + left + '%) scaleX(' + (width / 100) + ')',
+ this.$.selectionBar);
+ },
- behaviors: [
- Polymer.PaperItemBehavior
- ]
+ _onBarTransitionEnd: function(e) {
+ var cl = this.$.selectionBar.classList;
+ // bar animation: expand -> contract
+ if (cl.contains('expand')) {
+ cl.remove('expand');
+ cl.add('contract');
+ this._positionBar(this._pos.width, this._pos.left);
+ // bar animation done
+ } else if (cl.contains('contract')) {
+ cl.remove('contract');
+ }
+ }
});
-/**
- * @param {!Function} selectCallback
- * @constructor
- */
- Polymer.IronSelection = function(selectCallback) {
- this.selection = [];
- this.selectCallback = selectCallback;
- };
+(function() {
+ 'use strict';
- Polymer.IronSelection.prototype = {
+ Polymer.IronA11yAnnouncer = Polymer({
+ is: 'iron-a11y-announcer',
- /**
- * Retrieves the selected item(s).
- *
- * @method get
- * @returns Returns the selected item(s). If the multi property is true,
- * `get` will return an array, otherwise it will return
- * the selected item or undefined if there is no selection.
- */
- get: function() {
- return this.multi ? this.selection.slice() : this.selection[0];
- },
+ properties: {
- /**
- * Clears all the selection except the ones indicated.
- *
- * @method clear
- * @param {Array} excludes items to be excluded.
- */
- clear: function(excludes) {
- this.selection.slice().forEach(function(item) {
- if (!excludes || excludes.indexOf(item) < 0) {
- this.setItemSelected(item, false);
- }
- }, this);
- },
+ /**
+ * The value of mode is used to set the `aria-live` attribute
+ * for the element that will be announced. Valid values are: `off`,
+ * `polite` and `assertive`.
+ */
+ mode: {
+ type: String,
+ value: 'polite'
+ },
- /**
- * Indicates if a given item is selected.
- *
- * @method isSelected
- * @param {*} item The item whose selection state should be checked.
- * @returns Returns true if `item` is selected.
- */
- isSelected: function(item) {
- return this.selection.indexOf(item) >= 0;
- },
+ _text: {
+ type: String,
+ value: ''
+ }
+ },
- /**
- * Sets the selection state for a given item to either selected or deselected.
- *
- * @method setItemSelected
- * @param {*} item The item to select.
- * @param {boolean} isSelected True for selected, false for deselected.
- */
- setItemSelected: function(item, isSelected) {
- if (item != null) {
- if (isSelected !== this.isSelected(item)) {
- // proceed to update selection only if requested state differs from current
- if (isSelected) {
- this.selection.push(item);
- } else {
- var i = this.selection.indexOf(item);
- if (i >= 0) {
- this.selection.splice(i, 1);
- }
+ created: function() {
+ if (!Polymer.IronA11yAnnouncer.instance) {
+ Polymer.IronA11yAnnouncer.instance = this;
}
- if (this.selectCallback) {
- this.selectCallback(item, isSelected);
+
+ document.body.addEventListener('iron-announce', this._onIronAnnounce.bind(this));
+ },
+
+ /**
+ * Cause a text string to be announced by screen readers.
+ *
+ * @param {string} text The text that should be announced.
+ */
+ announce: function(text) {
+ this._text = '';
+ this.async(function() {
+ this._text = text;
+ }, 100);
+ },
+
+ _onIronAnnounce: function(event) {
+ if (event.detail && event.detail.text) {
+ this.announce(event.detail.text);
}
}
- }
- },
+ });
- /**
- * Sets the selection state for a given item. If the `multi` property
- * is true, then the selected state of `item` will be toggled; otherwise
- * the `item` will be selected.
- *
- * @method select
- * @param {*} item The item to select.
- */
- select: function(item) {
- if (this.multi) {
- this.toggle(item);
- } else if (this.get() !== item) {
- this.setItemSelected(this.get(), false);
- this.setItemSelected(item, true);
- }
- },
-
- /**
- * Toggles the selection state for `item`.
- *
- * @method toggle
- * @param {*} item The item to toggle.
- */
- toggle: function(item) {
- this.setItemSelected(item, !this.isSelected(item));
- }
-
- };
-/** @polymerBehavior */
- Polymer.IronSelectableBehavior = {
-
- /**
- * Fired when iron-selector is activated (selected or deselected).
- * It is fired before the selected items are changed.
- * Cancel the event to abort selection.
- *
- * @event iron-activate
- */
+ Polymer.IronA11yAnnouncer.instance = null;
- /**
- * Fired when an item is selected
- *
- * @event iron-select
- */
+ Polymer.IronA11yAnnouncer.requestAvailability = function() {
+ if (!Polymer.IronA11yAnnouncer.instance) {
+ Polymer.IronA11yAnnouncer.instance = document.createElement('iron-a11y-announcer');
+ }
- /**
- * Fired when an item is deselected
- *
- * @event iron-deselect
- */
+ document.body.appendChild(Polymer.IronA11yAnnouncer.instance);
+ };
+ })();
+/**
+ * Singleton IronMeta instance.
+ */
+ Polymer.IronValidatableBehaviorMeta = null;
- /**
- * Fired when the list of selectable items changes (e.g., items are
- * added or removed). The detail of the event is a mutation record that
- * describes what changed.
- *
- * @event iron-items-changed
- */
+ /**
+ * `Use Polymer.IronValidatableBehavior` to implement an element that validates user input.
+ * Use the related `Polymer.IronValidatorBehavior` to add custom validation logic to an iron-input.
+ *
+ * By default, an `<iron-form>` element validates its fields when the user presses the submit button.
+ * To validate a form imperatively, call the form's `validate()` method, which in turn will
+ * call `validate()` on all its children. By using `Polymer.IronValidatableBehavior`, your
+ * custom element will get a public `validate()`, which
+ * will return the validity of the element, and a corresponding `invalid` attribute,
+ * which can be used for styling.
+ *
+ * To implement the custom validation logic of your element, you must override
+ * the protected `_getValidity()` method of this behaviour, rather than `validate()`.
+ * See [this](https://github.com/PolymerElements/iron-form/blob/master/demo/simple-element.html)
+ * for an example.
+ *
+ * ### Accessibility
+ *
+ * Changing the `invalid` property, either manually or by calling `validate()` will update the
+ * `aria-invalid` attribute.
+ *
+ * @demo demo/index.html
+ * @polymerBehavior
+ */
+ Polymer.IronValidatableBehavior = {
properties: {
/**
- * If you want to use an attribute value or property of an element for
- * `selected` instead of the index, set this to the name of the attribute
- * or property. Hyphenated values are converted to camel case when used to
- * look up the property of a selectable element. Camel cased values are
- * *not* converted to hyphenated values for attribute lookup. It's
- * recommended that you provide the hyphenated form of the name so that
- * selection works in both cases. (Use `attr-or-property-name` instead of
- * `attrOrPropertyName`.)
- */
- attrForSelected: {
- type: String,
- value: null
- },
-
- /**
- * Gets or sets the selected element. The default is to use the index of the item.
- * @type {string|number}
- */
- selected: {
- type: String,
- notify: true
- },
-
- /**
- * Returns the currently selected item.
- *
- * @type {?Object}
- */
- selectedItem: {
- type: Object,
- readOnly: true,
- notify: true
- },
-
- /**
- * The event that fires from items when they are selected. Selectable
- * will listen for this event from items and update the selection state.
- * Set to empty string to listen to no events.
+ * Name of the validator to use.
*/
- activateEvent: {
- type: String,
- value: 'tap',
- observer: '_activateEventChanged'
+ validator: {
+ type: String
},
/**
- * This is a CSS selector string. If this is set, only items that match the CSS selector
- * are selectable.
- */
- selectable: String,
-
- /**
- * The class to set on elements when selected.
+ * True if the last call to `validate` is invalid.
*/
- selectedClass: {
- type: String,
- value: 'iron-selected'
+ invalid: {
+ notify: true,
+ reflectToAttribute: true,
+ type: Boolean,
+ value: false
},
/**
- * The attribute to set on elements when selected.
+ * This property is deprecated and should not be used. Use the global
+ * validator meta singleton, `Polymer.IronValidatableBehaviorMeta` instead.
*/
- selectedAttribute: {
- type: String,
- value: null
+ _validatorMeta: {
+ type: Object
},
/**
- * Default fallback if the selection based on selected with `attrForSelected`
- * is not found.
+ * Namespace for this validator. This property is deprecated and should
+ * not be used. For all intents and purposes, please consider it a
+ * read-only, config-time property.
*/
- fallbackSelection: {
+ validatorType: {
type: String,
- value: null
- },
-
- /**
- * The list of items from which a selection can be made.
- */
- items: {
- type: Array,
- readOnly: true,
- notify: true,
- value: function() {
- return [];
- }
+ value: 'validator'
},
- /**
- * The set of excluded elements where the key is the `localName`
- * of the element that will be ignored from the item list.
- *
- * @default {template: 1}
- */
- _excludedLocalNames: {
+ _validator: {
type: Object,
- value: function() {
- return {
- 'template': 1
- };
- }
+ computed: '__computeValidator(validator)'
}
},
observers: [
- '_updateAttrForSelected(attrForSelected)',
- '_updateSelected(selected)',
- '_checkFallback(fallbackSelection)'
+ '_invalidChanged(invalid)'
],
- created: function() {
- this._bindFilterItem = this._filterItem.bind(this);
- this._selection = new Polymer.IronSelection(this._applySelection.bind(this));
- },
-
- attached: function() {
- this._observer = this._observeItems(this);
- this._updateItems();
- if (!this._shouldUpdateSelection) {
- this._updateSelected();
- }
- this._addListener(this.activateEvent);
+ registered: function() {
+ Polymer.IronValidatableBehaviorMeta = new Polymer.IronMeta({type: 'validator'});
},
- detached: function() {
- if (this._observer) {
- Polymer.dom(this).unobserveNodes(this._observer);
+ _invalidChanged: function() {
+ if (this.invalid) {
+ this.setAttribute('aria-invalid', 'true');
+ } else {
+ this.removeAttribute('aria-invalid');
}
- this._removeListener(this.activateEvent);
},
/**
- * Returns the index of the given item.
- *
- * @method indexOf
- * @param {Object} item
- * @returns Returns the index of the item
+ * @return {boolean} True if the validator `validator` exists.
*/
- indexOf: function(item) {
- return this.items.indexOf(item);
+ hasValidator: function() {
+ return this._validator != null;
},
/**
- * Selects the given value.
- *
- * @method select
- * @param {string|number} value the value to select.
+ * Returns true if the `value` is valid, and updates `invalid`. If you want
+ * your element to have custom validation logic, do not override this method;
+ * override `_getValidity(value)` instead.
+
+ * @param {Object} value The value to be validated. By default, it is passed
+ * to the validator's `validate()` function, if a validator is set.
+ * @return {boolean} True if `value` is valid.
*/
- select: function(value) {
- this.selected = value;
+ validate: function(value) {
+ this.invalid = !this._getValidity(value);
+ return !this.invalid;
},
/**
- * Selects the previous item.
+ * Returns true if `value` is valid. By default, it is passed
+ * to the validator's `validate()` function, if a validator is set. You
+ * should override this method if you want to implement custom validity
+ * logic for your element.
*
- * @method selectPrevious
+ * @param {Object} value The value to be validated.
+ * @return {boolean} True if `value` is valid.
*/
- selectPrevious: function() {
- var length = this.items.length;
- var index = (Number(this._valueToIndex(this.selected)) - 1 + length) % length;
- this.selected = this._indexToValue(index);
+
+ _getValidity: function(value) {
+ if (this.hasValidator()) {
+ return this._validator.validate(value);
+ }
+ return true;
},
- /**
- * Selects the next item.
- *
- * @method selectNext
- */
- selectNext: function() {
- var index = (Number(this._valueToIndex(this.selected)) + 1) % this.items.length;
- this.selected = this._indexToValue(index);
- },
-
- /**
- * Selects the item at the given index.
- *
- * @method selectIndex
- */
- selectIndex: function(index) {
- this.select(this._indexToValue(index));
- },
-
- /**
- * Force a synchronous update of the `items` property.
- *
- * NOTE: Consider listening for the `iron-items-changed` event to respond to
- * updates to the set of selectable items after updates to the DOM list and
- * selection state have been made.
- *
- * WARNING: If you are using this method, you should probably consider an
- * alternate approach. Synchronously querying for items is potentially
- * slow for many use cases. The `items` property will update asynchronously
- * on its own to reflect selectable items in the DOM.
- */
- forceSynchronousItemUpdate: function() {
- this._updateItems();
- },
-
- get _shouldUpdateSelection() {
- return this.selected != null;
- },
-
- _checkFallback: function() {
- if (this._shouldUpdateSelection) {
- this._updateSelected();
- }
- },
-
- _addListener: function(eventName) {
- this.listen(this, eventName, '_activateHandler');
- },
-
- _removeListener: function(eventName) {
- this.unlisten(this, eventName, '_activateHandler');
- },
-
- _activateEventChanged: function(eventName, old) {
- this._removeListener(old);
- this._addListener(eventName);
- },
+ __computeValidator: function() {
+ return Polymer.IronValidatableBehaviorMeta &&
+ Polymer.IronValidatableBehaviorMeta.byKey(this.validator);
+ }
+ };
+/*
+`<iron-input>` adds two-way binding and custom validators using `Polymer.IronValidatorBehavior`
+to `<input>`.
- _updateItems: function() {
- var nodes = Polymer.dom(this).queryDistributedElements(this.selectable || '*');
- nodes = Array.prototype.filter.call(nodes, this._bindFilterItem);
- this._setItems(nodes);
- },
+### Two-way binding
- _updateAttrForSelected: function() {
- if (this._shouldUpdateSelection) {
- this.selected = this._indexToValue(this.indexOf(this.selectedItem));
- }
- },
+By default you can only get notified of changes to an `input`'s `value` due to user input:
- _updateSelected: function() {
- this._selectSelected(this.selected);
- },
+ <input value="{{myValue::input}}">
- _selectSelected: function(selected) {
- this._selection.select(this._valueToItem(this.selected));
- // Check for items, since this array is populated only when attached
- // Since Number(0) is falsy, explicitly check for undefined
- if (this.fallbackSelection && this.items.length && (this._selection.get() === undefined)) {
- this.selected = this.fallbackSelection;
- }
- },
+`iron-input` adds the `bind-value` property that mirrors the `value` property, and can be used
+for two-way data binding. `bind-value` will notify if it is changed either by user input or by script.
- _filterItem: function(node) {
- return !this._excludedLocalNames[node.localName];
- },
+ <input is="iron-input" bind-value="{{myValue}}">
- _valueToItem: function(value) {
- return (value == null) ? null : this.items[this._valueToIndex(value)];
- },
+### Custom validators
- _valueToIndex: function(value) {
- if (this.attrForSelected) {
- for (var i = 0, item; item = this.items[i]; i++) {
- if (this._valueForItem(item) == value) {
- return i;
- }
- }
- } else {
- return Number(value);
- }
- },
+You can use custom validators that implement `Polymer.IronValidatorBehavior` with `<iron-input>`.
- _indexToValue: function(index) {
- if (this.attrForSelected) {
- var item = this.items[index];
- if (item) {
- return this._valueForItem(item);
- }
- } else {
- return index;
- }
- },
+ <input is="iron-input" validator="my-custom-validator">
- _valueForItem: function(item) {
- var propValue = item[Polymer.CaseMap.dashToCamelCase(this.attrForSelected)];
- return propValue != undefined ? propValue : item.getAttribute(this.attrForSelected);
- },
+### Stopping invalid input
- _applySelection: function(item, isSelected) {
- if (this.selectedClass) {
- this.toggleClass(this.selectedClass, isSelected, item);
- }
- if (this.selectedAttribute) {
- this.toggleAttribute(this.selectedAttribute, isSelected, item);
- }
- this._selectionChange();
- this.fire('iron-' + (isSelected ? 'select' : 'deselect'), {item: item});
- },
+It may be desirable to only allow users to enter certain characters. You can use the
+`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.
- _selectionChange: function() {
- this._setSelectedItem(this._selection.get());
- },
+ \x3c!-- only allow characters that match [0-9] --\x3e
+ <input is="iron-input" prevent-invalid-input allowed-pattern="[0-9]">
- // observe items change under the given node.
- _observeItems: function(node) {
- return Polymer.dom(node).observeNodes(function(mutation) {
- this._updateItems();
+@hero hero.svg
+@demo demo/index.html
+*/
- if (this._shouldUpdateSelection) {
- this._updateSelected();
- }
+ Polymer({
- // Let other interested parties know about the change so that
- // we don't have to recreate mutation observers everywhere.
- this.fire('iron-items-changed', mutation, {
- bubbles: false,
- cancelable: false
- });
- });
- },
+ is: 'iron-input',
- _activateHandler: function(e) {
- var t = e.target;
- var items = this.items;
- while (t && t != this) {
- var i = items.indexOf(t);
- if (i >= 0) {
- var value = this._indexToValue(i);
- this._itemActivate(value, t);
- return;
- }
- t = t.parentNode;
- }
- },
+ extends: 'input',
- _itemActivate: function(value, item) {
- if (!this.fire('iron-activate',
- {selected: value, item: item}, {cancelable: true}).defaultPrevented) {
- this.select(value);
- }
- }
+ behaviors: [
+ Polymer.IronValidatableBehavior
+ ],
- };
-/** @polymerBehavior Polymer.IronMultiSelectableBehavior */
- Polymer.IronMultiSelectableBehaviorImpl = {
properties: {
/**
- * If true, multiple selections are allowed.
+ * Use this property instead of `value` for two-way data binding.
*/
- multi: {
- type: Boolean,
- value: false,
- observer: 'multiChanged'
+ bindValue: {
+ observer: '_bindValueChanged',
+ type: String
},
/**
- * Gets or sets the selected elements. This is used instead of `selected` when `multi`
- * is true.
+ * Set to true to prevent the user from entering invalid input. If `allowedPattern` is set,
+ * any character typed by the user will be matched against that pattern, and rejected if it's not a match.
+ * Pasted input will have each character checked individually; if any character
+ * doesn't match `allowedPattern`, the entire pasted string will be rejected.
+ * If `allowedPattern` is not set, it will use the `type` attribute (only supported for `type=number`).
*/
- selectedValues: {
- type: Array,
- notify: true
+ preventInvalidInput: {
+ type: Boolean
},
/**
- * Returns an array of currently selected items.
+ * Regular expression that list the characters allowed as input.
+ * This pattern represents the allowed characters for the field; as the user inputs text,
+ * each individual character will be checked against the pattern (rather than checking
+ * the entire value as a whole). The recommended format should be a list of allowed characters;
+ * for example, `[a-zA-Z0-9.+-!;:]`
*/
- selectedItems: {
- type: Array,
- readOnly: true,
- notify: true
+ allowedPattern: {
+ type: String,
+ observer: "_allowedPatternChanged"
},
- },
-
- observers: [
- '_updateSelected(selectedValues.splices)'
- ],
+ _previousValidInput: {
+ type: String,
+ value: ''
+ },
- /**
- * Selects the given value. If the `multi` property is true, then the selected state of the
- * `value` will be toggled; otherwise the `value` will be selected.
- *
- * @method select
- * @param {string|number} value the value to select.
- */
- select: function(value) {
- if (this.multi) {
- if (this.selectedValues) {
- this._toggleSelected(value);
- } else {
- this.selectedValues = [value];
- }
- } else {
- this.selected = value;
+ _patternAlreadyChecked: {
+ type: Boolean,
+ value: false
}
- },
- multiChanged: function(multi) {
- this._selection.multi = multi;
},
- get _shouldUpdateSelection() {
- return this.selected != null ||
- (this.selectedValues != null && this.selectedValues.length);
+ listeners: {
+ 'input': '_onInput',
+ 'keypress': '_onKeypress'
},
- _updateAttrForSelected: function() {
- if (!this.multi) {
- Polymer.IronSelectableBehavior._updateAttrForSelected.apply(this);
- } else if (this._shouldUpdateSelection) {
- this.selectedValues = this.selectedItems.map(function(selectedItem) {
- return this._indexToValue(this.indexOf(selectedItem));
- }, this).filter(function(unfilteredValue) {
- return unfilteredValue != null;
- }, this);
+ /** @suppress {checkTypes} */
+ registered: function() {
+ // Feature detect whether we need to patch dispatchEvent (i.e. on FF and IE).
+ if (!this._canDispatchEventOnDisabled()) {
+ this._origDispatchEvent = this.dispatchEvent;
+ this.dispatchEvent = this._dispatchEventFirefoxIE;
}
},
- _updateSelected: function() {
- if (this.multi) {
- this._selectMulti(this.selectedValues);
- } else {
- this._selectSelected(this.selected);
- }
+ created: function() {
+ Polymer.IronA11yAnnouncer.requestAvailability();
},
- _selectMulti: function(values) {
- if (values) {
- var selectedItems = this._valuesToItems(values);
- // clear all but the current selected items
- this._selection.clear(selectedItems);
- // select only those not selected yet
- for (var i = 0; i < selectedItems.length; i++) {
- this._selection.setItemSelected(selectedItems[i], true);
- }
- // Check for items, since this array is populated only when attached
- if (this.fallbackSelection && this.items.length && !this._selection.get().length) {
- var fallback = this._valueToItem(this.fallbackSelection);
- if (fallback) {
- this.selectedValues = [this.fallbackSelection];
- }
- }
- } else {
- this._selection.clear();
- }
+ _canDispatchEventOnDisabled: function() {
+ var input = document.createElement('input');
+ var canDispatch = false;
+ input.disabled = true;
+
+ input.addEventListener('feature-check-dispatch-event', function() {
+ canDispatch = true;
+ });
+
+ try {
+ input.dispatchEvent(new Event('feature-check-dispatch-event'));
+ } catch(e) {}
+
+ return canDispatch;
},
- _selectionChange: function() {
- var s = this._selection.get();
- if (this.multi) {
- this._setSelectedItems(s);
- } else {
- this._setSelectedItems([s]);
- this._setSelectedItem(s);
- }
+ _dispatchEventFirefoxIE: function() {
+ // Due to Firefox bug, events fired on disabled form controls can throw
+ // errors; furthermore, neither IE nor Firefox will actually dispatch
+ // events from disabled form controls; as such, we toggle disable around
+ // the dispatch to allow notifying properties to notify
+ // See issue #47 for details
+ var disabled = this.disabled;
+ this.disabled = false;
+ this._origDispatchEvent.apply(this, arguments);
+ this.disabled = disabled;
},
- _toggleSelected: function(value) {
- var i = this.selectedValues.indexOf(value);
- var unselected = i < 0;
- if (unselected) {
- this.push('selectedValues',value);
+ get _patternRegExp() {
+ var pattern;
+ if (this.allowedPattern) {
+ pattern = new RegExp(this.allowedPattern);
} else {
- this.splice('selectedValues',i,1);
+ switch (this.type) {
+ case 'number':
+ pattern = /[0-9.,e-]/;
+ break;
+ }
}
+ return pattern;
},
- _valuesToItems: function(values) {
- return (values == null) ? null : values.map(function(value) {
- return this._valueToItem(value);
- }, this);
- }
- };
-
- /** @polymerBehavior */
- Polymer.IronMultiSelectableBehavior = [
- Polymer.IronSelectableBehavior,
- Polymer.IronMultiSelectableBehaviorImpl
- ];
-/**
- * `Polymer.IronMenuBehavior` implements accessible menu behavior.
- *
- * @demo demo/index.html
- * @polymerBehavior Polymer.IronMenuBehavior
- */
- Polymer.IronMenuBehaviorImpl = {
-
- properties: {
-
- /**
- * Returns the currently focused item.
- * @type {?Object}
- */
- focusedItem: {
- observer: '_focusedItemChanged',
- readOnly: true,
- type: Object
- },
+ ready: function() {
+ this.bindValue = this.value;
+ },
- /**
- * The attribute to use on menu items to look up the item title. Typing the first
- * letter of an item when the menu is open focuses that item. If unset, `textContent`
- * will be used.
- */
- attrForItemTitle: {
- type: String
+ /**
+ * @suppress {checkTypes}
+ */
+ _bindValueChanged: function() {
+ if (this.value !== this.bindValue) {
+ this.value = !(this.bindValue || this.bindValue === 0 || this.bindValue === false) ? '' : this.bindValue;
}
+ // manually notify because we don't want to notify until after setting value
+ this.fire('bind-value-changed', {value: this.bindValue});
},
- hostAttributes: {
- 'role': 'menu',
- 'tabindex': '0'
+ _allowedPatternChanged: function() {
+ // Force to prevent invalid input when an `allowed-pattern` is set
+ this.preventInvalidInput = this.allowedPattern ? true : false;
},
- observers: [
- '_updateMultiselectable(multi)'
- ],
+ _onInput: function() {
+ // Need to validate each of the characters pasted if they haven't
+ // been validated inside `_onKeypress` already.
+ if (this.preventInvalidInput && !this._patternAlreadyChecked) {
+ var valid = this._checkPatternValidity();
+ if (!valid) {
+ this._announceInvalidCharacter('Invalid string of characters not entered.');
+ this.value = this._previousValidInput;
+ }
+ }
- listeners: {
- 'focus': '_onFocus',
- 'keydown': '_onKeydown',
- 'iron-items-changed': '_onIronItemsChanged'
+ this.bindValue = this.value;
+ this._previousValidInput = this.value;
+ this._patternAlreadyChecked = false;
},
- keyBindings: {
- 'up': '_onUpKey',
- 'down': '_onDownKey',
- 'esc': '_onEscKey',
- 'shift+tab:keydown': '_onShiftTabDown'
- },
+ _isPrintable: function(event) {
+ // What a control/printable character is varies wildly based on the browser.
+ // - most control characters (arrows, backspace) do not send a `keypress` event
+ // in Chrome, but the *do* on Firefox
+ // - in Firefox, when they do send a `keypress` event, control chars have
+ // a charCode = 0, keyCode = xx (for ex. 40 for down arrow)
+ // - printable characters always send a keypress event.
+ // - in Firefox, printable chars always have a keyCode = 0. In Chrome, the keyCode
+ // always matches the charCode.
+ // None of this makes any sense.
- attached: function() {
- this._resetTabindices();
+ // For these keys, ASCII code == browser keycode.
+ var anyNonPrintable =
+ (event.keyCode == 8) || // backspace
+ (event.keyCode == 9) || // tab
+ (event.keyCode == 13) || // enter
+ (event.keyCode == 27); // escape
+
+ // For these keys, make sure it's a browser keycode and not an ASCII code.
+ var mozNonPrintable =
+ (event.keyCode == 19) || // pause
+ (event.keyCode == 20) || // caps lock
+ (event.keyCode == 45) || // insert
+ (event.keyCode == 46) || // delete
+ (event.keyCode == 144) || // num lock
+ (event.keyCode == 145) || // scroll lock
+ (event.keyCode > 32 && event.keyCode < 41) || // page up/down, end, home, arrows
+ (event.keyCode > 111 && event.keyCode < 124); // fn keys
+
+ return !anyNonPrintable && !(event.charCode == 0 && mozNonPrintable);
},
- /**
- * Selects the given value. If the `multi` property is true, then the selected state of the
- * `value` will be toggled; otherwise the `value` will be selected.
- *
- * @param {string|number} value the value to select.
- */
- select: function(value) {
- // Cancel automatically focusing a default item if the menu received focus
- // through a user action selecting a particular item.
- if (this._defaultFocusAsync) {
- this.cancelAsync(this._defaultFocusAsync);
- this._defaultFocusAsync = null;
+ _onKeypress: function(event) {
+ if (!this.preventInvalidInput && this.type !== 'number') {
+ return;
+ }
+ var regexp = this._patternRegExp;
+ if (!regexp) {
+ return;
}
- var item = this._valueToItem(value);
- if (item && item.hasAttribute('disabled')) return;
- this._setFocusedItem(item);
- Polymer.IronMultiSelectableBehaviorImpl.select.apply(this, arguments);
- },
- /**
- * Resets all tabindex attributes to the appropriate value based on the
- * current selection state. The appropriate value is `0` (focusable) for
- * the default selected item, and `-1` (not keyboard focusable) for all
- * other items.
- */
- _resetTabindices: function() {
- var selectedItem = this.multi ? (this.selectedItems && this.selectedItems[0]) : this.selectedItem;
+ // Handle special keys and backspace
+ if (event.metaKey || event.ctrlKey || event.altKey)
+ return;
- this.items.forEach(function(item) {
- item.setAttribute('tabindex', item === selectedItem ? '0' : '-1');
- }, this);
+ // Check the pattern either here or in `_onInput`, but not in both.
+ this._patternAlreadyChecked = true;
+
+ var thisChar = String.fromCharCode(event.charCode);
+ if (this._isPrintable(event) && !regexp.test(thisChar)) {
+ event.preventDefault();
+ this._announceInvalidCharacter('Invalid character ' + thisChar + ' not entered.');
+ }
},
- /**
- * Sets appropriate ARIA based on whether or not the menu is meant to be
- * multi-selectable.
- *
- * @param {boolean} multi True if the menu should be multi-selectable.
- */
- _updateMultiselectable: function(multi) {
- if (multi) {
- this.setAttribute('aria-multiselectable', 'true');
- } else {
- this.removeAttribute('aria-multiselectable');
+ _checkPatternValidity: function() {
+ var regexp = this._patternRegExp;
+ if (!regexp) {
+ return true;
}
- },
-
- /**
- * Given a KeyboardEvent, this method will focus the appropriate item in the
- * menu (if there is a relevant item, and it is possible to focus it).
- *
- * @param {KeyboardEvent} event A KeyboardEvent.
- */
- _focusWithKeyboardEvent: function(event) {
- for (var i = 0, item; item = this.items[i]; i++) {
- var attr = this.attrForItemTitle || 'textContent';
- var title = item[attr] || item.getAttribute(attr);
-
- if (!item.hasAttribute('disabled') && title &&
- title.trim().charAt(0).toLowerCase() === String.fromCharCode(event.keyCode).toLowerCase()) {
- this._setFocusedItem(item);
- break;
+ for (var i = 0; i < this.value.length; i++) {
+ if (!regexp.test(this.value[i])) {
+ return false;
}
}
+ return true;
},
/**
- * Focuses the previous item (relative to the currently focused item) in the
- * menu, disabled items will be skipped.
- * Loop until length + 1 to handle case of single item in menu.
+ * Returns true if `value` is valid. The validator provided in `validator` will be used first,
+ * then any constraints.
+ * @return {boolean} True if the value is valid.
*/
- _focusPrevious: function() {
- var length = this.items.length;
- var curFocusIndex = Number(this.indexOf(this.focusedItem));
- for (var i = 1; i < length + 1; i++) {
- var item = this.items[(curFocusIndex - i + length) % length];
- if (!item.hasAttribute('disabled')) {
- this._setFocusedItem(item);
- return;
- }
- }
- },
+ validate: function() {
+ // First, check what the browser thinks. Some inputs (like type=number)
+ // behave weirdly and will set the value to "" if something invalid is
+ // entered, but will set the validity correctly.
+ var valid = this.checkValidity();
- /**
- * Focuses the next item (relative to the currently focused item) in the
- * menu, disabled items will be skipped.
- * Loop until length + 1 to handle case of single item in menu.
- */
- _focusNext: function() {
- var length = this.items.length;
- var curFocusIndex = Number(this.indexOf(this.focusedItem));
- for (var i = 1; i < length + 1; i++) {
- var item = this.items[(curFocusIndex + i) % length];
- if (!item.hasAttribute('disabled')) {
- this._setFocusedItem(item);
- return;
+ // Only do extra checking if the browser thought this was valid.
+ if (valid) {
+ // Empty, required input is invalid
+ if (this.required && this.value === '') {
+ valid = false;
+ } else if (this.hasValidator()) {
+ valid = Polymer.IronValidatableBehavior.validate.call(this, this.value);
}
}
- },
- /**
- * Mutates items in the menu based on provided selection details, so that
- * all items correctly reflect selection state.
- *
- * @param {Element} item An item in the menu.
- * @param {boolean} isSelected True if the item should be shown in a
- * selected state, otherwise false.
- */
- _applySelection: function(item, isSelected) {
- if (isSelected) {
- item.setAttribute('aria-selected', 'true');
- } else {
- item.removeAttribute('aria-selected');
- }
- Polymer.IronSelectableBehavior._applySelection.apply(this, arguments);
+ this.invalid = !valid;
+ this.fire('iron-input-validate');
+ return valid;
},
- /**
- * Discretely updates tabindex values among menu items as the focused item
- * changes.
- *
- * @param {Element} focusedItem The element that is currently focused.
- * @param {?Element} old The last element that was considered focused, if
- * applicable.
- */
- _focusedItemChanged: function(focusedItem, old) {
- old && old.setAttribute('tabindex', '-1');
- if (focusedItem) {
- focusedItem.setAttribute('tabindex', '0');
- focusedItem.focus();
- }
- },
+ _announceInvalidCharacter: function(message) {
+ this.fire('iron-announce', { text: message });
+ }
+ });
- /**
- * A handler that responds to mutation changes related to the list of items
- * in the menu.
- *
- * @param {CustomEvent} event An event containing mutation records as its
- * detail.
- */
- _onIronItemsChanged: function(event) {
- if (event.detail.addedNodes.length) {
- this._resetTabindices();
- }
- },
+ /*
+ The `iron-input-validate` event is fired whenever `validate()` is called.
+ @event iron-input-validate
+ */
+Polymer({
+ is: 'paper-input-container',
- /**
- * Handler that is called when a shift+tab keypress is detected by the menu.
- *
- * @param {CustomEvent} event A key combination event.
- */
- _onShiftTabDown: function(event) {
- var oldTabIndex = this.getAttribute('tabindex');
+ properties: {
+ /**
+ * Set to true to disable the floating label. The label disappears when the input value is
+ * not null.
+ */
+ noLabelFloat: {
+ type: Boolean,
+ value: false
+ },
- Polymer.IronMenuBehaviorImpl._shiftTabPressed = true;
+ /**
+ * Set to true to always float the floating label.
+ */
+ alwaysFloatLabel: {
+ type: Boolean,
+ value: false
+ },
- this._setFocusedItem(null);
+ /**
+ * The attribute to listen for value changes on.
+ */
+ attrForValue: {
+ type: String,
+ value: 'bind-value'
+ },
- this.setAttribute('tabindex', '-1');
+ /**
+ * Set to true to auto-validate the input value when it changes.
+ */
+ autoValidate: {
+ type: Boolean,
+ value: false
+ },
- this.async(function() {
- this.setAttribute('tabindex', oldTabIndex);
- Polymer.IronMenuBehaviorImpl._shiftTabPressed = false;
- // NOTE(cdata): polymer/polymer#1305
- }, 1);
- },
+ /**
+ * True if the input is invalid. This property is set automatically when the input value
+ * changes if auto-validating, or when the `iron-input-validate` event is heard from a child.
+ */
+ invalid: {
+ observer: '_invalidChanged',
+ type: Boolean,
+ value: false
+ },
- /**
- * Handler that is called when the menu receives focus.
- *
- * @param {FocusEvent} event A focus event.
- */
- _onFocus: function(event) {
- if (Polymer.IronMenuBehaviorImpl._shiftTabPressed) {
- // do not focus the menu itself
- return;
- }
+ /**
+ * True if the input has focus.
+ */
+ focused: {
+ readOnly: true,
+ type: Boolean,
+ value: false,
+ notify: true
+ },
- // Do not focus the selected tab if the deepest target is part of the
- // menu element's local DOM and is focusable.
- var rootTarget = /** @type {?HTMLElement} */(
- Polymer.dom(event).rootTarget);
- if (rootTarget !== this && typeof rootTarget.tabIndex !== "undefined" && !this.isLightDescendant(rootTarget)) {
- return;
- }
+ _addons: {
+ type: Array
+ // do not set a default value here intentionally - it will be initialized lazily when a
+ // distributed child is attached, which may occur before configuration for this element
+ // in polyfill.
+ },
- // clear the cached focus item
- this._defaultFocusAsync = this.async(function() {
- // focus the selected item when the menu receives focus, or the first item
- // if no item is selected
- var selectedItem = this.multi ? (this.selectedItems && this.selectedItems[0]) : this.selectedItem;
+ _inputHasContent: {
+ type: Boolean,
+ value: false
+ },
- this._setFocusedItem(null);
+ _inputSelector: {
+ type: String,
+ value: 'input,textarea,.paper-input-input'
+ },
- if (selectedItem) {
- this._setFocusedItem(selectedItem);
- } else if (this.items[0]) {
- // We find the first none-disabled item (if one exists)
- this._focusNext();
+ _boundOnFocus: {
+ type: Function,
+ value: function() {
+ return this._onFocus.bind(this);
}
- });
- },
-
- /**
- * Handler that is called when the up key is pressed.
- *
- * @param {CustomEvent} event A key combination event.
- */
- _onUpKey: function(event) {
- // up and down arrows moves the focus
- this._focusPrevious();
- event.detail.keyboardEvent.preventDefault();
- },
+ },
- /**
- * Handler that is called when the down key is pressed.
- *
- * @param {CustomEvent} event A key combination event.
- */
- _onDownKey: function(event) {
- this._focusNext();
- event.detail.keyboardEvent.preventDefault();
- },
-
- /**
- * Handler that is called when the esc key is pressed.
- *
- * @param {CustomEvent} event A key combination event.
- */
- _onEscKey: function(event) {
- // esc blurs the control
- this.focusedItem.blur();
- },
-
- /**
- * Handler that is called when a keydown event is detected.
- *
- * @param {KeyboardEvent} event A keyboard event.
- */
- _onKeydown: function(event) {
- if (!this.keyboardEventMatchesKeys(event, 'up down esc')) {
- // all other keys focus the menu item starting with that character
- this._focusWithKeyboardEvent(event);
- }
- event.stopPropagation();
- },
-
- // override _activateHandler
- _activateHandler: function(event) {
- Polymer.IronSelectableBehavior._activateHandler.call(this, event);
- event.stopPropagation();
- }
- };
-
- Polymer.IronMenuBehaviorImpl._shiftTabPressed = false;
-
- /** @polymerBehavior Polymer.IronMenuBehavior */
- Polymer.IronMenuBehavior = [
- Polymer.IronMultiSelectableBehavior,
- Polymer.IronA11yKeysBehavior,
- Polymer.IronMenuBehaviorImpl
- ];
-(function() {
- Polymer({
- is: 'paper-menu',
-
- behaviors: [
- Polymer.IronMenuBehavior
- ]
- });
- })();
-/**
-`Polymer.IronFitBehavior` fits an element in another element using `max-height` and `max-width`, and
-optionally centers it in the window or another element.
-
-The element will only be sized and/or positioned if it has not already been sized and/or positioned
-by CSS.
-
-CSS properties | Action
------------------------------|-------------------------------------------
-`position` set | Element is not centered horizontally or vertically
-`top` or `bottom` set | Element is not vertically centered
-`left` or `right` set | Element is not horizontally centered
-`max-height` set | Element respects `max-height`
-`max-width` set | Element respects `max-width`
-
-`Polymer.IronFitBehavior` can position an element into another element using
-`verticalAlign` and `horizontalAlign`. This will override the element's css position.
-
- <div class="container">
- <iron-fit-impl vertical-align="top" horizontal-align="auto">
- Positioned into the container
- </iron-fit-impl>
- </div>
-
-Use `noOverlap` to position the element around another element without overlapping it.
-
- <div class="container">
- <iron-fit-impl no-overlap vertical-align="auto" horizontal-align="auto">
- Positioned around the container
- </iron-fit-impl>
- </div>
-
-@demo demo/index.html
-@polymerBehavior
-*/
-
- Polymer.IronFitBehavior = {
-
- properties: {
-
- /**
- * The element that will receive a `max-height`/`width`. By default it is the same as `this`,
- * but it can be set to a child element. This is useful, for example, for implementing a
- * scrolling region inside the element.
- * @type {!Element}
- */
- sizingTarget: {
- type: Object,
+ _boundOnBlur: {
+ type: Function,
value: function() {
- return this;
+ return this._onBlur.bind(this);
}
},
- /**
- * The element to fit `this` into.
- */
- fitInto: {
- type: Object,
- value: window
- },
-
- /**
- * Will position the element around the positionTarget without overlapping it.
- */
- noOverlap: {
- type: Boolean
- },
-
- /**
- * The element that should be used to position the element. If not set, it will
- * default to the parent node.
- * @type {!Element}
- */
- positionTarget: {
- type: Element
- },
-
- /**
- * The orientation against which to align the element horizontally
- * relative to the `positionTarget`. Possible values are "left", "right", "auto".
- */
- horizontalAlign: {
- type: String
+ _boundOnInput: {
+ type: Function,
+ value: function() {
+ return this._onInput.bind(this);
+ }
},
- /**
- * The orientation against which to align the element vertically
- * relative to the `positionTarget`. Possible values are "top", "bottom", "auto".
- */
- verticalAlign: {
- type: String
- },
+ _boundValueChanged: {
+ type: Function,
+ value: function() {
+ return this._onValueChanged.bind(this);
+ }
+ }
+ },
- /**
- * If true, it will use `horizontalAlign` and `verticalAlign` values as preferred alignment
- * and if there's not enough space, it will pick the values which minimize the cropping.
- */
- dynamicAlign: {
- type: Boolean
- },
+ listeners: {
+ 'addon-attached': '_onAddonAttached',
+ 'iron-input-validate': '_onIronInputValidate'
+ },
- /**
- * The same as setting margin-left and margin-right css properties.
- * @deprecated
- */
- horizontalOffset: {
- type: Number,
- value: 0,
- notify: true
- },
+ get _valueChangedEvent() {
+ return this.attrForValue + '-changed';
+ },
- /**
- * The same as setting margin-top and margin-bottom css properties.
- * @deprecated
- */
- verticalOffset: {
- type: Number,
- value: 0,
- notify: true
- },
+ get _propertyForValue() {
+ return Polymer.CaseMap.dashToCamelCase(this.attrForValue);
+ },
- /**
- * Set to true to auto-fit on attach.
- */
- autoFitOnAttach: {
- type: Boolean,
- value: false
- },
+ get _inputElement() {
+ return Polymer.dom(this).querySelector(this._inputSelector);
+ },
- /** @type {?Object} */
- _fitInfo: {
- type: Object
- }
+ get _inputElementValue() {
+ return this._inputElement[this._propertyForValue] || this._inputElement.value;
},
- get _fitWidth() {
- var fitWidth;
- if (this.fitInto === window) {
- fitWidth = this.fitInto.innerWidth;
- } else {
- fitWidth = this.fitInto.getBoundingClientRect().width;
+ ready: function() {
+ if (!this._addons) {
+ this._addons = [];
}
- return fitWidth;
+ this.addEventListener('focus', this._boundOnFocus, true);
+ this.addEventListener('blur', this._boundOnBlur, true);
},
- get _fitHeight() {
- var fitHeight;
- if (this.fitInto === window) {
- fitHeight = this.fitInto.innerHeight;
+ attached: function() {
+ if (this.attrForValue) {
+ this._inputElement.addEventListener(this._valueChangedEvent, this._boundValueChanged);
} else {
- fitHeight = this.fitInto.getBoundingClientRect().height;
+ this.addEventListener('input', this._onInput);
}
- return fitHeight;
- },
- get _fitLeft() {
- var fitLeft;
- if (this.fitInto === window) {
- fitLeft = 0;
+ // Only validate when attached if the input already has a value.
+ if (this._inputElementValue != '') {
+ this._handleValueAndAutoValidate(this._inputElement);
} else {
- fitLeft = this.fitInto.getBoundingClientRect().left;
+ this._handleValue(this._inputElement);
}
- return fitLeft;
},
- get _fitTop() {
- var fitTop;
- if (this.fitInto === window) {
- fitTop = 0;
- } else {
- fitTop = this.fitInto.getBoundingClientRect().top;
+ _onAddonAttached: function(event) {
+ if (!this._addons) {
+ this._addons = [];
+ }
+ var target = event.target;
+ if (this._addons.indexOf(target) === -1) {
+ this._addons.push(target);
+ if (this.isAttached) {
+ this._handleValue(this._inputElement);
+ }
}
- return fitTop;
},
- /**
- * The element that should be used to position the element,
- * if no position target is configured.
- */
- get _defaultPositionTarget() {
- var parent = Polymer.dom(this).parentNode;
+ _onFocus: function() {
+ this._setFocused(true);
+ },
- if (parent && parent.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
- parent = parent.host;
- }
+ _onBlur: function() {
+ this._setFocused(false);
+ this._handleValueAndAutoValidate(this._inputElement);
+ },
- return parent;
+ _onInput: function(event) {
+ this._handleValueAndAutoValidate(event.target);
},
- /**
- * The horizontal align value, accounting for the RTL/LTR text direction.
- */
- get _localeHorizontalAlign() {
- if (this._isRTL) {
- // In RTL, "left" becomes "right".
- if (this.horizontalAlign === 'right') {
- return 'left';
- }
- if (this.horizontalAlign === 'left') {
- return 'right';
- }
+ _onValueChanged: function(event) {
+ this._handleValueAndAutoValidate(event.target);
+ },
+
+ _handleValue: function(inputElement) {
+ var value = this._inputElementValue;
+
+ // type="number" hack needed because this.value is empty until it's valid
+ if (value || value === 0 || (inputElement.type === 'number' && !inputElement.checkValidity())) {
+ this._inputHasContent = true;
+ } else {
+ this._inputHasContent = false;
}
- return this.horizontalAlign;
+
+ this.updateAddons({
+ inputElement: inputElement,
+ value: value,
+ invalid: this.invalid
+ });
},
- attached: function() {
- // Memoize this to avoid expensive calculations & relayouts.
- this._isRTL = window.getComputedStyle(this).direction == 'rtl';
- this.positionTarget = this.positionTarget || this._defaultPositionTarget;
- if (this.autoFitOnAttach) {
- if (window.getComputedStyle(this).display === 'none') {
- setTimeout(function() {
- this.fit();
- }.bind(this));
+ _handleValueAndAutoValidate: function(inputElement) {
+ if (this.autoValidate) {
+ var valid;
+ if (inputElement.validate) {
+ valid = inputElement.validate(this._inputElementValue);
} else {
- this.fit();
+ valid = inputElement.checkValidity();
}
+ this.invalid = !valid;
}
+
+ // Call this last to notify the add-ons.
+ this._handleValue(inputElement);
},
- /**
- * Positions and fits the element into the `fitInto` element.
- */
- fit: function() {
- this.position();
- this.constrain();
- this.center();
+ _onIronInputValidate: function(event) {
+ this.invalid = this._inputElement.invalid;
+ },
+
+ _invalidChanged: function() {
+ if (this._addons) {
+ this.updateAddons({invalid: this.invalid});
+ }
},
/**
- * Memoize information needed to position and size the target element.
- * @suppress {deprecated}
+ * Call this to update the state of add-ons.
+ * @param {Object} state Add-on state.
*/
- _discoverInfo: function() {
- if (this._fitInfo) {
- return;
+ updateAddons: function(state) {
+ for (var addon, index = 0; addon = this._addons[index]; index++) {
+ addon.update(state);
}
- var target = window.getComputedStyle(this);
- var sizer = window.getComputedStyle(this.sizingTarget);
+ },
- this._fitInfo = {
- inlineStyle: {
- top: this.style.top || '',
- left: this.style.left || '',
- position: this.style.position || ''
- },
- sizerInlineStyle: {
- maxWidth: this.sizingTarget.style.maxWidth || '',
- maxHeight: this.sizingTarget.style.maxHeight || '',
- boxSizing: this.sizingTarget.style.boxSizing || ''
- },
- positionedBy: {
- vertically: target.top !== 'auto' ? 'top' : (target.bottom !== 'auto' ?
- 'bottom' : null),
- horizontally: target.left !== 'auto' ? 'left' : (target.right !== 'auto' ?
- 'right' : null)
- },
- sizedBy: {
- height: sizer.maxHeight !== 'none',
- width: sizer.maxWidth !== 'none',
- minWidth: parseInt(sizer.minWidth, 10) || 0,
- minHeight: parseInt(sizer.minHeight, 10) || 0
- },
- margin: {
- top: parseInt(target.marginTop, 10) || 0,
- right: parseInt(target.marginRight, 10) || 0,
- bottom: parseInt(target.marginBottom, 10) || 0,
- left: parseInt(target.marginLeft, 10) || 0
- }
- };
+ _computeInputContentClass: function(noLabelFloat, alwaysFloatLabel, focused, invalid, _inputHasContent) {
+ var cls = 'input-content';
+ if (!noLabelFloat) {
+ var label = this.querySelector('label');
- // Support these properties until they are removed.
- if (this.verticalOffset) {
- this._fitInfo.margin.top = this._fitInfo.margin.bottom = this.verticalOffset;
- this._fitInfo.inlineStyle.marginTop = this.style.marginTop || '';
- this._fitInfo.inlineStyle.marginBottom = this.style.marginBottom || '';
- this.style.marginTop = this.style.marginBottom = this.verticalOffset + 'px';
+ if (alwaysFloatLabel || _inputHasContent) {
+ cls += ' label-is-floating';
+ // If the label is floating, ignore any offsets that may have been
+ // applied from a prefix element.
+ this.$.labelAndInputContainer.style.position = 'static';
+
+ if (invalid) {
+ cls += ' is-invalid';
+ } else if (focused) {
+ cls += " label-is-highlighted";
+ }
+ } else {
+ // When the label is not floating, it should overlap the input element.
+ if (label) {
+ this.$.labelAndInputContainer.style.position = 'relative';
+ }
+ }
+ } else {
+ if (_inputHasContent) {
+ cls += ' label-is-hidden';
+ }
}
- if (this.horizontalOffset) {
- this._fitInfo.margin.left = this._fitInfo.margin.right = this.horizontalOffset;
- this._fitInfo.inlineStyle.marginLeft = this.style.marginLeft || '';
- this._fitInfo.inlineStyle.marginRight = this.style.marginRight || '';
- this.style.marginLeft = this.style.marginRight = this.horizontalOffset + 'px';
+ return cls;
+ },
+
+ _computeUnderlineClass: function(focused, invalid) {
+ var cls = 'underline';
+ if (invalid) {
+ cls += ' is-invalid';
+ } else if (focused) {
+ cls += ' is-highlighted'
}
+ return cls;
},
- /**
- * Resets the target element's position and size constraints, and clear
- * the memoized data.
- */
- resetFit: function() {
- var info = this._fitInfo || {};
- for (var property in info.sizerInlineStyle) {
- this.sizingTarget.style[property] = info.sizerInlineStyle[property];
+ _computeAddOnContentClass: function(focused, invalid) {
+ var cls = 'add-on-content';
+ if (invalid) {
+ cls += ' is-invalid';
+ } else if (focused) {
+ cls += ' is-highlighted'
}
- for (var property in info.inlineStyle) {
- this.style[property] = info.inlineStyle[property];
+ return cls;
+ }
+ });
+/** @polymerBehavior */
+ Polymer.PaperSpinnerBehavior = {
+
+ listeners: {
+ 'animationend': '__reset',
+ 'webkitAnimationEnd': '__reset'
+ },
+
+ properties: {
+ /**
+ * Displays the spinner.
+ */
+ active: {
+ type: Boolean,
+ value: false,
+ reflectToAttribute: true,
+ observer: '__activeChanged'
+ },
+
+ /**
+ * Alternative text content for accessibility support.
+ * If alt is present, it will add an aria-label whose content matches alt when active.
+ * If alt is not present, it will default to 'loading' as the alt value.
+ */
+ alt: {
+ type: String,
+ value: 'loading',
+ observer: '__altChanged'
+ },
+
+ __coolingDown: {
+ type: Boolean,
+ value: false
}
+ },
- this._fitInfo = null;
+ __computeContainerClasses: function(active, coolingDown) {
+ return [
+ active || coolingDown ? 'active' : '',
+ coolingDown ? 'cooldown' : ''
+ ].join(' ');
},
- /**
- * Equivalent to calling `resetFit()` and `fit()`. Useful to call this after
- * the element or the `fitInto` element has been resized, or if any of the
- * positioning properties (e.g. `horizontalAlign, verticalAlign`) is updated.
- * It preserves the scroll position of the sizingTarget.
- */
- refit: function() {
- var scrollLeft = this.sizingTarget.scrollLeft;
- var scrollTop = this.sizingTarget.scrollTop;
- this.resetFit();
- this.fit();
- this.sizingTarget.scrollLeft = scrollLeft;
- this.sizingTarget.scrollTop = scrollTop;
+ __activeChanged: function(active, old) {
+ this.__setAriaHidden(!active);
+ this.__coolingDown = !active && old;
},
- /**
- * Positions the element according to `horizontalAlign, verticalAlign`.
- */
- position: function() {
- if (!this.horizontalAlign && !this.verticalAlign) {
- // needs to be centered, and it is done after constrain.
- return;
+ __altChanged: function(alt) {
+ // user-provided `aria-label` takes precedence over prototype default
+ if (alt === this.getPropertyInfo('alt').value) {
+ this.alt = this.getAttribute('aria-label') || alt;
+ } else {
+ this.__setAriaHidden(alt==='');
+ this.setAttribute('aria-label', alt);
}
- this._discoverInfo();
-
- this.style.position = 'fixed';
- // Need border-box for margin/padding.
- this.sizingTarget.style.boxSizing = 'border-box';
- // Set to 0, 0 in order to discover any offset caused by parent stacking contexts.
- this.style.left = '0px';
- this.style.top = '0px';
+ },
- var rect = this.getBoundingClientRect();
- var positionRect = this.__getNormalizedRect(this.positionTarget);
- var fitRect = this.__getNormalizedRect(this.fitInto);
+ __setAriaHidden: function(hidden) {
+ var attr = 'aria-hidden';
+ if (hidden) {
+ this.setAttribute(attr, 'true');
+ } else {
+ this.removeAttribute(attr);
+ }
+ },
- var margin = this._fitInfo.margin;
+ __reset: function() {
+ this.active = false;
+ this.__coolingDown = false;
+ }
+ };
+Polymer({
+ is: 'paper-spinner-lite',
- // Consider the margin as part of the size for position calculations.
- var size = {
- width: rect.width + margin.left + margin.right,
- height: rect.height + margin.top + margin.bottom
- };
+ behaviors: [
+ Polymer.PaperSpinnerBehavior
+ ]
+ });
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
- var position = this.__getPosition(this._localeHorizontalAlign, this.verticalAlign, size, positionRect, fitRect);
+/**
+ * Implements an incremental search field which can be shown and hidden.
+ * Canonical implementation is <cr-search-field>.
+ * @polymerBehavior
+ */
+var CrSearchFieldBehavior = {
+ properties: {
+ label: {
+ type: String,
+ value: '',
+ },
- var left = position.left + margin.left;
- var top = position.top + margin.top;
+ clearLabel: {
+ type: String,
+ value: '',
+ },
- // Use original size (without margin).
- var right = Math.min(fitRect.right - margin.right, left + rect.width);
- var bottom = Math.min(fitRect.bottom - margin.bottom, top + rect.height);
+ showingSearch: {
+ type: Boolean,
+ value: false,
+ notify: true,
+ observer: 'showingSearchChanged_',
+ reflectToAttribute: true
+ },
- var minWidth = this._fitInfo.sizedBy.minWidth;
- var minHeight = this._fitInfo.sizedBy.minHeight;
- if (left < margin.left) {
- left = margin.left;
- if (right - left < minWidth) {
- left = right - minWidth;
- }
- }
- if (top < margin.top) {
- top = margin.top;
- if (bottom - top < minHeight) {
- top = bottom - minHeight;
- }
- }
+ /** @private */
+ lastValue_: {
+ type: String,
+ value: '',
+ },
+ },
- this.sizingTarget.style.maxWidth = (right - left) + 'px';
- this.sizingTarget.style.maxHeight = (bottom - top) + 'px';
+ /**
+ * @abstract
+ * @return {!HTMLInputElement} The input field element the behavior should
+ * use.
+ */
+ getSearchInput: function() {},
- // Remove the offset caused by any stacking context.
- this.style.left = (left - rect.left) + 'px';
- this.style.top = (top - rect.top) + 'px';
- },
+ /**
+ * @return {string} The value of the search field.
+ */
+ getValue: function() {
+ return this.getSearchInput().value;
+ },
- /**
- * Constrains the size of the element to `fitInto` by setting `max-height`
- * and/or `max-width`.
- */
- constrain: function() {
- if (this.horizontalAlign || this.verticalAlign) {
- return;
- }
- this._discoverInfo();
+ /**
+ * Sets the value of the search field.
+ * @param {string} value
+ */
+ setValue: function(value) {
+ // Use bindValue when setting the input value so that changes propagate
+ // correctly.
+ this.getSearchInput().bindValue = value;
+ this.onValueChanged_(value);
+ },
- var info = this._fitInfo;
- // position at (0px, 0px) if not already positioned, so we can measure the natural size.
- if (!info.positionedBy.vertically) {
- this.style.position = 'fixed';
- this.style.top = '0px';
- }
- if (!info.positionedBy.horizontally) {
- this.style.position = 'fixed';
- this.style.left = '0px';
- }
+ showAndFocus: function() {
+ this.showingSearch = true;
+ this.focus_();
+ },
- // need border-box for margin/padding
- this.sizingTarget.style.boxSizing = 'border-box';
- // constrain the width and height if not already set
- var rect = this.getBoundingClientRect();
- if (!info.sizedBy.height) {
- this.__sizeDimension(rect, info.positionedBy.vertically, 'top', 'bottom', 'Height');
- }
- if (!info.sizedBy.width) {
- this.__sizeDimension(rect, info.positionedBy.horizontally, 'left', 'right', 'Width');
- }
- },
+ /** @private */
+ focus_: function() {
+ this.getSearchInput().focus();
+ },
- /**
- * @protected
- * @deprecated
- */
- _sizeDimension: function(rect, positionedBy, start, end, extent) {
- this.__sizeDimension(rect, positionedBy, start, end, extent);
- },
+ onSearchTermSearch: function() {
+ this.onValueChanged_(this.getValue());
+ },
- /**
- * @private
- */
- __sizeDimension: function(rect, positionedBy, start, end, extent) {
- var info = this._fitInfo;
- var fitRect = this.__getNormalizedRect(this.fitInto);
- var max = extent === 'Width' ? fitRect.width : fitRect.height;
- var flip = (positionedBy === end);
- var offset = flip ? max - rect[end] : rect[start];
- var margin = info.margin[flip ? start : end];
- var offsetExtent = 'offset' + extent;
- var sizingOffset = this[offsetExtent] - this.sizingTarget[offsetExtent];
- this.sizingTarget.style['max' + extent] = (max - margin - offset - sizingOffset) + 'px';
- },
+ /**
+ * Updates the internal state of the search field based on a change that has
+ * already happened.
+ * @param {string} newValue
+ * @private
+ */
+ onValueChanged_: function(newValue) {
+ if (newValue == this.lastValue_)
+ return;
- /**
- * Centers horizontally and vertically if not already positioned. This also sets
- * `position:fixed`.
- */
- center: function() {
- if (this.horizontalAlign || this.verticalAlign) {
- return;
- }
- this._discoverInfo();
+ this.fire('search-changed', newValue);
+ this.lastValue_ = newValue;
+ },
- var positionedBy = this._fitInfo.positionedBy;
- if (positionedBy.vertically && positionedBy.horizontally) {
- // Already positioned.
- return;
- }
- // Need position:fixed to center
- this.style.position = 'fixed';
- // Take into account the offset caused by parents that create stacking
- // contexts (e.g. with transform: translate3d). Translate to 0,0 and
- // measure the bounding rect.
- if (!positionedBy.vertically) {
- this.style.top = '0px';
- }
- if (!positionedBy.horizontally) {
- this.style.left = '0px';
- }
- // It will take in consideration margins and transforms
- var rect = this.getBoundingClientRect();
- var fitRect = this.__getNormalizedRect(this.fitInto);
- if (!positionedBy.vertically) {
- var top = fitRect.top - rect.top + (fitRect.height - rect.height) / 2;
- this.style.top = top + 'px';
- }
- if (!positionedBy.horizontally) {
- var left = fitRect.left - rect.left + (fitRect.width - rect.width) / 2;
- this.style.left = left + 'px';
- }
- },
+ onSearchTermKeydown: function(e) {
+ if (e.key == 'Escape')
+ this.showingSearch = false;
+ },
- __getNormalizedRect: function(target) {
- if (target === document.documentElement || target === window) {
- return {
- top: 0,
- left: 0,
- width: window.innerWidth,
- height: window.innerHeight,
- right: window.innerWidth,
- bottom: window.innerHeight
- };
- }
- return target.getBoundingClientRect();
+ /** @private */
+ showingSearchChanged_: function() {
+ if (this.showingSearch) {
+ this.focus_();
+ return;
+ }
+
+ this.setValue('');
+ this.getSearchInput().blur();
+ }
+};
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// TODO(tsergeant): Add tests for cr-toolbar-search-field.
+Polymer({
+ is: 'cr-toolbar-search-field',
+
+ behaviors: [CrSearchFieldBehavior],
+
+ properties: {
+ narrow: {
+ type: Boolean,
+ reflectToAttribute: true,
},
- __getCroppedArea: function(position, size, fitRect) {
- var verticalCrop = Math.min(0, position.top) + Math.min(0, fitRect.bottom - (position.top + size.height));
- var horizontalCrop = Math.min(0, position.left) + Math.min(0, fitRect.right - (position.left + size.width));
- return Math.abs(verticalCrop) * size.width + Math.abs(horizontalCrop) * size.height;
+ // Prompt text to display in the search field.
+ label: String,
+
+ // Tooltip to display on the clear search button.
+ clearLabel: String,
+
+ // When true, show a loading spinner to indicate that the backend is
+ // processing the search. Will only show if the search field is open.
+ spinnerActive: {
+ type: Boolean,
+ reflectToAttribute: true
},
+ /** @private */
+ hasSearchText_: Boolean,
+ },
- __getPosition: function(hAlign, vAlign, size, positionRect, fitRect) {
- // All the possible configurations.
- // Ordered as top-left, top-right, bottom-left, bottom-right.
- var positions = [{
- verticalAlign: 'top',
- horizontalAlign: 'left',
- top: positionRect.top,
- left: positionRect.left
- }, {
- verticalAlign: 'top',
- horizontalAlign: 'right',
- top: positionRect.top,
- left: positionRect.right - size.width
- }, {
- verticalAlign: 'bottom',
- horizontalAlign: 'left',
- top: positionRect.bottom - size.height,
- left: positionRect.left
- }, {
- verticalAlign: 'bottom',
- horizontalAlign: 'right',
- top: positionRect.bottom - size.height,
- left: positionRect.right - size.width
- }];
+ listeners: {
+ 'tap': 'showSearch_',
+ 'searchInput.bind-value-changed': 'onBindValueChanged_',
+ },
- if (this.noOverlap) {
- // Duplicate.
- for (var i = 0, l = positions.length; i < l; i++) {
- var copy = {};
- for (var key in positions[i]) {
- copy[key] = positions[i][key];
- }
- positions.push(copy);
- }
- // Horizontal overlap only.
- positions[0].top = positions[1].top += positionRect.height;
- positions[2].top = positions[3].top -= positionRect.height;
- // Vertical overlap only.
- positions[4].left = positions[6].left += positionRect.width;
- positions[5].left = positions[7].left -= positionRect.width;
- }
+ /** @return {!HTMLInputElement} */
+ getSearchInput: function() {
+ return this.$.searchInput;
+ },
- // Consider auto as null for coding convenience.
- vAlign = vAlign === 'auto' ? null : vAlign;
- hAlign = hAlign === 'auto' ? null : hAlign;
+ /** @return {boolean} */
+ isSearchFocused: function() {
+ return this.$.searchTerm.focused;
+ },
- var position;
- for (var i = 0; i < positions.length; i++) {
- var pos = positions[i];
+ /**
+ * @param {boolean} narrow
+ * @return {number}
+ * @private
+ */
+ computeIconTabIndex_: function(narrow) {
+ return narrow ? 0 : -1;
+ },
- // If both vAlign and hAlign are defined, return exact match.
- // For dynamicAlign and noOverlap we'll have more than one candidate, so
- // we'll have to check the croppedArea to make the best choice.
- if (!this.dynamicAlign && !this.noOverlap &&
- pos.verticalAlign === vAlign && pos.horizontalAlign === hAlign) {
- position = pos;
- break;
- }
+ /**
+ * @param {boolean} spinnerActive
+ * @param {boolean} showingSearch
+ * @return {boolean}
+ * @private
+ */
+ isSpinnerShown_: function(spinnerActive, showingSearch) {
+ return spinnerActive && showingSearch;
+ },
- // Align is ok if alignment preferences are respected. If no preferences,
- // it is considered ok.
- var alignOk = (!vAlign || pos.verticalAlign === vAlign) &&
- (!hAlign || pos.horizontalAlign === hAlign);
+ /** @private */
+ onInputBlur_: function() {
+ if (!this.hasSearchText_)
+ this.showingSearch = false;
+ },
- // Filter out elements that don't match the alignment (if defined).
- // With dynamicAlign, we need to consider all the positions to find the
- // one that minimizes the cropped area.
- if (!this.dynamicAlign && !alignOk) {
- continue;
- }
+ /**
+ * Update the state of the search field whenever the underlying input value
+ * changes. Unlike onsearch or onkeypress, this is reliably called immediately
+ * after any change, whether the result of user input or JS modification.
+ * @private
+ */
+ onBindValueChanged_: function() {
+ var newValue = this.$.searchInput.bindValue;
+ this.hasSearchText_ = newValue != '';
+ if (newValue != '')
+ this.showingSearch = true;
+ },
- position = position || pos;
- pos.croppedArea = this.__getCroppedArea(pos, size, fitRect);
- var diff = pos.croppedArea - position.croppedArea;
- // Check which crops less. If it crops equally, check if align is ok.
- if (diff < 0 || (diff === 0 && alignOk)) {
- position = pos;
- }
- // If not cropped and respects the align requirements, keep it.
- // This allows to prefer positions overlapping horizontally over the
- // ones overlapping vertically.
- if (position.croppedArea === 0 && alignOk) {
- break;
- }
- }
+ /**
+ * @param {Event} e
+ * @private
+ */
+ showSearch_: function(e) {
+ if (e.target != this.$.clearSearch)
+ this.showingSearch = true;
+ },
- return position;
- }
+ /**
+ * @param {Event} e
+ * @private
+ */
+ hideSearch_: function(e) {
+ this.showingSearch = false;
+ e.stopPropagation();
+ }
+});
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
- };
-(function() {
-'use strict';
+Polymer({
+ is: 'cr-toolbar',
- Polymer({
+ properties: {
+ // Name to display in the toolbar, in titlecase.
+ pageName: String,
- is: 'iron-overlay-backdrop',
+ // Prompt text to display in the search field.
+ searchPrompt: String,
- properties: {
+ // Tooltip to display on the clear search button.
+ clearLabel: String,
- /**
- * Returns true if the backdrop is opened.
- */
- opened: {
- reflectToAttribute: true,
- type: Boolean,
- value: false,
- observer: '_openedChanged'
- }
+ // Value is proxied through to cr-toolbar-search-field. When true,
+ // the search field will show a processing spinner.
+ spinnerActive: Boolean,
+ // Controls whether the menu button is shown at the start of the menu.
+ showMenu: {
+ type: Boolean,
+ reflectToAttribute: true,
+ value: true
},
- listeners: {
- 'transitionend': '_onTransitionend'
+ /** @private */
+ narrow_: {
+ type: Boolean,
+ reflectToAttribute: true
},
- created: function() {
- // Used to cancel previous requestAnimationFrame calls when opened changes.
- this.__openedRaf = null;
+ /** @private */
+ showingSearch_: {
+ type: Boolean,
+ reflectToAttribute: true,
},
+ },
- attached: function() {
- this.opened && this._openedChanged(this.opened);
+ /** @return {!CrToolbarSearchFieldElement} */
+ getSearchField: function() {
+ return this.$.search;
+ },
+
+ /** @private */
+ onMenuTap_: function(e) {
+ this.fire('cr-menu-tap');
+ }
+});
+// 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.
+
+Polymer({
+ is: 'history-toolbar',
+ properties: {
+ // Number of history items currently selected.
+ // TODO(calamity): bind this to
+ // listContainer.selectedItem.selectedPaths.length.
+ count: {
+ type: Number,
+ value: 0,
+ observer: 'changeToolbarView_'
},
- /**
- * Appends the backdrop to document body if needed.
- */
- prepare: function() {
- if (this.opened && !this.parentNode) {
- Polymer.dom(document.body).appendChild(this);
- }
+ // True if 1 or more history items are selected. When this value changes
+ // the background colour changes.
+ itemsSelected_: {
+ type: Boolean,
+ value: false,
+ reflectToAttribute: true
},
- /**
- * Shows the backdrop.
- */
- open: function() {
- this.opened = true;
+ // The most recent term entered in the search field. Updated incrementally
+ // as the user types.
+ searchTerm: {
+ type: String,
+ notify: true,
},
- /**
- * Hides the backdrop.
- */
- close: function() {
- this.opened = false;
+ // True if the backend is processing and a spinner should be shown in the
+ // toolbar.
+ spinnerActive: {
+ type: Boolean,
+ value: false
},
- /**
- * Removes the backdrop from document body if needed.
- */
- complete: function() {
- if (!this.opened && this.parentNode === document.body) {
- Polymer.dom(this.parentNode).removeChild(this);
- }
+ hasDrawer: {
+ type: Boolean,
+ observer: 'hasDrawerChanged_',
+ reflectToAttribute: true,
},
- _onTransitionend: function(event) {
- if (event && event.target === this) {
- this.complete();
- }
+ // Whether domain-grouped history is enabled.
+ isGroupedMode: {
+ type: Boolean,
+ reflectToAttribute: true,
},
- /**
- * @param {boolean} opened
- * @private
- */
- _openedChanged: function(opened) {
- if (opened) {
- // Auto-attach.
- this.prepare();
- } else {
- // Animation might be disabled via the mixin or opacity custom property.
- // If it is disabled in other ways, it's up to the user to call complete.
- var cs = window.getComputedStyle(this);
- if (cs.transitionDuration === '0s' || cs.opacity == 0) {
- this.complete();
- }
- }
+ // The period to search over. Matches BrowsingHistoryHandler::Range.
+ groupedRange: {
+ type: Number,
+ value: 0,
+ reflectToAttribute: true,
+ notify: true
+ },
- if (!this.isAttached) {
- return;
- }
+ // The start time of the query range.
+ queryStartTime: String,
- // Always cancel previous requestAnimationFrame.
- if (this.__openedRaf) {
- window.cancelAnimationFrame(this.__openedRaf);
- this.__openedRaf = null;
- }
- // Force relayout to ensure proper transitions.
- this.scrollTop = this.scrollTop;
- this.__openedRaf = window.requestAnimationFrame(function() {
- this.__openedRaf = null;
- this.toggleClass('opened', this.opened);
- }.bind(this));
- }
- });
+ // The end time of the query range.
+ queryEndTime: String,
+ },
-})();
-/**
- * @struct
- * @constructor
+ /**
+ * Changes the toolbar background color depending on whether any history items
+ * are currently selected.
* @private
*/
- Polymer.IronOverlayManagerClass = function() {
- /**
- * Used to keep track of the opened overlays.
- * @private {Array<Element>}
- */
- this._overlays = [];
+ changeToolbarView_: function() {
+ this.itemsSelected_ = this.count > 0;
+ },
- /**
- * iframes have a default z-index of 100,
- * so this default should be at least that.
- * @private {number}
- */
- this._minimumZ = 101;
+ /**
+ * When changing the search term externally, update the search field to
+ * reflect the new search term.
+ * @param {string} search
+ */
+ setSearchTerm: function(search) {
+ if (this.searchTerm == search)
+ return;
- /**
- * Memoized backdrop element.
- * @private {Element|null}
- */
- this._backdropElement = null;
+ this.searchTerm = search;
+ var searchField = /** @type {!CrToolbarElement} */(this.$['main-toolbar'])
+ .getSearchField();
+ searchField.showAndFocus();
+ searchField.setValue(search);
+ },
- // Enable document-wide tap recognizer.
- Polymer.Gestures.add(document, 'tap', this._onCaptureClick.bind(this));
+ /**
+ * @param {!CustomEvent} event
+ * @private
+ */
+ onSearchChanged_: function(event) {
+ this.searchTerm = /** @type {string} */ (event.detail);
+ },
- document.addEventListener('focus', this._onCaptureFocus.bind(this), true);
- document.addEventListener('keydown', this._onCaptureKeyDown.bind(this), true);
- };
+ onClearSelectionTap_: function() {
+ this.fire('unselect-all');
+ },
- Polymer.IronOverlayManagerClass.prototype = {
+ onDeleteTap_: function() {
+ this.fire('delete-selected');
+ },
- constructor: Polymer.IronOverlayManagerClass,
+ get searchBar() {
+ return this.$['main-toolbar'].getSearchField();
+ },
- /**
- * The shared backdrop element.
- * @type {!Element} backdropElement
- */
- get backdropElement() {
- if (!this._backdropElement) {
- this._backdropElement = document.createElement('iron-overlay-backdrop');
- }
- return this._backdropElement;
- },
+ showSearchField: function() {
+ /** @type {!CrToolbarElement} */(this.$['main-toolbar'])
+ .getSearchField()
+ .showAndFocus();
+ },
- /**
- * The deepest active element.
- * @type {!Element} activeElement the active element
- */
- get deepActiveElement() {
- // document.activeElement can be null
- // https://developer.mozilla.org/en-US/docs/Web/API/Document/activeElement
- // In case of null, default it to document.body.
- var active = document.activeElement || document.body;
- while (active.root && Polymer.dom(active.root).activeElement) {
- active = Polymer.dom(active.root).activeElement;
- }
- return active;
- },
+ /**
+ * If the user is a supervised user the delete button is not shown.
+ * @private
+ */
+ deletingAllowed_: function() {
+ return loadTimeData.getBoolean('allowDeletingHistory');
+ },
- /**
- * Brings the overlay at the specified index to the front.
- * @param {number} i
- * @private
- */
- _bringOverlayAtIndexToFront: function(i) {
- var overlay = this._overlays[i];
- if (!overlay) {
- return;
- }
- var lastI = this._overlays.length - 1;
- var currentOverlay = this._overlays[lastI];
- // Ensure always-on-top overlay stays on top.
- if (currentOverlay && this._shouldBeBehindOverlay(overlay, currentOverlay)) {
- lastI--;
- }
- // If already the top element, return.
- if (i >= lastI) {
- return;
- }
- // Update z-index to be on top.
- var minimumZ = Math.max(this.currentOverlayZ(), this._minimumZ);
- if (this._getZ(overlay) <= minimumZ) {
- this._applyOverlayZ(overlay, minimumZ);
- }
+ numberOfItemsSelected_: function(count) {
+ return count > 0 ? loadTimeData.getStringF('itemsSelected', count) : '';
+ },
- // Shift other overlays behind the new on top.
- while (i < lastI) {
- this._overlays[i] = this._overlays[i + 1];
- i++;
- }
- this._overlays[lastI] = overlay;
- },
+ getHistoryInterval_: function(queryStartTime, queryEndTime) {
+ // TODO(calamity): Fix the format of these dates.
+ return loadTimeData.getStringF(
+ 'historyInterval', queryStartTime, queryEndTime);
+ },
- /**
- * Adds the overlay and updates its z-index if it's opened, or removes it if it's closed.
- * Also updates the backdrop z-index.
- * @param {!Element} overlay
- */
- addOrRemoveOverlay: function(overlay) {
- if (overlay.opened) {
- this.addOverlay(overlay);
- } else {
- this.removeOverlay(overlay);
- }
- },
+ /** @private */
+ hasDrawerChanged_: function() {
+ this.updateStyles();
+ },
+});
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
- /**
- * Tracks overlays for z-index and focus management.
- * Ensures the last added overlay with always-on-top remains on top.
- * @param {!Element} overlay
- */
- addOverlay: function(overlay) {
- var i = this._overlays.indexOf(overlay);
- if (i >= 0) {
- this._bringOverlayAtIndexToFront(i);
- this.trackBackdrop();
- return;
- }
- var insertionIndex = this._overlays.length;
- var currentOverlay = this._overlays[insertionIndex - 1];
- var minimumZ = Math.max(this._getZ(currentOverlay), this._minimumZ);
- var newZ = this._getZ(overlay);
+/**
+ * @fileoverview 'cr-dialog' is a component for showing a modal dialog. If the
+ * dialog is closed via close(), a 'close' event is fired. If the dialog is
+ * canceled via cancel(), a 'cancel' event is fired followed by a 'close' event.
+ * Additionally clients can inspect the dialog's |returnValue| property inside
+ * the 'close' event listener to determine whether it was canceled or just
+ * closed, where a truthy value means success, and a falsy value means it was
+ * canceled.
+ */
+Polymer({
+ is: 'cr-dialog',
+ extends: 'dialog',
- // Ensure always-on-top overlay stays on top.
- if (currentOverlay && this._shouldBeBehindOverlay(overlay, currentOverlay)) {
- // This bumps the z-index of +2.
- this._applyOverlayZ(currentOverlay, minimumZ);
- insertionIndex--;
- // Update minimumZ to match previous overlay's z-index.
- var previousOverlay = this._overlays[insertionIndex - 1];
- minimumZ = Math.max(this._getZ(previousOverlay), this._minimumZ);
- }
+ cancel: function() {
+ this.fire('cancel');
+ HTMLDialogElement.prototype.close.call(this, '');
+ },
- // Update z-index and insert overlay.
- if (newZ <= minimumZ) {
- this._applyOverlayZ(overlay, minimumZ);
- }
- this._overlays.splice(insertionIndex, 0, overlay);
+ /**
+ * @param {string=} opt_returnValue
+ * @override
+ */
+ close: function(opt_returnValue) {
+ HTMLDialogElement.prototype.close.call(this, 'success');
+ },
- this.trackBackdrop();
- },
+ /** @return {!PaperIconButtonElement} */
+ getCloseButton: function() {
+ return this.$.close;
+ },
+});
+/**
+`Polymer.IronFitBehavior` fits an element in another element using `max-height` and `max-width`, and
+optionally centers it in the window or another element.
- /**
- * @param {!Element} overlay
- */
- removeOverlay: function(overlay) {
- var i = this._overlays.indexOf(overlay);
- if (i === -1) {
- return;
- }
- this._overlays.splice(i, 1);
+The element will only be sized and/or positioned if it has not already been sized and/or positioned
+by CSS.
- this.trackBackdrop();
- },
+CSS properties | Action
+-----------------------------|-------------------------------------------
+`position` set | Element is not centered horizontally or vertically
+`top` or `bottom` set | Element is not vertically centered
+`left` or `right` set | Element is not horizontally centered
+`max-height` set | Element respects `max-height`
+`max-width` set | Element respects `max-width`
- /**
- * Returns the current overlay.
- * @return {Element|undefined}
- */
- currentOverlay: function() {
- var i = this._overlays.length - 1;
- return this._overlays[i];
- },
+`Polymer.IronFitBehavior` can position an element into another element using
+`verticalAlign` and `horizontalAlign`. This will override the element's css position.
- /**
- * Returns the current overlay z-index.
- * @return {number}
- */
- currentOverlayZ: function() {
- return this._getZ(this.currentOverlay());
- },
+ <div class="container">
+ <iron-fit-impl vertical-align="top" horizontal-align="auto">
+ Positioned into the container
+ </iron-fit-impl>
+ </div>
- /**
- * Ensures that the minimum z-index of new overlays is at least `minimumZ`.
- * This does not effect the z-index of any existing overlays.
- * @param {number} minimumZ
- */
- ensureMinimumZ: function(minimumZ) {
- this._minimumZ = Math.max(this._minimumZ, minimumZ);
- },
+Use `noOverlap` to position the element around another element without overlapping it.
- focusOverlay: function() {
- var current = /** @type {?} */ (this.currentOverlay());
- if (current) {
- current._applyFocus();
- }
- },
+ <div class="container">
+ <iron-fit-impl no-overlap vertical-align="auto" horizontal-align="auto">
+ Positioned around the container
+ </iron-fit-impl>
+ </div>
- /**
- * Updates the backdrop z-index.
- */
- trackBackdrop: function() {
- var overlay = this._overlayWithBackdrop();
- // Avoid creating the backdrop if there is no overlay with backdrop.
- if (!overlay && !this._backdropElement) {
- return;
- }
- this.backdropElement.style.zIndex = this._getZ(overlay) - 1;
- this.backdropElement.opened = !!overlay;
- },
+@demo demo/index.html
+@polymerBehavior
+*/
- /**
- * @return {Array<Element>}
- */
- getBackdrops: function() {
- var backdrops = [];
- for (var i = 0; i < this._overlays.length; i++) {
- if (this._overlays[i].withBackdrop) {
- backdrops.push(this._overlays[i]);
- }
- }
- return backdrops;
- },
+ Polymer.IronFitBehavior = {
- /**
- * Returns the z-index for the backdrop.
- * @return {number}
- */
- backdropZ: function() {
- return this._getZ(this._overlayWithBackdrop()) - 1;
- },
+ properties: {
- /**
- * Returns the first opened overlay that has a backdrop.
- * @return {Element|undefined}
- * @private
- */
- _overlayWithBackdrop: function() {
- for (var i = 0; i < this._overlays.length; i++) {
- if (this._overlays[i].withBackdrop) {
- return this._overlays[i];
+ /**
+ * The element that will receive a `max-height`/`width`. By default it is the same as `this`,
+ * but it can be set to a child element. This is useful, for example, for implementing a
+ * scrolling region inside the element.
+ * @type {!Element}
+ */
+ sizingTarget: {
+ type: Object,
+ value: function() {
+ return this;
}
- }
- },
-
- /**
- * Calculates the minimum z-index for the overlay.
- * @param {Element=} overlay
- * @private
- */
- _getZ: function(overlay) {
- var z = this._minimumZ;
- if (overlay) {
- var z1 = Number(overlay.style.zIndex || window.getComputedStyle(overlay).zIndex);
- // Check if is a number
- // Number.isNaN not supported in IE 10+
- if (z1 === z1) {
- z = z1;
- }
- }
- return z;
- },
-
- /**
- * @param {!Element} element
- * @param {number|string} z
- * @private
- */
- _setZ: function(element, z) {
- element.style.zIndex = z;
- },
-
- /**
- * @param {!Element} overlay
- * @param {number} aboveZ
- * @private
- */
- _applyOverlayZ: function(overlay, aboveZ) {
- this._setZ(overlay, aboveZ + 2);
- },
-
- /**
- * Returns the deepest overlay in the path.
- * @param {Array<Element>=} path
- * @return {Element|undefined}
- * @suppress {missingProperties}
- * @private
- */
- _overlayInPath: function(path) {
- path = path || [];
- for (var i = 0; i < path.length; i++) {
- if (path[i]._manager === this) {
- return path[i];
- }
- }
- },
-
- /**
- * Ensures the click event is delegated to the right overlay.
- * @param {!Event} event
- * @private
- */
- _onCaptureClick: function(event) {
- var overlay = /** @type {?} */ (this.currentOverlay());
- // Check if clicked outside of top overlay.
- if (overlay && this._overlayInPath(Polymer.dom(event).path) !== overlay) {
- overlay._onCaptureClick(event);
- }
- },
-
- /**
- * Ensures the focus event is delegated to the right overlay.
- * @param {!Event} event
- * @private
- */
- _onCaptureFocus: function(event) {
- var overlay = /** @type {?} */ (this.currentOverlay());
- if (overlay) {
- overlay._onCaptureFocus(event);
- }
- },
-
- /**
- * Ensures TAB and ESC keyboard events are delegated to the right overlay.
- * @param {!Event} event
- * @private
- */
- _onCaptureKeyDown: function(event) {
- var overlay = /** @type {?} */ (this.currentOverlay());
- if (overlay) {
- if (Polymer.IronA11yKeysBehavior.keyboardEventMatchesKeys(event, 'esc')) {
- overlay._onCaptureEsc(event);
- } else if (Polymer.IronA11yKeysBehavior.keyboardEventMatchesKeys(event, 'tab')) {
- overlay._onCaptureTab(event);
- }
- }
- },
-
- /**
- * Returns if the overlay1 should be behind overlay2.
- * @param {!Element} overlay1
- * @param {!Element} overlay2
- * @return {boolean}
- * @suppress {missingProperties}
- * @private
- */
- _shouldBeBehindOverlay: function(overlay1, overlay2) {
- return !overlay1.alwaysOnTop && overlay2.alwaysOnTop;
- }
- };
-
- Polymer.IronOverlayManager = new Polymer.IronOverlayManagerClass();
-(function() {
-'use strict';
-
-/**
-Use `Polymer.IronOverlayBehavior` to implement an element that can be hidden or shown, and displays
-on top of other content. It includes an optional backdrop, and can be used to implement a variety
-of UI controls including dialogs and drop downs. Multiple overlays may be displayed at once.
-
-See the [demo source code](https://github.com/PolymerElements/iron-overlay-behavior/blob/master/demo/simple-overlay.html)
-for an example.
-
-### Closing and canceling
-
-An overlay may be hidden by closing or canceling. The difference between close and cancel is user
-intent. Closing generally implies that the user acknowledged the content on the overlay. By default,
-it will cancel whenever the user taps outside it or presses the escape key. This behavior is
-configurable with the `no-cancel-on-esc-key` and the `no-cancel-on-outside-click` properties.
-`close()` should be called explicitly by the implementer when the user interacts with a control
-in the overlay element. When the dialog is canceled, the overlay fires an 'iron-overlay-canceled'
-event. Call `preventDefault` on this event to prevent the overlay from closing.
-
-### Positioning
-
-By default the element is sized and positioned to fit and centered inside the window. You can
-position and size it manually using CSS. See `Polymer.IronFitBehavior`.
-
-### Backdrop
-
-Set the `with-backdrop` attribute to display a backdrop behind the overlay. The backdrop is
-appended to `<body>` and is of type `<iron-overlay-backdrop>`. See its doc page for styling
-options.
-
-In addition, `with-backdrop` will wrap the focus within the content in the light DOM.
-Override the [`_focusableNodes` getter](#Polymer.IronOverlayBehavior:property-_focusableNodes)
-to achieve a different behavior.
-
-### Limitations
-
-The element is styled to appear on top of other content by setting its `z-index` property. You
-must ensure no element has a stacking context with a higher `z-index` than its parent stacking
-context. You should place this element as a child of `<body>` whenever possible.
-
-@demo demo/index.html
-@polymerBehavior Polymer.IronOverlayBehavior
-*/
-
- Polymer.IronOverlayBehaviorImpl = {
-
- properties: {
-
- /**
- * True if the overlay is currently displayed.
- */
- opened: {
- observer: '_openedChanged',
- type: Boolean,
- value: false,
- notify: true
- },
+ },
/**
- * True if the overlay was canceled when it was last closed.
+ * The element to fit `this` into.
*/
- canceled: {
- observer: '_canceledChanged',
- readOnly: true,
- type: Boolean,
- value: false
+ fitInto: {
+ type: Object,
+ value: window
},
/**
- * Set to true to display a backdrop behind the overlay. It traps the focus
- * within the light DOM of the overlay.
+ * Will position the element around the positionTarget without overlapping it.
*/
- withBackdrop: {
- observer: '_withBackdropChanged',
+ noOverlap: {
type: Boolean
},
/**
- * Set to true to disable auto-focusing the overlay or child nodes with
- * the `autofocus` attribute` when the overlay is opened.
+ * The element that should be used to position the element. If not set, it will
+ * default to the parent node.
+ * @type {!Element}
*/
- noAutoFocus: {
- type: Boolean,
- value: false
+ positionTarget: {
+ type: Element
},
/**
- * Set to true to disable canceling the overlay with the ESC key.
+ * The orientation against which to align the element horizontally
+ * relative to the `positionTarget`. Possible values are "left", "right", "auto".
*/
- noCancelOnEscKey: {
- type: Boolean,
- value: false
+ horizontalAlign: {
+ type: String
},
/**
- * Set to true to disable canceling the overlay by clicking outside it.
+ * The orientation against which to align the element vertically
+ * relative to the `positionTarget`. Possible values are "top", "bottom", "auto".
*/
- noCancelOnOutsideClick: {
- type: Boolean,
- value: false
+ verticalAlign: {
+ type: String
},
/**
- * Contains the reason(s) this overlay was last closed (see `iron-overlay-closed`).
- * `IronOverlayBehavior` provides the `canceled` reason; implementers of the
- * behavior can provide other reasons in addition to `canceled`.
+ * If true, it will use `horizontalAlign` and `verticalAlign` values as preferred alignment
+ * and if there's not enough space, it will pick the values which minimize the cropping.
*/
- closingReason: {
- // was a getter before, but needs to be a property so other
- // behaviors can override this.
- type: Object
+ dynamicAlign: {
+ type: Boolean
},
/**
- * Set to true to enable restoring of focus when overlay is closed.
+ * The same as setting margin-left and margin-right css properties.
+ * @deprecated
*/
- restoreFocusOnClose: {
- type: Boolean,
- value: false
+ horizontalOffset: {
+ type: Number,
+ value: 0,
+ notify: true
},
/**
- * Set to true to keep overlay always on top.
+ * The same as setting margin-top and margin-bottom css properties.
+ * @deprecated
*/
- alwaysOnTop: {
- type: Boolean
+ verticalOffset: {
+ type: Number,
+ value: 0,
+ notify: true
},
/**
- * Shortcut to access to the overlay manager.
- * @private
- * @type {Polymer.IronOverlayManagerClass}
+ * Set to true to auto-fit on attach.
*/
- _manager: {
- type: Object,
- value: Polymer.IronOverlayManager
+ autoFitOnAttach: {
+ type: Boolean,
+ value: false
},
- /**
- * The node being focused.
- * @type {?Node}
- */
- _focusedChild: {
+ /** @type {?Object} */
+ _fitInfo: {
type: Object
}
-
- },
-
- listeners: {
- 'iron-resize': '_onIronResize'
},
- /**
- * The backdrop element.
- * @type {Element}
- */
- get backdropElement() {
- return this._manager.backdropElement;
- },
-
- /**
- * Returns the node to give focus to.
- * @type {Node}
- */
- get _focusNode() {
- return this._focusedChild || Polymer.dom(this).querySelector('[autofocus]') || this;
- },
-
- /**
- * Array of nodes that can receive focus (overlay included), ordered by `tabindex`.
- * This is used to retrieve which is the first and last focusable nodes in order
- * to wrap the focus for overlays `with-backdrop`.
- *
- * If you know what is your content (specifically the first and last focusable children),
- * you can override this method to return only `[firstFocusable, lastFocusable];`
- * @type {Array<Node>}
- * @protected
- */
- get _focusableNodes() {
- // Elements that can be focused even if they have [disabled] attribute.
- var FOCUSABLE_WITH_DISABLED = [
- 'a[href]',
- 'area[href]',
- 'iframe',
- '[tabindex]',
- '[contentEditable=true]'
- ];
-
- // Elements that cannot be focused if they have [disabled] attribute.
- var FOCUSABLE_WITHOUT_DISABLED = [
- 'input',
- 'select',
- 'textarea',
- 'button'
- ];
-
- // Discard elements with tabindex=-1 (makes them not focusable).
- var selector = FOCUSABLE_WITH_DISABLED.join(':not([tabindex="-1"]),') +
- ':not([tabindex="-1"]),' +
- FOCUSABLE_WITHOUT_DISABLED.join(':not([disabled]):not([tabindex="-1"]),') +
- ':not([disabled]):not([tabindex="-1"])';
-
- var focusables = Polymer.dom(this).querySelectorAll(selector);
- if (this.tabIndex >= 0) {
- // Insert at the beginning because we might have all elements with tabIndex = 0,
- // and the overlay should be the first of the list.
- focusables.splice(0, 0, this);
+ get _fitWidth() {
+ var fitWidth;
+ if (this.fitInto === window) {
+ fitWidth = this.fitInto.innerWidth;
+ } else {
+ fitWidth = this.fitInto.getBoundingClientRect().width;
}
- // Sort by tabindex.
- return focusables.sort(function (a, b) {
- if (a.tabIndex === b.tabIndex) {
- return 0;
- }
- if (a.tabIndex === 0 || a.tabIndex > b.tabIndex) {
- return 1;
- }
- return -1;
- });
+ return fitWidth;
},
- ready: function() {
- // Used to skip calls to notifyResize and refit while the overlay is animating.
- this.__isAnimating = false;
- // with-backdrop needs tabindex to be set in order to trap the focus.
- // If it is not set, IronOverlayBehavior will set it, and remove it if with-backdrop = false.
- this.__shouldRemoveTabIndex = false;
- // Used for wrapping the focus on TAB / Shift+TAB.
- this.__firstFocusableNode = this.__lastFocusableNode = null;
- // Used by __onNextAnimationFrame to cancel any previous callback.
- this.__raf = null;
- // Focused node before overlay gets opened. Can be restored on close.
- this.__restoreFocusNode = null;
- this._ensureSetup();
+ get _fitHeight() {
+ var fitHeight;
+ if (this.fitInto === window) {
+ fitHeight = this.fitInto.innerHeight;
+ } else {
+ fitHeight = this.fitInto.getBoundingClientRect().height;
+ }
+ return fitHeight;
},
- attached: function() {
- // Call _openedChanged here so that position can be computed correctly.
- if (this.opened) {
- this._openedChanged(this.opened);
+ get _fitLeft() {
+ var fitLeft;
+ if (this.fitInto === window) {
+ fitLeft = 0;
+ } else {
+ fitLeft = this.fitInto.getBoundingClientRect().left;
}
- this._observer = Polymer.dom(this).observeNodes(this._onNodesChange);
+ return fitLeft;
},
- detached: function() {
- Polymer.dom(this).unobserveNodes(this._observer);
- this._observer = null;
- if (this.__raf) {
- window.cancelAnimationFrame(this.__raf);
- this.__raf = null;
+ get _fitTop() {
+ var fitTop;
+ if (this.fitInto === window) {
+ fitTop = 0;
+ } else {
+ fitTop = this.fitInto.getBoundingClientRect().top;
}
- this._manager.removeOverlay(this);
+ return fitTop;
},
/**
- * Toggle the opened state of the overlay.
+ * The element that should be used to position the element,
+ * if no position target is configured.
*/
- toggle: function() {
- this._setCanceled(false);
- this.opened = !this.opened;
+ get _defaultPositionTarget() {
+ var parent = Polymer.dom(this).parentNode;
+
+ if (parent && parent.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
+ parent = parent.host;
+ }
+
+ return parent;
},
/**
- * Open the overlay.
+ * The horizontal align value, accounting for the RTL/LTR text direction.
*/
- open: function() {
- this._setCanceled(false);
- this.opened = true;
+ get _localeHorizontalAlign() {
+ if (this._isRTL) {
+ // In RTL, "left" becomes "right".
+ if (this.horizontalAlign === 'right') {
+ return 'left';
+ }
+ if (this.horizontalAlign === 'left') {
+ return 'right';
+ }
+ }
+ return this.horizontalAlign;
+ },
+
+ attached: function() {
+ // Memoize this to avoid expensive calculations & relayouts.
+ this._isRTL = window.getComputedStyle(this).direction == 'rtl';
+ this.positionTarget = this.positionTarget || this._defaultPositionTarget;
+ if (this.autoFitOnAttach) {
+ if (window.getComputedStyle(this).display === 'none') {
+ setTimeout(function() {
+ this.fit();
+ }.bind(this));
+ } else {
+ this.fit();
+ }
+ }
},
/**
- * Close the overlay.
+ * Positions and fits the element into the `fitInto` element.
*/
- close: function() {
- this._setCanceled(false);
- this.opened = false;
+ fit: function() {
+ this.position();
+ this.constrain();
+ this.center();
},
/**
- * Cancels the overlay.
- * @param {Event=} event The original event
+ * Memoize information needed to position and size the target element.
+ * @suppress {deprecated}
*/
- cancel: function(event) {
- var cancelEvent = this.fire('iron-overlay-canceled', event, {cancelable: true});
- if (cancelEvent.defaultPrevented) {
+ _discoverInfo: function() {
+ if (this._fitInfo) {
return;
}
+ var target = window.getComputedStyle(this);
+ var sizer = window.getComputedStyle(this.sizingTarget);
- this._setCanceled(true);
- this.opened = false;
- },
+ this._fitInfo = {
+ inlineStyle: {
+ top: this.style.top || '',
+ left: this.style.left || '',
+ position: this.style.position || ''
+ },
+ sizerInlineStyle: {
+ maxWidth: this.sizingTarget.style.maxWidth || '',
+ maxHeight: this.sizingTarget.style.maxHeight || '',
+ boxSizing: this.sizingTarget.style.boxSizing || ''
+ },
+ positionedBy: {
+ vertically: target.top !== 'auto' ? 'top' : (target.bottom !== 'auto' ?
+ 'bottom' : null),
+ horizontally: target.left !== 'auto' ? 'left' : (target.right !== 'auto' ?
+ 'right' : null)
+ },
+ sizedBy: {
+ height: sizer.maxHeight !== 'none',
+ width: sizer.maxWidth !== 'none',
+ minWidth: parseInt(sizer.minWidth, 10) || 0,
+ minHeight: parseInt(sizer.minHeight, 10) || 0
+ },
+ margin: {
+ top: parseInt(target.marginTop, 10) || 0,
+ right: parseInt(target.marginRight, 10) || 0,
+ bottom: parseInt(target.marginBottom, 10) || 0,
+ left: parseInt(target.marginLeft, 10) || 0
+ }
+ };
- _ensureSetup: function() {
- if (this._overlaySetup) {
- return;
+ // Support these properties until they are removed.
+ if (this.verticalOffset) {
+ this._fitInfo.margin.top = this._fitInfo.margin.bottom = this.verticalOffset;
+ this._fitInfo.inlineStyle.marginTop = this.style.marginTop || '';
+ this._fitInfo.inlineStyle.marginBottom = this.style.marginBottom || '';
+ this.style.marginTop = this.style.marginBottom = this.verticalOffset + 'px';
+ }
+ if (this.horizontalOffset) {
+ this._fitInfo.margin.left = this._fitInfo.margin.right = this.horizontalOffset;
+ this._fitInfo.inlineStyle.marginLeft = this.style.marginLeft || '';
+ this._fitInfo.inlineStyle.marginRight = this.style.marginRight || '';
+ this.style.marginLeft = this.style.marginRight = this.horizontalOffset + 'px';
}
- this._overlaySetup = true;
- this.style.outline = 'none';
- this.style.display = 'none';
},
/**
- * Called when `opened` changes.
- * @param {boolean=} opened
- * @protected
+ * Resets the target element's position and size constraints, and clear
+ * the memoized data.
*/
- _openedChanged: function(opened) {
- if (opened) {
- this.removeAttribute('aria-hidden');
- } else {
- this.setAttribute('aria-hidden', 'true');
+ resetFit: function() {
+ var info = this._fitInfo || {};
+ for (var property in info.sizerInlineStyle) {
+ this.sizingTarget.style[property] = info.sizerInlineStyle[property];
}
-
- // Defer any animation-related code on attached
- // (_openedChanged gets called again on attached).
- if (!this.isAttached) {
- return;
+ for (var property in info.inlineStyle) {
+ this.style[property] = info.inlineStyle[property];
}
- this.__isAnimating = true;
-
- // Use requestAnimationFrame for non-blocking rendering.
- this.__onNextAnimationFrame(this.__openedChanged);
- },
-
- _canceledChanged: function() {
- this.closingReason = this.closingReason || {};
- this.closingReason.canceled = this.canceled;
- },
-
- _withBackdropChanged: function() {
- // If tabindex is already set, no need to override it.
- if (this.withBackdrop && !this.hasAttribute('tabindex')) {
- this.setAttribute('tabindex', '-1');
- this.__shouldRemoveTabIndex = true;
- } else if (this.__shouldRemoveTabIndex) {
- this.removeAttribute('tabindex');
- this.__shouldRemoveTabIndex = false;
- }
- if (this.opened && this.isAttached) {
- this._manager.trackBackdrop();
- }
- },
+ this._fitInfo = null;
+ },
/**
- * tasks which must occur before opening; e.g. making the element visible.
- * @protected
+ * Equivalent to calling `resetFit()` and `fit()`. Useful to call this after
+ * the element or the `fitInto` element has been resized, or if any of the
+ * positioning properties (e.g. `horizontalAlign, verticalAlign`) is updated.
+ * It preserves the scroll position of the sizingTarget.
*/
- _prepareRenderOpened: function() {
- // Store focused node.
- this.__restoreFocusNode = this._manager.deepActiveElement;
-
- // Needed to calculate the size of the overlay so that transitions on its size
- // will have the correct starting points.
- this._preparePositioning();
- this.refit();
- this._finishPositioning();
-
- // Safari will apply the focus to the autofocus element when displayed
- // for the first time, so we make sure to return the focus where it was.
- if (this.noAutoFocus && document.activeElement === this._focusNode) {
- this._focusNode.blur();
- this.__restoreFocusNode.focus();
- }
+ refit: function() {
+ var scrollLeft = this.sizingTarget.scrollLeft;
+ var scrollTop = this.sizingTarget.scrollTop;
+ this.resetFit();
+ this.fit();
+ this.sizingTarget.scrollLeft = scrollLeft;
+ this.sizingTarget.scrollTop = scrollTop;
},
/**
- * Tasks which cause the overlay to actually open; typically play an animation.
- * @protected
+ * Positions the element according to `horizontalAlign, verticalAlign`.
*/
- _renderOpened: function() {
- this._finishRenderOpened();
- },
+ position: function() {
+ if (!this.horizontalAlign && !this.verticalAlign) {
+ // needs to be centered, and it is done after constrain.
+ return;
+ }
+ this._discoverInfo();
- /**
- * Tasks which cause the overlay to actually close; typically play an animation.
- * @protected
- */
- _renderClosed: function() {
- this._finishRenderClosed();
- },
+ this.style.position = 'fixed';
+ // Need border-box for margin/padding.
+ this.sizingTarget.style.boxSizing = 'border-box';
+ // Set to 0, 0 in order to discover any offset caused by parent stacking contexts.
+ this.style.left = '0px';
+ this.style.top = '0px';
- /**
- * Tasks to be performed at the end of open action. Will fire `iron-overlay-opened`.
- * @protected
- */
- _finishRenderOpened: function() {
- this.notifyResize();
- this.__isAnimating = false;
+ var rect = this.getBoundingClientRect();
+ var positionRect = this.__getNormalizedRect(this.positionTarget);
+ var fitRect = this.__getNormalizedRect(this.fitInto);
- // Store it so we don't query too much.
- var focusableNodes = this._focusableNodes;
- this.__firstFocusableNode = focusableNodes[0];
- this.__lastFocusableNode = focusableNodes[focusableNodes.length - 1];
+ var margin = this._fitInfo.margin;
- this.fire('iron-overlay-opened');
- },
+ // Consider the margin as part of the size for position calculations.
+ var size = {
+ width: rect.width + margin.left + margin.right,
+ height: rect.height + margin.top + margin.bottom
+ };
- /**
- * Tasks to be performed at the end of close action. Will fire `iron-overlay-closed`.
- * @protected
- */
- _finishRenderClosed: function() {
- // Hide the overlay.
- this.style.display = 'none';
- // Reset z-index only at the end of the animation.
- this.style.zIndex = '';
- this.notifyResize();
- this.__isAnimating = false;
- this.fire('iron-overlay-closed', this.closingReason);
- },
+ var position = this.__getPosition(this._localeHorizontalAlign, this.verticalAlign, size, positionRect, fitRect);
- _preparePositioning: function() {
- this.style.transition = this.style.webkitTransition = 'none';
- this.style.transform = this.style.webkitTransform = 'none';
- this.style.display = '';
- },
+ var left = position.left + margin.left;
+ var top = position.top + margin.top;
- _finishPositioning: function() {
- // First, make it invisible & reactivate animations.
- this.style.display = 'none';
- // Force reflow before re-enabling animations so that they don't start.
- // Set scrollTop to itself so that Closure Compiler doesn't remove this.
- this.scrollTop = this.scrollTop;
- this.style.transition = this.style.webkitTransition = '';
- this.style.transform = this.style.webkitTransform = '';
- // Now that animations are enabled, make it visible again
- this.style.display = '';
- // Force reflow, so that following animations are properly started.
- // Set scrollTop to itself so that Closure Compiler doesn't remove this.
- this.scrollTop = this.scrollTop;
- },
+ // Use original size (without margin).
+ var right = Math.min(fitRect.right - margin.right, left + rect.width);
+ var bottom = Math.min(fitRect.bottom - margin.bottom, top + rect.height);
- /**
- * Applies focus according to the opened state.
- * @protected
- */
- _applyFocus: function() {
- if (this.opened) {
- if (!this.noAutoFocus) {
- this._focusNode.focus();
+ var minWidth = this._fitInfo.sizedBy.minWidth;
+ var minHeight = this._fitInfo.sizedBy.minHeight;
+ if (left < margin.left) {
+ left = margin.left;
+ if (right - left < minWidth) {
+ left = right - minWidth;
}
}
- else {
- this._focusNode.blur();
- this._focusedChild = null;
- // Restore focus.
- if (this.restoreFocusOnClose && this.__restoreFocusNode) {
- this.__restoreFocusNode.focus();
- }
- this.__restoreFocusNode = null;
- // If many overlays get closed at the same time, one of them would still
- // be the currentOverlay even if already closed, and would call _applyFocus
- // infinitely, so we check for this not to be the current overlay.
- var currentOverlay = this._manager.currentOverlay();
- if (currentOverlay && this !== currentOverlay) {
- currentOverlay._applyFocus();
+ if (top < margin.top) {
+ top = margin.top;
+ if (bottom - top < minHeight) {
+ top = bottom - minHeight;
}
}
+
+ this.sizingTarget.style.maxWidth = (right - left) + 'px';
+ this.sizingTarget.style.maxHeight = (bottom - top) + 'px';
+
+ // Remove the offset caused by any stacking context.
+ this.style.left = (left - rect.left) + 'px';
+ this.style.top = (top - rect.top) + 'px';
},
/**
- * Cancels (closes) the overlay. Call when click happens outside the overlay.
- * @param {!Event} event
- * @protected
+ * Constrains the size of the element to `fitInto` by setting `max-height`
+ * and/or `max-width`.
*/
- _onCaptureClick: function(event) {
- if (!this.noCancelOnOutsideClick) {
- this.cancel(event);
+ constrain: function() {
+ if (this.horizontalAlign || this.verticalAlign) {
+ return;
+ }
+ this._discoverInfo();
+
+ var info = this._fitInfo;
+ // position at (0px, 0px) if not already positioned, so we can measure the natural size.
+ if (!info.positionedBy.vertically) {
+ this.style.position = 'fixed';
+ this.style.top = '0px';
+ }
+ if (!info.positionedBy.horizontally) {
+ this.style.position = 'fixed';
+ this.style.left = '0px';
+ }
+
+ // need border-box for margin/padding
+ this.sizingTarget.style.boxSizing = 'border-box';
+ // constrain the width and height if not already set
+ var rect = this.getBoundingClientRect();
+ if (!info.sizedBy.height) {
+ this.__sizeDimension(rect, info.positionedBy.vertically, 'top', 'bottom', 'Height');
+ }
+ if (!info.sizedBy.width) {
+ this.__sizeDimension(rect, info.positionedBy.horizontally, 'left', 'right', 'Width');
}
},
/**
- * Keeps track of the focused child. If withBackdrop, traps focus within overlay.
- * @param {!Event} event
* @protected
+ * @deprecated
*/
- _onCaptureFocus: function (event) {
- if (!this.withBackdrop) {
- return;
- }
- var path = Polymer.dom(event).path;
- if (path.indexOf(this) === -1) {
- event.stopPropagation();
- this._applyFocus();
- } else {
- this._focusedChild = path[0];
- }
+ _sizeDimension: function(rect, positionedBy, start, end, extent) {
+ this.__sizeDimension(rect, positionedBy, start, end, extent);
},
/**
- * Handles the ESC key event and cancels (closes) the overlay.
- * @param {!Event} event
- * @protected
+ * @private
*/
- _onCaptureEsc: function(event) {
- if (!this.noCancelOnEscKey) {
- this.cancel(event);
- }
+ __sizeDimension: function(rect, positionedBy, start, end, extent) {
+ var info = this._fitInfo;
+ var fitRect = this.__getNormalizedRect(this.fitInto);
+ var max = extent === 'Width' ? fitRect.width : fitRect.height;
+ var flip = (positionedBy === end);
+ var offset = flip ? max - rect[end] : rect[start];
+ var margin = info.margin[flip ? start : end];
+ var offsetExtent = 'offset' + extent;
+ var sizingOffset = this[offsetExtent] - this.sizingTarget[offsetExtent];
+ this.sizingTarget.style['max' + extent] = (max - margin - offset - sizingOffset) + 'px';
},
/**
- * Handles TAB key events to track focus changes.
- * Will wrap focus for overlays withBackdrop.
- * @param {!Event} event
- * @protected
+ * Centers horizontally and vertically if not already positioned. This also sets
+ * `position:fixed`.
*/
- _onCaptureTab: function(event) {
- if (!this.withBackdrop) {
+ center: function() {
+ if (this.horizontalAlign || this.verticalAlign) {
return;
}
- // TAB wraps from last to first focusable.
- // Shift + TAB wraps from first to last focusable.
- var shift = event.shiftKey;
- var nodeToCheck = shift ? this.__firstFocusableNode : this.__lastFocusableNode;
- var nodeToSet = shift ? this.__lastFocusableNode : this.__firstFocusableNode;
- var shouldWrap = false;
- if (nodeToCheck === nodeToSet) {
- // If nodeToCheck is the same as nodeToSet, it means we have an overlay
- // with 0 or 1 focusables; in either case we still need to trap the
- // focus within the overlay.
- shouldWrap = true;
- } else {
- // In dom=shadow, the manager will receive focus changes on the main
- // root but not the ones within other shadow roots, so we can't rely on
- // _focusedChild, but we should check the deepest active element.
- var focusedNode = this._manager.deepActiveElement;
- // If the active element is not the nodeToCheck but the overlay itself,
- // it means the focus is about to go outside the overlay, hence we
- // should prevent that (e.g. user opens the overlay and hit Shift+TAB).
- shouldWrap = (focusedNode === nodeToCheck || focusedNode === this);
- }
+ this._discoverInfo();
- if (shouldWrap) {
- // When the overlay contains the last focusable element of the document
- // and it's already focused, pressing TAB would move the focus outside
- // the document (e.g. to the browser search bar). Similarly, when the
- // overlay contains the first focusable element of the document and it's
- // already focused, pressing Shift+TAB would move the focus outside the
- // document (e.g. to the browser search bar).
- // In both cases, we would not receive a focus event, but only a blur.
- // In order to achieve focus wrapping, we prevent this TAB event and
- // force the focus. This will also prevent the focus to temporarily move
- // outside the overlay, which might cause scrolling.
- event.preventDefault();
- this._focusedChild = nodeToSet;
- this._applyFocus();
+ var positionedBy = this._fitInfo.positionedBy;
+ if (positionedBy.vertically && positionedBy.horizontally) {
+ // Already positioned.
+ return;
+ }
+ // Need position:fixed to center
+ this.style.position = 'fixed';
+ // Take into account the offset caused by parents that create stacking
+ // contexts (e.g. with transform: translate3d). Translate to 0,0 and
+ // measure the bounding rect.
+ if (!positionedBy.vertically) {
+ this.style.top = '0px';
+ }
+ if (!positionedBy.horizontally) {
+ this.style.left = '0px';
+ }
+ // It will take in consideration margins and transforms
+ var rect = this.getBoundingClientRect();
+ var fitRect = this.__getNormalizedRect(this.fitInto);
+ if (!positionedBy.vertically) {
+ var top = fitRect.top - rect.top + (fitRect.height - rect.height) / 2;
+ this.style.top = top + 'px';
+ }
+ if (!positionedBy.horizontally) {
+ var left = fitRect.left - rect.left + (fitRect.width - rect.width) / 2;
+ this.style.left = left + 'px';
}
},
- /**
- * Refits if the overlay is opened and not animating.
- * @protected
- */
- _onIronResize: function() {
- if (this.opened && !this.__isAnimating) {
- this.__onNextAnimationFrame(this.refit);
+ __getNormalizedRect: function(target) {
+ if (target === document.documentElement || target === window) {
+ return {
+ top: 0,
+ left: 0,
+ width: window.innerWidth,
+ height: window.innerHeight,
+ right: window.innerWidth,
+ bottom: window.innerHeight
+ };
}
+ return target.getBoundingClientRect();
},
- /**
- * Will call notifyResize if overlay is opened.
- * Can be overridden in order to avoid multiple observers on the same node.
- * @protected
- */
- _onNodesChange: function() {
- if (this.opened && !this.__isAnimating) {
- this.notifyResize();
- }
+ __getCroppedArea: function(position, size, fitRect) {
+ var verticalCrop = Math.min(0, position.top) + Math.min(0, fitRect.bottom - (position.top + size.height));
+ var horizontalCrop = Math.min(0, position.left) + Math.min(0, fitRect.right - (position.left + size.width));
+ return Math.abs(verticalCrop) * size.width + Math.abs(horizontalCrop) * size.height;
},
- /**
- * Tasks executed when opened changes: prepare for the opening, move the
- * focus, update the manager, render opened/closed.
- * @private
- */
- __openedChanged: function() {
- if (this.opened) {
- // Make overlay visible, then add it to the manager.
- this._prepareRenderOpened();
- this._manager.addOverlay(this);
- // Move the focus to the child node with [autofocus].
- this._applyFocus();
- this._renderOpened();
- } else {
- // Remove overlay, then restore the focus before actually closing.
- this._manager.removeOverlay(this);
- this._applyFocus();
+ __getPosition: function(hAlign, vAlign, size, positionRect, fitRect) {
+ // All the possible configurations.
+ // Ordered as top-left, top-right, bottom-left, bottom-right.
+ var positions = [{
+ verticalAlign: 'top',
+ horizontalAlign: 'left',
+ top: positionRect.top,
+ left: positionRect.left
+ }, {
+ verticalAlign: 'top',
+ horizontalAlign: 'right',
+ top: positionRect.top,
+ left: positionRect.right - size.width
+ }, {
+ verticalAlign: 'bottom',
+ horizontalAlign: 'left',
+ top: positionRect.bottom - size.height,
+ left: positionRect.left
+ }, {
+ verticalAlign: 'bottom',
+ horizontalAlign: 'right',
+ top: positionRect.bottom - size.height,
+ left: positionRect.right - size.width
+ }];
- this._renderClosed();
+ if (this.noOverlap) {
+ // Duplicate.
+ for (var i = 0, l = positions.length; i < l; i++) {
+ var copy = {};
+ for (var key in positions[i]) {
+ copy[key] = positions[i][key];
+ }
+ positions.push(copy);
+ }
+ // Horizontal overlap only.
+ positions[0].top = positions[1].top += positionRect.height;
+ positions[2].top = positions[3].top -= positionRect.height;
+ // Vertical overlap only.
+ positions[4].left = positions[6].left += positionRect.width;
+ positions[5].left = positions[7].left -= positionRect.width;
}
- },
- /**
- * Executes a callback on the next animation frame, overriding any previous
- * callback awaiting for the next animation frame. e.g.
- * `__onNextAnimationFrame(callback1) && __onNextAnimationFrame(callback2)`;
- * `callback1` will never be invoked.
- * @param {!Function} callback Its `this` parameter is the overlay itself.
- * @private
- */
- __onNextAnimationFrame: function(callback) {
- if (this.__raf) {
- window.cancelAnimationFrame(this.__raf);
- }
- var self = this;
- this.__raf = window.requestAnimationFrame(function nextAnimationFrame() {
- self.__raf = null;
- callback.call(self);
- });
- }
+ // Consider auto as null for coding convenience.
+ vAlign = vAlign === 'auto' ? null : vAlign;
+ hAlign = hAlign === 'auto' ? null : hAlign;
- };
+ var position;
+ for (var i = 0; i < positions.length; i++) {
+ var pos = positions[i];
- /** @polymerBehavior */
- Polymer.IronOverlayBehavior = [Polymer.IronFitBehavior, Polymer.IronResizableBehavior, Polymer.IronOverlayBehaviorImpl];
+ // If both vAlign and hAlign are defined, return exact match.
+ // For dynamicAlign and noOverlap we'll have more than one candidate, so
+ // we'll have to check the croppedArea to make the best choice.
+ if (!this.dynamicAlign && !this.noOverlap &&
+ pos.verticalAlign === vAlign && pos.horizontalAlign === hAlign) {
+ position = pos;
+ break;
+ }
- /**
- * Fired after the overlay opens.
- * @event iron-overlay-opened
- */
+ // Align is ok if alignment preferences are respected. If no preferences,
+ // it is considered ok.
+ var alignOk = (!vAlign || pos.verticalAlign === vAlign) &&
+ (!hAlign || pos.horizontalAlign === hAlign);
- /**
- * Fired when the overlay is canceled, but before it is closed.
- * @event iron-overlay-canceled
- * @param {Event} event The closing of the overlay can be prevented
- * by calling `event.preventDefault()`. The `event.detail` is the original event that
- * originated the canceling (e.g. ESC keyboard event or click event outside the overlay).
- */
+ // Filter out elements that don't match the alignment (if defined).
+ // With dynamicAlign, we need to consider all the positions to find the
+ // one that minimizes the cropped area.
+ if (!this.dynamicAlign && !alignOk) {
+ continue;
+ }
- /**
- * Fired after the overlay closes.
- * @event iron-overlay-closed
- * @param {Event} event The `event.detail` is the `closingReason` property
- * (contains `canceled`, whether the overlay was canceled).
- */
+ position = position || pos;
+ pos.croppedArea = this.__getCroppedArea(pos, size, fitRect);
+ var diff = pos.croppedArea - position.croppedArea;
+ // Check which crops less. If it crops equally, check if align is ok.
+ if (diff < 0 || (diff === 0 && alignOk)) {
+ position = pos;
+ }
+ // If not cropped and respects the align requirements, keep it.
+ // This allows to prefer positions overlapping horizontally over the
+ // ones overlapping vertically.
+ if (position.croppedArea === 0 && alignOk) {
+ break;
+ }
+ }
-})();
-/**
- * `Polymer.NeonAnimatableBehavior` is implemented by elements containing animations for use with
- * elements implementing `Polymer.NeonAnimationRunnerBehavior`.
- * @polymerBehavior
- */
- Polymer.NeonAnimatableBehavior = {
+ return position;
+ }
+
+ };
+(function() {
+'use strict';
+
+ Polymer({
+
+ is: 'iron-overlay-backdrop',
properties: {
/**
- * Animation configuration. See README for more info.
+ * Returns true if the backdrop is opened.
*/
- animationConfig: {
- type: Object
- },
+ opened: {
+ reflectToAttribute: true,
+ type: Boolean,
+ value: false,
+ observer: '_openedChanged'
+ }
- /**
- * Convenience property for setting an 'entry' animation. Do not set `animationConfig.entry`
- * manually if using this. The animated node is set to `this` if using this property.
- */
- entryAnimation: {
- observer: '_entryAnimationChanged',
- type: String
- },
-
- /**
- * Convenience property for setting an 'exit' animation. Do not set `animationConfig.exit`
- * manually if using this. The animated node is set to `this` if using this property.
- */
- exitAnimation: {
- observer: '_exitAnimationChanged',
- type: String
- }
+ },
+ listeners: {
+ 'transitionend': '_onTransitionend'
},
- _entryAnimationChanged: function() {
- this.animationConfig = this.animationConfig || {};
- this.animationConfig['entry'] = [{
- name: this.entryAnimation,
- node: this
- }];
+ created: function() {
+ // Used to cancel previous requestAnimationFrame calls when opened changes.
+ this.__openedRaf = null;
},
- _exitAnimationChanged: function() {
- this.animationConfig = this.animationConfig || {};
- this.animationConfig['exit'] = [{
- name: this.exitAnimation,
- node: this
- }];
+ attached: function() {
+ this.opened && this._openedChanged(this.opened);
},
- _copyProperties: function(config1, config2) {
- // shallowly copy properties from config2 to config1
- for (var property in config2) {
- config1[property] = config2[property];
+ /**
+ * Appends the backdrop to document body if needed.
+ */
+ prepare: function() {
+ if (this.opened && !this.parentNode) {
+ Polymer.dom(document.body).appendChild(this);
}
},
- _cloneConfig: function(config) {
- var clone = {
- isClone: true
- };
- this._copyProperties(clone, config);
- return clone;
+ /**
+ * Shows the backdrop.
+ */
+ open: function() {
+ this.opened = true;
},
- _getAnimationConfigRecursive: function(type, map, allConfigs) {
- if (!this.animationConfig) {
- return;
+ /**
+ * Hides the backdrop.
+ */
+ close: function() {
+ this.opened = false;
+ },
+
+ /**
+ * Removes the backdrop from document body if needed.
+ */
+ complete: function() {
+ if (!this.opened && this.parentNode === document.body) {
+ Polymer.dom(this.parentNode).removeChild(this);
}
+ },
- if(this.animationConfig.value && typeof this.animationConfig.value === 'function') {
- this._warn(this._logf('playAnimation', "Please put 'animationConfig' inside of your components 'properties' object instead of outside of it."));
- return;
+ _onTransitionend: function(event) {
+ if (event && event.target === this) {
+ this.complete();
}
+ },
- // type is optional
- var thisConfig;
- if (type) {
- thisConfig = this.animationConfig[type];
+ /**
+ * @param {boolean} opened
+ * @private
+ */
+ _openedChanged: function(opened) {
+ if (opened) {
+ // Auto-attach.
+ this.prepare();
} else {
- thisConfig = this.animationConfig;
+ // Animation might be disabled via the mixin or opacity custom property.
+ // If it is disabled in other ways, it's up to the user to call complete.
+ var cs = window.getComputedStyle(this);
+ if (cs.transitionDuration === '0s' || cs.opacity == 0) {
+ this.complete();
+ }
}
- if (!Array.isArray(thisConfig)) {
- thisConfig = [thisConfig];
+ if (!this.isAttached) {
+ return;
}
- // iterate animations and recurse to process configurations from child nodes
- if (thisConfig) {
- for (var config, index = 0; config = thisConfig[index]; index++) {
- if (config.animatable) {
- config.animatable._getAnimationConfigRecursive(config.type || type, map, allConfigs);
- } else {
- if (config.id) {
- var cachedConfig = map[config.id];
- if (cachedConfig) {
- // merge configurations with the same id, making a clone lazily
- if (!cachedConfig.isClone) {
- map[config.id] = this._cloneConfig(cachedConfig)
- cachedConfig = map[config.id];
- }
- this._copyProperties(cachedConfig, config);
- } else {
- // put any configs with an id into a map
- map[config.id] = config;
- }
- } else {
- allConfigs.push(config);
- }
- }
- }
+ // Always cancel previous requestAnimationFrame.
+ if (this.__openedRaf) {
+ window.cancelAnimationFrame(this.__openedRaf);
+ this.__openedRaf = null;
}
- },
+ // Force relayout to ensure proper transitions.
+ this.scrollTop = this.scrollTop;
+ this.__openedRaf = window.requestAnimationFrame(function() {
+ this.__openedRaf = null;
+ this.toggleClass('opened', this.opened);
+ }.bind(this));
+ }
+ });
+})();
+/**
+ * @struct
+ * @constructor
+ * @private
+ */
+ Polymer.IronOverlayManagerClass = function() {
/**
- * An element implementing `Polymer.NeonAnimationRunnerBehavior` calls this method to configure
- * an animation with an optional type. Elements implementing `Polymer.NeonAnimatableBehavior`
- * should define the property `animationConfig`, which is either a configuration object
- * or a map of animation type to array of configuration objects.
+ * Used to keep track of the opened overlays.
+ * @private {Array<Element>}
*/
- getAnimationConfig: function(type) {
- var map = {};
- var allConfigs = [];
- this._getAnimationConfigRecursive(type, map, allConfigs);
- // append the configurations saved in the map to the array
- for (var key in map) {
- allConfigs.push(map[key]);
- }
- return allConfigs;
- }
+ this._overlays = [];
+
+ /**
+ * iframes have a default z-index of 100,
+ * so this default should be at least that.
+ * @private {number}
+ */
+ this._minimumZ = 101;
+
+ /**
+ * Memoized backdrop element.
+ * @private {Element|null}
+ */
+ this._backdropElement = null;
+ // Enable document-wide tap recognizer.
+ Polymer.Gestures.add(document, 'tap', this._onCaptureClick.bind(this));
+
+ document.addEventListener('focus', this._onCaptureFocus.bind(this), true);
+ document.addEventListener('keydown', this._onCaptureKeyDown.bind(this), true);
};
-/**
- * `Polymer.NeonAnimationRunnerBehavior` adds a method to run animations.
- *
- * @polymerBehavior Polymer.NeonAnimationRunnerBehavior
- */
- Polymer.NeonAnimationRunnerBehaviorImpl = {
- _configureAnimations: function(configs) {
- var results = [];
- if (configs.length > 0) {
- for (var config, index = 0; config = configs[index]; index++) {
- var neonAnimation = document.createElement(config.name);
- // is this element actually a neon animation?
- if (neonAnimation.isNeonAnimation) {
- var result = null;
- // configuration or play could fail if polyfills aren't loaded
- try {
- result = neonAnimation.configure(config);
- // Check if we have an Effect rather than an Animation
- if (typeof result.cancel != 'function') {
- result = document.timeline.play(result);
- }
- } catch (e) {
- result = null;
- console.warn('Couldnt play', '(', config.name, ').', e);
- }
- if (result) {
- results.push({
- neonAnimation: neonAnimation,
- config: config,
- animation: result,
- });
- }
- } else {
- console.warn(this.is + ':', config.name, 'not found!');
- }
- }
- }
- return results;
- },
+ Polymer.IronOverlayManagerClass.prototype = {
- _shouldComplete: function(activeEntries) {
- var finished = true;
- for (var i = 0; i < activeEntries.length; i++) {
- if (activeEntries[i].animation.playState != 'finished') {
- finished = false;
- break;
- }
- }
- return finished;
- },
+ constructor: Polymer.IronOverlayManagerClass,
- _complete: function(activeEntries) {
- for (var i = 0; i < activeEntries.length; i++) {
- activeEntries[i].neonAnimation.complete(activeEntries[i].config);
- }
- for (var i = 0; i < activeEntries.length; i++) {
- activeEntries[i].animation.cancel();
+ /**
+ * The shared backdrop element.
+ * @type {!Element} backdropElement
+ */
+ get backdropElement() {
+ if (!this._backdropElement) {
+ this._backdropElement = document.createElement('iron-overlay-backdrop');
}
+ return this._backdropElement;
},
/**
- * Plays an animation with an optional `type`.
- * @param {string=} type
- * @param {!Object=} cookie
+ * The deepest active element.
+ * @type {!Element} activeElement the active element
*/
- playAnimation: function(type, cookie) {
- var configs = this.getAnimationConfig(type);
- if (!configs) {
- return;
- }
- this._active = this._active || {};
- if (this._active[type]) {
- this._complete(this._active[type]);
- delete this._active[type];
+ get deepActiveElement() {
+ // document.activeElement can be null
+ // https://developer.mozilla.org/en-US/docs/Web/API/Document/activeElement
+ // In case of null, default it to document.body.
+ var active = document.activeElement || document.body;
+ while (active.root && Polymer.dom(active.root).activeElement) {
+ active = Polymer.dom(active.root).activeElement;
}
+ return active;
+ },
- var activeEntries = this._configureAnimations(configs);
-
- if (activeEntries.length == 0) {
- this.fire('neon-animation-finish', cookie, {bubbles: false});
+ /**
+ * Brings the overlay at the specified index to the front.
+ * @param {number} i
+ * @private
+ */
+ _bringOverlayAtIndexToFront: function(i) {
+ var overlay = this._overlays[i];
+ if (!overlay) {
+ return;
+ }
+ var lastI = this._overlays.length - 1;
+ var currentOverlay = this._overlays[lastI];
+ // Ensure always-on-top overlay stays on top.
+ if (currentOverlay && this._shouldBeBehindOverlay(overlay, currentOverlay)) {
+ lastI--;
+ }
+ // If already the top element, return.
+ if (i >= lastI) {
return;
}
+ // Update z-index to be on top.
+ var minimumZ = Math.max(this.currentOverlayZ(), this._minimumZ);
+ if (this._getZ(overlay) <= minimumZ) {
+ this._applyOverlayZ(overlay, minimumZ);
+ }
- this._active[type] = activeEntries;
+ // Shift other overlays behind the new on top.
+ while (i < lastI) {
+ this._overlays[i] = this._overlays[i + 1];
+ i++;
+ }
+ this._overlays[lastI] = overlay;
+ },
- for (var i = 0; i < activeEntries.length; i++) {
- activeEntries[i].animation.onfinish = function() {
- if (this._shouldComplete(activeEntries)) {
- this._complete(activeEntries);
- delete this._active[type];
- this.fire('neon-animation-finish', cookie, {bubbles: false});
- }
- }.bind(this);
+ /**
+ * Adds the overlay and updates its z-index if it's opened, or removes it if it's closed.
+ * Also updates the backdrop z-index.
+ * @param {!Element} overlay
+ */
+ addOrRemoveOverlay: function(overlay) {
+ if (overlay.opened) {
+ this.addOverlay(overlay);
+ } else {
+ this.removeOverlay(overlay);
}
},
/**
- * Cancels the currently running animations.
+ * Tracks overlays for z-index and focus management.
+ * Ensures the last added overlay with always-on-top remains on top.
+ * @param {!Element} overlay
*/
- cancelAnimation: function() {
- for (var k in this._animations) {
- this._animations[k].cancel();
+ addOverlay: function(overlay) {
+ var i = this._overlays.indexOf(overlay);
+ if (i >= 0) {
+ this._bringOverlayAtIndexToFront(i);
+ this.trackBackdrop();
+ return;
}
- this._animations = {};
- }
- };
+ var insertionIndex = this._overlays.length;
+ var currentOverlay = this._overlays[insertionIndex - 1];
+ var minimumZ = Math.max(this._getZ(currentOverlay), this._minimumZ);
+ var newZ = this._getZ(overlay);
- /** @polymerBehavior Polymer.NeonAnimationRunnerBehavior */
- Polymer.NeonAnimationRunnerBehavior = [
- Polymer.NeonAnimatableBehavior,
- Polymer.NeonAnimationRunnerBehaviorImpl
- ];
-/**
- * Use `Polymer.NeonAnimationBehavior` to implement an animation.
- * @polymerBehavior
- */
- Polymer.NeonAnimationBehavior = {
+ // Ensure always-on-top overlay stays on top.
+ if (currentOverlay && this._shouldBeBehindOverlay(overlay, currentOverlay)) {
+ // This bumps the z-index of +2.
+ this._applyOverlayZ(currentOverlay, minimumZ);
+ insertionIndex--;
+ // Update minimumZ to match previous overlay's z-index.
+ var previousOverlay = this._overlays[insertionIndex - 1];
+ minimumZ = Math.max(this._getZ(previousOverlay), this._minimumZ);
+ }
- properties: {
+ // Update z-index and insert overlay.
+ if (newZ <= minimumZ) {
+ this._applyOverlayZ(overlay, minimumZ);
+ }
+ this._overlays.splice(insertionIndex, 0, overlay);
- /**
- * Defines the animation timing.
- */
- animationTiming: {
- type: Object,
- value: function() {
- return {
- duration: 500,
- easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
- fill: 'both'
- }
- }
+ this.trackBackdrop();
+ },
+
+ /**
+ * @param {!Element} overlay
+ */
+ removeOverlay: function(overlay) {
+ var i = this._overlays.indexOf(overlay);
+ if (i === -1) {
+ return;
}
+ this._overlays.splice(i, 1);
+ this.trackBackdrop();
},
/**
- * Can be used to determine that elements implement this behavior.
+ * Returns the current overlay.
+ * @return {Element|undefined}
*/
- isNeonAnimation: true,
+ currentOverlay: function() {
+ var i = this._overlays.length - 1;
+ return this._overlays[i];
+ },
/**
- * Do any animation configuration here.
+ * Returns the current overlay z-index.
+ * @return {number}
*/
- // configure: function(config) {
- // },
+ currentOverlayZ: function() {
+ return this._getZ(this.currentOverlay());
+ },
/**
- * Returns the animation timing by mixing in properties from `config` to the defaults defined
- * by the animation.
+ * Ensures that the minimum z-index of new overlays is at least `minimumZ`.
+ * This does not effect the z-index of any existing overlays.
+ * @param {number} minimumZ
*/
- timingFromConfig: function(config) {
- if (config.timing) {
- for (var property in config.timing) {
- this.animationTiming[property] = config.timing[property];
- }
+ ensureMinimumZ: function(minimumZ) {
+ this._minimumZ = Math.max(this._minimumZ, minimumZ);
+ },
+
+ focusOverlay: function() {
+ var current = /** @type {?} */ (this.currentOverlay());
+ if (current) {
+ current._applyFocus();
}
- return this.animationTiming;
},
/**
- * Sets `transform` and `transformOrigin` properties along with the prefixed versions.
+ * Updates the backdrop z-index.
*/
- setPrefixedProperty: function(node, property, value) {
- var map = {
- 'transform': ['webkitTransform'],
- 'transformOrigin': ['mozTransformOrigin', 'webkitTransformOrigin']
- };
- var prefixes = map[property];
- for (var prefix, index = 0; prefix = prefixes[index]; index++) {
- node.style[prefix] = value;
+ trackBackdrop: function() {
+ var overlay = this._overlayWithBackdrop();
+ // Avoid creating the backdrop if there is no overlay with backdrop.
+ if (!overlay && !this._backdropElement) {
+ return;
}
- node.style[property] = value;
+ this.backdropElement.style.zIndex = this._getZ(overlay) - 1;
+ this.backdropElement.opened = !!overlay;
},
/**
- * Called when the animation finishes.
+ * @return {Array<Element>}
*/
- complete: function() {}
-
- };
-Polymer({
-
- is: 'opaque-animation',
-
- behaviors: [
- Polymer.NeonAnimationBehavior
- ],
-
- configure: function(config) {
- var node = config.node;
- this._effect = new KeyframeEffect(node, [
- {'opacity': '1'},
- {'opacity': '1'}
- ], this.timingFromConfig(config));
- node.style.opacity = '0';
- return this._effect;
+ getBackdrops: function() {
+ var backdrops = [];
+ for (var i = 0; i < this._overlays.length; i++) {
+ if (this._overlays[i].withBackdrop) {
+ backdrops.push(this._overlays[i]);
+ }
+ }
+ return backdrops;
},
- complete: function(config) {
- config.node.style.opacity = '';
- }
-
- });
-(function() {
- 'use strict';
- // Used to calculate the scroll direction during touch events.
- var LAST_TOUCH_POSITION = {
- pageX: 0,
- pageY: 0
- };
- // Used to avoid computing event.path and filter scrollable nodes (better perf).
- var ROOT_TARGET = null;
- var SCROLLABLE_NODES = [];
-
/**
- * The IronDropdownScrollManager is intended to provide a central source
- * of authority and control over which elements in a document are currently
- * allowed to scroll.
+ * Returns the z-index for the backdrop.
+ * @return {number}
*/
+ backdropZ: function() {
+ return this._getZ(this._overlayWithBackdrop()) - 1;
+ },
- Polymer.IronDropdownScrollManager = {
+ /**
+ * Returns the first opened overlay that has a backdrop.
+ * @return {Element|undefined}
+ * @private
+ */
+ _overlayWithBackdrop: function() {
+ for (var i = 0; i < this._overlays.length; i++) {
+ if (this._overlays[i].withBackdrop) {
+ return this._overlays[i];
+ }
+ }
+ },
- /**
- * The current element that defines the DOM boundaries of the
- * scroll lock. This is always the most recently locking element.
- */
- get currentLockingElement() {
- return this._lockingElements[this._lockingElements.length - 1];
- },
-
- /**
- * Returns true if the provided element is "scroll locked", which is to
- * say that it cannot be scrolled via pointer or keyboard interactions.
- *
- * @param {HTMLElement} element An HTML element instance which may or may
- * not be scroll locked.
- */
- elementIsScrollLocked: function(element) {
- var currentLockingElement = this.currentLockingElement;
-
- if (currentLockingElement === undefined)
- return false;
-
- var scrollLocked;
-
- if (this._hasCachedLockedElement(element)) {
- return true;
+ /**
+ * Calculates the minimum z-index for the overlay.
+ * @param {Element=} overlay
+ * @private
+ */
+ _getZ: function(overlay) {
+ var z = this._minimumZ;
+ if (overlay) {
+ var z1 = Number(overlay.style.zIndex || window.getComputedStyle(overlay).zIndex);
+ // Check if is a number
+ // Number.isNaN not supported in IE 10+
+ if (z1 === z1) {
+ z = z1;
}
+ }
+ return z;
+ },
- if (this._hasCachedUnlockedElement(element)) {
- return false;
- }
+ /**
+ * @param {!Element} element
+ * @param {number|string} z
+ * @private
+ */
+ _setZ: function(element, z) {
+ element.style.zIndex = z;
+ },
- scrollLocked = !!currentLockingElement &&
- currentLockingElement !== element &&
- !this._composedTreeContains(currentLockingElement, element);
+ /**
+ * @param {!Element} overlay
+ * @param {number} aboveZ
+ * @private
+ */
+ _applyOverlayZ: function(overlay, aboveZ) {
+ this._setZ(overlay, aboveZ + 2);
+ },
- if (scrollLocked) {
- this._lockedElementCache.push(element);
- } else {
- this._unlockedElementCache.push(element);
+ /**
+ * Returns the deepest overlay in the path.
+ * @param {Array<Element>=} path
+ * @return {Element|undefined}
+ * @suppress {missingProperties}
+ * @private
+ */
+ _overlayInPath: function(path) {
+ path = path || [];
+ for (var i = 0; i < path.length; i++) {
+ if (path[i]._manager === this) {
+ return path[i];
}
+ }
+ },
- return scrollLocked;
- },
+ /**
+ * Ensures the click event is delegated to the right overlay.
+ * @param {!Event} event
+ * @private
+ */
+ _onCaptureClick: function(event) {
+ var overlay = /** @type {?} */ (this.currentOverlay());
+ // Check if clicked outside of top overlay.
+ if (overlay && this._overlayInPath(Polymer.dom(event).path) !== overlay) {
+ overlay._onCaptureClick(event);
+ }
+ },
- /**
- * Push an element onto the current scroll lock stack. The most recently
- * pushed element and its children will be considered scrollable. All
- * other elements will not be scrollable.
- *
- * Scroll locking is implemented as a stack so that cases such as
- * dropdowns within dropdowns are handled well.
- *
- * @param {HTMLElement} element The element that should lock scroll.
- */
- pushScrollLock: function(element) {
- // Prevent pushing the same element twice
- if (this._lockingElements.indexOf(element) >= 0) {
- return;
- }
+ /**
+ * Ensures the focus event is delegated to the right overlay.
+ * @param {!Event} event
+ * @private
+ */
+ _onCaptureFocus: function(event) {
+ var overlay = /** @type {?} */ (this.currentOverlay());
+ if (overlay) {
+ overlay._onCaptureFocus(event);
+ }
+ },
- if (this._lockingElements.length === 0) {
- this._lockScrollInteractions();
+ /**
+ * Ensures TAB and ESC keyboard events are delegated to the right overlay.
+ * @param {!Event} event
+ * @private
+ */
+ _onCaptureKeyDown: function(event) {
+ var overlay = /** @type {?} */ (this.currentOverlay());
+ if (overlay) {
+ if (Polymer.IronA11yKeysBehavior.keyboardEventMatchesKeys(event, 'esc')) {
+ overlay._onCaptureEsc(event);
+ } else if (Polymer.IronA11yKeysBehavior.keyboardEventMatchesKeys(event, 'tab')) {
+ overlay._onCaptureTab(event);
}
+ }
+ },
- this._lockingElements.push(element);
-
- this._lockedElementCache = [];
- this._unlockedElementCache = [];
- },
-
- /**
- * Remove an element from the scroll lock stack. The element being
- * removed does not need to be the most recently pushed element. However,
- * the scroll lock constraints only change when the most recently pushed
- * element is removed.
- *
- * @param {HTMLElement} element The element to remove from the scroll
- * lock stack.
- */
- removeScrollLock: function(element) {
- var index = this._lockingElements.indexOf(element);
-
- if (index === -1) {
- return;
- }
+ /**
+ * Returns if the overlay1 should be behind overlay2.
+ * @param {!Element} overlay1
+ * @param {!Element} overlay2
+ * @return {boolean}
+ * @suppress {missingProperties}
+ * @private
+ */
+ _shouldBeBehindOverlay: function(overlay1, overlay2) {
+ return !overlay1.alwaysOnTop && overlay2.alwaysOnTop;
+ }
+ };
- this._lockingElements.splice(index, 1);
+ Polymer.IronOverlayManager = new Polymer.IronOverlayManagerClass();
+(function() {
+'use strict';
- this._lockedElementCache = [];
- this._unlockedElementCache = [];
+/**
+Use `Polymer.IronOverlayBehavior` to implement an element that can be hidden or shown, and displays
+on top of other content. It includes an optional backdrop, and can be used to implement a variety
+of UI controls including dialogs and drop downs. Multiple overlays may be displayed at once.
- if (this._lockingElements.length === 0) {
- this._unlockScrollInteractions();
- }
- },
+See the [demo source code](https://github.com/PolymerElements/iron-overlay-behavior/blob/master/demo/simple-overlay.html)
+for an example.
- _lockingElements: [],
+### Closing and canceling
- _lockedElementCache: null,
+An overlay may be hidden by closing or canceling. The difference between close and cancel is user
+intent. Closing generally implies that the user acknowledged the content on the overlay. By default,
+it will cancel whenever the user taps outside it or presses the escape key. This behavior is
+configurable with the `no-cancel-on-esc-key` and the `no-cancel-on-outside-click` properties.
+`close()` should be called explicitly by the implementer when the user interacts with a control
+in the overlay element. When the dialog is canceled, the overlay fires an 'iron-overlay-canceled'
+event. Call `preventDefault` on this event to prevent the overlay from closing.
- _unlockedElementCache: null,
+### Positioning
- _hasCachedLockedElement: function(element) {
- return this._lockedElementCache.indexOf(element) > -1;
- },
+By default the element is sized and positioned to fit and centered inside the window. You can
+position and size it manually using CSS. See `Polymer.IronFitBehavior`.
- _hasCachedUnlockedElement: function(element) {
- return this._unlockedElementCache.indexOf(element) > -1;
- },
+### Backdrop
- _composedTreeContains: function(element, child) {
- // NOTE(cdata): This method iterates over content elements and their
- // corresponding distributed nodes to implement a contains-like method
- // that pierces through the composed tree of the ShadowDOM. Results of
- // this operation are cached (elsewhere) on a per-scroll-lock basis, to
- // guard against potentially expensive lookups happening repeatedly as
- // a user scrolls / touchmoves.
- var contentElements;
- var distributedNodes;
- var contentIndex;
- var nodeIndex;
+Set the `with-backdrop` attribute to display a backdrop behind the overlay. The backdrop is
+appended to `<body>` and is of type `<iron-overlay-backdrop>`. See its doc page for styling
+options.
- if (element.contains(child)) {
- return true;
- }
+In addition, `with-backdrop` will wrap the focus within the content in the light DOM.
+Override the [`_focusableNodes` getter](#Polymer.IronOverlayBehavior:property-_focusableNodes)
+to achieve a different behavior.
- contentElements = Polymer.dom(element).querySelectorAll('content');
+### Limitations
- for (contentIndex = 0; contentIndex < contentElements.length; ++contentIndex) {
+The element is styled to appear on top of other content by setting its `z-index` property. You
+must ensure no element has a stacking context with a higher `z-index` than its parent stacking
+context. You should place this element as a child of `<body>` whenever possible.
- distributedNodes = Polymer.dom(contentElements[contentIndex]).getDistributedNodes();
+@demo demo/index.html
+@polymerBehavior Polymer.IronOverlayBehavior
+*/
- for (nodeIndex = 0; nodeIndex < distributedNodes.length; ++nodeIndex) {
+ Polymer.IronOverlayBehaviorImpl = {
- if (this._composedTreeContains(distributedNodes[nodeIndex], child)) {
- return true;
- }
- }
- }
+ properties: {
- return false;
+ /**
+ * True if the overlay is currently displayed.
+ */
+ opened: {
+ observer: '_openedChanged',
+ type: Boolean,
+ value: false,
+ notify: true
},
- _scrollInteractionHandler: function(event) {
- // Avoid canceling an event with cancelable=false, e.g. scrolling is in
- // progress and cannot be interrupted.
- if (event.cancelable && this._shouldPreventScrolling(event)) {
- event.preventDefault();
- }
- // If event has targetTouches (touch event), update last touch position.
- if (event.targetTouches) {
- var touch = event.targetTouches[0];
- LAST_TOUCH_POSITION.pageX = touch.pageX;
- LAST_TOUCH_POSITION.pageY = touch.pageY;
- }
+ /**
+ * True if the overlay was canceled when it was last closed.
+ */
+ canceled: {
+ observer: '_canceledChanged',
+ readOnly: true,
+ type: Boolean,
+ value: false
},
- _lockScrollInteractions: function() {
- this._boundScrollHandler = this._boundScrollHandler ||
- this._scrollInteractionHandler.bind(this);
- // Modern `wheel` event for mouse wheel scrolling:
- document.addEventListener('wheel', this._boundScrollHandler, true);
- // Older, non-standard `mousewheel` event for some FF:
- document.addEventListener('mousewheel', this._boundScrollHandler, true);
- // IE:
- document.addEventListener('DOMMouseScroll', this._boundScrollHandler, true);
- // Save the SCROLLABLE_NODES on touchstart, to be used on touchmove.
- document.addEventListener('touchstart', this._boundScrollHandler, true);
- // Mobile devices can scroll on touch move:
- document.addEventListener('touchmove', this._boundScrollHandler, true);
+ /**
+ * Set to true to display a backdrop behind the overlay. It traps the focus
+ * within the light DOM of the overlay.
+ */
+ withBackdrop: {
+ observer: '_withBackdropChanged',
+ type: Boolean
},
- _unlockScrollInteractions: function() {
- document.removeEventListener('wheel', this._boundScrollHandler, true);
- document.removeEventListener('mousewheel', this._boundScrollHandler, true);
- document.removeEventListener('DOMMouseScroll', this._boundScrollHandler, true);
- document.removeEventListener('touchstart', this._boundScrollHandler, true);
- document.removeEventListener('touchmove', this._boundScrollHandler, true);
+ /**
+ * Set to true to disable auto-focusing the overlay or child nodes with
+ * the `autofocus` attribute` when the overlay is opened.
+ */
+ noAutoFocus: {
+ type: Boolean,
+ value: false
},
/**
- * Returns true if the event causes scroll outside the current locking
- * element, e.g. pointer/keyboard interactions, or scroll "leaking"
- * outside the locking element when it is already at its scroll boundaries.
- * @param {!Event} event
- * @return {boolean}
- * @private
+ * Set to true to disable canceling the overlay with the ESC key.
*/
- _shouldPreventScrolling: function(event) {
+ noCancelOnEscKey: {
+ type: Boolean,
+ value: false
+ },
- // Update if root target changed. For touch events, ensure we don't
- // update during touchmove.
- var target = Polymer.dom(event).rootTarget;
- if (event.type !== 'touchmove' && ROOT_TARGET !== target) {
- ROOT_TARGET = target;
- SCROLLABLE_NODES = this._getScrollableNodes(Polymer.dom(event).path);
- }
+ /**
+ * Set to true to disable canceling the overlay by clicking outside it.
+ */
+ noCancelOnOutsideClick: {
+ type: Boolean,
+ value: false
+ },
- // Prevent event if no scrollable nodes.
- if (!SCROLLABLE_NODES.length) {
- return true;
- }
- // Don't prevent touchstart event inside the locking element when it has
- // scrollable nodes.
- if (event.type === 'touchstart') {
- return false;
- }
- // Get deltaX/Y.
- var info = this._getScrollInfo(event);
- // Prevent if there is no child that can scroll.
- return !this._getScrollingNode(SCROLLABLE_NODES, info.deltaX, info.deltaY);
+ /**
+ * Contains the reason(s) this overlay was last closed (see `iron-overlay-closed`).
+ * `IronOverlayBehavior` provides the `canceled` reason; implementers of the
+ * behavior can provide other reasons in addition to `canceled`.
+ */
+ closingReason: {
+ // was a getter before, but needs to be a property so other
+ // behaviors can override this.
+ type: Object
},
/**
- * Returns an array of scrollable nodes up to the current locking element,
- * which is included too if scrollable.
- * @param {!Array<Node>} nodes
- * @return {Array<Node>} scrollables
- * @private
+ * Set to true to enable restoring of focus when overlay is closed.
*/
- _getScrollableNodes: function(nodes) {
- var scrollables = [];
- var lockingIndex = nodes.indexOf(this.currentLockingElement);
- // Loop from root target to locking element (included).
- for (var i = 0; i <= lockingIndex; i++) {
- var node = nodes[i];
- // Skip document fragments.
- if (node.nodeType === 11) {
- continue;
- }
- // Check inline style before checking computed style.
- var style = node.style;
- if (style.overflow !== 'scroll' && style.overflow !== 'auto') {
- style = window.getComputedStyle(node);
- }
- if (style.overflow === 'scroll' || style.overflow === 'auto') {
- scrollables.push(node);
- }
- }
- return scrollables;
+ restoreFocusOnClose: {
+ type: Boolean,
+ value: false
},
/**
- * Returns the node that is scrolling. If there is no scrolling,
- * returns undefined.
- * @param {!Array<Node>} nodes
- * @param {number} deltaX Scroll delta on the x-axis
- * @param {number} deltaY Scroll delta on the y-axis
- * @return {Node|undefined}
- * @private
+ * Set to true to keep overlay always on top.
*/
- _getScrollingNode: function(nodes, deltaX, deltaY) {
- // No scroll.
- if (!deltaX && !deltaY) {
- return;
- }
- // Check only one axis according to where there is more scroll.
- // Prefer vertical to horizontal.
- var verticalScroll = Math.abs(deltaY) >= Math.abs(deltaX);
- for (var i = 0; i < nodes.length; i++) {
- var node = nodes[i];
- var canScroll = false;
- if (verticalScroll) {
- // delta < 0 is scroll up, delta > 0 is scroll down.
- canScroll = deltaY < 0 ? node.scrollTop > 0 :
- node.scrollTop < node.scrollHeight - node.clientHeight;
- } else {
- // delta < 0 is scroll left, delta > 0 is scroll right.
- canScroll = deltaX < 0 ? node.scrollLeft > 0 :
- node.scrollLeft < node.scrollWidth - node.clientWidth;
- }
- if (canScroll) {
- return node;
- }
- }
+ alwaysOnTop: {
+ type: Boolean
},
/**
- * Returns scroll `deltaX` and `deltaY`.
- * @param {!Event} event The scroll event
- * @return {{
- * deltaX: number The x-axis scroll delta (positive: scroll right,
- * negative: scroll left, 0: no scroll),
- * deltaY: number The y-axis scroll delta (positive: scroll down,
- * negative: scroll up, 0: no scroll)
- * }} info
+ * Shortcut to access to the overlay manager.
* @private
+ * @type {Polymer.IronOverlayManagerClass}
*/
- _getScrollInfo: function(event) {
- var info = {
- deltaX: event.deltaX,
- deltaY: event.deltaY
- };
- // Already available.
- if ('deltaX' in event) {
- // do nothing, values are already good.
- }
- // Safari has scroll info in `wheelDeltaX/Y`.
- else if ('wheelDeltaX' in event) {
- info.deltaX = -event.wheelDeltaX;
- info.deltaY = -event.wheelDeltaY;
- }
- // Firefox has scroll info in `detail` and `axis`.
- else if ('axis' in event) {
- info.deltaX = event.axis === 1 ? event.detail : 0;
- info.deltaY = event.axis === 2 ? event.detail : 0;
- }
- // On mobile devices, calculate scroll direction.
- else if (event.targetTouches) {
- var touch = event.targetTouches[0];
- // Touch moves from right to left => scrolling goes right.
- info.deltaX = LAST_TOUCH_POSITION.pageX - touch.pageX;
- // Touch moves from down to up => scrolling goes down.
- info.deltaY = LAST_TOUCH_POSITION.pageY - touch.pageY;
- }
- return info;
- }
- };
- })();
-(function() {
- 'use strict';
+ _manager: {
+ type: Object,
+ value: Polymer.IronOverlayManager
+ },
- Polymer({
- is: 'iron-dropdown',
+ /**
+ * The node being focused.
+ * @type {?Node}
+ */
+ _focusedChild: {
+ type: Object
+ }
- behaviors: [
- Polymer.IronControlState,
- Polymer.IronA11yKeysBehavior,
- Polymer.IronOverlayBehavior,
- Polymer.NeonAnimationRunnerBehavior
- ],
+ },
- properties: {
- /**
- * The orientation against which to align the dropdown content
- * horizontally relative to the dropdown trigger.
- * Overridden from `Polymer.IronFitBehavior`.
- */
- horizontalAlign: {
- type: String,
- value: 'left',
- reflectToAttribute: true
- },
+ listeners: {
+ 'iron-resize': '_onIronResize'
+ },
- /**
- * The orientation against which to align the dropdown content
- * vertically relative to the dropdown trigger.
- * Overridden from `Polymer.IronFitBehavior`.
- */
- verticalAlign: {
- type: String,
- value: 'top',
- reflectToAttribute: true
- },
+ /**
+ * The backdrop element.
+ * @type {Element}
+ */
+ get backdropElement() {
+ return this._manager.backdropElement;
+ },
- /**
- * An animation config. If provided, this will be used to animate the
+ /**
+ * Returns the node to give focus to.
+ * @type {Node}
+ */
+ get _focusNode() {
+ return this._focusedChild || Polymer.dom(this).querySelector('[autofocus]') || this;
+ },
+
+ /**
+ * Array of nodes that can receive focus (overlay included), ordered by `tabindex`.
+ * This is used to retrieve which is the first and last focusable nodes in order
+ * to wrap the focus for overlays `with-backdrop`.
+ *
+ * If you know what is your content (specifically the first and last focusable children),
+ * you can override this method to return only `[firstFocusable, lastFocusable];`
+ * @type {Array<Node>}
+ * @protected
+ */
+ get _focusableNodes() {
+ // Elements that can be focused even if they have [disabled] attribute.
+ var FOCUSABLE_WITH_DISABLED = [
+ 'a[href]',
+ 'area[href]',
+ 'iframe',
+ '[tabindex]',
+ '[contentEditable=true]'
+ ];
+
+ // Elements that cannot be focused if they have [disabled] attribute.
+ var FOCUSABLE_WITHOUT_DISABLED = [
+ 'input',
+ 'select',
+ 'textarea',
+ 'button'
+ ];
+
+ // Discard elements with tabindex=-1 (makes them not focusable).
+ var selector = FOCUSABLE_WITH_DISABLED.join(':not([tabindex="-1"]),') +
+ ':not([tabindex="-1"]),' +
+ FOCUSABLE_WITHOUT_DISABLED.join(':not([disabled]):not([tabindex="-1"]),') +
+ ':not([disabled]):not([tabindex="-1"])';
+
+ var focusables = Polymer.dom(this).querySelectorAll(selector);
+ if (this.tabIndex >= 0) {
+ // Insert at the beginning because we might have all elements with tabIndex = 0,
+ // and the overlay should be the first of the list.
+ focusables.splice(0, 0, this);
+ }
+ // Sort by tabindex.
+ return focusables.sort(function (a, b) {
+ if (a.tabIndex === b.tabIndex) {
+ return 0;
+ }
+ if (a.tabIndex === 0 || a.tabIndex > b.tabIndex) {
+ return 1;
+ }
+ return -1;
+ });
+ },
+
+ ready: function() {
+ // Used to skip calls to notifyResize and refit while the overlay is animating.
+ this.__isAnimating = false;
+ // with-backdrop needs tabindex to be set in order to trap the focus.
+ // If it is not set, IronOverlayBehavior will set it, and remove it if with-backdrop = false.
+ this.__shouldRemoveTabIndex = false;
+ // Used for wrapping the focus on TAB / Shift+TAB.
+ this.__firstFocusableNode = this.__lastFocusableNode = null;
+ // Used by __onNextAnimationFrame to cancel any previous callback.
+ this.__raf = null;
+ // Focused node before overlay gets opened. Can be restored on close.
+ this.__restoreFocusNode = null;
+ this._ensureSetup();
+ },
+
+ attached: function() {
+ // Call _openedChanged here so that position can be computed correctly.
+ if (this.opened) {
+ this._openedChanged(this.opened);
+ }
+ this._observer = Polymer.dom(this).observeNodes(this._onNodesChange);
+ },
+
+ detached: function() {
+ Polymer.dom(this).unobserveNodes(this._observer);
+ this._observer = null;
+ if (this.__raf) {
+ window.cancelAnimationFrame(this.__raf);
+ this.__raf = null;
+ }
+ this._manager.removeOverlay(this);
+ },
+
+ /**
+ * Toggle the opened state of the overlay.
+ */
+ toggle: function() {
+ this._setCanceled(false);
+ this.opened = !this.opened;
+ },
+
+ /**
+ * Open the overlay.
+ */
+ open: function() {
+ this._setCanceled(false);
+ this.opened = true;
+ },
+
+ /**
+ * Close the overlay.
+ */
+ close: function() {
+ this._setCanceled(false);
+ this.opened = false;
+ },
+
+ /**
+ * Cancels the overlay.
+ * @param {Event=} event The original event
+ */
+ cancel: function(event) {
+ var cancelEvent = this.fire('iron-overlay-canceled', event, {cancelable: true});
+ if (cancelEvent.defaultPrevented) {
+ return;
+ }
+
+ this._setCanceled(true);
+ this.opened = false;
+ },
+
+ _ensureSetup: function() {
+ if (this._overlaySetup) {
+ return;
+ }
+ this._overlaySetup = true;
+ this.style.outline = 'none';
+ this.style.display = 'none';
+ },
+
+ /**
+ * Called when `opened` changes.
+ * @param {boolean=} opened
+ * @protected
+ */
+ _openedChanged: function(opened) {
+ if (opened) {
+ this.removeAttribute('aria-hidden');
+ } else {
+ this.setAttribute('aria-hidden', 'true');
+ }
+
+ // Defer any animation-related code on attached
+ // (_openedChanged gets called again on attached).
+ if (!this.isAttached) {
+ return;
+ }
+
+ this.__isAnimating = true;
+
+ // Use requestAnimationFrame for non-blocking rendering.
+ this.__onNextAnimationFrame(this.__openedChanged);
+ },
+
+ _canceledChanged: function() {
+ this.closingReason = this.closingReason || {};
+ this.closingReason.canceled = this.canceled;
+ },
+
+ _withBackdropChanged: function() {
+ // If tabindex is already set, no need to override it.
+ if (this.withBackdrop && !this.hasAttribute('tabindex')) {
+ this.setAttribute('tabindex', '-1');
+ this.__shouldRemoveTabIndex = true;
+ } else if (this.__shouldRemoveTabIndex) {
+ this.removeAttribute('tabindex');
+ this.__shouldRemoveTabIndex = false;
+ }
+ if (this.opened && this.isAttached) {
+ this._manager.trackBackdrop();
+ }
+ },
+
+ /**
+ * tasks which must occur before opening; e.g. making the element visible.
+ * @protected
+ */
+ _prepareRenderOpened: function() {
+ // Store focused node.
+ this.__restoreFocusNode = this._manager.deepActiveElement;
+
+ // Needed to calculate the size of the overlay so that transitions on its size
+ // will have the correct starting points.
+ this._preparePositioning();
+ this.refit();
+ this._finishPositioning();
+
+ // Safari will apply the focus to the autofocus element when displayed
+ // for the first time, so we make sure to return the focus where it was.
+ if (this.noAutoFocus && document.activeElement === this._focusNode) {
+ this._focusNode.blur();
+ this.__restoreFocusNode.focus();
+ }
+ },
+
+ /**
+ * Tasks which cause the overlay to actually open; typically play an animation.
+ * @protected
+ */
+ _renderOpened: function() {
+ this._finishRenderOpened();
+ },
+
+ /**
+ * Tasks which cause the overlay to actually close; typically play an animation.
+ * @protected
+ */
+ _renderClosed: function() {
+ this._finishRenderClosed();
+ },
+
+ /**
+ * Tasks to be performed at the end of open action. Will fire `iron-overlay-opened`.
+ * @protected
+ */
+ _finishRenderOpened: function() {
+ this.notifyResize();
+ this.__isAnimating = false;
+
+ // Store it so we don't query too much.
+ var focusableNodes = this._focusableNodes;
+ this.__firstFocusableNode = focusableNodes[0];
+ this.__lastFocusableNode = focusableNodes[focusableNodes.length - 1];
+
+ this.fire('iron-overlay-opened');
+ },
+
+ /**
+ * Tasks to be performed at the end of close action. Will fire `iron-overlay-closed`.
+ * @protected
+ */
+ _finishRenderClosed: function() {
+ // Hide the overlay.
+ this.style.display = 'none';
+ // Reset z-index only at the end of the animation.
+ this.style.zIndex = '';
+ this.notifyResize();
+ this.__isAnimating = false;
+ this.fire('iron-overlay-closed', this.closingReason);
+ },
+
+ _preparePositioning: function() {
+ this.style.transition = this.style.webkitTransition = 'none';
+ this.style.transform = this.style.webkitTransform = 'none';
+ this.style.display = '';
+ },
+
+ _finishPositioning: function() {
+ // First, make it invisible & reactivate animations.
+ this.style.display = 'none';
+ // Force reflow before re-enabling animations so that they don't start.
+ // Set scrollTop to itself so that Closure Compiler doesn't remove this.
+ this.scrollTop = this.scrollTop;
+ this.style.transition = this.style.webkitTransition = '';
+ this.style.transform = this.style.webkitTransform = '';
+ // Now that animations are enabled, make it visible again
+ this.style.display = '';
+ // Force reflow, so that following animations are properly started.
+ // Set scrollTop to itself so that Closure Compiler doesn't remove this.
+ this.scrollTop = this.scrollTop;
+ },
+
+ /**
+ * Applies focus according to the opened state.
+ * @protected
+ */
+ _applyFocus: function() {
+ if (this.opened) {
+ if (!this.noAutoFocus) {
+ this._focusNode.focus();
+ }
+ }
+ else {
+ this._focusNode.blur();
+ this._focusedChild = null;
+ // Restore focus.
+ if (this.restoreFocusOnClose && this.__restoreFocusNode) {
+ this.__restoreFocusNode.focus();
+ }
+ this.__restoreFocusNode = null;
+ // If many overlays get closed at the same time, one of them would still
+ // be the currentOverlay even if already closed, and would call _applyFocus
+ // infinitely, so we check for this not to be the current overlay.
+ var currentOverlay = this._manager.currentOverlay();
+ if (currentOverlay && this !== currentOverlay) {
+ currentOverlay._applyFocus();
+ }
+ }
+ },
+
+ /**
+ * Cancels (closes) the overlay. Call when click happens outside the overlay.
+ * @param {!Event} event
+ * @protected
+ */
+ _onCaptureClick: function(event) {
+ if (!this.noCancelOnOutsideClick) {
+ this.cancel(event);
+ }
+ },
+
+ /**
+ * Keeps track of the focused child. If withBackdrop, traps focus within overlay.
+ * @param {!Event} event
+ * @protected
+ */
+ _onCaptureFocus: function (event) {
+ if (!this.withBackdrop) {
+ return;
+ }
+ var path = Polymer.dom(event).path;
+ if (path.indexOf(this) === -1) {
+ event.stopPropagation();
+ this._applyFocus();
+ } else {
+ this._focusedChild = path[0];
+ }
+ },
+
+ /**
+ * Handles the ESC key event and cancels (closes) the overlay.
+ * @param {!Event} event
+ * @protected
+ */
+ _onCaptureEsc: function(event) {
+ if (!this.noCancelOnEscKey) {
+ this.cancel(event);
+ }
+ },
+
+ /**
+ * Handles TAB key events to track focus changes.
+ * Will wrap focus for overlays withBackdrop.
+ * @param {!Event} event
+ * @protected
+ */
+ _onCaptureTab: function(event) {
+ if (!this.withBackdrop) {
+ return;
+ }
+ // TAB wraps from last to first focusable.
+ // Shift + TAB wraps from first to last focusable.
+ var shift = event.shiftKey;
+ var nodeToCheck = shift ? this.__firstFocusableNode : this.__lastFocusableNode;
+ var nodeToSet = shift ? this.__lastFocusableNode : this.__firstFocusableNode;
+ var shouldWrap = false;
+ if (nodeToCheck === nodeToSet) {
+ // If nodeToCheck is the same as nodeToSet, it means we have an overlay
+ // with 0 or 1 focusables; in either case we still need to trap the
+ // focus within the overlay.
+ shouldWrap = true;
+ } else {
+ // In dom=shadow, the manager will receive focus changes on the main
+ // root but not the ones within other shadow roots, so we can't rely on
+ // _focusedChild, but we should check the deepest active element.
+ var focusedNode = this._manager.deepActiveElement;
+ // If the active element is not the nodeToCheck but the overlay itself,
+ // it means the focus is about to go outside the overlay, hence we
+ // should prevent that (e.g. user opens the overlay and hit Shift+TAB).
+ shouldWrap = (focusedNode === nodeToCheck || focusedNode === this);
+ }
+
+ if (shouldWrap) {
+ // When the overlay contains the last focusable element of the document
+ // and it's already focused, pressing TAB would move the focus outside
+ // the document (e.g. to the browser search bar). Similarly, when the
+ // overlay contains the first focusable element of the document and it's
+ // already focused, pressing Shift+TAB would move the focus outside the
+ // document (e.g. to the browser search bar).
+ // In both cases, we would not receive a focus event, but only a blur.
+ // In order to achieve focus wrapping, we prevent this TAB event and
+ // force the focus. This will also prevent the focus to temporarily move
+ // outside the overlay, which might cause scrolling.
+ event.preventDefault();
+ this._focusedChild = nodeToSet;
+ this._applyFocus();
+ }
+ },
+
+ /**
+ * Refits if the overlay is opened and not animating.
+ * @protected
+ */
+ _onIronResize: function() {
+ if (this.opened && !this.__isAnimating) {
+ this.__onNextAnimationFrame(this.refit);
+ }
+ },
+
+ /**
+ * Will call notifyResize if overlay is opened.
+ * Can be overridden in order to avoid multiple observers on the same node.
+ * @protected
+ */
+ _onNodesChange: function() {
+ if (this.opened && !this.__isAnimating) {
+ this.notifyResize();
+ }
+ },
+
+ /**
+ * Tasks executed when opened changes: prepare for the opening, move the
+ * focus, update the manager, render opened/closed.
+ * @private
+ */
+ __openedChanged: function() {
+ if (this.opened) {
+ // Make overlay visible, then add it to the manager.
+ this._prepareRenderOpened();
+ this._manager.addOverlay(this);
+ // Move the focus to the child node with [autofocus].
+ this._applyFocus();
+
+ this._renderOpened();
+ } else {
+ // Remove overlay, then restore the focus before actually closing.
+ this._manager.removeOverlay(this);
+ this._applyFocus();
+
+ this._renderClosed();
+ }
+ },
+
+ /**
+ * Executes a callback on the next animation frame, overriding any previous
+ * callback awaiting for the next animation frame. e.g.
+ * `__onNextAnimationFrame(callback1) && __onNextAnimationFrame(callback2)`;
+ * `callback1` will never be invoked.
+ * @param {!Function} callback Its `this` parameter is the overlay itself.
+ * @private
+ */
+ __onNextAnimationFrame: function(callback) {
+ if (this.__raf) {
+ window.cancelAnimationFrame(this.__raf);
+ }
+ var self = this;
+ this.__raf = window.requestAnimationFrame(function nextAnimationFrame() {
+ self.__raf = null;
+ callback.call(self);
+ });
+ }
+
+ };
+
+ /** @polymerBehavior */
+ Polymer.IronOverlayBehavior = [Polymer.IronFitBehavior, Polymer.IronResizableBehavior, Polymer.IronOverlayBehaviorImpl];
+
+ /**
+ * Fired after the overlay opens.
+ * @event iron-overlay-opened
+ */
+
+ /**
+ * Fired when the overlay is canceled, but before it is closed.
+ * @event iron-overlay-canceled
+ * @param {Event} event The closing of the overlay can be prevented
+ * by calling `event.preventDefault()`. The `event.detail` is the original event that
+ * originated the canceling (e.g. ESC keyboard event or click event outside the overlay).
+ */
+
+ /**
+ * Fired after the overlay closes.
+ * @event iron-overlay-closed
+ * @param {Event} event The `event.detail` is the `closingReason` property
+ * (contains `canceled`, whether the overlay was canceled).
+ */
+
+})();
+/**
+ * `Polymer.NeonAnimatableBehavior` is implemented by elements containing animations for use with
+ * elements implementing `Polymer.NeonAnimationRunnerBehavior`.
+ * @polymerBehavior
+ */
+ Polymer.NeonAnimatableBehavior = {
+
+ properties: {
+
+ /**
+ * Animation configuration. See README for more info.
+ */
+ animationConfig: {
+ type: Object
+ },
+
+ /**
+ * Convenience property for setting an 'entry' animation. Do not set `animationConfig.entry`
+ * manually if using this. The animated node is set to `this` if using this property.
+ */
+ entryAnimation: {
+ observer: '_entryAnimationChanged',
+ type: String
+ },
+
+ /**
+ * Convenience property for setting an 'exit' animation. Do not set `animationConfig.exit`
+ * manually if using this. The animated node is set to `this` if using this property.
+ */
+ exitAnimation: {
+ observer: '_exitAnimationChanged',
+ type: String
+ }
+
+ },
+
+ _entryAnimationChanged: function() {
+ this.animationConfig = this.animationConfig || {};
+ this.animationConfig['entry'] = [{
+ name: this.entryAnimation,
+ node: this
+ }];
+ },
+
+ _exitAnimationChanged: function() {
+ this.animationConfig = this.animationConfig || {};
+ this.animationConfig['exit'] = [{
+ name: this.exitAnimation,
+ node: this
+ }];
+ },
+
+ _copyProperties: function(config1, config2) {
+ // shallowly copy properties from config2 to config1
+ for (var property in config2) {
+ config1[property] = config2[property];
+ }
+ },
+
+ _cloneConfig: function(config) {
+ var clone = {
+ isClone: true
+ };
+ this._copyProperties(clone, config);
+ return clone;
+ },
+
+ _getAnimationConfigRecursive: function(type, map, allConfigs) {
+ if (!this.animationConfig) {
+ return;
+ }
+
+ if(this.animationConfig.value && typeof this.animationConfig.value === 'function') {
+ this._warn(this._logf('playAnimation', "Please put 'animationConfig' inside of your components 'properties' object instead of outside of it."));
+ return;
+ }
+
+ // type is optional
+ var thisConfig;
+ if (type) {
+ thisConfig = this.animationConfig[type];
+ } else {
+ thisConfig = this.animationConfig;
+ }
+
+ if (!Array.isArray(thisConfig)) {
+ thisConfig = [thisConfig];
+ }
+
+ // iterate animations and recurse to process configurations from child nodes
+ if (thisConfig) {
+ for (var config, index = 0; config = thisConfig[index]; index++) {
+ if (config.animatable) {
+ config.animatable._getAnimationConfigRecursive(config.type || type, map, allConfigs);
+ } else {
+ if (config.id) {
+ var cachedConfig = map[config.id];
+ if (cachedConfig) {
+ // merge configurations with the same id, making a clone lazily
+ if (!cachedConfig.isClone) {
+ map[config.id] = this._cloneConfig(cachedConfig)
+ cachedConfig = map[config.id];
+ }
+ this._copyProperties(cachedConfig, config);
+ } else {
+ // put any configs with an id into a map
+ map[config.id] = config;
+ }
+ } else {
+ allConfigs.push(config);
+ }
+ }
+ }
+ }
+ },
+
+ /**
+ * An element implementing `Polymer.NeonAnimationRunnerBehavior` calls this method to configure
+ * an animation with an optional type. Elements implementing `Polymer.NeonAnimatableBehavior`
+ * should define the property `animationConfig`, which is either a configuration object
+ * or a map of animation type to array of configuration objects.
+ */
+ getAnimationConfig: function(type) {
+ var map = {};
+ var allConfigs = [];
+ this._getAnimationConfigRecursive(type, map, allConfigs);
+ // append the configurations saved in the map to the array
+ for (var key in map) {
+ allConfigs.push(map[key]);
+ }
+ return allConfigs;
+ }
+
+ };
+/**
+ * `Polymer.NeonAnimationRunnerBehavior` adds a method to run animations.
+ *
+ * @polymerBehavior Polymer.NeonAnimationRunnerBehavior
+ */
+ Polymer.NeonAnimationRunnerBehaviorImpl = {
+
+ _configureAnimations: function(configs) {
+ var results = [];
+ if (configs.length > 0) {
+ for (var config, index = 0; config = configs[index]; index++) {
+ var neonAnimation = document.createElement(config.name);
+ // is this element actually a neon animation?
+ if (neonAnimation.isNeonAnimation) {
+ var result = null;
+ // configuration or play could fail if polyfills aren't loaded
+ try {
+ result = neonAnimation.configure(config);
+ // Check if we have an Effect rather than an Animation
+ if (typeof result.cancel != 'function') {
+ result = document.timeline.play(result);
+ }
+ } catch (e) {
+ result = null;
+ console.warn('Couldnt play', '(', config.name, ').', e);
+ }
+ if (result) {
+ results.push({
+ neonAnimation: neonAnimation,
+ config: config,
+ animation: result,
+ });
+ }
+ } else {
+ console.warn(this.is + ':', config.name, 'not found!');
+ }
+ }
+ }
+ return results;
+ },
+
+ _shouldComplete: function(activeEntries) {
+ var finished = true;
+ for (var i = 0; i < activeEntries.length; i++) {
+ if (activeEntries[i].animation.playState != 'finished') {
+ finished = false;
+ break;
+ }
+ }
+ return finished;
+ },
+
+ _complete: function(activeEntries) {
+ for (var i = 0; i < activeEntries.length; i++) {
+ activeEntries[i].neonAnimation.complete(activeEntries[i].config);
+ }
+ for (var i = 0; i < activeEntries.length; i++) {
+ activeEntries[i].animation.cancel();
+ }
+ },
+
+ /**
+ * Plays an animation with an optional `type`.
+ * @param {string=} type
+ * @param {!Object=} cookie
+ */
+ playAnimation: function(type, cookie) {
+ var configs = this.getAnimationConfig(type);
+ if (!configs) {
+ return;
+ }
+ this._active = this._active || {};
+ if (this._active[type]) {
+ this._complete(this._active[type]);
+ delete this._active[type];
+ }
+
+ var activeEntries = this._configureAnimations(configs);
+
+ if (activeEntries.length == 0) {
+ this.fire('neon-animation-finish', cookie, {bubbles: false});
+ return;
+ }
+
+ this._active[type] = activeEntries;
+
+ for (var i = 0; i < activeEntries.length; i++) {
+ activeEntries[i].animation.onfinish = function() {
+ if (this._shouldComplete(activeEntries)) {
+ this._complete(activeEntries);
+ delete this._active[type];
+ this.fire('neon-animation-finish', cookie, {bubbles: false});
+ }
+ }.bind(this);
+ }
+ },
+
+ /**
+ * Cancels the currently running animations.
+ */
+ cancelAnimation: function() {
+ for (var k in this._animations) {
+ this._animations[k].cancel();
+ }
+ this._animations = {};
+ }
+ };
+
+ /** @polymerBehavior Polymer.NeonAnimationRunnerBehavior */
+ Polymer.NeonAnimationRunnerBehavior = [
+ Polymer.NeonAnimatableBehavior,
+ Polymer.NeonAnimationRunnerBehaviorImpl
+ ];
+/**
+ * Use `Polymer.NeonAnimationBehavior` to implement an animation.
+ * @polymerBehavior
+ */
+ Polymer.NeonAnimationBehavior = {
+
+ properties: {
+
+ /**
+ * Defines the animation timing.
+ */
+ animationTiming: {
+ type: Object,
+ value: function() {
+ return {
+ duration: 500,
+ easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ fill: 'both'
+ }
+ }
+ }
+
+ },
+
+ /**
+ * Can be used to determine that elements implement this behavior.
+ */
+ isNeonAnimation: true,
+
+ /**
+ * Do any animation configuration here.
+ */
+ // configure: function(config) {
+ // },
+
+ /**
+ * Returns the animation timing by mixing in properties from `config` to the defaults defined
+ * by the animation.
+ */
+ timingFromConfig: function(config) {
+ if (config.timing) {
+ for (var property in config.timing) {
+ this.animationTiming[property] = config.timing[property];
+ }
+ }
+ return this.animationTiming;
+ },
+
+ /**
+ * Sets `transform` and `transformOrigin` properties along with the prefixed versions.
+ */
+ setPrefixedProperty: function(node, property, value) {
+ var map = {
+ 'transform': ['webkitTransform'],
+ 'transformOrigin': ['mozTransformOrigin', 'webkitTransformOrigin']
+ };
+ var prefixes = map[property];
+ for (var prefix, index = 0; prefix = prefixes[index]; index++) {
+ node.style[prefix] = value;
+ }
+ node.style[property] = value;
+ },
+
+ /**
+ * Called when the animation finishes.
+ */
+ complete: function() {}
+
+ };
+Polymer({
+
+ is: 'opaque-animation',
+
+ behaviors: [
+ Polymer.NeonAnimationBehavior
+ ],
+
+ configure: function(config) {
+ var node = config.node;
+ this._effect = new KeyframeEffect(node, [
+ {'opacity': '1'},
+ {'opacity': '1'}
+ ], this.timingFromConfig(config));
+ node.style.opacity = '0';
+ return this._effect;
+ },
+
+ complete: function(config) {
+ config.node.style.opacity = '';
+ }
+
+ });
+(function() {
+ 'use strict';
+ // Used to calculate the scroll direction during touch events.
+ var LAST_TOUCH_POSITION = {
+ pageX: 0,
+ pageY: 0
+ };
+ // Used to avoid computing event.path and filter scrollable nodes (better perf).
+ var ROOT_TARGET = null;
+ var SCROLLABLE_NODES = [];
+
+ /**
+ * The IronDropdownScrollManager is intended to provide a central source
+ * of authority and control over which elements in a document are currently
+ * allowed to scroll.
+ */
+
+ Polymer.IronDropdownScrollManager = {
+
+ /**
+ * The current element that defines the DOM boundaries of the
+ * scroll lock. This is always the most recently locking element.
+ */
+ get currentLockingElement() {
+ return this._lockingElements[this._lockingElements.length - 1];
+ },
+
+ /**
+ * Returns true if the provided element is "scroll locked", which is to
+ * say that it cannot be scrolled via pointer or keyboard interactions.
+ *
+ * @param {HTMLElement} element An HTML element instance which may or may
+ * not be scroll locked.
+ */
+ elementIsScrollLocked: function(element) {
+ var currentLockingElement = this.currentLockingElement;
+
+ if (currentLockingElement === undefined)
+ return false;
+
+ var scrollLocked;
+
+ if (this._hasCachedLockedElement(element)) {
+ return true;
+ }
+
+ if (this._hasCachedUnlockedElement(element)) {
+ return false;
+ }
+
+ scrollLocked = !!currentLockingElement &&
+ currentLockingElement !== element &&
+ !this._composedTreeContains(currentLockingElement, element);
+
+ if (scrollLocked) {
+ this._lockedElementCache.push(element);
+ } else {
+ this._unlockedElementCache.push(element);
+ }
+
+ return scrollLocked;
+ },
+
+ /**
+ * Push an element onto the current scroll lock stack. The most recently
+ * pushed element and its children will be considered scrollable. All
+ * other elements will not be scrollable.
+ *
+ * Scroll locking is implemented as a stack so that cases such as
+ * dropdowns within dropdowns are handled well.
+ *
+ * @param {HTMLElement} element The element that should lock scroll.
+ */
+ pushScrollLock: function(element) {
+ // Prevent pushing the same element twice
+ if (this._lockingElements.indexOf(element) >= 0) {
+ return;
+ }
+
+ if (this._lockingElements.length === 0) {
+ this._lockScrollInteractions();
+ }
+
+ this._lockingElements.push(element);
+
+ this._lockedElementCache = [];
+ this._unlockedElementCache = [];
+ },
+
+ /**
+ * Remove an element from the scroll lock stack. The element being
+ * removed does not need to be the most recently pushed element. However,
+ * the scroll lock constraints only change when the most recently pushed
+ * element is removed.
+ *
+ * @param {HTMLElement} element The element to remove from the scroll
+ * lock stack.
+ */
+ removeScrollLock: function(element) {
+ var index = this._lockingElements.indexOf(element);
+
+ if (index === -1) {
+ return;
+ }
+
+ this._lockingElements.splice(index, 1);
+
+ this._lockedElementCache = [];
+ this._unlockedElementCache = [];
+
+ if (this._lockingElements.length === 0) {
+ this._unlockScrollInteractions();
+ }
+ },
+
+ _lockingElements: [],
+
+ _lockedElementCache: null,
+
+ _unlockedElementCache: null,
+
+ _hasCachedLockedElement: function(element) {
+ return this._lockedElementCache.indexOf(element) > -1;
+ },
+
+ _hasCachedUnlockedElement: function(element) {
+ return this._unlockedElementCache.indexOf(element) > -1;
+ },
+
+ _composedTreeContains: function(element, child) {
+ // NOTE(cdata): This method iterates over content elements and their
+ // corresponding distributed nodes to implement a contains-like method
+ // that pierces through the composed tree of the ShadowDOM. Results of
+ // this operation are cached (elsewhere) on a per-scroll-lock basis, to
+ // guard against potentially expensive lookups happening repeatedly as
+ // a user scrolls / touchmoves.
+ var contentElements;
+ var distributedNodes;
+ var contentIndex;
+ var nodeIndex;
+
+ if (element.contains(child)) {
+ return true;
+ }
+
+ contentElements = Polymer.dom(element).querySelectorAll('content');
+
+ for (contentIndex = 0; contentIndex < contentElements.length; ++contentIndex) {
+
+ distributedNodes = Polymer.dom(contentElements[contentIndex]).getDistributedNodes();
+
+ for (nodeIndex = 0; nodeIndex < distributedNodes.length; ++nodeIndex) {
+
+ if (this._composedTreeContains(distributedNodes[nodeIndex], child)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ },
+
+ _scrollInteractionHandler: function(event) {
+ // Avoid canceling an event with cancelable=false, e.g. scrolling is in
+ // progress and cannot be interrupted.
+ if (event.cancelable && this._shouldPreventScrolling(event)) {
+ event.preventDefault();
+ }
+ // If event has targetTouches (touch event), update last touch position.
+ if (event.targetTouches) {
+ var touch = event.targetTouches[0];
+ LAST_TOUCH_POSITION.pageX = touch.pageX;
+ LAST_TOUCH_POSITION.pageY = touch.pageY;
+ }
+ },
+
+ _lockScrollInteractions: function() {
+ this._boundScrollHandler = this._boundScrollHandler ||
+ this._scrollInteractionHandler.bind(this);
+ // Modern `wheel` event for mouse wheel scrolling:
+ document.addEventListener('wheel', this._boundScrollHandler, true);
+ // Older, non-standard `mousewheel` event for some FF:
+ document.addEventListener('mousewheel', this._boundScrollHandler, true);
+ // IE:
+ document.addEventListener('DOMMouseScroll', this._boundScrollHandler, true);
+ // Save the SCROLLABLE_NODES on touchstart, to be used on touchmove.
+ document.addEventListener('touchstart', this._boundScrollHandler, true);
+ // Mobile devices can scroll on touch move:
+ document.addEventListener('touchmove', this._boundScrollHandler, true);
+ },
+
+ _unlockScrollInteractions: function() {
+ document.removeEventListener('wheel', this._boundScrollHandler, true);
+ document.removeEventListener('mousewheel', this._boundScrollHandler, true);
+ document.removeEventListener('DOMMouseScroll', this._boundScrollHandler, true);
+ document.removeEventListener('touchstart', this._boundScrollHandler, true);
+ document.removeEventListener('touchmove', this._boundScrollHandler, true);
+ },
+
+ /**
+ * Returns true if the event causes scroll outside the current locking
+ * element, e.g. pointer/keyboard interactions, or scroll "leaking"
+ * outside the locking element when it is already at its scroll boundaries.
+ * @param {!Event} event
+ * @return {boolean}
+ * @private
+ */
+ _shouldPreventScrolling: function(event) {
+
+ // Update if root target changed. For touch events, ensure we don't
+ // update during touchmove.
+ var target = Polymer.dom(event).rootTarget;
+ if (event.type !== 'touchmove' && ROOT_TARGET !== target) {
+ ROOT_TARGET = target;
+ SCROLLABLE_NODES = this._getScrollableNodes(Polymer.dom(event).path);
+ }
+
+ // Prevent event if no scrollable nodes.
+ if (!SCROLLABLE_NODES.length) {
+ return true;
+ }
+ // Don't prevent touchstart event inside the locking element when it has
+ // scrollable nodes.
+ if (event.type === 'touchstart') {
+ return false;
+ }
+ // Get deltaX/Y.
+ var info = this._getScrollInfo(event);
+ // Prevent if there is no child that can scroll.
+ return !this._getScrollingNode(SCROLLABLE_NODES, info.deltaX, info.deltaY);
+ },
+
+ /**
+ * Returns an array of scrollable nodes up to the current locking element,
+ * which is included too if scrollable.
+ * @param {!Array<Node>} nodes
+ * @return {Array<Node>} scrollables
+ * @private
+ */
+ _getScrollableNodes: function(nodes) {
+ var scrollables = [];
+ var lockingIndex = nodes.indexOf(this.currentLockingElement);
+ // Loop from root target to locking element (included).
+ for (var i = 0; i <= lockingIndex; i++) {
+ var node = nodes[i];
+ // Skip document fragments.
+ if (node.nodeType === 11) {
+ continue;
+ }
+ // Check inline style before checking computed style.
+ var style = node.style;
+ if (style.overflow !== 'scroll' && style.overflow !== 'auto') {
+ style = window.getComputedStyle(node);
+ }
+ if (style.overflow === 'scroll' || style.overflow === 'auto') {
+ scrollables.push(node);
+ }
+ }
+ return scrollables;
+ },
+
+ /**
+ * Returns the node that is scrolling. If there is no scrolling,
+ * returns undefined.
+ * @param {!Array<Node>} nodes
+ * @param {number} deltaX Scroll delta on the x-axis
+ * @param {number} deltaY Scroll delta on the y-axis
+ * @return {Node|undefined}
+ * @private
+ */
+ _getScrollingNode: function(nodes, deltaX, deltaY) {
+ // No scroll.
+ if (!deltaX && !deltaY) {
+ return;
+ }
+ // Check only one axis according to where there is more scroll.
+ // Prefer vertical to horizontal.
+ var verticalScroll = Math.abs(deltaY) >= Math.abs(deltaX);
+ for (var i = 0; i < nodes.length; i++) {
+ var node = nodes[i];
+ var canScroll = false;
+ if (verticalScroll) {
+ // delta < 0 is scroll up, delta > 0 is scroll down.
+ canScroll = deltaY < 0 ? node.scrollTop > 0 :
+ node.scrollTop < node.scrollHeight - node.clientHeight;
+ } else {
+ // delta < 0 is scroll left, delta > 0 is scroll right.
+ canScroll = deltaX < 0 ? node.scrollLeft > 0 :
+ node.scrollLeft < node.scrollWidth - node.clientWidth;
+ }
+ if (canScroll) {
+ return node;
+ }
+ }
+ },
+
+ /**
+ * Returns scroll `deltaX` and `deltaY`.
+ * @param {!Event} event The scroll event
+ * @return {{
+ * deltaX: number The x-axis scroll delta (positive: scroll right,
+ * negative: scroll left, 0: no scroll),
+ * deltaY: number The y-axis scroll delta (positive: scroll down,
+ * negative: scroll up, 0: no scroll)
+ * }} info
+ * @private
+ */
+ _getScrollInfo: function(event) {
+ var info = {
+ deltaX: event.deltaX,
+ deltaY: event.deltaY
+ };
+ // Already available.
+ if ('deltaX' in event) {
+ // do nothing, values are already good.
+ }
+ // Safari has scroll info in `wheelDeltaX/Y`.
+ else if ('wheelDeltaX' in event) {
+ info.deltaX = -event.wheelDeltaX;
+ info.deltaY = -event.wheelDeltaY;
+ }
+ // Firefox has scroll info in `detail` and `axis`.
+ else if ('axis' in event) {
+ info.deltaX = event.axis === 1 ? event.detail : 0;
+ info.deltaY = event.axis === 2 ? event.detail : 0;
+ }
+ // On mobile devices, calculate scroll direction.
+ else if (event.targetTouches) {
+ var touch = event.targetTouches[0];
+ // Touch moves from right to left => scrolling goes right.
+ info.deltaX = LAST_TOUCH_POSITION.pageX - touch.pageX;
+ // Touch moves from down to up => scrolling goes down.
+ info.deltaY = LAST_TOUCH_POSITION.pageY - touch.pageY;
+ }
+ return info;
+ }
+ };
+ })();
+(function() {
+ 'use strict';
+
+ Polymer({
+ is: 'iron-dropdown',
+
+ behaviors: [
+ Polymer.IronControlState,
+ Polymer.IronA11yKeysBehavior,
+ Polymer.IronOverlayBehavior,
+ Polymer.NeonAnimationRunnerBehavior
+ ],
+
+ properties: {
+ /**
+ * The orientation against which to align the dropdown content
+ * horizontally relative to the dropdown trigger.
+ * Overridden from `Polymer.IronFitBehavior`.
+ */
+ horizontalAlign: {
+ type: String,
+ value: 'left',
+ reflectToAttribute: true
+ },
+
+ /**
+ * The orientation against which to align the dropdown content
+ * vertically relative to the dropdown trigger.
+ * Overridden from `Polymer.IronFitBehavior`.
+ */
+ verticalAlign: {
+ type: String,
+ value: 'top',
+ reflectToAttribute: true
+ },
+
+ /**
+ * An animation config. If provided, this will be used to animate the
* opening of the dropdown.
*/
openAnimationConfig: {
type: Object
},
- /**
- * An animation config. If provided, this will be used to animate the
- * closing of the dropdown.
- */
- closeAnimationConfig: {
- type: Object
- },
+ /**
+ * An animation config. If provided, this will be used to animate the
+ * closing of the dropdown.
+ */
+ closeAnimationConfig: {
+ type: Object
+ },
+
+ /**
+ * If provided, this will be the element that will be focused when
+ * the dropdown opens.
+ */
+ focusTarget: {
+ type: Object
+ },
+
+ /**
+ * Set to true to disable animations when opening and closing the
+ * dropdown.
+ */
+ noAnimations: {
+ type: Boolean,
+ value: false
+ },
+
+ /**
+ * By default, the dropdown will constrain scrolling on the page
+ * to itself when opened.
+ * Set to true in order to prevent scroll from being constrained
+ * to the dropdown when it opens.
+ */
+ allowOutsideScroll: {
+ type: Boolean,
+ value: false
+ },
+
+ /**
+ * Callback for scroll events.
+ * @type {Function}
+ * @private
+ */
+ _boundOnCaptureScroll: {
+ type: Function,
+ value: function() {
+ return this._onCaptureScroll.bind(this);
+ }
+ }
+ },
+
+ listeners: {
+ 'neon-animation-finish': '_onNeonAnimationFinish'
+ },
+
+ observers: [
+ '_updateOverlayPosition(positionTarget, verticalAlign, horizontalAlign, verticalOffset, horizontalOffset)'
+ ],
+
+ /**
+ * The element that is contained by the dropdown, if any.
+ */
+ get containedElement() {
+ return Polymer.dom(this.$.content).getDistributedNodes()[0];
+ },
+
+ /**
+ * The element that should be focused when the dropdown opens.
+ * @deprecated
+ */
+ get _focusTarget() {
+ return this.focusTarget || this.containedElement;
+ },
+
+ ready: function() {
+ // Memoized scrolling position, used to block scrolling outside.
+ this._scrollTop = 0;
+ this._scrollLeft = 0;
+ // Used to perform a non-blocking refit on scroll.
+ this._refitOnScrollRAF = null;
+ },
+
+ detached: function() {
+ this.cancelAnimation();
+ Polymer.IronDropdownScrollManager.removeScrollLock(this);
+ },
+
+ /**
+ * Called when the value of `opened` changes.
+ * Overridden from `IronOverlayBehavior`
+ */
+ _openedChanged: function() {
+ if (this.opened && this.disabled) {
+ this.cancel();
+ } else {
+ this.cancelAnimation();
+ this.sizingTarget = this.containedElement || this.sizingTarget;
+ this._updateAnimationConfig();
+ this._saveScrollPosition();
+ if (this.opened) {
+ document.addEventListener('scroll', this._boundOnCaptureScroll);
+ !this.allowOutsideScroll && Polymer.IronDropdownScrollManager.pushScrollLock(this);
+ } else {
+ document.removeEventListener('scroll', this._boundOnCaptureScroll);
+ Polymer.IronDropdownScrollManager.removeScrollLock(this);
+ }
+ Polymer.IronOverlayBehaviorImpl._openedChanged.apply(this, arguments);
+ }
+ },
+
+ /**
+ * Overridden from `IronOverlayBehavior`.
+ */
+ _renderOpened: function() {
+ if (!this.noAnimations && this.animationConfig.open) {
+ this.$.contentWrapper.classList.add('animating');
+ this.playAnimation('open');
+ } else {
+ Polymer.IronOverlayBehaviorImpl._renderOpened.apply(this, arguments);
+ }
+ },
+
+ /**
+ * Overridden from `IronOverlayBehavior`.
+ */
+ _renderClosed: function() {
+
+ if (!this.noAnimations && this.animationConfig.close) {
+ this.$.contentWrapper.classList.add('animating');
+ this.playAnimation('close');
+ } else {
+ Polymer.IronOverlayBehaviorImpl._renderClosed.apply(this, arguments);
+ }
+ },
+
+ /**
+ * Called when animation finishes on the dropdown (when opening or
+ * closing). Responsible for "completing" the process of opening or
+ * closing the dropdown by positioning it or setting its display to
+ * none.
+ */
+ _onNeonAnimationFinish: function() {
+ this.$.contentWrapper.classList.remove('animating');
+ if (this.opened) {
+ this._finishRenderOpened();
+ } else {
+ this._finishRenderClosed();
+ }
+ },
+
+ _onCaptureScroll: function() {
+ if (!this.allowOutsideScroll) {
+ this._restoreScrollPosition();
+ } else {
+ this._refitOnScrollRAF && window.cancelAnimationFrame(this._refitOnScrollRAF);
+ this._refitOnScrollRAF = window.requestAnimationFrame(this.refit.bind(this));
+ }
+ },
+
+ /**
+ * Memoizes the scroll position of the outside scrolling element.
+ * @private
+ */
+ _saveScrollPosition: function() {
+ if (document.scrollingElement) {
+ this._scrollTop = document.scrollingElement.scrollTop;
+ this._scrollLeft = document.scrollingElement.scrollLeft;
+ } else {
+ // Since we don't know if is the body or html, get max.
+ this._scrollTop = Math.max(document.documentElement.scrollTop, document.body.scrollTop);
+ this._scrollLeft = Math.max(document.documentElement.scrollLeft, document.body.scrollLeft);
+ }
+ },
+
+ /**
+ * Resets the scroll position of the outside scrolling element.
+ * @private
+ */
+ _restoreScrollPosition: function() {
+ if (document.scrollingElement) {
+ document.scrollingElement.scrollTop = this._scrollTop;
+ document.scrollingElement.scrollLeft = this._scrollLeft;
+ } else {
+ // Since we don't know if is the body or html, set both.
+ document.documentElement.scrollTop = this._scrollTop;
+ document.documentElement.scrollLeft = this._scrollLeft;
+ document.body.scrollTop = this._scrollTop;
+ document.body.scrollLeft = this._scrollLeft;
+ }
+ },
+
+ /**
+ * Constructs the final animation config from different properties used
+ * to configure specific parts of the opening and closing animations.
+ */
+ _updateAnimationConfig: function() {
+ var animations = (this.openAnimationConfig || []).concat(this.closeAnimationConfig || []);
+ for (var i = 0; i < animations.length; i++) {
+ animations[i].node = this.containedElement;
+ }
+ this.animationConfig = {
+ open: this.openAnimationConfig,
+ close: this.closeAnimationConfig
+ };
+ },
+
+ /**
+ * Updates the overlay position based on configured horizontal
+ * and vertical alignment.
+ */
+ _updateOverlayPosition: function() {
+ if (this.isAttached) {
+ // This triggers iron-resize, and iron-overlay-behavior will call refit if needed.
+ this.notifyResize();
+ }
+ },
+
+ /**
+ * Apply focus to focusTarget or containedElement
+ */
+ _applyFocus: function () {
+ var focusTarget = this.focusTarget || this.containedElement;
+ if (focusTarget && this.opened && !this.noAutoFocus) {
+ focusTarget.focus();
+ } else {
+ Polymer.IronOverlayBehaviorImpl._applyFocus.apply(this, arguments);
+ }
+ }
+ });
+ })();
+Polymer({
+
+ is: 'fade-in-animation',
+
+ behaviors: [
+ Polymer.NeonAnimationBehavior
+ ],
+
+ configure: function(config) {
+ var node = config.node;
+ this._effect = new KeyframeEffect(node, [
+ {'opacity': '0'},
+ {'opacity': '1'}
+ ], this.timingFromConfig(config));
+ return this._effect;
+ }
+
+ });
+Polymer({
+
+ is: 'fade-out-animation',
+
+ behaviors: [
+ Polymer.NeonAnimationBehavior
+ ],
+
+ configure: function(config) {
+ var node = config.node;
+ this._effect = new KeyframeEffect(node, [
+ {'opacity': '1'},
+ {'opacity': '0'}
+ ], this.timingFromConfig(config));
+ return this._effect;
+ }
+
+ });
+Polymer({
+ is: 'paper-menu-grow-height-animation',
+
+ behaviors: [
+ Polymer.NeonAnimationBehavior
+ ],
+
+ configure: function(config) {
+ var node = config.node;
+ var rect = node.getBoundingClientRect();
+ var height = rect.height;
+
+ this._effect = new KeyframeEffect(node, [{
+ height: (height / 2) + 'px'
+ }, {
+ height: height + 'px'
+ }], this.timingFromConfig(config));
+
+ return this._effect;
+ }
+ });
+
+ Polymer({
+ is: 'paper-menu-grow-width-animation',
+
+ behaviors: [
+ Polymer.NeonAnimationBehavior
+ ],
+
+ configure: function(config) {
+ var node = config.node;
+ var rect = node.getBoundingClientRect();
+ var width = rect.width;
+
+ this._effect = new KeyframeEffect(node, [{
+ width: (width / 2) + 'px'
+ }, {
+ width: width + 'px'
+ }], this.timingFromConfig(config));
+
+ return this._effect;
+ }
+ });
+
+ Polymer({
+ is: 'paper-menu-shrink-width-animation',
+
+ behaviors: [
+ Polymer.NeonAnimationBehavior
+ ],
+
+ configure: function(config) {
+ var node = config.node;
+ var rect = node.getBoundingClientRect();
+ var width = rect.width;
+
+ this._effect = new KeyframeEffect(node, [{
+ width: width + 'px'
+ }, {
+ width: width - (width / 20) + 'px'
+ }], this.timingFromConfig(config));
+
+ return this._effect;
+ }
+ });
+
+ Polymer({
+ is: 'paper-menu-shrink-height-animation',
+
+ behaviors: [
+ Polymer.NeonAnimationBehavior
+ ],
+
+ configure: function(config) {
+ var node = config.node;
+ var rect = node.getBoundingClientRect();
+ var height = rect.height;
+ var top = rect.top;
+
+ this.setPrefixedProperty(node, 'transformOrigin', '0 0');
+
+ this._effect = new KeyframeEffect(node, [{
+ height: height + 'px',
+ transform: 'translateY(0)'
+ }, {
+ height: height / 2 + 'px',
+ transform: 'translateY(-20px)'
+ }], this.timingFromConfig(config));
+
+ return this._effect;
+ }
+ });
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/** Same as paper-menu-button's custom easing cubic-bezier param. */
+var SLIDE_CUBIC_BEZIER = 'cubic-bezier(0.3, 0.95, 0.5, 1)';
+
+Polymer({
+ is: 'cr-shared-menu',
+
+ behaviors: [Polymer.IronA11yKeysBehavior],
+
+ properties: {
+ menuOpen: {
+ type: Boolean,
+ observer: 'menuOpenChanged_',
+ value: false,
+ },
+
+ /**
+ * The contextual item that this menu was clicked for.
+ * e.g. the data used to render an item in an <iron-list> or <dom-repeat>
+ * @type {?Object}
+ */
+ itemData: {
+ type: Object,
+ value: null,
+ },
+
+ /** @override */
+ keyEventTarget: {
+ type: Object,
+ value: function() {
+ return this.$.menu;
+ }
+ },
+
+ openAnimationConfig: {
+ type: Object,
+ value: function() {
+ return [{
+ name: 'fade-in-animation',
+ timing: {
+ delay: 50,
+ duration: 200
+ }
+ }, {
+ name: 'paper-menu-grow-width-animation',
+ timing: {
+ delay: 50,
+ duration: 150,
+ easing: SLIDE_CUBIC_BEZIER
+ }
+ }, {
+ name: 'paper-menu-grow-height-animation',
+ timing: {
+ delay: 100,
+ duration: 275,
+ easing: SLIDE_CUBIC_BEZIER
+ }
+ }];
+ }
+ },
+
+ closeAnimationConfig: {
+ type: Object,
+ value: function() {
+ return [{
+ name: 'fade-out-animation',
+ timing: {
+ duration: 150
+ }
+ }];
+ }
+ }
+ },
+
+ keyBindings: {
+ 'tab': 'onTabPressed_',
+ },
+
+ listeners: {
+ 'dropdown.iron-overlay-canceled': 'onOverlayCanceled_',
+ },
+
+ /**
+ * The last anchor that was used to open a menu. It's necessary for toggling.
+ * @private {?Element}
+ */
+ lastAnchor_: null,
+
+ /**
+ * The first focusable child in the menu's light DOM.
+ * @private {?Element}
+ */
+ firstFocus_: null,
+
+ /**
+ * The last focusable child in the menu's light DOM.
+ * @private {?Element}
+ */
+ lastFocus_: null,
+
+ /** @override */
+ attached: function() {
+ window.addEventListener('resize', this.closeMenu.bind(this));
+ },
+
+ /** Closes the menu. */
+ closeMenu: function() {
+ if (this.root.activeElement == null) {
+ // Something else has taken focus away from the menu. Do not attempt to
+ // restore focus to the button which opened the menu.
+ this.$.dropdown.restoreFocusOnClose = false;
+ }
+ this.menuOpen = false;
+ },
+
+ /**
+ * Opens the menu at the anchor location.
+ * @param {!Element} anchor The location to display the menu.
+ * @param {!Object} itemData The contextual item's data.
+ */
+ openMenu: function(anchor, itemData) {
+ if (this.lastAnchor_ == anchor && this.menuOpen)
+ return;
+
+ if (this.menuOpen)
+ this.closeMenu();
+
+ this.itemData = itemData;
+ this.lastAnchor_ = anchor;
+ this.$.dropdown.restoreFocusOnClose = true;
+
+ var focusableChildren = Polymer.dom(this).querySelectorAll(
+ '[tabindex]:not([hidden]),button:not([hidden])');
+ if (focusableChildren.length > 0) {
+ this.$.dropdown.focusTarget = focusableChildren[0];
+ this.firstFocus_ = focusableChildren[0];
+ this.lastFocus_ = focusableChildren[focusableChildren.length - 1];
+ }
+
+ // Move the menu to the anchor.
+ this.$.dropdown.positionTarget = anchor;
+ this.menuOpen = true;
+ },
+
+ /**
+ * Toggles the menu for the anchor that is passed in.
+ * @param {!Element} anchor The location to display the menu.
+ * @param {!Object} itemData The contextual item's data.
+ */
+ toggleMenu: function(anchor, itemData) {
+ if (anchor == this.lastAnchor_ && this.menuOpen)
+ this.closeMenu();
+ else
+ this.openMenu(anchor, itemData);
+ },
+
+ /**
+ * Trap focus inside the menu. As a very basic heuristic, will wrap focus from
+ * the first element with a nonzero tabindex to the last such element.
+ * TODO(tsergeant): Use iron-focus-wrap-behavior once it is available
+ * (https://github.com/PolymerElements/iron-overlay-behavior/issues/179).
+ * @param {CustomEvent} e
+ */
+ onTabPressed_: function(e) {
+ if (!this.firstFocus_ || !this.lastFocus_)
+ return;
+
+ var toFocus;
+ var keyEvent = e.detail.keyboardEvent;
+ if (keyEvent.shiftKey && keyEvent.target == this.firstFocus_)
+ toFocus = this.lastFocus_;
+ else if (keyEvent.target == this.lastFocus_)
+ toFocus = this.firstFocus_;
+
+ if (!toFocus)
+ return;
+
+ e.preventDefault();
+ toFocus.focus();
+ },
+
+ /**
+ * Ensure the menu is reset properly when it is closed by the dropdown (eg,
+ * clicking outside).
+ * @private
+ */
+ menuOpenChanged_: function() {
+ if (!this.menuOpen) {
+ this.itemData = null;
+ this.lastAnchor_ = null;
+ }
+ },
+
+ /**
+ * Prevent focus restoring when tapping outside the menu. This stops the
+ * focus moving around unexpectedly when closing the menu with the mouse.
+ * @param {CustomEvent} e
+ * @private
+ */
+ onOverlayCanceled_: function(e) {
+ if (e.detail.type == 'tap')
+ this.$.dropdown.restoreFocusOnClose = false;
+ },
+});
+/** @polymerBehavior Polymer.PaperItemBehavior */
+ Polymer.PaperItemBehaviorImpl = {
+ hostAttributes: {
+ role: 'option',
+ tabindex: '0'
+ }
+ };
+
+ /** @polymerBehavior */
+ Polymer.PaperItemBehavior = [
+ Polymer.IronButtonState,
+ Polymer.IronControlState,
+ Polymer.PaperItemBehaviorImpl
+ ];
+Polymer({
+ is: 'paper-item',
+
+ behaviors: [
+ Polymer.PaperItemBehavior
+ ]
+ });
+Polymer({
+
+ is: 'iron-collapse',
+
+ behaviors: [
+ Polymer.IronResizableBehavior
+ ],
+
+ properties: {
+
+ /**
+ * If true, the orientation is horizontal; otherwise is vertical.
+ *
+ * @attribute horizontal
+ */
+ horizontal: {
+ type: Boolean,
+ value: false,
+ observer: '_horizontalChanged'
+ },
+
+ /**
+ * Set opened to true to show the collapse element and to false to hide it.
+ *
+ * @attribute opened
+ */
+ opened: {
+ type: Boolean,
+ value: false,
+ notify: true,
+ observer: '_openedChanged'
+ },
+
+ /**
+ * Set noAnimation to true to disable animations
+ *
+ * @attribute noAnimation
+ */
+ noAnimation: {
+ type: Boolean
+ },
+
+ },
+
+ get dimension() {
+ return this.horizontal ? 'width' : 'height';
+ },
+
+ /**
+ * `maxWidth` or `maxHeight`.
+ * @private
+ */
+ get _dimensionMax() {
+ return this.horizontal ? 'maxWidth' : 'maxHeight';
+ },
+
+ /**
+ * `max-width` or `max-height`.
+ * @private
+ */
+ get _dimensionMaxCss() {
+ return this.horizontal ? 'max-width' : 'max-height';
+ },
+
+ hostAttributes: {
+ role: 'group',
+ 'aria-hidden': 'true',
+ 'aria-expanded': 'false'
+ },
+
+ listeners: {
+ transitionend: '_transitionEnd'
+ },
+
+ attached: function() {
+ // It will take care of setting correct classes and styles.
+ this._transitionEnd();
+ },
+
+ /**
+ * Toggle the opened state.
+ *
+ * @method toggle
+ */
+ toggle: function() {
+ this.opened = !this.opened;
+ },
+
+ show: function() {
+ this.opened = true;
+ },
+
+ hide: function() {
+ this.opened = false;
+ },
+
+ /**
+ * Updates the size of the element.
+ * @param {string} size The new value for `maxWidth`/`maxHeight` as css property value, usually `auto` or `0px`.
+ * @param {boolean=} animated if `true` updates the size with an animation, otherwise without.
+ */
+ updateSize: function(size, animated) {
+ // No change!
+ var curSize = this.style[this._dimensionMax];
+ if (curSize === size || (size === 'auto' && !curSize)) {
+ return;
+ }
+
+ this._updateTransition(false);
+ // If we can animate, must do some prep work.
+ if (animated && !this.noAnimation && this._isDisplayed) {
+ // Animation will start at the current size.
+ var startSize = this._calcSize();
+ // For `auto` we must calculate what is the final size for the animation.
+ // After the transition is done, _transitionEnd will set the size back to `auto`.
+ if (size === 'auto') {
+ this.style[this._dimensionMax] = '';
+ size = this._calcSize();
+ }
+ // Go to startSize without animation.
+ this.style[this._dimensionMax] = startSize;
+ // Force layout to ensure transition will go. Set scrollTop to itself
+ // so that compilers won't remove it.
+ this.scrollTop = this.scrollTop;
+ // Enable animation.
+ this._updateTransition(true);
+ }
+ // Set the final size.
+ if (size === 'auto') {
+ this.style[this._dimensionMax] = '';
+ } else {
+ this.style[this._dimensionMax] = size;
+ }
+ },
+
+ /**
+ * enableTransition() is deprecated, but left over so it doesn't break existing code.
+ * Please use `noAnimation` property instead.
+ *
+ * @method enableTransition
+ * @deprecated since version 1.0.4
+ */
+ enableTransition: function(enabled) {
+ Polymer.Base._warn('`enableTransition()` is deprecated, use `noAnimation` instead.');
+ this.noAnimation = !enabled;
+ },
+
+ _updateTransition: function(enabled) {
+ this.style.transitionDuration = (enabled && !this.noAnimation) ? '' : '0s';
+ },
+
+ _horizontalChanged: function() {
+ this.style.transitionProperty = this._dimensionMaxCss;
+ var otherDimension = this._dimensionMax === 'maxWidth' ? 'maxHeight' : 'maxWidth';
+ this.style[otherDimension] = '';
+ this.updateSize(this.opened ? 'auto' : '0px', false);
+ },
+
+ _openedChanged: function() {
+ this.setAttribute('aria-expanded', this.opened);
+ this.setAttribute('aria-hidden', !this.opened);
+
+ this.toggleClass('iron-collapse-closed', false);
+ this.toggleClass('iron-collapse-opened', false);
+ this.updateSize(this.opened ? 'auto' : '0px', true);
+
+ // Focus the current collapse.
+ if (this.opened) {
+ this.focus();
+ }
+ if (this.noAnimation) {
+ this._transitionEnd();
+ }
+ },
+
+ _transitionEnd: function() {
+ if (this.opened) {
+ this.style[this._dimensionMax] = '';
+ }
+ this.toggleClass('iron-collapse-closed', !this.opened);
+ this.toggleClass('iron-collapse-opened', this.opened);
+ this._updateTransition(false);
+ this.notifyResize();
+ },
+
+ /**
+ * Simplistic heuristic to detect if element has a parent with display: none
+ *
+ * @private
+ */
+ get _isDisplayed() {
+ var rect = this.getBoundingClientRect();
+ for (var prop in rect) {
+ if (rect[prop] !== 0) return true;
+ }
+ return false;
+ },
+
+ _calcSize: function() {
+ return this.getBoundingClientRect()[this.dimension] + 'px';
+ }
+
+ });
+/**
+ Polymer.IronFormElementBehavior enables a custom element to be included
+ in an `iron-form`.
+
+ @demo demo/index.html
+ @polymerBehavior
+ */
+ Polymer.IronFormElementBehavior = {
+
+ properties: {
+ /**
+ * Fired when the element is added to an `iron-form`.
+ *
+ * @event iron-form-element-register
+ */
+
+ /**
+ * Fired when the element is removed from an `iron-form`.
+ *
+ * @event iron-form-element-unregister
+ */
+
+ /**
+ * The name of this element.
+ */
+ name: {
+ type: String
+ },
+
+ /**
+ * The value for this element.
+ */
+ value: {
+ notify: true,
+ type: String
+ },
+
+ /**
+ * Set to true to mark the input as required. If used in a form, a
+ * custom element that uses this behavior should also use
+ * Polymer.IronValidatableBehavior and define a custom validation method.
+ * Otherwise, a `required` element will always be considered valid.
+ * It's also strongly recommended to provide a visual style for the element
+ * when its value is invalid.
+ */
+ required: {
+ type: Boolean,
+ value: false
+ },
+
+ /**
+ * The form that the element is registered to.
+ */
+ _parentForm: {
+ type: Object
+ }
+ },
+
+ attached: function() {
+ // Note: the iron-form that this element belongs to will set this
+ // element's _parentForm property when handling this event.
+ this.fire('iron-form-element-register');
+ },
+
+ detached: function() {
+ if (this._parentForm) {
+ this._parentForm.fire('iron-form-element-unregister', {target: this});
+ }
+ }
+
+ };
+/**
+ * Use `Polymer.IronCheckedElementBehavior` to implement a custom element
+ * that has a `checked` property, which can be used for validation if the
+ * element is also `required`. Element instances implementing this behavior
+ * will also be registered for use in an `iron-form` element.
+ *
+ * @demo demo/index.html
+ * @polymerBehavior Polymer.IronCheckedElementBehavior
+ */
+ Polymer.IronCheckedElementBehaviorImpl = {
+
+ properties: {
+ /**
+ * Fired when the checked state changes.
+ *
+ * @event iron-change
+ */
+
+ /**
+ * Gets or sets the state, `true` is checked and `false` is unchecked.
+ */
+ checked: {
+ type: Boolean,
+ value: false,
+ reflectToAttribute: true,
+ notify: true,
+ observer: '_checkedChanged'
+ },
+
+ /**
+ * If true, the button toggles the active state with each tap or press
+ * of the spacebar.
+ */
+ toggles: {
+ type: Boolean,
+ value: true,
+ reflectToAttribute: true
+ },
+
+ /* Overriden from Polymer.IronFormElementBehavior */
+ value: {
+ type: String,
+ value: 'on',
+ observer: '_valueChanged'
+ }
+ },
+
+ observers: [
+ '_requiredChanged(required)'
+ ],
+
+ created: function() {
+ // Used by `iron-form` to handle the case that an element with this behavior
+ // doesn't have a role of 'checkbox' or 'radio', but should still only be
+ // included when the form is serialized if `this.checked === true`.
+ this._hasIronCheckedElementBehavior = true;
+ },
+
+ /**
+ * Returns false if the element is required and not checked, and true otherwise.
+ * @param {*=} _value Ignored.
+ * @return {boolean} true if `required` is false or if `checked` is true.
+ */
+ _getValidity: function(_value) {
+ return this.disabled || !this.required || this.checked;
+ },
+
+ /**
+ * Update the aria-required label when `required` is changed.
+ */
+ _requiredChanged: function() {
+ if (this.required) {
+ this.setAttribute('aria-required', 'true');
+ } else {
+ this.removeAttribute('aria-required');
+ }
+ },
+
+ /**
+ * Fire `iron-changed` when the checked state changes.
+ */
+ _checkedChanged: function() {
+ this.active = this.checked;
+ this.fire('iron-change');
+ },
+
+ /**
+ * Reset value to 'on' if it is set to `undefined`.
+ */
+ _valueChanged: function() {
+ if (this.value === undefined || this.value === null) {
+ this.value = 'on';
+ }
+ }
+ };
+
+ /** @polymerBehavior Polymer.IronCheckedElementBehavior */
+ Polymer.IronCheckedElementBehavior = [
+ Polymer.IronFormElementBehavior,
+ Polymer.IronValidatableBehavior,
+ Polymer.IronCheckedElementBehaviorImpl
+ ];
+/**
+ * Use `Polymer.PaperCheckedElementBehavior` to implement a custom element
+ * that has a `checked` property similar to `Polymer.IronCheckedElementBehavior`
+ * and is compatible with having a ripple effect.
+ * @polymerBehavior Polymer.PaperCheckedElementBehavior
+ */
+ Polymer.PaperCheckedElementBehaviorImpl = {
+ /**
+ * Synchronizes the element's checked state with its ripple effect.
+ */
+ _checkedChanged: function() {
+ Polymer.IronCheckedElementBehaviorImpl._checkedChanged.call(this);
+ if (this.hasRipple()) {
+ if (this.checked) {
+ this._ripple.setAttribute('checked', '');
+ } else {
+ this._ripple.removeAttribute('checked');
+ }
+ }
+ },
+
+ /**
+ * Synchronizes the element's `active` and `checked` state.
+ */
+ _buttonStateChanged: function() {
+ Polymer.PaperRippleBehavior._buttonStateChanged.call(this);
+ if (this.disabled) {
+ return;
+ }
+ if (this.isAttached) {
+ this.checked = this.active;
+ }
+ }
+ };
+
+ /** @polymerBehavior Polymer.PaperCheckedElementBehavior */
+ Polymer.PaperCheckedElementBehavior = [
+ Polymer.PaperInkyFocusBehavior,
+ Polymer.IronCheckedElementBehavior,
+ Polymer.PaperCheckedElementBehaviorImpl
+ ];
+Polymer({
+ is: 'paper-checkbox',
+
+ behaviors: [
+ Polymer.PaperCheckedElementBehavior
+ ],
+
+ hostAttributes: {
+ role: 'checkbox',
+ 'aria-checked': false,
+ tabindex: 0
+ },
+
+ properties: {
+ /**
+ * Fired when the checked state changes due to user interaction.
+ *
+ * @event change
+ */
+
+ /**
+ * Fired when the checked state changes.
+ *
+ * @event iron-change
+ */
+ ariaActiveAttribute: {
+ type: String,
+ value: 'aria-checked'
+ }
+ },
+
+ _computeCheckboxClass: function(checked, invalid) {
+ var className = '';
+ if (checked) {
+ className += 'checked ';
+ }
+ if (invalid) {
+ className += 'invalid';
+ }
+ return className;
+ },
+
+ _computeCheckmarkClass: function(checked) {
+ return checked ? '' : 'hidden';
+ },
+
+ // create ripple inside the checkboxContainer
+ _createRipple: function() {
+ this._rippleContainer = this.$.checkboxContainer;
+ return Polymer.PaperInkyFocusBehaviorImpl._createRipple.call(this);
+ }
+
+ });
+Polymer({
+ is: 'paper-icon-button-light',
+ extends: 'button',
+
+ behaviors: [
+ Polymer.PaperRippleBehavior
+ ],
+
+ listeners: {
+ 'down': '_rippleDown',
+ 'up': '_rippleUp',
+ 'focus': '_rippleDown',
+ 'blur': '_rippleUp',
+ },
+
+ _rippleDown: function() {
+ this.getRipple().downAction();
+ },
+
+ _rippleUp: function() {
+ this.getRipple().upAction();
+ },
+
+ /**
+ * @param {...*} var_args
+ */
+ ensureRipple: function(var_args) {
+ var lastRipple = this._ripple;
+ Polymer.PaperRippleBehavior.ensureRipple.apply(this, arguments);
+ if (this._ripple && this._ripple !== lastRipple) {
+ this._ripple.center = true;
+ this._ripple.classList.add('circle');
+ }
+ }
+ });
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+cr.define('cr.icon', function() {
+ /**
+ * @return {!Array<number>} The scale factors supported by this platform for
+ * webui resources.
+ */
+ function getSupportedScaleFactors() {
+ var supportedScaleFactors = [];
+ if (cr.isMac || cr.isChromeOS || cr.isWindows || cr.isLinux) {
+ // All desktop platforms support zooming which also updates the
+ // renderer's device scale factors (a.k.a devicePixelRatio), and
+ // these platforms has high DPI assets for 2.0x. Use 1x and 2x in
+ // image-set on these platforms so that the renderer can pick the
+ // closest image for the current device scale factor.
+ supportedScaleFactors.push(1);
+ supportedScaleFactors.push(2);
+ } else {
+ // For other platforms that use fixed device scale factor, use
+ // the window's device pixel ratio.
+ // TODO(oshima): Investigate if Android/iOS need to use image-set.
+ supportedScaleFactors.push(window.devicePixelRatio);
+ }
+ return supportedScaleFactors;
+ }
+
+ /**
+ * Returns the URL of the image, or an image set of URLs for the profile
+ * avatar. Default avatars have resources available for multiple scalefactors,
+ * whereas the GAIA profile image only comes in one size.
+ *
+ * @param {string} path The path of the image.
+ * @return {string} The url, or an image set of URLs of the avatar image.
+ */
+ function getProfileAvatarIcon(path) {
+ var chromeThemePath = 'chrome://theme';
+ var isDefaultAvatar =
+ (path.slice(0, chromeThemePath.length) == chromeThemePath);
+ return isDefaultAvatar ? imageset(path + '@scalefactorx'): url(path);
+ }
+
+ /**
+ * Generates a CSS -webkit-image-set for a chrome:// url.
+ * An entry in the image set is added for each of getSupportedScaleFactors().
+ * The scale-factor-specific url is generated by replacing the first instance
+ * of 'scalefactor' in |path| with the numeric scale factor.
+ * @param {string} path The URL to generate an image set for.
+ * 'scalefactor' should be a substring of |path|.
+ * @return {string} The CSS -webkit-image-set.
+ */
+ function imageset(path) {
+ var supportedScaleFactors = getSupportedScaleFactors();
+
+ var replaceStartIndex = path.indexOf('scalefactor');
+ if (replaceStartIndex < 0)
+ return url(path);
+
+ var s = '';
+ for (var i = 0; i < supportedScaleFactors.length; ++i) {
+ var scaleFactor = supportedScaleFactors[i];
+ var pathWithScaleFactor = path.substr(0, replaceStartIndex) +
+ scaleFactor + path.substr(replaceStartIndex + 'scalefactor'.length);
+
+ s += url(pathWithScaleFactor) + ' ' + scaleFactor + 'x';
+
+ if (i != supportedScaleFactors.length - 1)
+ s += ', ';
+ }
+ return '-webkit-image-set(' + s + ')';
+ }
+
+ /**
+ * A regular expression for identifying favicon URLs.
+ * @const {!RegExp}
+ */
+ var FAVICON_URL_REGEX = /\.ico$/i;
+
+ /**
+ * Creates a CSS -webkit-image-set for a favicon request.
+ * @param {string} url Either the URL of the original page or of the favicon
+ * itself.
+ * @param {number=} opt_size Optional preferred size of the favicon.
+ * @param {string=} opt_type Optional type of favicon to request. Valid values
+ * are 'favicon' and 'touch-icon'. Default is 'favicon'.
+ * @return {string} -webkit-image-set for the favicon.
+ */
+ function getFaviconImageSet(url, opt_size, opt_type) {
+ var size = opt_size || 16;
+ var type = opt_type || 'favicon';
+
+ return imageset(
+ 'chrome://' + type + '/size/' + size + '@scalefactorx/' +
+ // Note: Literal 'iconurl' must match |kIconURLParameter| in
+ // components/favicon_base/favicon_url_parser.cc.
+ (FAVICON_URL_REGEX.test(url) ? 'iconurl/' : '') + url);
+ }
+
+ return {
+ getSupportedScaleFactors: getSupportedScaleFactors,
+ getProfileAvatarIcon: getProfileAvatarIcon,
+ getFaviconImageSet: getFaviconImageSet,
+ };
+});
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview Defines a singleton object, md_history.BrowserService, which
+ * provides access to chrome.send APIs.
+ */
+
+cr.define('md_history', function() {
+ /** @constructor */
+ function BrowserService() {
+ /** @private {Array<!HistoryEntry>} */
+ this.pendingDeleteItems_ = null;
+ /** @private {PromiseResolver} */
+ this.pendingDeletePromise_ = null;
+ }
+
+ BrowserService.prototype = {
+ /**
+ * @param {!Array<!HistoryEntry>} items
+ * @return {Promise<!Array<!HistoryEntry>>}
+ */
+ deleteItems: function(items) {
+ if (this.pendingDeleteItems_ != null) {
+ // There's already a deletion in progress, reject immediately.
+ return new Promise(function(resolve, reject) { reject(items); });
+ }
+
+ var removalList = items.map(function(item) {
+ return {
+ url: item.url,
+ timestamps: item.allTimestamps
+ };
+ });
+
+ this.pendingDeleteItems_ = items;
+ this.pendingDeletePromise_ = new PromiseResolver();
+
+ chrome.send('removeVisits', removalList);
+
+ return this.pendingDeletePromise_.promise;
+ },
+
+ /**
+ * @param {!string} url
+ */
+ removeBookmark: function(url) {
+ chrome.send('removeBookmark', [url]);
+ },
+
+ /**
+ * @param {string} sessionTag
+ */
+ openForeignSessionAllTabs: function(sessionTag) {
+ chrome.send('openForeignSession', [sessionTag]);
+ },
+
+ /**
+ * @param {string} sessionTag
+ * @param {number} windowId
+ * @param {number} tabId
+ * @param {MouseEvent} e
+ */
+ openForeignSessionTab: function(sessionTag, windowId, tabId, e) {
+ chrome.send('openForeignSession', [
+ sessionTag, String(windowId), String(tabId), e.button || 0, e.altKey,
+ e.ctrlKey, e.metaKey, e.shiftKey
+ ]);
+ },
+
+ /**
+ * @param {string} sessionTag
+ */
+ deleteForeignSession: function(sessionTag) {
+ chrome.send('deleteForeignSession', [sessionTag]);
+ },
+
+ openClearBrowsingData: function() {
+ chrome.send('clearBrowsingData');
+ },
+
+ /**
+ * @param {boolean} successful
+ * @private
+ */
+ resolveDelete_: function(successful) {
+ if (this.pendingDeleteItems_ == null ||
+ this.pendingDeletePromise_ == null) {
+ return;
+ }
+
+ if (successful)
+ this.pendingDeletePromise_.resolve(this.pendingDeleteItems_);
+ else
+ this.pendingDeletePromise_.reject(this.pendingDeleteItems_);
+
+ this.pendingDeleteItems_ = null;
+ this.pendingDeletePromise_ = null;
+ },
+ };
+
+ cr.addSingletonGetter(BrowserService);
+
+ return {BrowserService: BrowserService};
+});
+
+/**
+ * Called by the history backend when deletion was succesful.
+ */
+function deleteComplete() {
+ md_history.BrowserService.getInstance().resolveDelete_(true);
+}
+
+/**
+ * Called by the history backend when the deletion failed.
+ */
+function deleteFailed() {
+ md_history.BrowserService.getInstance().resolveDelete_(false);
+};
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+Polymer({
+ is: 'history-searched-label',
+
+ properties: {
+ // The text to show in this label.
+ title: String,
+
+ // The search term to bold within the title.
+ searchTerm: String,
+ },
+
+ observers: ['setSearchedTextToBold_(title, searchTerm)'],
+
+ /**
+ * Updates the page title. If a search term is specified, highlights any
+ * occurrences of the search term in bold.
+ * @private
+ */
+ setSearchedTextToBold_: function() {
+ var i = 0;
+ var titleElem = this.$.container;
+ var titleText = this.title;
+
+ if (this.searchTerm == '' || this.searchTerm == null) {
+ titleElem.textContent = titleText;
+ return;
+ }
+
+ var re = new RegExp(quoteString(this.searchTerm), 'gim');
+ var match;
+ titleElem.textContent = '';
+ while (match = re.exec(titleText)) {
+ if (match.index > i)
+ titleElem.appendChild(document.createTextNode(
+ titleText.slice(i, match.index)));
+ i = re.lastIndex;
+ // Mark the highlighted text in bold.
+ var b = document.createElement('b');
+ b.textContent = titleText.substring(match.index, i);
+ titleElem.appendChild(b);
+ }
+ if (i < titleText.length)
+ titleElem.appendChild(
+ document.createTextNode(titleText.slice(i)));
+ },
+});
+// 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('md_history', function() {
+ var HistoryItem = Polymer({
+ is: 'history-item',
+
+ properties: {
+ // Underlying HistoryEntry data for this item. Contains read-only fields
+ // from the history backend, as well as fields computed by history-list.
+ item: {type: Object, observer: 'showIcon_'},
+
+ // Search term used to obtain this history-item.
+ searchTerm: {type: String},
+
+ selected: {type: Boolean, notify: true},
+
+ isFirstItem: {type: Boolean, reflectToAttribute: true},
+
+ isCardStart: {type: Boolean, reflectToAttribute: true},
+
+ isCardEnd: {type: Boolean, reflectToAttribute: true},
+
+ // True if the item is being displayed embedded in another element and
+ // should not manage its own borders or size.
+ embedded: {type: Boolean, reflectToAttribute: true},
+
+ hasTimeGap: {type: Boolean},
+
+ numberOfItems: {type: Number},
+
+ // The path of this history item inside its parent.
+ path: String,
+ },
+
+ /**
+ * When a history-item is selected the toolbar is notified and increases
+ * or decreases its count of selected items accordingly.
+ * @private
+ */
+ onCheckboxSelected_: function() {
+ // TODO(calamity): Fire this event whenever |selected| changes.
+ this.fire('history-checkbox-select', {
+ element: this,
+ countAddition: this.$.checkbox.checked ? 1 : -1
+ });
+ },
+
+ /**
+ * Remove bookmark of current item when bookmark-star is clicked.
+ * @private
+ */
+ onRemoveBookmarkTap_: function() {
+ if (!this.item.starred)
+ return;
+
+ if (this.$$('#bookmark-star') == this.root.activeElement)
+ this.$['menu-button'].focus();
+
+ md_history.BrowserService.getInstance()
+ .removeBookmark(this.item.url);
+ this.fire('remove-bookmark-stars', this.item.url);
+ },
+
+ /**
+ * Fires a custom event when the menu button is clicked. Sends the details
+ * of the history item and where the menu should appear.
+ */
+ onMenuButtonTap_: function(e) {
+ this.fire('toggle-menu', {
+ target: Polymer.dom(e).localTarget,
+ item: this.item,
+ });
+
+ // Stops the 'tap' event from closing the menu when it opens.
+ e.stopPropagation();
+ },
+
+ /**
+ * Set the favicon image, based on the URL of the history item.
+ * @private
+ */
+ showIcon_: function() {
+ this.$.icon.style.backgroundImage =
+ cr.icon.getFaviconImageSet(this.item.url);
+ },
+
+ selectionNotAllowed_: function() {
+ return !loadTimeData.getBoolean('allowDeletingHistory');
+ },
+
+ /**
+ * Generates the title for this history card.
+ * @param {number} numberOfItems The number of items in the card.
+ * @param {string} search The search term associated with these results.
+ * @private
+ */
+ cardTitle_: function(numberOfItems, historyDate, search) {
+ if (!search)
+ return this.item.dateRelativeDay;
+
+ var resultId = numberOfItems == 1 ? 'searchResult' : 'searchResults';
+ return loadTimeData.getStringF('foundSearchResults', numberOfItems,
+ loadTimeData.getString(resultId), search);
+ },
+
+ /**
+ * Crop long item titles to reduce their effect on layout performance. See
+ * crbug.com/621347.
+ * @param {string} title
+ * @return {string}
+ */
+ cropItemTitle_: function(title) {
+ return (title.length > TITLE_MAX_LENGTH) ?
+ title.substr(0, TITLE_MAX_LENGTH) :
+ title;
+ }
+ });
+
+ /**
+ * Check whether the time difference between the given history item and the
+ * next one is large enough for a spacer to be required.
+ * @param {Array<HistoryEntry>} visits
+ * @param {number} currentIndex
+ * @param {string} searchedTerm
+ * @return {boolean} Whether or not time gap separator is required.
+ * @private
+ */
+ HistoryItem.needsTimeGap = function(visits, currentIndex, searchedTerm) {
+ if (currentIndex >= visits.length - 1 || visits.length == 0)
+ return false;
+
+ var currentItem = visits[currentIndex];
+ var nextItem = visits[currentIndex + 1];
+
+ if (searchedTerm)
+ return currentItem.dateShort != nextItem.dateShort;
+
+ return currentItem.time - nextItem.time > BROWSING_GAP_TIME &&
+ currentItem.dateRelativeDay == nextItem.dateRelativeDay;
+ };
+
+ return { HistoryItem: HistoryItem };
+});
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @constructor
+ * @param {string} currentPath
+ */
+var SelectionTreeNode = function(currentPath) {
+ /** @type {string} */
+ this.currentPath = currentPath;
+ /** @type {boolean} */
+ this.leaf = false;
+ /** @type {Array<number>} */
+ this.indexes = [];
+ /** @type {Array<SelectionTreeNode>} */
+ this.children = [];
+};
+
+/**
+ * @param {number} index
+ * @param {string} path
+ */
+SelectionTreeNode.prototype.addChild = function(index, path) {
+ this.indexes.push(index);
+ this.children[index] = new SelectionTreeNode(path);
+};
+
+/** @polymerBehavior */
+var HistoryListBehavior = {
+ properties: {
+ /**
+ * Polymer paths to the history items contained in this list.
+ * @type {Array<string>} selectedPaths
+ */
+ selectedPaths: {
+ type: Array,
+ value: /** @return {Array<string>} */ function() { return []; }
+ },
+ },
+
+ listeners: {
+ 'history-checkbox-select': 'itemSelected_',
+ },
+
+ /**
+ * @param {number} historyDataLength
+ * @return {boolean}
+ * @private
+ */
+ hasResults: function(historyDataLength) { return historyDataLength > 0; },
+
+ /**
+ * @param {string} searchedTerm
+ * @param {boolean} isLoading
+ * @return {string}
+ * @private
+ */
+ noResultsMessage: function(searchedTerm, isLoading) {
+ if (isLoading)
+ return '';
+
+ var messageId = searchedTerm !== '' ? 'noSearchResults' : 'noResults';
+ return loadTimeData.getString(messageId);
+ },
+
+ /**
+ * Deselect each item in |selectedPaths|.
+ */
+ unselectAllItems: function() {
+ this.selectedPaths.forEach(function(path) {
+ this.set(path + '.selected', false);
+ }.bind(this));
+
+ this.selectedPaths = [];
+ },
+
+ /**
+ * Performs a request to the backend to delete all selected items. If
+ * successful, removes them from the view. Does not prompt the user before
+ * deleting -- see <history-list-container> for a version of this method which
+ * does prompt.
+ */
+ deleteSelected: function() {
+ var toBeRemoved = this.selectedPaths.map(function(path) {
+ return this.get(path);
+ }.bind(this));
+ md_history.BrowserService.getInstance()
+ .deleteItems(toBeRemoved)
+ .then(function() {
+ this.removeItemsByPath(this.selectedPaths);
+ this.fire('unselect-all');
+ }.bind(this));
+ },
+
+ /**
+ * Removes the history items in |paths|. Assumes paths are of a.0.b.0...
+ * structure.
+ *
+ * We want to use notifySplices to update the arrays for performance reasons
+ * which requires manually batching and sending the notifySplices for each
+ * level. To do this, we build a tree where each node is an array and then
+ * depth traverse it to remove items. Each time a node has all children
+ * deleted, we can also remove the node.
+ *
+ * @param {Array<string>} paths
+ * @private
+ */
+ removeItemsByPath: function(paths) {
+ if (paths.length == 0)
+ return;
+
+ this.removeItemsBeneathNode_(this.buildRemovalTree_(paths));
+ },
+
+ /**
+ * Creates the tree to traverse in order to remove |paths| from this list.
+ * Assumes paths are of a.0.b.0...
+ * structure.
+ *
+ * @param {Array<string>} paths
+ * @return {SelectionTreeNode}
+ * @private
+ */
+ buildRemovalTree_: function(paths) {
+ var rootNode = new SelectionTreeNode(paths[0].split('.')[0]);
+
+ // Build a tree to each history item specified in |paths|.
+ paths.forEach(function(path) {
+ var components = path.split('.');
+ var node = rootNode;
+ components.shift();
+ while (components.length > 1) {
+ var index = Number(components.shift());
+ var arrayName = components.shift();
+
+ if (!node.children[index])
+ node.addChild(index, [node.currentPath, index, arrayName].join('.'));
+
+ node = node.children[index];
+ }
+ node.leaf = true;
+ node.indexes.push(Number(components.shift()));
+ });
+
+ return rootNode;
+ },
+
+ /**
+ * Removes the history items underneath |node| and deletes container arrays as
+ * they become empty.
+ * @param {SelectionTreeNode} node
+ * @return {boolean} Whether this node's array should be deleted.
+ * @private
+ */
+ removeItemsBeneathNode_: function(node) {
+ var array = this.get(node.currentPath);
+ var splices = [];
+
+ node.indexes.sort(function(a, b) { return b - a; });
+ node.indexes.forEach(function(index) {
+ if (node.leaf || this.removeItemsBeneathNode_(node.children[index])) {
+ var item = array.splice(index, 1);
+ splices.push({
+ index: index,
+ removed: [item],
+ addedCount: 0,
+ object: array,
+ type: 'splice'
+ });
+ }
+ }.bind(this));
+
+ if (array.length == 0)
+ return true;
+
+ // notifySplices gives better performance than individually splicing as it
+ // batches all of the updates together.
+ this.notifySplices(node.currentPath, splices);
+ return false;
+ },
+
+ /**
+ * @param {Event} e
+ * @private
+ */
+ itemSelected_: function(e) {
+ var item = e.detail.element;
+ var path = item.path;
+ if (item.selected) {
+ this.push('selectedPaths', path);
+ return;
+ }
+
+ var index = this.selectedPaths.indexOf(path);
+ if (index != -1)
+ this.splice('selectedPaths', index, 1);
+ },
+};
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @typedef {{domain: string,
+ * visits: !Array<HistoryEntry>,
+ * rendered: boolean,
+ * expanded: boolean}}
+ */
+var HistoryDomain;
+
+/**
+ * @typedef {{title: string,
+ * domains: !Array<HistoryDomain>}}
+ */
+var HistoryGroup;
+
+Polymer({
+ is: 'history-grouped-list',
+
+ behaviors: [HistoryListBehavior],
+
+ properties: {
+ // An array of history entries in reverse chronological order.
+ historyData: {
+ type: Array,
+ },
+
+ /**
+ * @type {Array<HistoryGroup>}
+ */
+ groupedHistoryData_: {
+ type: Array,
+ },
+
+ searchedTerm: {
+ type: String,
+ value: ''
+ },
+
+ range: {
+ type: Number,
+ },
+
+ queryStartTime: String,
+ queryEndTime: String,
+ },
+
+ observers: [
+ 'updateGroupedHistoryData_(range, historyData)'
+ ],
+
+ /**
+ * Make a list of domains from visits.
+ * @param {!Array<!HistoryEntry>} visits
+ * @return {!Array<!HistoryDomain>}
+ */
+ createHistoryDomains_: function(visits) {
+ var domainIndexes = {};
+ var domains = [];
+
+ // Group the visits into a dictionary and generate a list of domains.
+ for (var i = 0, visit; visit = visits[i]; i++) {
+ var domain = visit.domain;
+ if (domainIndexes[domain] == undefined) {
+ domainIndexes[domain] = domains.length;
+ domains.push({
+ domain: domain,
+ visits: [],
+ expanded: false,
+ rendered: false,
+ });
+ }
+ domains[domainIndexes[domain]].visits.push(visit);
+ }
+ var sortByVisits = function(a, b) {
+ return b.visits.length - a.visits.length;
+ };
+ domains.sort(sortByVisits);
+
+ return domains;
+ },
+
+ updateGroupedHistoryData_: function() {
+ if (this.historyData.length == 0) {
+ this.groupedHistoryData_ = [];
+ return;
+ }
+
+ if (this.range == HistoryRange.WEEK) {
+ // Group each day into a list of results.
+ var days = [];
+ var currentDayVisits = [this.historyData[0]];
+
+ var pushCurrentDay = function() {
+ days.push({
+ title: this.searchedTerm ? currentDayVisits[0].dateShort :
+ currentDayVisits[0].dateRelativeDay,
+ domains: this.createHistoryDomains_(currentDayVisits),
+ });
+ }.bind(this);
+
+ var visitsSameDay = function(a, b) {
+ if (this.searchedTerm)
+ return a.dateShort == b.dateShort;
+
+ return a.dateRelativeDay == b.dateRelativeDay;
+ }.bind(this);
+
+ for (var i = 1; i < this.historyData.length; i++) {
+ var visit = this.historyData[i];
+ if (!visitsSameDay(visit, currentDayVisits[0])) {
+ pushCurrentDay();
+ currentDayVisits = [];
+ }
+ currentDayVisits.push(visit);
+ }
+ pushCurrentDay();
+
+ this.groupedHistoryData_ = days;
+ } else if (this.range == HistoryRange.MONTH) {
+ // Group each all visits into a single list.
+ this.groupedHistoryData_ = [{
+ title: this.queryStartTime + ' – ' + this.queryEndTime,
+ domains: this.createHistoryDomains_(this.historyData)
+ }];
+ }
+ },
+
+ /**
+ * @param {{model:Object, currentTarget:IronCollapseElement}} e
+ */
+ toggleDomainExpanded_: function(e) {
+ var collapse = e.currentTarget.parentNode.querySelector('iron-collapse');
+ e.model.set('domain.rendered', true);
+
+ // Give the history-items time to render.
+ setTimeout(function() { collapse.toggle() }, 0);
+ },
+
+ /**
+ * Check whether the time difference between the given history item and the
+ * next one is large enough for a spacer to be required.
+ * @param {number} groupIndex
+ * @param {number} domainIndex
+ * @param {number} itemIndex
+ * @return {boolean} Whether or not time gap separator is required.
+ * @private
+ */
+ needsTimeGap_: function(groupIndex, domainIndex, itemIndex) {
+ var visits =
+ this.groupedHistoryData_[groupIndex].domains[domainIndex].visits;
+
+ return md_history.HistoryItem.needsTimeGap(
+ visits, itemIndex, this.searchedTerm);
+ },
+
+ /**
+ * @param {number} groupIndex
+ * @param {number} domainIndex
+ * @param {number} itemIndex
+ * @return {string}
+ * @private
+ */
+ pathForItem_: function(groupIndex, domainIndex, itemIndex) {
+ return [
+ 'groupedHistoryData_', groupIndex, 'domains', domainIndex, 'visits',
+ itemIndex
+ ].join('.');
+ },
+
+ /**
+ * @param {HistoryDomain} domain
+ * @return {string}
+ * @private
+ */
+ getWebsiteIconStyle_: function(domain) {
+ return 'background-image: ' +
+ cr.icon.getFaviconImageSet(domain.visits[0].url);
+ },
+
+ /**
+ * @param {boolean} expanded
+ * @return {string}
+ * @private
+ */
+ getDropdownIcon_: function(expanded) {
+ return expanded ? 'cr:expand-less' : 'cr:expand-more';
+ },
+});
+/**
+ * `Polymer.IronScrollTargetBehavior` allows an element to respond to scroll events from a
+ * designated scroll target.
+ *
+ * Elements that consume this behavior can override the `_scrollHandler`
+ * method to add logic on the scroll event.
+ *
+ * @demo demo/scrolling-region.html Scrolling Region
+ * @demo demo/document.html Document Element
+ * @polymerBehavior
+ */
+ Polymer.IronScrollTargetBehavior = {
+
+ properties: {
+
+ /**
+ * Specifies the element that will handle the scroll event
+ * on the behalf of the current element. This is typically a reference to an element,
+ * but there are a few more posibilities:
+ *
+ * ### Elements id
+ *
+ *```html
+ * <div id="scrollable-element" style="overflow: auto;">
+ * <x-element scroll-target="scrollable-element">
+ * \x3c!-- Content--\x3e
+ * </x-element>
+ * </div>
+ *```
+ * In this case, the `scrollTarget` will point to the outer div element.
+ *
+ * ### Document scrolling
+ *
+ * For document scrolling, you can use the reserved word `document`:
+ *
+ *```html
+ * <x-element scroll-target="document">
+ * \x3c!-- Content --\x3e
+ * </x-element>
+ *```
+ *
+ * ### Elements reference
+ *
+ *```js
+ * appHeader.scrollTarget = document.querySelector('#scrollable-element');
+ *```
+ *
+ * @type {HTMLElement}
+ */
+ scrollTarget: {
+ type: HTMLElement,
+ value: function() {
+ return this._defaultScrollTarget;
+ }
+ }
+ },
+
+ observers: [
+ '_scrollTargetChanged(scrollTarget, isAttached)'
+ ],
+
+ _scrollTargetChanged: function(scrollTarget, isAttached) {
+ var eventTarget;
+
+ if (this._oldScrollTarget) {
+ eventTarget = this._oldScrollTarget === this._doc ? window : this._oldScrollTarget;
+ eventTarget.removeEventListener('scroll', this._boundScrollHandler);
+ this._oldScrollTarget = null;
+ }
+
+ if (!isAttached) {
+ return;
+ }
+ // Support element id references
+ if (scrollTarget === 'document') {
+
+ this.scrollTarget = this._doc;
+
+ } else if (typeof scrollTarget === 'string') {
+
+ this.scrollTarget = this.domHost ? this.domHost.$[scrollTarget] :
+ Polymer.dom(this.ownerDocument).querySelector('#' + scrollTarget);
+
+ } else if (this._isValidScrollTarget()) {
+
+ eventTarget = scrollTarget === this._doc ? window : scrollTarget;
+ this._boundScrollHandler = this._boundScrollHandler || this._scrollHandler.bind(this);
+ this._oldScrollTarget = scrollTarget;
+
+ eventTarget.addEventListener('scroll', this._boundScrollHandler);
+ }
+ },
+
+ /**
+ * Runs on every scroll event. Consumer of this behavior may override this method.
+ *
+ * @protected
+ */
+ _scrollHandler: function scrollHandler() {},
+
+ /**
+ * The default scroll target. Consumers of this behavior may want to customize
+ * the default scroll target.
+ *
+ * @type {Element}
+ */
+ get _defaultScrollTarget() {
+ return this._doc;
+ },
+
+ /**
+ * Shortcut for the document element
+ *
+ * @type {Element}
+ */
+ get _doc() {
+ return this.ownerDocument.documentElement;
+ },
+
+ /**
+ * Gets the number of pixels that the content of an element is scrolled upward.
+ *
+ * @type {number}
+ */
+ get _scrollTop() {
+ if (this._isValidScrollTarget()) {
+ return this.scrollTarget === this._doc ? window.pageYOffset : this.scrollTarget.scrollTop;
+ }
+ return 0;
+ },
+
+ /**
+ * Gets the number of pixels that the content of an element is scrolled to the left.
+ *
+ * @type {number}
+ */
+ get _scrollLeft() {
+ if (this._isValidScrollTarget()) {
+ return this.scrollTarget === this._doc ? window.pageXOffset : this.scrollTarget.scrollLeft;
+ }
+ return 0;
+ },
+
+ /**
+ * Sets the number of pixels that the content of an element is scrolled upward.
+ *
+ * @type {number}
+ */
+ set _scrollTop(top) {
+ if (this.scrollTarget === this._doc) {
+ window.scrollTo(window.pageXOffset, top);
+ } else if (this._isValidScrollTarget()) {
+ this.scrollTarget.scrollTop = top;
+ }
+ },
+
+ /**
+ * Sets the number of pixels that the content of an element is scrolled to the left.
+ *
+ * @type {number}
+ */
+ set _scrollLeft(left) {
+ if (this.scrollTarget === this._doc) {
+ window.scrollTo(left, window.pageYOffset);
+ } else if (this._isValidScrollTarget()) {
+ this.scrollTarget.scrollLeft = left;
+ }
+ },
+
+ /**
+ * Scrolls the content to a particular place.
+ *
+ * @method scroll
+ * @param {number} left The left position
+ * @param {number} top The top position
+ */
+ 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;
+ }
+ },
+
+ /**
+ * Gets the width of the scroll target.
+ *
+ * @type {number}
+ */
+ get _scrollTargetWidth() {
+ if (this._isValidScrollTarget()) {
+ return this.scrollTarget === this._doc ? window.innerWidth : this.scrollTarget.offsetWidth;
+ }
+ return 0;
+ },
+
+ /**
+ * 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;
+ },
+
+ /**
+ * Returns true if the scroll target is a valid HTMLElement.
+ *
+ * @return {boolean}
+ */
+ _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 HIDDEN_Y = '-10000px';
+ var DEFAULT_GRID_SIZE = 200;
+ var SECRET_TABINDEX = -100;
+
+ 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 max count of physical items the pool can extend to.
+ */
+ maxPhysicalCount: {
+ type: Number,
+ value: 500
+ },
+
+ /**
+ * The name of the variable to add to the binding scope for the array
+ * element associated with a given template instance.
+ */
+ as: {
+ type: String,
+ value: 'item'
+ },
+
+ /**
+ * The name of the variable to add to the binding scope with the index
+ * for the row.
+ */
+ indexAs: {
+ type: String,
+ value: 'index'
+ },
+
+ /**
+ * The name of the variable to add to the binding scope to indicate
+ * if the row is selected.
+ */
+ selectedAs: {
+ type: String,
+ value: 'selected'
+ },
+
+ /**
+ * When true, the list is rendered as a grid. Grid items must have
+ * fixed width and height set via CSS. e.g.
+ *
+ * ```html
+ * <iron-list grid>
+ * <template>
+ * <div style="width: 100px; height: 100px;"> 100x100 </div>
+ * </template>
+ * </iron-list>
+ * ```
+ */
+ grid: {
+ type: Boolean,
+ value: false,
+ reflectToAttribute: true
+ },
+
+ /**
+ * When true, tapping a row will select the item, placing its data model
+ * in the set of selected items retrievable via the selection property.
+ *
+ * Note that tapping focusable elements within the list item will not
+ * result in selection, since they are presumed to have their * own action.
+ */
+ selectionEnabled: {
+ type: Boolean,
+ value: false
+ },
+
+ /**
+ * When `multiSelection` is false, this is the currently selected item, or `null`
+ * if no item is selected.
+ */
+ selectedItem: {
+ type: Object,
+ notify: true
+ },
+
+ /**
+ * When `multiSelection` is true, this is an array that contains the selected items.
+ */
+ selectedItems: {
+ type: Object,
+ notify: true
+ },
+
+ /**
+ * When `true`, multiple items may be selected at once (in this case,
+ * `selected` is an array of currently selected items). When `false`,
+ * only one item may be selected at a time.
+ */
+ multiSelection: {
+ type: Boolean,
+ value: false
+ }
+ },
+
+ observers: [
+ '_itemsChanged(items.*)',
+ '_selectionEnabledChanged(selectionEnabled)',
+ '_multiSelectionChanged(multiSelection)',
+ '_setOverflow(scrollTarget)'
+ ],
+
+ behaviors: [
+ Polymer.Templatizer,
+ Polymer.IronResizableBehavior,
+ Polymer.IronA11yKeysBehavior,
+ Polymer.IronScrollTargetBehavior
+ ],
+
+ keyBindings: {
+ 'up': '_didMoveUp',
+ 'down': '_didMoveDown',
+ 'enter': '_didEnter'
+ },
+
+ /**
+ * The ratio of hidden tiles that should remain in the scroll direction.
+ * Recommended value ~0.5, so it will distribute tiles evely in both directions.
+ */
+ _ratio: 0.5,
+
+ /**
+ * The padding-top value for the list.
+ */
+ _scrollerPaddingTop: 0,
+
+ /**
+ * This value is the same as `scrollTop`.
+ */
+ _scrollPosition: 0,
+
+ /**
+ * The sum of the heights of all the tiles in the DOM.
+ */
+ _physicalSize: 0,
+
+ /**
+ * The average `offsetHeight` of the tiles observed till now.
+ */
+ _physicalAverage: 0,
+
+ /**
+ * The number of tiles which `offsetHeight` > 0 observed until now.
+ */
+ _physicalAverageCount: 0,
+
+ /**
+ * The Y position of the item rendered in the `_physicalStart`
+ * tile relative to the scrolling list.
+ */
+ _physicalTop: 0,
+
+ /**
+ * The number of items in the list.
+ */
+ _virtualCount: 0,
+
+ /**
+ * A map between an item key and its physical item index
+ */
+ _physicalIndexForKey: null,
+
+ /**
+ * The estimated scroll height based on `_physicalAverage`
+ */
+ _estScrollHeight: 0,
+
+ /**
+ * The scroll height of the dom node
+ */
+ _scrollHeight: 0,
+
+ /**
+ * The height of the list. This is referred as the viewport in the context of list.
+ */
+ _viewportHeight: 0,
+
+ /**
+ * The width of the list. This is referred as the viewport in the context of list.
+ */
+ _viewportWidth: 0,
+
+ /**
+ * An array of DOM nodes that are currently in the tree
+ * @type {?Array<!TemplatizerNode>}
+ */
+ _physicalItems: null,
+
+ /**
+ * An array of heights for each item in `_physicalItems`
+ * @type {?Array<number>}
+ */
+ _physicalSizes: null,
+
+ /**
+ * A cached value for the first visible index.
+ * See `firstVisibleIndex`
+ * @type {?number}
+ */
+ _firstVisibleIndexVal: null,
+
+ /**
+ * A cached value for the last visible index.
+ * See `lastVisibleIndex`
+ * @type {?number}
+ */
+ _lastVisibleIndexVal: null,
+
+ /**
+ * A Polymer collection for the items.
+ * @type {?Polymer.Collection}
+ */
+ _collection: null,
+
+ /**
+ * True if the current item list was rendered for the first time
+ * after attached.
+ */
+ _itemsRendered: false,
+
+ /**
+ * The page that is currently rendered.
+ */
+ _lastPage: null,
+
+ /**
+ * The max number of pages to render. One page is equivalent to the height of the list.
+ */
+ _maxPages: 3,
+
+ /**
+ * The currently focused physical item.
+ */
+ _focusedItem: null,
+
+ /**
+ * The index of the `_focusedItem`.
+ */
+ _focusedIndex: -1,
+
+ /**
+ * The the item that is focused if it is moved offscreen.
+ * @private {?TemplatizerNode}
+ */
+ _offscreenFocusedItem: null,
+
+ /**
+ * The item that backfills the `_offscreenFocusedItem` in the physical items
+ * list when that item is moved offscreen.
+ */
+ _focusBackfillItem: null,
+
+ /**
+ * The maximum items per row
+ */
+ _itemsPerRow: 1,
+
+ /**
+ * The width of each grid item
+ */
+ _itemWidth: 0,
+
+ /**
+ * The height of the row in grid layout.
+ */
+ _rowHeight: 0,
+
+ /**
+ * The bottom of the physical content.
+ */
+ get _physicalBottom() {
+ return this._physicalTop + this._physicalSize;
+ },
+
+ /**
+ * The bottom of the scroll.
+ */
+ get _scrollBottom() {
+ return this._scrollPosition + this._viewportHeight;
+ },
+
+ /**
+ * The n-th item rendered in the last physical item.
+ */
+ get _virtualEnd() {
+ return this._virtualStart + this._physicalCount - 1;
+ },
+
+ /**
+ * The height of the physical content that isn't on the screen.
+ */
+ get _hiddenContentSize() {
+ var size = this.grid ? this._physicalRows * this._rowHeight : this._physicalSize;
+ return size - this._viewportHeight;
+ },
+
+ /**
+ * The maximum scroll top value.
+ */
+ get _maxScrollTop() {
+ return this._estScrollHeight - this._viewportHeight + this._scrollerPaddingTop;
+ },
+
+ /**
+ * The lowest n-th value for an item such that it can be rendered in `_physicalStart`.
+ */
+ _minVirtualStart: 0,
+
+ /**
+ * 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 n-th item rendered in the `_physicalStart` tile.
+ */
+ _virtualStartVal: 0,
+
+ set _virtualStart(val) {
+ this._virtualStartVal = Math.min(this._maxVirtualStart, Math.max(this._minVirtualStart, val));
+ },
+
+ get _virtualStart() {
+ return this._virtualStartVal || 0;
+ },
+
+ /**
+ * The k-th tile that is at the top of the scrolling list.
+ */
+ _physicalStartVal: 0,
+
+ 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;
+ },
+
+ get _physicalStart() {
+ return this._physicalStartVal || 0;
+ },
+
+ /**
+ * The number of tiles in the DOM.
+ */
+ _physicalCountVal: 0,
+
+ set _physicalCount(val) {
+ this._physicalCountVal = val;
+ this._physicalEnd = (this._physicalStart + this._physicalCount - 1) % this._physicalCount;
+ },
+
+ get _physicalCount() {
+ return this._physicalCountVal;
+ },
+
+ /**
+ * The k-th tile that is at the bottom of the scrolling list.
+ */
+ _physicalEnd: 0,
- /**
- * If provided, this will be the element that will be focused when
- * the dropdown opens.
- */
- focusTarget: {
- type: Object
- },
+ /**
+ * 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.
+ */
+ get _optPhysicalSize() {
+ if (this.grid) {
+ return this._estRowsInView * this._rowHeight * this._maxPages;
+ }
+ return this._viewportHeight * this._maxPages;
+ },
- /**
- * Set to true to disable animations when opening and closing the
- * dropdown.
- */
- noAnimations: {
- type: Boolean,
- value: false
- },
+ get _optPhysicalCount() {
+ return this._estRowsInView * this._itemsPerRow * this._maxPages;
+ },
- /**
- * By default, the dropdown will constrain scrolling on the page
- * to itself when opened.
- * Set to true in order to prevent scroll from being constrained
- * to the dropdown when it opens.
- */
- allowOutsideScroll: {
- type: Boolean,
- value: false
- },
+ /**
+ * True if the current list is visible.
+ */
+ get _isVisible() {
+ return this.scrollTarget && Boolean(this.scrollTarget.offsetWidth || this.scrollTarget.offsetHeight);
+ },
- /**
- * Callback for scroll events.
- * @type {Function}
- * @private
- */
- _boundOnCaptureScroll: {
- type: Function,
- value: function() {
- return this._onCaptureScroll.bind(this);
+ /**
+ * Gets the index of the first visible item in the viewport.
+ *
+ * @type {number}
+ */
+ get firstVisibleIndex() {
+ if (this._firstVisibleIndexVal === null) {
+ var physicalOffset = Math.floor(this._physicalTop + this._scrollerPaddingTop);
+
+ this._firstVisibleIndexVal = this._iterateItems(
+ function(pidx, vidx) {
+ physicalOffset += this._getPhysicalSizeIncrement(pidx);
+
+ if (physicalOffset > this._scrollPosition) {
+ return this.grid ? vidx - (vidx % this._itemsPerRow) : vidx;
}
- }
- },
+ // Handle a partially rendered final row in grid mode
+ if (this.grid && this._virtualCount - 1 === vidx) {
+ return vidx - (vidx % this._itemsPerRow);
+ }
+ }) || 0;
+ }
+ return this._firstVisibleIndexVal;
+ },
+
+ /**
+ * Gets the index of the last visible item in the viewport.
+ *
+ * @type {number}
+ */
+ get lastVisibleIndex() {
+ if (this._lastVisibleIndexVal === null) {
+ if (this.grid) {
+ var lastIndex = this.firstVisibleIndex + this._estRowsInView * this._itemsPerRow - 1;
+ this._lastVisibleIndexVal = Math.min(this._virtualCount, lastIndex);
+ } else {
+ var physicalOffset = this._physicalTop;
+ this._iterateItems(function(pidx, vidx) {
+ if (physicalOffset < this._scrollBottom) {
+ this._lastVisibleIndexVal = vidx;
+ } else {
+ // Break _iterateItems
+ return true;
+ }
+ physicalOffset += this._getPhysicalSizeIncrement(pidx);
+ });
+ }
+ }
+ return this._lastVisibleIndexVal;
+ },
+
+ get _defaultScrollTarget() {
+ return this;
+ },
+ get _virtualRowCount() {
+ return Math.ceil(this._virtualCount / this._itemsPerRow);
+ },
+
+ get _estRowsInView() {
+ return Math.ceil(this._viewportHeight / this._rowHeight);
+ },
+
+ get _physicalRows() {
+ return Math.ceil(this._physicalCount / this._itemsPerRow);
+ },
+
+ ready: function() {
+ this.addEventListener('focus', this._didFocus.bind(this), true);
+ },
+
+ attached: function() {
+ this.updateViewportBoundaries();
+ this._render();
+ // `iron-resize` is fired when the list is attached if the event is added
+ // before attached causing unnecessary work.
+ this.listen(this, 'iron-resize', '_resizeHandler');
+ },
+
+ detached: function() {
+ this._itemsRendered = false;
+ this.unlisten(this, 'iron-resize', '_resizeHandler');
+ },
+
+ /**
+ * Set the overflow property if this element has its own scrolling region
+ */
+ _setOverflow: function(scrollTarget) {
+ this.style.webkitOverflowScrolling = scrollTarget === this ? 'touch' : '';
+ this.style.overflow = scrollTarget === this ? 'auto' : '';
+ },
+
+ /**
+ * Invoke this method if you dynamically update the viewport's
+ * size or CSS padding.
+ *
+ * @method updateViewportBoundaries
+ */
+ updateViewportBoundaries: function() {
+ this._scrollerPaddingTop = this.scrollTarget === this ? 0 :
+ parseInt(window.getComputedStyle(this)['padding-top'], 10);
+
+ this._viewportHeight = this._scrollTargetHeight;
+ if (this.grid) {
+ this._updateGridMetrics();
+ }
+ },
+
+ /**
+ * Update the models, the position of the
+ * items in the viewport and recycle tiles as needed.
+ */
+ _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 = [];
+
+ // track the last `scrollTop`
+ this._scrollPosition = scrollTop;
+
+ // clear cached visible indexes
+ this._firstVisibleIndexVal = null;
+ this._lastVisibleIndexVal = null;
+
+ scrollBottom = this._scrollBottom;
+ physicalBottom = this._physicalBottom;
+
+ // random access
+ if (Math.abs(delta) > this._physicalSize) {
+ this._physicalTop += delta;
+ recycledTiles = Math.round(delta / this._physicalAverage);
+ }
+ // scroll up
+ else if (delta < 0) {
+ var topSpace = scrollTop - this._physicalTop;
+ var virtualStart = this._virtualStart;
+
+ recycledTileSet = [];
+
+ kth = this._physicalEnd;
+ currentRatio = topSpace / hiddenContentSize;
+
+ // move tiles from bottom to top
+ while (
+ // approximate `currentRatio` to `ratio`
+ currentRatio < ratio &&
+ // recycle less physical items than the total
+ recycledTiles < this._physicalCount &&
+ // ensure that these recycled tiles are needed
+ virtualStart - recycledTiles > 0 &&
+ // ensure that the tile is not visible
+ physicalBottom - this._getPhysicalSizeIncrement(kth) > scrollBottom
+ ) {
+
+ tileHeight = this._getPhysicalSizeIncrement(kth);
+ currentRatio += tileHeight / hiddenContentSize;
+ physicalBottom -= tileHeight;
+ recycledTileSet.push(kth);
+ recycledTiles++;
+ kth = (kth === 0) ? this._physicalCount - 1 : kth - 1;
+ }
+
+ movingUp = recycledTileSet;
+ recycledTiles = -recycledTiles;
+ }
+ // scroll down
+ else if (delta > 0) {
+ var bottomSpace = physicalBottom - scrollBottom;
+ var virtualEnd = this._virtualEnd;
+ var lastVirtualItemIndex = this._virtualCount-1;
+
+ 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._getPhysicalSizeIncrement(kth) < scrollTop
+ ) {
- listeners: {
- 'neon-animation-finish': '_onNeonAnimationFinish'
- },
+ tileHeight = this._getPhysicalSizeIncrement(kth);
+ currentRatio += tileHeight / hiddenContentSize;
- observers: [
- '_updateOverlayPosition(positionTarget, verticalAlign, horizontalAlign, verticalOffset, horizontalOffset)'
- ],
+ this._physicalTop += tileHeight;
+ recycledTileSet.push(kth);
+ recycledTiles++;
+ kth = (kth + 1) % this._physicalCount;
+ }
+ }
- /**
- * The element that is contained by the dropdown, if any.
- */
- get containedElement() {
- return Polymer.dom(this.$.content).getDistributedNodes()[0];
- },
+ if (recycledTiles === 0) {
+ // Try to increase the pool if the list's client height isn't filled up with physical items
+ if (physicalBottom < scrollBottom || this._physicalTop > scrollTop) {
+ this._increasePoolIfNeeded();
+ }
+ } else {
+ this._virtualStart = this._virtualStart + recycledTiles;
+ this._physicalStart = this._physicalStart + recycledTiles;
+ this._update(recycledTileSet, movingUp);
+ }
+ },
- /**
- * The element that should be focused when the dropdown opens.
- * @deprecated
- */
- get _focusTarget() {
- return this.focusTarget || this.containedElement;
- },
+ /**
+ * Update the list of items, starting from the `_virtualStart` item.
+ * @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) {
+ var idx = movingUp.pop();
+ this._physicalTop -= this._getPhysicalSizeIncrement(idx);
+ }
+ }
+ // update the position of the items
+ this._positionItems();
+ // set the scroller size
+ this._updateScrollerSize();
+ // increase the pool of physical items
+ this._increasePoolIfNeeded();
+ },
- ready: function() {
- // Memoized scrolling position, used to block scrolling outside.
- this._scrollTop = 0;
- this._scrollLeft = 0;
- // Used to perform a non-blocking refit on scroll.
- this._refitOnScrollRAF = null;
- },
+ /**
+ * Creates a pool of DOM elements and attaches them to the local dom.
+ */
+ _createPool: function(size) {
+ var physicalItems = new Array(size);
- detached: function() {
- this.cancelAnimation();
- Polymer.IronDropdownScrollManager.removeScrollLock(this);
- },
+ this._ensureTemplatized();
- /**
- * Called when the value of `opened` changes.
- * Overridden from `IronOverlayBehavior`
- */
- _openedChanged: function() {
- if (this.opened && this.disabled) {
- this.cancel();
- } else {
- this.cancelAnimation();
- this.sizingTarget = this.containedElement || this.sizingTarget;
- this._updateAnimationConfig();
- this._saveScrollPosition();
- if (this.opened) {
- document.addEventListener('scroll', this._boundOnCaptureScroll);
- !this.allowOutsideScroll && Polymer.IronDropdownScrollManager.pushScrollLock(this);
- } else {
- document.removeEventListener('scroll', this._boundOnCaptureScroll);
- Polymer.IronDropdownScrollManager.removeScrollLock(this);
- }
- Polymer.IronOverlayBehaviorImpl._openedChanged.apply(this, arguments);
- }
- },
+ 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;
+ },
- /**
- * Overridden from `IronOverlayBehavior`.
- */
- _renderOpened: function() {
- if (!this.noAnimations && this.animationConfig.open) {
- this.$.contentWrapper.classList.add('animating');
- this.playAnimation('open');
- } else {
- Polymer.IronOverlayBehaviorImpl._renderOpened.apply(this, arguments);
- }
- },
+ /**
+ * Increases the pool of physical items only if needed.
+ *
+ * @return {boolean} True if the pool was increased.
+ */
+ _increasePoolIfNeeded: function() {
+ // Base case 1: the list has no height.
+ if (this._viewportHeight === 0) {
+ return false;
+ }
+ // Base case 2: If the physical size is optimal and the list's client height is full
+ // with physical items, don't increase the pool.
+ var isClientHeightFull = this._physicalBottom >= this._scrollBottom && this._physicalTop <= this._scrollPosition;
+ if (this._physicalSize >= this._optPhysicalSize && isClientHeightFull) {
+ return false;
+ }
+ // this value should range between [0 <= `currentPage` <= `_maxPages`]
+ var currentPage = Math.floor(this._physicalSize / this._viewportHeight);
- /**
- * Overridden from `IronOverlayBehavior`.
- */
- _renderClosed: function() {
+ if (currentPage === 0) {
+ // fill the first page
+ this._debounceTemplate(this._increasePool.bind(this, Math.round(this._physicalCount * 0.5)));
+ } else if (this._lastPage !== currentPage && isClientHeightFull) {
+ // 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, this._itemsPerRow), 16));
+ } else {
+ // fill the rest of the pages
+ this._debounceTemplate(this._increasePool.bind(this, this._itemsPerRow));
+ }
- if (!this.noAnimations && this.animationConfig.close) {
- this.$.contentWrapper.classList.add('animating');
- this.playAnimation('close');
- } else {
- Polymer.IronOverlayBehaviorImpl._renderClosed.apply(this, arguments);
- }
- },
+ this._lastPage = currentPage;
- /**
- * Called when animation finishes on the dropdown (when opening or
- * closing). Responsible for "completing" the process of opening or
- * closing the dropdown by positioning it or setting its display to
- * none.
- */
- _onNeonAnimationFinish: function() {
- this.$.contentWrapper.classList.remove('animating');
- if (this.opened) {
- this._finishRenderOpened();
- } else {
- this._finishRenderClosed();
- }
- },
+ return true;
+ },
- _onCaptureScroll: function() {
- if (!this.allowOutsideScroll) {
- this._restoreScrollPosition();
- } else {
- this._refitOnScrollRAF && window.cancelAnimationFrame(this._refitOnScrollRAF);
- this._refitOnScrollRAF = window.requestAnimationFrame(this.refit.bind(this));
- }
- },
+ /**
+ * Increases the pool size.
+ */
+ _increasePool: function(missingItems) {
+ var nextPhysicalCount = Math.min(
+ this._physicalCount + missingItems,
+ this._virtualCount - this._virtualStart,
+ Math.max(this.maxPhysicalCount, DEFAULT_PHYSICAL_COUNT)
+ );
+ var prevPhysicalCount = this._physicalCount;
+ var delta = nextPhysicalCount - prevPhysicalCount;
- /**
- * Memoizes the scroll position of the outside scrolling element.
- * @private
- */
- _saveScrollPosition: function() {
- if (document.scrollingElement) {
- this._scrollTop = document.scrollingElement.scrollTop;
- this._scrollLeft = document.scrollingElement.scrollLeft;
- } else {
- // Since we don't know if is the body or html, get max.
- this._scrollTop = Math.max(document.documentElement.scrollTop, document.body.scrollTop);
- this._scrollLeft = Math.max(document.documentElement.scrollLeft, document.body.scrollLeft);
- }
- },
+ if (delta <= 0) {
+ return;
+ }
- /**
- * Resets the scroll position of the outside scrolling element.
- * @private
- */
- _restoreScrollPosition: function() {
- if (document.scrollingElement) {
- document.scrollingElement.scrollTop = this._scrollTop;
- document.scrollingElement.scrollLeft = this._scrollLeft;
- } else {
- // Since we don't know if is the body or html, set both.
- document.documentElement.scrollTop = this._scrollTop;
- document.documentElement.scrollLeft = this._scrollLeft;
- document.body.scrollTop = this._scrollTop;
- document.body.scrollLeft = this._scrollLeft;
- }
- },
+ [].push.apply(this._physicalItems, this._createPool(delta));
+ [].push.apply(this._physicalSizes, new Array(delta));
- /**
- * Constructs the final animation config from different properties used
- * to configure specific parts of the opening and closing animations.
- */
- _updateAnimationConfig: function() {
- var animations = (this.openAnimationConfig || []).concat(this.closeAnimationConfig || []);
- for (var i = 0; i < animations.length; i++) {
- animations[i].node = this.containedElement;
- }
- this.animationConfig = {
- open: this.openAnimationConfig,
- close: this.closeAnimationConfig
- };
- },
+ 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;
+ }
+ this._update();
+ },
- /**
- * Updates the overlay position based on configured horizontal
- * and vertical alignment.
- */
- _updateOverlayPosition: function() {
- if (this.isAttached) {
- // This triggers iron-resize, and iron-overlay-behavior will call refit if needed.
- this.notifyResize();
- }
- },
+ /**
+ * 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.
+ */
+ _render: function() {
+ var requiresUpdate = this._virtualCount > 0 || this._physicalCount > 0;
- /**
- * Apply focus to focusTarget or containedElement
- */
- _applyFocus: function () {
- var focusTarget = this.focusTarget || this.containedElement;
- if (focusTarget && this.opened && !this.noAutoFocus) {
- focusTarget.focus();
- } else {
- Polymer.IronOverlayBehaviorImpl._applyFocus.apply(this, arguments);
- }
- }
- });
- })();
-Polymer({
+ if (this.isAttached && !this._itemsRendered && this._isVisible && requiresUpdate) {
+ this._lastPage = 0;
+ this._update();
+ this._itemsRendered = true;
+ }
+ },
- is: 'fade-in-animation',
+ /**
+ * Templetizes the user template.
+ */
+ _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;
- behaviors: [
- Polymer.NeonAnimationBehavior
- ],
+ this._instanceProps = props;
+ this._userTemplate = Polymer.dom(this).querySelector('template');
- configure: function(config) {
- var node = config.node;
- this._effect = new KeyframeEffect(node, [
- {'opacity': '0'},
- {'opacity': '1'}
- ], this.timingFromConfig(config));
- return this._effect;
- }
+ if (this._userTemplate) {
+ this.templatize(this._userTemplate);
+ } else {
+ console.warn('iron-list requires a template to be provided in light-dom');
+ }
+ }
+ },
- });
-Polymer({
+ /**
+ * Implements extension point from Templatizer mixin.
+ */
+ _getStampedChildren: function() {
+ return this._physicalItems;
+ },
- is: 'fade-out-animation',
+ /**
+ * 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.
+ */
+ _forwardInstancePath: function(inst, path, value) {
+ if (path.indexOf(this.as + '.') === 0) {
+ this.notifyPath('items.' + inst.__key__ + '.' +
+ path.slice(this.as.length + 1), value);
+ }
+ },
- behaviors: [
- Polymer.NeonAnimationBehavior
- ],
+ /**
+ * 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);
+ }
+ },
- configure: function(config) {
- var node = config.node;
- this._effect = new KeyframeEffect(node, [
- {'opacity': '1'},
- {'opacity': '0'}
- ], this.timingFromConfig(config));
- return this._effect;
- }
+ /**
+ * Implements extension point from Templatizer
+ * Called as side-effect of a host path change, responsible for
+ * notifying parent.<path> path change on each row.
+ */
+ _forwardParentPath: function(path, value) {
+ if (this._physicalItems) {
+ this._physicalItems.forEach(function(item) {
+ item._templateInstance.notifyPath(path, value, true);
+ }, this);
+ }
+ },
- });
-Polymer({
- is: 'paper-menu-grow-height-animation',
+ /**
+ * Called as a side effect of a host items.<key>.<path> path change,
+ * responsible for notifying item.<path> changes.
+ */
+ _forwardItemPath: function(path, value) {
+ if (!this._physicalIndexForKey) {
+ return;
+ }
+ var dot = path.indexOf('.');
+ var key = path.substring(0, dot < 0 ? path.length : dot);
+ var idx = this._physicalIndexForKey[key];
+ var offscreenItem = this._offscreenFocusedItem;
+ var el = offscreenItem && offscreenItem._templateInstance.__key__ === key ?
+ offscreenItem : this._physicalItems[idx];
- behaviors: [
- Polymer.NeonAnimationBehavior
- ],
+ if (!el || el._templateInstance.__key__ !== key) {
+ return;
+ }
+ if (dot >= 0) {
+ path = this.as + '.' + path.substring(dot+1);
+ el._templateInstance.notifyPath(path, value, true);
+ } else {
+ // Update selection if needed
+ var currentItem = el._templateInstance[this.as];
+ if (Array.isArray(this.selectedItems)) {
+ for (var i = 0; i < this.selectedItems.length; i++) {
+ if (this.selectedItems[i] === currentItem) {
+ this.set('selectedItems.' + i, value);
+ break;
+ }
+ }
+ } else if (this.selectedItem === currentItem) {
+ this.set('selectedItem', value);
+ }
+ el._templateInstance[this.as] = value;
+ }
+ },
- configure: function(config) {
- var node = config.node;
- var rect = node.getBoundingClientRect();
- var height = rect.height;
+ /**
+ * Called when the items have changed. That is, ressignments
+ * to `items`, splices or updates to a single item.
+ */
+ _itemsChanged: function(change) {
+ if (change.path === 'items') {
+ // 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._firstVisibleIndexVal = null;
+ this._lastVisibleIndexVal = null;
- this._effect = new KeyframeEffect(node, [{
- height: (height / 2) + 'px'
- }, {
- height: height + 'px'
- }], this.timingFromConfig(config));
+ 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);
+ }
- return this._effect;
- }
- });
+ this._physicalStart = 0;
- Polymer({
- is: 'paper-menu-grow-width-animation',
+ } else if (change.path === 'items.splices') {
- behaviors: [
- Polymer.NeonAnimationBehavior
- ],
+ this._adjustVirtualIndex(change.value.indexSplices);
+ this._virtualCount = this.items ? this.items.length : 0;
- configure: function(config) {
- var node = config.node;
- var rect = node.getBoundingClientRect();
- var width = rect.width;
+ } else {
+ // update a single item
+ this._forwardItemPath(change.path.split('.').slice(1).join('.'), change.value);
+ return;
+ }
- this._effect = new KeyframeEffect(node, [{
- width: (width / 2) + 'px'
- }, {
- width: width + 'px'
- }], this.timingFromConfig(config));
+ this._itemsRendered = false;
+ this._debounceTemplate(this._render);
+ },
- return this._effect;
- }
- });
+ /**
+ * @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);
- Polymer({
- is: 'paper-menu-shrink-width-animation',
+ this._virtualStart = this._virtualStart + delta;
+
+ if (this._focusedIndex >= 0) {
+ this._focusedIndex = this._focusedIndex + delta;
+ }
+ }
+ }, this);
+ },
- behaviors: [
- Polymer.NeonAnimationBehavior
- ],
+ _removeItem: function(item) {
+ this.$.selector.deselect(item);
+ // remove the current focused item
+ if (this._focusedItem && this._focusedItem._templateInstance[this.as] === item) {
+ this._removeFocusedItem();
+ }
+ },
- configure: function(config) {
- var node = config.node;
- var rect = node.getBoundingClientRect();
- var width = rect.width;
+ /**
+ * 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._effect = new KeyframeEffect(node, [{
- width: width + 'px'
- }, {
- width: width - (width / 20) + 'px'
- }], this.timingFromConfig(config));
+ if (arguments.length === 2 && itemSet) {
+ for (i = 0; i < itemSet.length; i++) {
+ pidx = itemSet[i];
+ vidx = this._computeVidx(pidx);
+ if ((rtn = fn.call(this, pidx, vidx)) != null) {
+ return rtn;
+ }
+ }
+ } else {
+ pidx = this._physicalStart;
+ vidx = this._virtualStart;
- return this._effect;
- }
- });
+ 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;
+ }
+ }
+ }
+ },
- Polymer({
- is: 'paper-menu-shrink-height-animation',
+ /**
+ * Returns the virtual index for a given physical index
+ *
+ * @param {number} pidx Physical index
+ * @return {number}
+ */
+ _computeVidx: function(pidx) {
+ if (pidx >= this._physicalStart) {
+ return this._virtualStart + (pidx - this._physicalStart);
+ }
+ return this._virtualStart + (this._physicalCount - this._physicalStart) + pidx;
+ },
- behaviors: [
- Polymer.NeonAnimationBehavior
- ],
+ /**
+ * Assigns the data models to a given set of items.
+ * @param {!Array<number>=} itemSet
+ */
+ _assignModels: function(itemSet) {
+ this._iterateItems(function(pidx, vidx) {
+ var el = this._physicalItems[pidx];
+ var inst = el._templateInstance;
+ var item = this.items && this.items[vidx];
- configure: function(config) {
- var node = config.node;
- var rect = node.getBoundingClientRect();
- var height = rect.height;
- var top = rect.top;
+ 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', '');
+ }
+ }, itemSet);
+ },
- this.setPrefixedProperty(node, 'transformOrigin', '0 0');
+ /**
+ * 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();
- this._effect = new KeyframeEffect(node, [{
- height: height + 'px',
- transform: 'translateY(0)'
- }, {
- height: height / 2 + 'px',
- transform: 'translateY(-20px)'
- }], this.timingFromConfig(config));
+ var newPhysicalSize = 0;
+ var oldPhysicalSize = 0;
+ var prevAvgCount = this._physicalAverageCount;
+ var prevPhysicalAvg = this._physicalAverage;
- return this._effect;
- }
- });
-(function() {
- 'use strict';
+ this._iterateItems(function(pidx, vidx) {
- var config = {
- ANIMATION_CUBIC_BEZIER: 'cubic-bezier(.3,.95,.5,1)',
- MAX_ANIMATION_TIME_MS: 400
- };
+ oldPhysicalSize += this._physicalSizes[pidx] || 0;
+ this._physicalSizes[pidx] = this._physicalItems[pidx].offsetHeight;
+ newPhysicalSize += this._physicalSizes[pidx];
+ this._physicalAverageCount += this._physicalSizes[pidx] ? 1 : 0;
- var PaperMenuButton = Polymer({
- is: 'paper-menu-button',
+ }, itemSet);
- /**
- * Fired when the dropdown opens.
- *
- * @event paper-dropdown-open
- */
+ this._viewportHeight = this._scrollTargetHeight;
+ if (this.grid) {
+ this._updateGridMetrics();
+ this._physicalSize = Math.ceil(this._physicalCount / this._itemsPerRow) * this._rowHeight;
+ } else {
+ this._physicalSize = this._physicalSize + newPhysicalSize - oldPhysicalSize;
+ }
- /**
- * Fired when the dropdown closes.
- *
- * @event paper-dropdown-close
- */
+ // update the average if we measured something
+ if (this._physicalAverageCount !== prevAvgCount) {
+ this._physicalAverage = Math.round(
+ ((prevPhysicalAvg * prevAvgCount) + newPhysicalSize) /
+ this._physicalAverageCount);
+ }
+ },
- behaviors: [
- Polymer.IronA11yKeysBehavior,
- Polymer.IronControlState
- ],
+ _updateGridMetrics: function() {
+ this._viewportWidth = this.$.items.offsetWidth;
+ // Set item width to the value of the _physicalItems offsetWidth
+ this._itemWidth = this._physicalCount > 0 ? this._physicalItems[0].getBoundingClientRect().width : DEFAULT_GRID_SIZE;
+ // Set row height to the value of the _physicalItems offsetHeight
+ this._rowHeight = this._physicalCount > 0 ? this._physicalItems[0].offsetHeight : DEFAULT_GRID_SIZE;
+ // If in grid mode compute how many items with exist in each row
+ this._itemsPerRow = this._itemWidth ? Math.floor(this._viewportWidth / this._itemWidth) : this._itemsPerRow;
+ },
- properties: {
- /**
- * True if the content is currently displayed.
- */
- opened: {
- type: Boolean,
- value: false,
- notify: true,
- observer: '_openedChanged'
- },
+ /**
+ * Updates the position of the physical items.
+ */
+ _positionItems: function() {
+ this._adjustScrollPosition();
- /**
- * The orientation against which to align the menu dropdown
- * horizontally relative to the dropdown trigger.
- */
- horizontalAlign: {
- type: String,
- value: 'left',
- reflectToAttribute: true
- },
+ var y = this._physicalTop;
- /**
- * The orientation against which to align the menu dropdown
- * vertically relative to the dropdown trigger.
- */
- verticalAlign: {
- type: String,
- value: 'top',
- reflectToAttribute: true
- },
+ if (this.grid) {
+ var totalItemWidth = this._itemsPerRow * this._itemWidth;
+ var rowOffset = (this._viewportWidth - totalItemWidth) / 2;
- /**
- * If true, the `horizontalAlign` and `verticalAlign` properties will
- * be considered preferences instead of strict requirements when
- * positioning the dropdown and may be changed if doing so reduces
- * the area of the dropdown falling outside of `fitInto`.
- */
- dynamicAlign: {
- type: Boolean
- },
+ this._iterateItems(function(pidx, vidx) {
- /**
- * A pixel value that will be added to the position calculated for the
- * given `horizontalAlign`. Use a negative value to offset to the
- * left, or a positive value to offset to the right.
- */
- horizontalOffset: {
- type: Number,
- value: 0,
- notify: true
- },
+ var modulus = vidx % this._itemsPerRow;
+ var x = Math.floor((modulus * this._itemWidth) + rowOffset);
- /**
- * A pixel value that will be added to the position calculated for the
- * given `verticalAlign`. Use a negative value to offset towards the
- * top, or a positive value to offset towards the bottom.
- */
- verticalOffset: {
- type: Number,
- value: 0,
- notify: true
- },
+ this.translate3d(x + 'px', y + 'px', 0, this._physicalItems[pidx]);
- /**
- * If true, the dropdown will be positioned so that it doesn't overlap
- * the button.
- */
- noOverlap: {
- type: Boolean
- },
+ if (this._shouldRenderNextRow(vidx)) {
+ y += this._rowHeight;
+ }
- /**
- * Set to true to disable animations when opening and closing the
- * dropdown.
- */
- noAnimations: {
- type: Boolean,
- value: false
- },
+ });
+ } else {
+ this._iterateItems(function(pidx, vidx) {
- /**
- * Set to true to disable automatically closing the dropdown after
- * a selection has been made.
- */
- ignoreSelect: {
- type: Boolean,
- value: false
- },
+ this.translate3d(0, y + 'px', 0, this._physicalItems[pidx]);
+ y += this._physicalSizes[pidx];
- /**
- * Set to true to enable automatically closing the dropdown after an
- * item has been activated, even if the selection did not change.
- */
- closeOnActivate: {
- type: Boolean,
- value: false
- },
+ });
+ }
+ },
- /**
- * An animation config. If provided, this will be used to animate the
- * opening of the dropdown.
- */
- openAnimationConfig: {
- type: Object,
- value: function() {
- return [{
- name: 'fade-in-animation',
- timing: {
- delay: 100,
- duration: 200
- }
- }, {
- name: 'paper-menu-grow-width-animation',
- timing: {
- delay: 100,
- duration: 150,
- easing: config.ANIMATION_CUBIC_BEZIER
- }
- }, {
- name: 'paper-menu-grow-height-animation',
- timing: {
- delay: 100,
- duration: 275,
- easing: config.ANIMATION_CUBIC_BEZIER
- }
- }];
- }
- },
+ _getPhysicalSizeIncrement: function(pidx) {
+ if (!this.grid) {
+ return this._physicalSizes[pidx];
+ }
+ if (this._computeVidx(pidx) % this._itemsPerRow !== this._itemsPerRow - 1) {
+ return 0;
+ }
+ return this._rowHeight;
+ },
- /**
- * An animation config. If provided, this will be used to animate the
- * closing of the dropdown.
- */
- closeAnimationConfig: {
- type: Object,
- value: function() {
- return [{
- name: 'fade-out-animation',
- timing: {
- duration: 150
- }
- }, {
- name: 'paper-menu-shrink-width-animation',
- timing: {
- delay: 100,
- duration: 50,
- easing: config.ANIMATION_CUBIC_BEZIER
- }
- }, {
- name: 'paper-menu-shrink-height-animation',
- timing: {
- duration: 200,
- easing: 'ease-in'
- }
- }];
- }
- },
+ /**
+ * Returns, based on the current index,
+ * whether or not the next index will need
+ * to be rendered on a new row.
+ *
+ * @param {number} vidx Virtual index
+ * @return {boolean}
+ */
+ _shouldRenderNextRow: function(vidx) {
+ return vidx % this._itemsPerRow === this._itemsPerRow - 1;
+ },
- /**
- * By default, the dropdown will constrain scrolling on the page
- * to itself when opened.
- * Set to true in order to prevent scroll from being constrained
- * to the dropdown when it opens.
- */
- allowOutsideScroll: {
- type: Boolean,
- value: false
- },
+ /**
+ * Adjusts the scroll position when it was overestimated.
+ */
+ _adjustScrollPosition: function() {
+ var deltaHeight = this._virtualStart === 0 ? this._physicalTop :
+ Math.min(this._scrollPosition + this._physicalTop, 0);
- /**
- * Whether focus should be restored to the button when the menu closes.
- */
- restoreFocusOnClose: {
- type: Boolean,
- value: true
- },
+ if (deltaHeight) {
+ this._physicalTop = this._physicalTop - deltaHeight;
+ // juking scroll position during interial scrolling on iOS is no bueno
+ if (!IOS_TOUCH_SCROLLING && this._physicalTop !== 0) {
+ this._resetScrollPosition(this._scrollTop - deltaHeight);
+ }
+ }
+ },
- /**
- * This is the element intended to be bound as the focus target
- * for the `iron-dropdown` contained by `paper-menu-button`.
- */
- _dropdownContent: {
- type: Object
- }
- },
+ /**
+ * Sets the position of the scroll.
+ */
+ _resetScrollPosition: function(pos) {
+ if (this.scrollTarget) {
+ this._scrollTop = pos;
+ this._scrollPosition = this._scrollTop;
+ }
+ },
- hostAttributes: {
- role: 'group',
- 'aria-haspopup': 'true'
- },
+ /**
+ * 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) {
+ if (this.grid) {
+ this._estScrollHeight = this._virtualRowCount * this._rowHeight;
+ } else {
+ this._estScrollHeight = (this._physicalBottom +
+ Math.max(this._virtualCount - this._physicalCount - this._virtualStart, 0) * this._physicalAverage);
+ }
- listeners: {
- 'iron-activate': '_onIronActivate',
- 'iron-select': '_onIronSelect'
- },
+ forceUpdate = forceUpdate || this._scrollHeight === 0;
+ forceUpdate = forceUpdate || this._scrollPosition >= this._estScrollHeight - this._physicalSize;
+ forceUpdate = forceUpdate || this.grid && this.$.items.style.height < this._estScrollHeight;
- /**
- * The content element that is contained by the menu button, if any.
- */
- get contentElement() {
- return Polymer.dom(this.$.content).getDistributedNodes()[0];
- },
+ // 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;
+ }
+ },
- /**
- * Toggles the drowpdown content between opened and closed.
- */
- toggle: function() {
- if (this.opened) {
- this.close();
- } else {
- this.open();
- }
- },
+ /**
+ * Scroll to a specific item in the virtual list regardless
+ * of the physical items in the DOM tree.
+ *
+ * @method scrollToItem
+ * @param {(Object)} item The item to be scrolled to
+ */
+ scrollToItem: function(item){
+ return this.scrollToIndex(this.items.indexOf(item));
+ },
- /**
- * Make the dropdown content appear as an overlay positioned relative
- * to the dropdown trigger.
- */
- open: function() {
- if (this.disabled) {
- return;
- }
+ /**
+ * Scroll to a specific index 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' || idx < 0 || idx > this.items.length - 1) {
+ return;
+ }
- this.$.dropdown.open();
- },
+ Polymer.dom.flush();
- /**
- * Hide the dropdown content.
- */
- close: function() {
- this.$.dropdown.close();
- },
+ 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 = this.grid ? (idx - this._itemsPerRow * 2) : (idx - 1);
+ }
+ // manage focus
+ this._manageFocus();
+ // assign new models
+ this._assignModels();
+ // measure the new sizes
+ this._updateMetrics();
- /**
- * When an `iron-select` event is received, the dropdown should
- * automatically close on the assumption that a value has been chosen.
- *
- * @param {CustomEvent} event A CustomEvent instance with type
- * set to `"iron-select"`.
- */
- _onIronSelect: function(event) {
- if (!this.ignoreSelect) {
- this.close();
- }
- },
+ // estimate new physical offset
+ var estPhysicalTop = Math.floor(this._virtualStart / this._itemsPerRow) * this._physicalAverage;
+ this._physicalTop = estPhysicalTop;
- /**
- * Closes the dropdown when an `iron-activate` event is received if
- * `closeOnActivate` is true.
- *
- * @param {CustomEvent} event A CustomEvent of type 'iron-activate'.
- */
- _onIronActivate: function(event) {
- if (this.closeOnActivate) {
- this.close();
- }
- },
+ var currentTopItem = this._physicalStart;
+ var currentVirtualItem = this._virtualStart;
+ var targetOffsetTop = 0;
+ var hiddenContentSize = this._hiddenContentSize;
- /**
- * When the dropdown opens, the `paper-menu-button` fires `paper-open`.
- * When the dropdown closes, the `paper-menu-button` fires `paper-close`.
- *
- * @param {boolean} opened True if the dropdown is opened, otherwise false.
- * @param {boolean} oldOpened The previous value of `opened`.
- */
- _openedChanged: function(opened, oldOpened) {
- if (opened) {
- // TODO(cdata): Update this when we can measure changes in distributed
- // children in an idiomatic way.
- // We poke this property in case the element has changed. This will
- // cause the focus target for the `iron-dropdown` to be updated as
- // necessary:
- this._dropdownContent = this.contentElement;
- this.fire('paper-dropdown-open');
- } else if (oldOpened != null) {
- this.fire('paper-dropdown-close');
- }
- },
+ // scroll to the item as much as we can
+ while (currentVirtualItem < idx && targetOffsetTop <= hiddenContentSize) {
+ targetOffsetTop = targetOffsetTop + this._getPhysicalSizeIncrement(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);
+ // increase the pool of physical items if needed
+ this._increasePoolIfNeeded();
+ // clear cached visible index
+ this._firstVisibleIndexVal = null;
+ this._lastVisibleIndexVal = null;
+ },
- /**
- * If the dropdown is open when disabled becomes true, close the
- * dropdown.
- *
- * @param {boolean} disabled True if disabled, otherwise false.
- */
- _disabledChanged: function(disabled) {
- Polymer.IronControlState._disabledChanged.apply(this, arguments);
- if (disabled && this.opened) {
- this.close();
- }
- },
+ /**
+ * Reset the physical average and the average count.
+ */
+ _resetAverage: function() {
+ this._physicalAverage = 0;
+ this._physicalAverageCount = 0;
+ },
- __onIronOverlayCanceled: function(event) {
- var uiEvent = event.detail;
- var target = Polymer.dom(uiEvent).rootTarget;
- var trigger = this.$.trigger;
- var path = Polymer.dom(uiEvent).path;
+ /**
+ * 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._viewportHeight - this._scrollTargetHeight) < 100) {
+ return;
+ }
+ // In Desktop Safari 9.0.3, if the scroll bars are always shown,
+ // changing the scroll position from a resize handler would result in
+ // the scroll position being reset. Waiting 1ms fixes the issue.
+ Polymer.dom.addDebouncer(this.debounce('_debounceTemplate', function() {
+ this.updateViewportBoundaries();
+ this._render();
- if (path.indexOf(trigger) > -1) {
- event.preventDefault();
- }
+ if (this._itemsRendered && this._physicalItems && this._isVisible) {
+ this._resetAverage();
+ this.scrollToIndex(this.firstVisibleIndex);
}
- });
-
- Object.keys(config).forEach(function (key) {
- PaperMenuButton[key] = config[key];
- });
+ }.bind(this), 1));
+ },
- Polymer.PaperMenuButton = PaperMenuButton;
- })();
-/**
- * `Polymer.PaperInkyFocusBehavior` implements a ripple when the element has keyboard focus.
- *
- * @polymerBehavior Polymer.PaperInkyFocusBehavior
- */
- Polymer.PaperInkyFocusBehaviorImpl = {
- observers: [
- '_focusedChanged(receivedFocusFromKeyboard)'
- ],
+ _getModelFromItem: function(item) {
+ var key = this._collection.getKey(item);
+ var pidx = this._physicalIndexForKey[key];
- _focusedChanged: function(receivedFocusFromKeyboard) {
- if (receivedFocusFromKeyboard) {
- this.ensureRipple();
+ if (pidx != null) {
+ return this._physicalItems[pidx]._templateInstance;
}
- if (this.hasRipple()) {
- this._ripple.holdDown = receivedFocusFromKeyboard;
+ return null;
+ },
+
+ /**
+ * 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');
}
+ return item;
},
- _createRipple: function() {
- var ripple = Polymer.PaperRippleBehavior._createRipple();
- ripple.id = 'ink';
- ripple.setAttribute('center', '');
- ripple.classList.add('circle');
- return ripple;
- }
- };
+ /**
+ * 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);
- /** @polymerBehavior Polymer.PaperInkyFocusBehavior */
- Polymer.PaperInkyFocusBehavior = [
- Polymer.IronButtonState,
- Polymer.IronControlState,
- Polymer.PaperRippleBehavior,
- Polymer.PaperInkyFocusBehaviorImpl
- ];
-Polymer({
- is: 'paper-icon-button',
+ if (!this.multiSelection && this.selectedItem) {
+ this.deselectItem(this.selectedItem);
+ }
+ if (model) {
+ model[this.selectedAs] = true;
+ }
+ this.$.selector.select(item);
+ this.updateSizeForItem(item);
+ },
- hostAttributes: {
- role: 'button',
- tabindex: '0'
- },
+ /**
+ * Deselects the given item list if it is already selected.
+ *
- behaviors: [
- Polymer.PaperInkyFocusBehavior
- ],
+ * @method deselect
+ * @param {(Object|number)} item The item object or its index
+ */
+ deselectItem: function(item) {
+ item = this._getNormalizedItem(item);
+ var model = this._getModelFromItem(item);
- properties: {
- /**
- * The URL of an image for the icon. If the src property is specified,
- * the icon property should not be.
- */
- src: {
- type: String
- },
+ if (model) {
+ model[this.selectedAs] = false;
+ }
+ this.$.selector.deselect(item);
+ this.updateSizeForItem(item);
+ },
- /**
- * Specifies the icon name or index in the set of icons available in
- * the icon's icon set. If the icon property is specified,
- * the src property should not be.
- */
- icon: {
- type: String
- },
+ /**
+ * 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 {
+ this.selectItem(item);
+ }
+ },
- /**
- * Specifies the alternate text for the button, for accessibility.
- */
- alt: {
- type: String,
- observer: "_altChanged"
+ /**
+ * Clears the current selection state of the list.
+ *
+ * @method clearSelection
+ */
+ clearSelection: function() {
+ function unselect(item) {
+ var model = this._getModelFromItem(item);
+ if (model) {
+ model[this.selectedAs] = false;
}
- },
-
- _altChanged: function(newValue, oldValue) {
- var label = this.getAttribute('aria-label');
+ }
- // Don't stomp over a user-set aria-label.
- if (!label || oldValue == label) {
- this.setAttribute('aria-label', newValue);
- }
+ if (Array.isArray(this.selectedItems)) {
+ this.selectedItems.forEach(unselect, this);
+ } else if (this.selectedItem) {
+ unselect.call(this, this.selectedItem);
}
- });
-// Copyright 2016 The Chromium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-/**
- * Implements an incremental search field which can be shown and hidden.
- * Canonical implementation is <cr-search-field>.
- * @polymerBehavior
- */
-var CrSearchFieldBehavior = {
- properties: {
- label: {
- type: String,
- value: '',
+ /** @type {!ArraySelectorElement} */ (this.$.selector).clearSelection();
},
- clearLabel: {
- type: String,
- value: '',
+ /**
+ * 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');
},
- showingSearch: {
- type: Boolean,
- value: false,
- notify: true,
- observer: 'showingSearchChanged_',
- reflectToAttribute: true
+ /**
+ * Select an item from an event object.
+ */
+ _selectionHandler: function(e) {
+ var model = this.modelForElement(e.target);
+ if (!model) {
+ return;
+ }
+ var modelTabIndex, activeElTabIndex;
+ var target = Polymer.dom(e).path[0];
+ var activeEl = Polymer.dom(this.domHost ? this.domHost.root : document).activeElement;
+ var physicalItem = this._physicalItems[this._getPhysicalIndex(model[this.indexAs])];
+ // Safari does not focus certain form controls via mouse
+ // https://bugs.webkit.org/show_bug.cgi?id=118043
+ if (target.localName === 'input' ||
+ target.localName === 'button' ||
+ target.localName === 'select') {
+ return;
+ }
+ // Set a temporary tabindex
+ modelTabIndex = model.tabIndex;
+ model.tabIndex = SECRET_TABINDEX;
+ activeElTabIndex = activeEl ? activeEl.tabIndex : -1;
+ model.tabIndex = modelTabIndex;
+ // Only select the item if the tap wasn't on a focusable child
+ // or the element bound to `tabIndex`
+ if (activeEl && physicalItem.contains(activeEl) && activeElTabIndex !== SECRET_TABINDEX) {
+ return;
+ }
+ this.toggleSelectionForItem(model[this.as]);
},
- /** @private */
- lastValue_: {
- type: String,
- value: '',
+ _multiSelectionChanged: function(multiSelection) {
+ this.clearSelection();
+ this.$.selector.multi = multiSelection;
},
- },
- /**
- * @abstract
- * @return {!HTMLInputElement} The input field element the behavior should
- * use.
- */
- getSearchInput: function() {},
+ /**
+ * Updates the size of an item.
+ *
+ * @method updateSizeForItem
+ * @param {(Object|number)} item The item object or its index
+ */
+ updateSizeForItem: function(item) {
+ item = this._getNormalizedItem(item);
+ var key = this._collection.getKey(item);
+ var pidx = this._physicalIndexForKey[key];
- /**
- * @return {string} The value of the search field.
- */
- getValue: function() {
- return this.getSearchInput().value;
- },
+ if (pidx != null) {
+ this._updateMetrics([pidx]);
+ this._positionItems();
+ }
+ },
- /**
- * Sets the value of the search field.
- * @param {string} value
- */
- setValue: function(value) {
- // Use bindValue when setting the input value so that changes propagate
- // correctly.
- this.getSearchInput().bindValue = value;
- this.onValueChanged_(value);
- },
+ /**
+ * 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;
- showAndFocus: function() {
- this.showingSearch = true;
- this.focus_();
- },
+ 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];
+ }
+ },
- /** @private */
- focus_: function() {
- this.getSearchInput().focus();
- },
+ _isIndexRendered: function(idx) {
+ return idx >= this._virtualStart && idx <= this._virtualEnd;
+ },
- onSearchTermSearch: function() {
- this.onValueChanged_(this.getValue());
- },
+ _isIndexVisible: function(idx) {
+ return idx >= this.firstVisibleIndex && idx <= this.lastVisibleIndex;
+ },
- /**
- * Updates the internal state of the search field based on a change that has
- * already happened.
- * @param {string} newValue
- * @private
- */
- onValueChanged_: function(newValue) {
- if (newValue == this.lastValue_)
- return;
+ _getPhysicalIndex: function(idx) {
+ return this._physicalIndexForKey[this._collection.getKey(this._getNormalizedItem(idx))];
+ },
- this.fire('search-changed', newValue);
- this.lastValue_ = newValue;
- },
+ _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);
+ }
- onSearchTermKeydown: function(e) {
- if (e.key == 'Escape')
- this.showingSearch = false;
- },
+ var physicalItem = this._physicalItems[this._getPhysicalIndex(idx)];
+ var model = physicalItem._templateInstance;
+ var focusable;
- /** @private */
- showingSearchChanged_: function() {
- if (this.showingSearch) {
- this.focus_();
- return;
- }
+ // set a secret tab index
+ model.tabIndex = SECRET_TABINDEX;
+ // check if focusable element is the physical item
+ if (physicalItem.tabIndex === SECRET_TABINDEX) {
+ focusable = physicalItem;
+ }
+ // search for the element which tabindex is bound to the secret tab index
+ if (!focusable) {
+ focusable = Polymer.dom(physicalItem).querySelector('[tabindex="' + SECRET_TABINDEX + '"]');
+ }
+ // restore the tab index
+ model.tabIndex = 0;
+ // focus the focusable element
+ this._focusedIndex = idx;
+ focusable && focusable.focus();
+ },
- this.setValue('');
- this.getSearchInput().blur();
- }
-};
-(function() {
- 'use strict';
+ _removeFocusedItem: function() {
+ if (this._offscreenFocusedItem) {
+ Polymer.dom(this).removeChild(this._offscreenFocusedItem);
+ }
+ this._offscreenFocusedItem = null;
+ this._focusBackfillItem = null;
+ this._focusedItem = null;
+ this._focusedIndex = -1;
+ },
- Polymer.IronA11yAnnouncer = Polymer({
- is: 'iron-a11y-announcer',
+ _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);
- properties: {
+ 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);
+ }
+ },
- /**
- * The value of mode is used to set the `aria-live` attribute
- * for the element that will be announced. Valid values are: `off`,
- * `polite` and `assertive`.
- */
- mode: {
- type: String,
- value: 'polite'
- },
+ _restoreFocusedItem: function() {
+ var pidx, fidx = this._focusedIndex;
- _text: {
- type: String,
- value: ''
- }
- },
+ 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);
- created: function() {
- if (!Polymer.IronA11yAnnouncer.instance) {
- Polymer.IronA11yAnnouncer.instance = this;
- }
+ 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);
+ }
+ },
- document.body.addEventListener('iron-announce', this._onIronAnnounce.bind(this));
- },
+ _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;
- /**
- * Cause a text string to be announced by screen readers.
- *
- * @param {string} text The text that should be announced.
- */
- announce: function(text) {
- this._text = '';
- this.async(function() {
- this._text = text;
- }, 100);
- },
+ 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)];
- _onIronAnnounce: function(event) {
- if (event.detail && event.detail.text) {
- this.announce(event.detail.text);
- }
+ if (hasOffscreenFocusedItem && !this._offscreenFocusedItem) {
+ this._update();
}
- });
+ }
+ },
- Polymer.IronA11yAnnouncer.instance = null;
+ _didMoveUp: function() {
+ this._focusPhysicalItem(this._focusedIndex - 1);
+ },
- Polymer.IronA11yAnnouncer.requestAvailability = function() {
- if (!Polymer.IronA11yAnnouncer.instance) {
- Polymer.IronA11yAnnouncer.instance = document.createElement('iron-a11y-announcer');
- }
+ _didMoveDown: function(e) {
+ // disable scroll when pressing the down key
+ e.detail.keyboardEvent.preventDefault();
+ this._focusPhysicalItem(this._focusedIndex + 1);
+ },
- document.body.appendChild(Polymer.IronA11yAnnouncer.instance);
- };
- })();
-/**
- * Singleton IronMeta instance.
- */
- Polymer.IronValidatableBehaviorMeta = null;
+ _didEnter: function(e) {
+ this._focusPhysicalItem(this._focusedIndex);
+ this._selectionHandler(e.detail.keyboardEvent);
+ }
+ });
- /**
- * `Use Polymer.IronValidatableBehavior` to implement an element that validates user input.
- * Use the related `Polymer.IronValidatorBehavior` to add custom validation logic to an iron-input.
- *
- * By default, an `<iron-form>` element validates its fields when the user presses the submit button.
- * To validate a form imperatively, call the form's `validate()` method, which in turn will
- * call `validate()` on all its children. By using `Polymer.IronValidatableBehavior`, your
- * custom element will get a public `validate()`, which
- * will return the validity of the element, and a corresponding `invalid` attribute,
- * which can be used for styling.
- *
- * To implement the custom validation logic of your element, you must override
- * the protected `_getValidity()` method of this behaviour, rather than `validate()`.
- * See [this](https://github.com/PolymerElements/iron-form/blob/master/demo/simple-element.html)
- * for an example.
- *
- * ### Accessibility
- *
- * Changing the `invalid` property, either manually or by calling `validate()` will update the
- * `aria-invalid` attribute.
- *
- * @demo demo/index.html
- * @polymerBehavior
- */
- Polymer.IronValidatableBehavior = {
+})();
+Polymer({
+
+ is: 'iron-scroll-threshold',
properties: {
/**
- * Name of the validator to use.
+ * Distance from the top (or left, for horizontal) bound of the scroller
+ * where the "upper trigger" will fire.
*/
- validator: {
- type: String
+ upperThreshold: {
+ type: Number,
+ value: 100
},
/**
- * True if the last call to `validate` is invalid.
+ * Distance from the bottom (or right, for horizontal) bound of the scroller
+ * where the "lower trigger" will fire.
*/
- invalid: {
- notify: true,
- reflectToAttribute: true,
+ lowerThreshold: {
+ type: Number,
+ value: 100
+ },
+
+ /**
+ * Read-only value that tracks the triggered state of the upper threshold.
+ */
+ upperTriggered: {
type: Boolean,
- value: false
+ value: false,
+ notify: true,
+ readOnly: true
},
/**
- * This property is deprecated and should not be used. Use the global
- * validator meta singleton, `Polymer.IronValidatableBehaviorMeta` instead.
+ * Read-only value that tracks the triggered state of the lower threshold.
*/
- _validatorMeta: {
- type: Object
+ lowerTriggered: {
+ type: Boolean,
+ value: false,
+ notify: true,
+ readOnly: true
},
/**
- * Namespace for this validator. This property is deprecated and should
- * not be used. For all intents and purposes, please consider it a
- * read-only, config-time property.
+ * True if the orientation of the scroller is horizontal.
*/
- validatorType: {
- type: String,
- value: 'validator'
- },
-
- _validator: {
- type: Object,
- computed: '__computeValidator(validator)'
+ horizontal: {
+ type: Boolean,
+ value: false
}
},
+ behaviors: [
+ Polymer.IronScrollTargetBehavior
+ ],
+
observers: [
- '_invalidChanged(invalid)'
+ '_setOverflow(scrollTarget)',
+ '_initCheck(horizontal, isAttached)'
],
- registered: function() {
- Polymer.IronValidatableBehaviorMeta = new Polymer.IronMeta({type: 'validator'});
+ get _defaultScrollTarget() {
+ return this;
},
- _invalidChanged: function() {
- if (this.invalid) {
- this.setAttribute('aria-invalid', 'true');
- } else {
- this.removeAttribute('aria-invalid');
- }
+ _setOverflow: function(scrollTarget) {
+ this.style.overflow = scrollTarget === this ? 'auto' : '';
},
- /**
- * @return {boolean} True if the validator `validator` exists.
- */
- hasValidator: function() {
- return this._validator != null;
+ _scrollHandler: function() {
+ // throttle the work on the scroll event
+ var THROTTLE_THRESHOLD = 200;
+ if (!this.isDebouncerActive('_checkTheshold')) {
+ this.debounce('_checkTheshold', function() {
+ this.checkScrollThesholds();
+ }, THROTTLE_THRESHOLD);
+ }
},
- /**
- * Returns true if the `value` is valid, and updates `invalid`. If you want
- * your element to have custom validation logic, do not override this method;
- * override `_getValidity(value)` instead.
-
- * @param {Object} value The value to be validated. By default, it is passed
- * to the validator's `validate()` function, if a validator is set.
- * @return {boolean} True if `value` is valid.
- */
- validate: function(value) {
- this.invalid = !this._getValidity(value);
- return !this.invalid;
+ _initCheck: function(horizontal, isAttached) {
+ if (isAttached) {
+ this.debounce('_init', function() {
+ this.clearTriggers();
+ this.checkScrollThesholds();
+ });
+ }
},
/**
- * Returns true if `value` is valid. By default, it is passed
- * to the validator's `validate()` function, if a validator is set. You
- * should override this method if you want to implement custom validity
- * logic for your element.
+ * Checks the scroll thresholds.
+ * This method is automatically called by iron-scroll-threshold.
*
- * @param {Object} value The value to be validated.
- * @return {boolean} True if `value` is valid.
+ * @method checkScrollThesholds
*/
+ checkScrollThesholds: function() {
+ if (!this.scrollTarget || (this.lowerTriggered && this.upperTriggered)) {
+ return;
+ }
+ var upperScrollValue = this.horizontal ? this._scrollLeft : this._scrollTop;
+ var lowerScrollValue = this.horizontal ?
+ this.scrollTarget.scrollWidth - this._scrollTargetWidth - this._scrollLeft :
+ this.scrollTarget.scrollHeight - this._scrollTargetHeight - this._scrollTop;
- _getValidity: function(value) {
- if (this.hasValidator()) {
- return this._validator.validate(value);
+ // Detect upper threshold
+ if (upperScrollValue <= this.upperThreshold && !this.upperTriggered) {
+ this._setUpperTriggered(true);
+ this.fire('upper-threshold');
+ }
+ // Detect lower threshold
+ if (lowerScrollValue <= this.lowerThreshold && !this.lowerTriggered) {
+ this._setLowerTriggered(true);
+ this.fire('lower-threshold');
}
- return true;
},
- __computeValidator: function() {
- return Polymer.IronValidatableBehaviorMeta &&
- Polymer.IronValidatableBehaviorMeta.byKey(this.validator);
+ /**
+ * Clear the upper and lower threshold states.
+ *
+ * @method clearTriggers
+ */
+ clearTriggers: function() {
+ this._setUpperTriggered(false);
+ this._setLowerTriggered(false);
}
- };
-/*
-`<iron-input>` adds two-way binding and custom validators using `Polymer.IronValidatorBehavior`
-to `<input>`.
-
-### Two-way binding
-
-By default you can only get notified of changes to an `input`'s `value` due to user input:
-
- <input value="{{myValue::input}}">
-
-`iron-input` adds the `bind-value` property that mirrors the `value` property, and can be used
-for two-way data binding. `bind-value` will notify if it is changed either by user input or by script.
-
- <input is="iron-input" bind-value="{{myValue}}">
-
-### Custom validators
-
-You can use custom validators that implement `Polymer.IronValidatorBehavior` with `<iron-input>`.
-
- <input is="iron-input" validator="my-custom-validator">
-
-### Stopping invalid input
-
-It may be desirable to only allow users to enter certain characters. You can use the
-`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.
- \x3c!-- only allow characters that match [0-9] --\x3e
- <input is="iron-input" prevent-invalid-input allowed-pattern="[0-9]">
+ /**
+ * Fires when the lower threshold has been reached.
+ *
+ * @event lower-threshold
+ */
-@hero hero.svg
-@demo demo/index.html
-*/
+ /**
+ * Fires when the upper threshold has been reached.
+ *
+ * @event upper-threshold
+ */
- Polymer({
+ });
+// 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.
- is: 'iron-input',
+Polymer({
+ is: 'history-list',
- extends: 'input',
+ behaviors: [HistoryListBehavior],
- behaviors: [
- Polymer.IronValidatableBehavior
- ],
+ properties: {
+ // The search term for the current query. Set when the query returns.
+ searchedTerm: {
+ type: String,
+ value: '',
+ },
- properties: {
+ lastSearchedTerm_: String,
- /**
- * Use this property instead of `value` for two-way data binding.
- */
- bindValue: {
- observer: '_bindValueChanged',
- type: String
- },
+ querying: Boolean,
- /**
- * Set to true to prevent the user from entering invalid input. If `allowedPattern` is set,
- * any character typed by the user will be matched against that pattern, and rejected if it's not a match.
- * Pasted input will have each character checked individually; if any character
- * doesn't match `allowedPattern`, the entire pasted string will be rejected.
- * If `allowedPattern` is not set, it will use the `type` attribute (only supported for `type=number`).
- */
- preventInvalidInput: {
- type: Boolean
- },
+ // An array of history entries in reverse chronological order.
+ historyData_: Array,
- /**
- * Regular expression that list the characters allowed as input.
- * This pattern represents the allowed characters for the field; as the user inputs text,
- * each individual character will be checked against the pattern (rather than checking
- * the entire value as a whole). The recommended format should be a list of allowed characters;
- * for example, `[a-zA-Z0-9.+-!;:]`
- */
- allowedPattern: {
- type: String,
- observer: "_allowedPatternChanged"
- },
+ resultLoadingDisabled_: {
+ type: Boolean,
+ value: false,
+ },
+ },
- _previousValidInput: {
- type: String,
- value: ''
- },
+ listeners: {
+ 'infinite-list.scroll': 'notifyListScroll_',
+ 'remove-bookmark-stars': 'removeBookmarkStars_',
+ },
- _patternAlreadyChecked: {
- type: Boolean,
- value: false
- }
+ /** @override */
+ attached: function() {
+ // It is possible (eg, when middle clicking the reload button) for all other
+ // resize events to fire before the list is attached and can be measured.
+ // Adding another resize here ensures it will get sized correctly.
+ /** @type {IronListElement} */(this.$['infinite-list']).notifyResize();
+ },
- },
+ /**
+ * Remove bookmark star for history items with matching URLs.
+ * @param {{detail: !string}} e
+ * @private
+ */
+ removeBookmarkStars_: function(e) {
+ var url = e.detail;
- listeners: {
- 'input': '_onInput',
- 'keypress': '_onKeypress'
- },
+ if (this.historyData_ === undefined)
+ return;
- /** @suppress {checkTypes} */
- registered: function() {
- // Feature detect whether we need to patch dispatchEvent (i.e. on FF and IE).
- if (!this._canDispatchEventOnDisabled()) {
- this._origDispatchEvent = this.dispatchEvent;
- this.dispatchEvent = this._dispatchEventFirefoxIE;
- }
- },
+ for (var i = 0; i < this.historyData_.length; i++) {
+ if (this.historyData_[i].url == url)
+ this.set('historyData_.' + i + '.starred', false);
+ }
+ },
- created: function() {
- Polymer.IronA11yAnnouncer.requestAvailability();
- },
+ /**
+ * Disables history result loading when there are no more history results.
+ */
+ disableResultLoading: function() {
+ this.resultLoadingDisabled_ = true;
+ },
- _canDispatchEventOnDisabled: function() {
- var input = document.createElement('input');
- var canDispatch = false;
- input.disabled = true;
+ /**
+ * Adds the newly updated history results into historyData_. Adds new fields
+ * for each result.
+ * @param {!Array<!HistoryEntry>} historyResults The new history results.
+ */
+ addNewResults: function(historyResults) {
+ var results = historyResults.slice();
+ /** @type {IronScrollThresholdElement} */(this.$['scroll-threshold'])
+ .clearTriggers();
+
+ if (this.lastSearchedTerm_ != this.searchedTerm) {
+ this.resultLoadingDisabled_ = false;
+ if (this.historyData_)
+ this.splice('historyData_', 0, this.historyData_.length);
+ this.fire('unselect-all');
+ this.lastSearchedTerm_ = this.searchedTerm;
+ }
- input.addEventListener('feature-check-dispatch-event', function() {
- canDispatch = true;
- });
+ if (this.historyData_) {
+ // If we have previously received data, push the new items onto the
+ // existing array.
+ results.unshift('historyData_');
+ this.push.apply(this, results);
+ } else {
+ // The first time we receive data, use set() to ensure the iron-list is
+ // initialized correctly.
+ this.set('historyData_', results);
+ }
+ },
- try {
- input.dispatchEvent(new Event('feature-check-dispatch-event'));
- } catch(e) {}
+ /**
+ * Called when the page is scrolled to near the bottom of the list.
+ * @private
+ */
+ loadMoreData_: function() {
+ if (this.resultLoadingDisabled_ || this.querying)
+ return;
- return canDispatch;
- },
+ this.fire('load-more-history');
+ },
- _dispatchEventFirefoxIE: function() {
- // Due to Firefox bug, events fired on disabled form controls can throw
- // errors; furthermore, neither IE nor Firefox will actually dispatch
- // events from disabled form controls; as such, we toggle disable around
- // the dispatch to allow notifying properties to notify
- // See issue #47 for details
- var disabled = this.disabled;
- this.disabled = false;
- this._origDispatchEvent.apply(this, arguments);
- this.disabled = disabled;
- },
+ /**
+ * Check whether the time difference between the given history item and the
+ * next one is large enough for a spacer to be required.
+ * @param {HistoryEntry} item
+ * @param {number} index The index of |item| in |historyData_|.
+ * @param {number} length The length of |historyData_|.
+ * @return {boolean} Whether or not time gap separator is required.
+ * @private
+ */
+ needsTimeGap_: function(item, index, length) {
+ return md_history.HistoryItem.needsTimeGap(
+ this.historyData_, index, this.searchedTerm);
+ },
- get _patternRegExp() {
- var pattern;
- if (this.allowedPattern) {
- pattern = new RegExp(this.allowedPattern);
- } else {
- switch (this.type) {
- case 'number':
- pattern = /[0-9.,e-]/;
- break;
- }
- }
- return pattern;
- },
+ /**
+ * True if the given item is the beginning of a new card.
+ * @param {HistoryEntry} item
+ * @param {number} i Index of |item| within |historyData_|.
+ * @param {number} length
+ * @return {boolean}
+ * @private
+ */
+ isCardStart_: function(item, i, length) {
+ if (length == 0 || i > length - 1)
+ return false;
+ return i == 0 ||
+ this.historyData_[i].dateRelativeDay !=
+ this.historyData_[i - 1].dateRelativeDay;
+ },
- ready: function() {
- this.bindValue = this.value;
- },
+ /**
+ * True if the given item is the end of a card.
+ * @param {HistoryEntry} item
+ * @param {number} i Index of |item| within |historyData_|.
+ * @param {number} length
+ * @return {boolean}
+ * @private
+ */
+ isCardEnd_: function(item, i, length) {
+ if (length == 0 || i > length - 1)
+ return false;
+ return i == length - 1 ||
+ this.historyData_[i].dateRelativeDay !=
+ this.historyData_[i + 1].dateRelativeDay;
+ },
- /**
- * @suppress {checkTypes}
- */
- _bindValueChanged: function() {
- if (this.value !== this.bindValue) {
- this.value = !(this.bindValue || this.bindValue === 0 || this.bindValue === false) ? '' : this.bindValue;
- }
- // manually notify because we don't want to notify until after setting value
- this.fire('bind-value-changed', {value: this.bindValue});
- },
+ /**
+ * @param {number} index
+ * @return {boolean}
+ * @private
+ */
+ isFirstItem_: function(index) {
+ return index == 0;
+ },
- _allowedPatternChanged: function() {
- // Force to prevent invalid input when an `allowed-pattern` is set
- this.preventInvalidInput = this.allowedPattern ? true : false;
- },
+ /**
+ * @private
+ */
+ notifyListScroll_: function() {
+ this.fire('history-list-scrolled');
+ },
- _onInput: function() {
- // Need to validate each of the characters pasted if they haven't
- // been validated inside `_onKeypress` already.
- if (this.preventInvalidInput && !this._patternAlreadyChecked) {
- var valid = this._checkPatternValidity();
- if (!valid) {
- this._announceInvalidCharacter('Invalid string of characters not entered.');
- this.value = this._previousValidInput;
- }
- }
+ /**
+ * @param {number} index
+ * @return {string}
+ * @private
+ */
+ pathForItem_: function(index) {
+ return 'historyData_.' + index;
+ },
+});
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
- this.bindValue = this.value;
- this._previousValidInput = this.value;
- this._patternAlreadyChecked = false;
- },
+Polymer({
+ is: 'history-list-container',
- _isPrintable: function(event) {
- // What a control/printable character is varies wildly based on the browser.
- // - most control characters (arrows, backspace) do not send a `keypress` event
- // in Chrome, but the *do* on Firefox
- // - in Firefox, when they do send a `keypress` event, control chars have
- // a charCode = 0, keyCode = xx (for ex. 40 for down arrow)
- // - printable characters always send a keypress event.
- // - in Firefox, printable chars always have a keyCode = 0. In Chrome, the keyCode
- // always matches the charCode.
- // None of this makes any sense.
+ properties: {
+ // The path of the currently selected page.
+ selectedPage_: String,
- // For these keys, ASCII code == browser keycode.
- var anyNonPrintable =
- (event.keyCode == 8) || // backspace
- (event.keyCode == 9) || // tab
- (event.keyCode == 13) || // enter
- (event.keyCode == 27); // escape
+ // Whether domain-grouped history is enabled.
+ grouped: Boolean,
- // For these keys, make sure it's a browser keycode and not an ASCII code.
- var mozNonPrintable =
- (event.keyCode == 19) || // pause
- (event.keyCode == 20) || // caps lock
- (event.keyCode == 45) || // insert
- (event.keyCode == 46) || // delete
- (event.keyCode == 144) || // num lock
- (event.keyCode == 145) || // scroll lock
- (event.keyCode > 32 && event.keyCode < 41) || // page up/down, end, home, arrows
- (event.keyCode > 111 && event.keyCode < 124); // fn keys
+ /** @type {!QueryState} */
+ queryState: Object,
- return !anyNonPrintable && !(event.charCode == 0 && mozNonPrintable);
- },
+ /** @type {!QueryResult} */
+ queryResult: Object,
+ },
- _onKeypress: function(event) {
- if (!this.preventInvalidInput && this.type !== 'number') {
- return;
- }
- var regexp = this._patternRegExp;
- if (!regexp) {
- return;
- }
+ observers: [
+ 'groupedRangeChanged_(queryState.range)',
+ ],
- // Handle special keys and backspace
- if (event.metaKey || event.ctrlKey || event.altKey)
- return;
+ listeners: {
+ 'history-list-scrolled': 'closeMenu_',
+ 'load-more-history': 'loadMoreHistory_',
+ 'toggle-menu': 'toggleMenu_',
+ },
- // Check the pattern either here or in `_onInput`, but not in both.
- this._patternAlreadyChecked = true;
+ /**
+ * @param {HistoryQuery} info An object containing information about the
+ * query.
+ * @param {!Array<HistoryEntry>} results A list of results.
+ */
+ historyResult: function(info, results) {
+ this.initializeResults_(info, results);
+ this.closeMenu_();
- var thisChar = String.fromCharCode(event.charCode);
- if (this._isPrintable(event) && !regexp.test(thisChar)) {
- event.preventDefault();
- this._announceInvalidCharacter('Invalid character ' + thisChar + ' not entered.');
- }
- },
+ if (this.selectedPage_ == 'grouped-list') {
+ this.$$('#grouped-list').historyData = results;
+ return;
+ }
- _checkPatternValidity: function() {
- var regexp = this._patternRegExp;
- if (!regexp) {
- return true;
- }
- for (var i = 0; i < this.value.length; i++) {
- if (!regexp.test(this.value[i])) {
- return false;
- }
- }
- return true;
- },
+ var list = /** @type {HistoryListElement} */(this.$['infinite-list']);
+ list.addNewResults(results);
+ if (info.finished)
+ list.disableResultLoading();
+ },
- /**
- * Returns true if `value` is valid. The validator provided in `validator` will be used first,
- * then any constraints.
- * @return {boolean} True if the value is valid.
- */
- validate: function() {
- // First, check what the browser thinks. Some inputs (like type=number)
- // behave weirdly and will set the value to "" if something invalid is
- // entered, but will set the validity correctly.
- var valid = this.checkValidity();
+ /**
+ * Queries the history backend for results based on queryState.
+ * @param {boolean} incremental Whether the new query should continue where
+ * the previous query stopped.
+ */
+ queryHistory: function(incremental) {
+ var queryState = this.queryState;
+ // Disable querying until the first set of results have been returned. If
+ // there is a search, query immediately to support search query params from
+ // the URL.
+ var noResults = !this.queryResult || this.queryResult.results == null;
+ if (queryState.queryingDisabled ||
+ (!this.queryState.searchTerm && noResults)) {
+ return;
+ }
- // Only do extra checking if the browser thought this was valid.
- if (valid) {
- // Empty, required input is invalid
- if (this.required && this.value === '') {
- valid = false;
- } else if (this.hasValidator()) {
- valid = Polymer.IronValidatableBehavior.validate.call(this, this.value);
- }
- }
+ // Close any open dialog if a new query is initiated.
+ if (!incremental && this.$.dialog.open)
+ this.$.dialog.close();
- this.invalid = !valid;
- this.fire('iron-input-validate');
- return valid;
- },
+ this.set('queryState.querying', true);
+ this.set('queryState.incremental', incremental);
- _announceInvalidCharacter: function(message) {
- this.fire('iron-announce', { text: message });
+ var lastVisitTime = 0;
+ if (incremental) {
+ var lastVisit = this.queryResult.results.slice(-1)[0];
+ lastVisitTime = lastVisit ? lastVisit.time : 0;
}
- });
- /*
- The `iron-input-validate` event is fired whenever `validate()` is called.
- @event iron-input-validate
- */
-Polymer({
- is: 'paper-input-container',
+ var maxResults =
+ queryState.range == HistoryRange.ALL_TIME ? RESULTS_PER_PAGE : 0;
+ chrome.send('queryHistory', [
+ queryState.searchTerm, queryState.groupedOffset, queryState.range,
+ lastVisitTime, maxResults
+ ]);
+ },
- properties: {
- /**
- * Set to true to disable the floating label. The label disappears when the input value is
- * not null.
- */
- noLabelFloat: {
- type: Boolean,
- value: false
- },
+ unselectAllItems: function(count) {
+ this.getSelectedList_().unselectAllItems(count);
+ },
- /**
- * Set to true to always float the floating label.
- */
- alwaysFloatLabel: {
- type: Boolean,
- value: false
- },
+ /**
+ * Delete all the currently selected history items. Will prompt the user with
+ * a dialog to confirm that the deletion should be performed.
+ */
+ deleteSelectedWithPrompt: function() {
+ if (!loadTimeData.getBoolean('allowDeletingHistory'))
+ return;
- /**
- * The attribute to listen for value changes on.
- */
- attrForValue: {
- type: String,
- value: 'bind-value'
- },
+ this.$.dialog.showModal();
+ },
- /**
- * Set to true to auto-validate the input value when it changes.
- */
- autoValidate: {
- type: Boolean,
- value: false
- },
+ /**
+ * @param {HistoryRange} range
+ * @private
+ */
+ groupedRangeChanged_: function(range) {
+ this.selectedPage_ = this.queryState.range == HistoryRange.ALL_TIME ?
+ 'infinite-list' : 'grouped-list';
- /**
- * True if the input is invalid. This property is set automatically when the input value
- * changes if auto-validating, or when the `iron-input-validate` event is heard from a child.
- */
- invalid: {
- observer: '_invalidChanged',
- type: Boolean,
- value: false
- },
+ this.queryHistory(false);
+ },
- /**
- * True if the input has focus.
- */
- focused: {
- readOnly: true,
- type: Boolean,
- value: false,
- notify: true
- },
+ /** @private */
+ loadMoreHistory_: function() { this.queryHistory(true); },
- _addons: {
- type: Array
- // do not set a default value here intentionally - it will be initialized lazily when a
- // distributed child is attached, which may occur before configuration for this element
- // in polyfill.
- },
+ /**
+ * @param {HistoryQuery} info
+ * @param {!Array<HistoryEntry>} results
+ * @private
+ */
+ initializeResults_: function(info, results) {
+ if (results.length == 0)
+ return;
- _inputHasContent: {
- type: Boolean,
- value: false
- },
+ var currentDate = results[0].dateRelativeDay;
- _inputSelector: {
- type: String,
- value: 'input,textarea,.paper-input-input'
- },
+ for (var i = 0; i < results.length; i++) {
+ // Sets the default values for these fields to prevent undefined types.
+ results[i].selected = false;
+ results[i].readableTimestamp =
+ info.term == '' ? results[i].dateTimeOfDay : results[i].dateShort;
- _boundOnFocus: {
- type: Function,
- value: function() {
- return this._onFocus.bind(this);
- }
- },
+ if (results[i].dateRelativeDay != currentDate) {
+ currentDate = results[i].dateRelativeDay;
+ }
+ }
+ },
+
+ /** @private */
+ onDialogConfirmTap_: function() {
+ this.getSelectedList_().deleteSelected();
+ this.$.dialog.close();
+ },
+
+ /** @private */
+ onDialogCancelTap_: function() {
+ this.$.dialog.close();
+ },
+
+ /**
+ * Closes the overflow menu.
+ * @private
+ */
+ closeMenu_: function() {
+ /** @type {CrSharedMenuElement} */(this.$.sharedMenu).closeMenu();
+ },
- _boundOnBlur: {
- type: Function,
- value: function() {
- return this._onBlur.bind(this);
- }
- },
+ /**
+ * Opens the overflow menu unless the menu is already open and the same button
+ * is pressed.
+ * @param {{detail: {item: !HistoryEntry, target: !HTMLElement}}} e
+ * @private
+ */
+ toggleMenu_: function(e) {
+ var target = e.detail.target;
+ /** @type {CrSharedMenuElement} */(this.$.sharedMenu).toggleMenu(
+ target, e.detail.item);
+ },
- _boundOnInput: {
- type: Function,
- value: function() {
- return this._onInput.bind(this);
- }
- },
+ /** @private */
+ onMoreFromSiteTap_: function() {
+ var menu = /** @type {CrSharedMenuElement} */(this.$.sharedMenu);
+ this.fire('search-domain', {domain: menu.itemData.domain});
+ menu.closeMenu();
+ },
- _boundValueChanged: {
- type: Function,
- value: function() {
- return this._onValueChanged.bind(this);
- }
- }
- },
+ /** @private */
+ onRemoveFromHistoryTap_: function() {
+ var menu = /** @type {CrSharedMenuElement} */(this.$.sharedMenu);
+ md_history.BrowserService.getInstance()
+ .deleteItems([menu.itemData])
+ .then(function(items) {
+ this.getSelectedList_().removeItemsByPath(items[0].path);
+ // This unselect-all is to reset the toolbar when deleting a selected
+ // item. TODO(tsergeant): Make this automatic based on observing list
+ // modifications.
+ this.fire('unselect-all');
+ }.bind(this));
+ menu.closeMenu();
+ },
- listeners: {
- 'addon-attached': '_onAddonAttached',
- 'iron-input-validate': '_onIronInputValidate'
- },
+ /**
+ * @return {HTMLElement}
+ * @private
+ */
+ getSelectedList_: function() {
+ return this.$.content.selectedItem;
+ },
+});
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
- get _valueChangedEvent() {
- return this.attrForValue + '-changed';
- },
+Polymer({
+ is: 'history-synced-device-card',
- get _propertyForValue() {
- return Polymer.CaseMap.dashToCamelCase(this.attrForValue);
- },
+ properties: {
+ // Name of the synced device.
+ device: String,
- get _inputElement() {
- return Polymer.dom(this).querySelector(this._inputSelector);
- },
+ // When the device information was last updated.
+ lastUpdateTime: String,
- get _inputElementValue() {
- return this._inputElement[this._propertyForValue] || this._inputElement.value;
+ /**
+ * The list of tabs open for this device.
+ * @type {!Array<!ForeignSessionTab>}
+ */
+ tabs: {
+ type: Array,
+ value: function() { return []; },
+ observer: 'updateIcons_'
},
- ready: function() {
- if (!this._addons) {
- this._addons = [];
- }
- this.addEventListener('focus', this._boundOnFocus, true);
- this.addEventListener('blur', this._boundOnBlur, true);
- },
+ /**
+ * The indexes where a window separator should be shown. The use of a
+ * separate array here is necessary for window separators to appear
+ * correctly in search. See http://crrev.com/2022003002 for more details.
+ * @type {!Array<number>}
+ */
+ separatorIndexes: Array,
- attached: function() {
- if (this.attrForValue) {
- this._inputElement.addEventListener(this._valueChangedEvent, this._boundValueChanged);
- } else {
- this.addEventListener('input', this._onInput);
- }
+ // Whether the card is open.
+ cardOpen_: {type: Boolean, value: true},
- // Only validate when attached if the input already has a value.
- if (this._inputElementValue != '') {
- this._handleValueAndAutoValidate(this._inputElement);
- } else {
- this._handleValue(this._inputElement);
- }
- },
+ searchTerm: String,
- _onAddonAttached: function(event) {
- if (!this._addons) {
- this._addons = [];
- }
- var target = event.target;
- if (this._addons.indexOf(target) === -1) {
- this._addons.push(target);
- if (this.isAttached) {
- this._handleValue(this._inputElement);
- }
- }
- },
+ // Internal identifier for the device.
+ sessionTag: String,
+ },
- _onFocus: function() {
- this._setFocused(true);
- },
+ /**
+ * Open a single synced tab. Listens to 'click' rather than 'tap'
+ * to determine what modifier keys were pressed.
+ * @param {DomRepeatClickEvent} e
+ * @private
+ */
+ openTab_: function(e) {
+ var tab = /** @type {ForeignSessionTab} */(e.model.tab);
+ md_history.BrowserService.getInstance().openForeignSessionTab(
+ this.sessionTag, tab.windowId, tab.sessionId, e);
+ e.preventDefault();
+ },
- _onBlur: function() {
- this._setFocused(false);
- this._handleValueAndAutoValidate(this._inputElement);
- },
+ /**
+ * Toggles the dropdown display of synced tabs for each device card.
+ */
+ toggleTabCard: function() {
+ this.$.collapse.toggle();
+ this.$['dropdown-indicator'].icon =
+ this.$.collapse.opened ? 'cr:expand-less' : 'cr:expand-more';
+ },
- _onInput: function(event) {
- this._handleValueAndAutoValidate(event.target);
- },
+ /**
+ * When the synced tab information is set, the icon associated with the tab
+ * website is also set.
+ * @private
+ */
+ updateIcons_: function() {
+ this.async(function() {
+ var icons = Polymer.dom(this.root).querySelectorAll('.website-icon');
- _onValueChanged: function(event) {
- this._handleValueAndAutoValidate(event.target);
- },
+ for (var i = 0; i < this.tabs.length; i++) {
+ icons[i].style.backgroundImage =
+ cr.icon.getFaviconImageSet(this.tabs[i].url);
+ }
+ });
+ },
- _handleValue: function(inputElement) {
- var value = this._inputElementValue;
+ /** @private */
+ isWindowSeparatorIndex_: function(index, separatorIndexes) {
+ return this.separatorIndexes.indexOf(index) != -1;
+ },
- // type="number" hack needed because this.value is empty until it's valid
- if (value || value === 0 || (inputElement.type === 'number' && !inputElement.checkValidity())) {
- this._inputHasContent = true;
- } else {
- this._inputHasContent = false;
- }
+ /**
+ * @param {boolean} cardOpen
+ * @return {string}
+ */
+ getCollapseTitle_: function(cardOpen) {
+ return cardOpen ? loadTimeData.getString('collapseSessionButton') :
+ loadTimeData.getString('expandSessionButton');
+ },
- this.updateAddons({
- inputElement: inputElement,
- value: value,
- invalid: this.invalid
- });
- },
+ /**
+ * @param {CustomEvent} e
+ * @private
+ */
+ onMenuButtonTap_: function(e) {
+ this.fire('toggle-menu', {
+ target: Polymer.dom(e).localTarget,
+ tag: this.sessionTag
+ });
+ e.stopPropagation(); // Prevent iron-collapse.
+ },
+});
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
- _handleValueAndAutoValidate: function(inputElement) {
- if (this.autoValidate) {
- var valid;
- if (inputElement.validate) {
- valid = inputElement.validate(this._inputElementValue);
- } else {
- valid = inputElement.checkValidity();
- }
- this.invalid = !valid;
- }
+/**
+ * @typedef {{device: string,
+ * lastUpdateTime: string,
+ * separatorIndexes: !Array<number>,
+ * timestamp: number,
+ * tabs: !Array<!ForeignSessionTab>,
+ * tag: string}}
+ */
+var ForeignDeviceInternal;
- // Call this last to notify the add-ons.
- this._handleValue(inputElement);
- },
+Polymer({
+ is: 'history-synced-device-manager',
- _onIronInputValidate: function(event) {
- this.invalid = this._inputElement.invalid;
+ properties: {
+ /**
+ * @type {?Array<!ForeignSession>}
+ */
+ sessionList: {
+ type: Array,
+ observer: 'updateSyncedDevices'
},
- _invalidChanged: function() {
- if (this._addons) {
- this.updateAddons({invalid: this.invalid});
- }
+ searchTerm: {
+ type: String,
+ observer: 'searchTermChanged'
},
/**
- * Call this to update the state of add-ons.
- * @param {Object} state Add-on state.
+ * An array of synced devices with synced tab data.
+ * @type {!Array<!ForeignDeviceInternal>}
*/
- updateAddons: function(state) {
- for (var addon, index = 0; addon = this._addons[index]; index++) {
- addon.update(state);
- }
+ syncedDevices_: {
+ type: Array,
+ value: function() { return []; }
},
- _computeInputContentClass: function(noLabelFloat, alwaysFloatLabel, focused, invalid, _inputHasContent) {
- var cls = 'input-content';
- if (!noLabelFloat) {
- var label = this.querySelector('label');
+ /** @private */
+ signInState_: {
+ type: Boolean,
+ value: loadTimeData.getBoolean('isUserSignedIn'),
+ },
- if (alwaysFloatLabel || _inputHasContent) {
- cls += ' label-is-floating';
- // If the label is floating, ignore any offsets that may have been
- // applied from a prefix element.
- this.$.labelAndInputContainer.style.position = 'static';
+ /** @private */
+ guestSession_: {
+ type: Boolean,
+ value: loadTimeData.getBoolean('isGuestSession'),
+ },
- if (invalid) {
- cls += ' is-invalid';
- } else if (focused) {
- cls += " label-is-highlighted";
- }
- } else {
- // When the label is not floating, it should overlap the input element.
- if (label) {
- this.$.labelAndInputContainer.style.position = 'relative';
- }
- }
+ /** @private */
+ fetchingSyncedTabs_: {
+ type: Boolean,
+ value: false,
+ }
+ },
+
+ listeners: {
+ 'toggle-menu': 'onToggleMenu_',
+ },
+
+ /** @override */
+ attached: function() {
+ // Update the sign in state.
+ chrome.send('otherDevicesInitialized');
+ },
+
+ /**
+ * @param {!ForeignSession} session
+ * @return {!ForeignDeviceInternal}
+ */
+ createInternalDevice_: function(session) {
+ var tabs = [];
+ var separatorIndexes = [];
+ for (var i = 0; i < session.windows.length; i++) {
+ var windowId = session.windows[i].sessionId;
+ var newTabs = session.windows[i].tabs;
+ if (newTabs.length == 0)
+ continue;
+
+ newTabs.forEach(function(tab) {
+ tab.windowId = windowId;
+ });
+
+ var windowAdded = false;
+ if (!this.searchTerm) {
+ // Add all the tabs if there is no search term.
+ tabs = tabs.concat(newTabs);
+ windowAdded = true;
} else {
- if (_inputHasContent) {
- cls += ' label-is-hidden';
+ var searchText = this.searchTerm.toLowerCase();
+ for (var j = 0; j < newTabs.length; j++) {
+ var tab = newTabs[j];
+ if (tab.title.toLowerCase().indexOf(searchText) != -1) {
+ tabs.push(tab);
+ windowAdded = true;
+ }
}
}
- return cls;
- },
+ if (windowAdded && i != session.windows.length - 1)
+ separatorIndexes.push(tabs.length - 1);
+ }
+ return {
+ device: session.name,
+ lastUpdateTime: '– ' + session.modifiedTime,
+ separatorIndexes: separatorIndexes,
+ timestamp: session.timestamp,
+ tabs: tabs,
+ tag: session.tag,
+ };
+ },
- _computeUnderlineClass: function(focused, invalid) {
- var cls = 'underline';
- if (invalid) {
- cls += ' is-invalid';
- } else if (focused) {
- cls += ' is-highlighted'
- }
- return cls;
- },
+ onSignInTap_: function() {
+ chrome.send('SyncSetupShowSetupUI');
+ chrome.send('SyncSetupStartSignIn', [false]);
+ },
- _computeAddOnContentClass: function(focused, invalid) {
- var cls = 'add-on-content';
- if (invalid) {
- cls += ' is-invalid';
- } else if (focused) {
- cls += ' is-highlighted'
- }
- return cls;
- }
- });
-// 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.
+ onToggleMenu_: function(e) {
+ this.$.menu.toggleMenu(e.detail.target, e.detail.tag);
+ },
+
+ onOpenAllTap_: function() {
+ md_history.BrowserService.getInstance().openForeignSessionAllTabs(
+ this.$.menu.itemData);
+ this.$.menu.closeMenu();
+ },
+
+ onDeleteSessionTap_: function() {
+ md_history.BrowserService.getInstance().deleteForeignSession(
+ this.$.menu.itemData);
+ this.$.menu.closeMenu();
+ },
+
+ /** @private */
+ clearDisplayedSyncedDevices_: function() {
+ this.syncedDevices_ = [];
+ },
-var SearchField = Polymer({
- is: 'cr-search-field',
+ /**
+ * Decide whether or not should display no synced tabs message.
+ * @param {boolean} signInState
+ * @param {number} syncedDevicesLength
+ * @param {boolean} guestSession
+ * @return {boolean}
+ */
+ showNoSyncedMessage: function(
+ signInState, syncedDevicesLength, guestSession) {
+ if (guestSession)
+ return true;
- behaviors: [CrSearchFieldBehavior],
+ return signInState && syncedDevicesLength == 0;
+ },
- properties: {
- value_: String,
+ /**
+ * Shows the signin guide when the user is not signed in and not in a guest
+ * session.
+ * @param {boolean} signInState
+ * @param {boolean} guestSession
+ * @return {boolean}
+ */
+ showSignInGuide: function(signInState, guestSession) {
+ return !signInState && !guestSession;
},
- /** @return {!HTMLInputElement} */
- getSearchInput: function() {
- return this.$.searchInput;
+ /**
+ * Decide what message should be displayed when user is logged in and there
+ * are no synced tabs.
+ * @param {boolean} fetchingSyncedTabs
+ * @return {string}
+ */
+ noSyncedTabsMessage: function(fetchingSyncedTabs) {
+ return loadTimeData.getString(
+ fetchingSyncedTabs ? 'loading' : 'noSyncedResults');
},
- /** @private */
- clearSearch_: function() {
- this.setValue('');
- this.getSearchInput().focus();
+ /**
+ * Replaces the currently displayed synced tabs with |sessionList|. It is
+ * common for only a single session within the list to have changed, We try to
+ * avoid doing extra work in this case. The logic could be more intelligent
+ * about updating individual tabs rather than replacing whole sessions, but
+ * this approach seems to have acceptable performance.
+ * @param {?Array<!ForeignSession>} sessionList
+ */
+ updateSyncedDevices: function(sessionList) {
+ this.fetchingSyncedTabs_ = false;
+
+ if (!sessionList)
+ return;
+
+ // First, update any existing devices that have changed.
+ var updateCount = Math.min(sessionList.length, this.syncedDevices_.length);
+ for (var i = 0; i < updateCount; i++) {
+ var oldDevice = this.syncedDevices_[i];
+ if (oldDevice.tag != sessionList[i].tag ||
+ oldDevice.timestamp != sessionList[i].timestamp) {
+ this.splice(
+ 'syncedDevices_', i, 1, this.createInternalDevice_(sessionList[i]));
+ }
+ }
+
+ // Then, append any new devices.
+ for (var i = updateCount; i < sessionList.length; i++) {
+ this.push('syncedDevices_', this.createInternalDevice_(sessionList[i]));
+ }
},
- /** @private */
- toggleShowingSearch_: function() {
- this.showingSearch = !this.showingSearch;
+ /**
+ * Get called when user's sign in state changes, this will affect UI of synced
+ * tabs page. Sign in promo gets displayed when user is signed out, and
+ * different messages are shown when there are no synced tabs.
+ * @param {boolean} isUserSignedIn
+ */
+ updateSignInState: function(isUserSignedIn) {
+ // If user's sign in state didn't change, then don't change message or
+ // update UI.
+ if (this.signInState_ == isUserSignedIn)
+ return;
+
+ this.signInState_ = isUserSignedIn;
+
+ // User signed out, clear synced device list and show the sign in promo.
+ if (!isUserSignedIn) {
+ this.clearDisplayedSyncedDevices_();
+ return;
+ }
+ // User signed in, show the loading message when querying for synced
+ // devices.
+ this.fetchingSyncedTabs_ = true;
},
+
+ searchTermChanged: function(searchTerm) {
+ this.clearDisplayedSyncedDevices_();
+ this.updateSyncedDevices(this.sessionList);
+ }
});
-// 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.
+/**
+ `iron-selector` is an element which can be used to manage a list of elements
+ that can be selected. Tapping on the item will make the item selected. The `selected` indicates
+ which item is being selected. The default is to use the index of the item.
-cr.define('downloads', function() {
- var Toolbar = Polymer({
- is: 'downloads-toolbar',
+ Example:
- attached: function() {
- // isRTL() only works after i18n_template.js runs to set <html dir>.
- this.overflowAlign_ = isRTL() ? 'left' : 'right';
- },
+ <iron-selector selected="0">
+ <div>Item 1</div>
+ <div>Item 2</div>
+ <div>Item 3</div>
+ </iron-selector>
- properties: {
- downloadsShowing: {
- reflectToAttribute: true,
- type: Boolean,
- value: false,
- observer: 'downloadsShowingChanged_',
- },
+ If you want to use the attribute value of an element for `selected` instead of the index,
+ set `attrForSelected` to the name of the attribute. For example, if you want to select item by
+ `name`, set `attrForSelected` to `name`.
- overflowAlign_: {
- type: String,
- value: 'right',
- },
- },
+ Example:
- listeners: {
- 'paper-dropdown-close': 'onPaperDropdownClose_',
- 'paper-dropdown-open': 'onPaperDropdownOpen_',
- },
+ <iron-selector attr-for-selected="name" selected="foo">
+ <div name="foo">Foo</div>
+ <div name="bar">Bar</div>
+ <div name="zot">Zot</div>
+ </iron-selector>
- /** @return {boolean} Whether removal can be undone. */
- canUndo: function() {
- return this.$['search-input'] != this.shadowRoot.activeElement;
- },
+ You can specify a default fallback with `fallbackSelection` in case the `selected` attribute does
+ not match the `attrForSelected` attribute of any elements.
- /** @return {boolean} Whether "Clear all" should be allowed. */
- canClearAll: function() {
- return !this.$['search-input'].getValue() && this.downloadsShowing;
- },
+ Example:
- onFindCommand: function() {
- this.$['search-input'].showAndFocus();
- },
+ <iron-selector attr-for-selected="name" selected="non-existing"
+ fallback-selection="default">
+ <div name="foo">Foo</div>
+ <div name="bar">Bar</div>
+ <div name="default">Default</div>
+ </iron-selector>
- /** @private */
- closeMoreActions_: function() {
- this.$.more.close();
- },
+ Note: When the selector is multi, the selection will set to `fallbackSelection` iff
+ the number of matching elements is zero.
- /** @private */
- downloadsShowingChanged_: function() {
- this.updateClearAll_();
- },
+ `iron-selector` is not styled. Use the `iron-selected` CSS class to style the selected element.
- /** @private */
- onClearAllTap_: function() {
- assert(this.canClearAll());
- downloads.ActionService.getInstance().clearAll();
- },
+ Example:
- /** @private */
- onPaperDropdownClose_: function() {
- window.removeEventListener('resize', assert(this.boundClose_));
- },
+ <style>
+ .iron-selected {
+ background: #eee;
+ }
+ </style>
- /**
- * @param {!Event} e
- * @private
- */
- onItemBlur_: function(e) {
- var menu = /** @type {PaperMenuElement} */(this.$$('paper-menu'));
- if (menu.items.indexOf(e.relatedTarget) >= 0)
- return;
+ ...
- this.$.more.restoreFocusOnClose = false;
- this.closeMoreActions_();
- this.$.more.restoreFocusOnClose = true;
- },
+ <iron-selector selected="0">
+ <div>Item 1</div>
+ <div>Item 2</div>
+ <div>Item 3</div>
+ </iron-selector>
- /** @private */
- onPaperDropdownOpen_: function() {
- this.boundClose_ = this.boundClose_ || this.closeMoreActions_.bind(this);
- window.addEventListener('resize', this.boundClose_);
- },
+ @demo demo/index.html
+ */
- /**
- * @param {!CustomEvent} event
- * @private
- */
- onSearchChanged_: function(event) {
- downloads.ActionService.getInstance().search(
- /** @type {string} */ (event.detail));
- this.updateClearAll_();
- },
+ Polymer({
- /** @private */
- onOpenDownloadsFolderTap_: function() {
- downloads.ActionService.getInstance().openDownloadsFolder();
+ is: 'iron-selector',
+
+ behaviors: [
+ Polymer.IronMultiSelectableBehavior
+ ]
+
+ });
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+Polymer({
+ is: 'history-side-bar',
+
+ properties: {
+ selectedPage: {
+ type: String,
+ notify: true
},
- /** @private */
- updateClearAll_: function() {
- this.$$('#actions .clear-all').hidden = !this.canClearAll();
- this.$$('paper-menu .clear-all').hidden = !this.canClearAll();
+ route: Object,
+
+ showFooter: Boolean,
+
+ // If true, the sidebar is contained within an app-drawer.
+ drawer: {
+ type: Boolean,
+ reflectToAttribute: true
},
- });
+ },
+
+ /** @private */
+ onSelectorActivate_: function() {
+ this.fire('history-close-drawer');
+ },
+
+ /**
+ * Relocates the user to the clear browsing data section of the settings page.
+ * @param {Event} e
+ * @private
+ */
+ onClearBrowsingDataTap_: function(e) {
+ md_history.BrowserService.getInstance().openClearBrowsingData();
+ e.preventDefault();
+ },
- return {Toolbar: Toolbar};
+ /**
+ * @param {Object} route
+ * @private
+ */
+ getQueryString_: function(route) {
+ return window.location.search;
+ }
});
-// Copyright 2015 The Chromium Authors. All rights reserved.
+// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-cr.define('downloads', function() {
- var Manager = Polymer({
- is: 'downloads-manager',
+Polymer({
+ is: 'history-app',
- properties: {
- hasDownloads_: {
- observer: 'hasDownloadsChanged_',
- type: Boolean,
- },
+ properties: {
+ showSidebarFooter: Boolean,
- items_: {
- type: Array,
- value: function() { return []; },
- },
- },
+ // The id of the currently selected page.
+ selectedPage_: {type: String, value: 'history', observer: 'unselectAll'},
- hostAttributes: {
- loading: true,
+ // Whether domain-grouped history is enabled.
+ grouped_: {type: Boolean, reflectToAttribute: true},
+
+ /** @type {!QueryState} */
+ queryState_: {
+ type: Object,
+ value: function() {
+ return {
+ // Whether the most recent query was incremental.
+ incremental: false,
+ // A query is initiated by page load.
+ querying: true,
+ queryingDisabled: false,
+ _range: HistoryRange.ALL_TIME,
+ searchTerm: '',
+ // TODO(calamity): Make history toolbar buttons change the offset
+ groupedOffset: 0,
+
+ set range(val) { this._range = Number(val); },
+ get range() { return this._range; },
+ };
+ }
},
- listeners: {
- 'downloads-list.scroll': 'onListScroll_',
+ /** @type {!QueryResult} */
+ queryResult_: {
+ type: Object,
+ value: function() {
+ return {
+ info: null,
+ results: null,
+ sessionList: null,
+ };
+ }
},
- observers: [
- 'itemsChanged_(items_.*)',
- ],
+ // Route data for the current page.
+ routeData_: Object,
- /** @private */
- clearAll_: function() {
- this.set('items_', []);
- },
+ // The query params for the page.
+ queryParams_: Object,
- /** @private */
- hasDownloadsChanged_: function() {
- if (loadTimeData.getBoolean('allowDeletingHistory'))
- this.$.toolbar.downloadsShowing = this.hasDownloads_;
+ // True if the window is narrow enough for the page to have a drawer.
+ hasDrawer_: Boolean,
+ },
- if (this.hasDownloads_) {
- this.$['downloads-list'].fire('iron-resize');
- } else {
- var isSearching = downloads.ActionService.getInstance().isSearching();
- var messageToShow = isSearching ? 'noSearchResults' : 'noDownloads';
- this.$['no-downloads'].querySelector('span').textContent =
- loadTimeData.getString(messageToShow);
- }
- },
+ observers: [
+ // routeData_.page <=> selectedPage
+ 'routeDataChanged_(routeData_.page)',
+ 'selectedPageChanged_(selectedPage_)',
- /**
- * @param {number} index
- * @param {!Array<!downloads.Data>} list
- * @private
- */
- insertItems_: function(index, list) {
- this.splice.apply(this, ['items_', index, 0].concat(list));
- this.updateHideDates_(index, index + list.length);
- this.removeAttribute('loading');
- },
+ // queryParams_.q <=> queryState.searchTerm
+ 'searchTermChanged_(queryState_.searchTerm)',
+ 'searchQueryParamChanged_(queryParams_.q)',
- /** @private */
- itemsChanged_: function() {
- this.hasDownloads_ = this.items_.length > 0;
- },
+ ],
- /**
- * @param {Event} e
- * @private
- */
- onCanExecute_: function(e) {
- e = /** @type {cr.ui.CanExecuteEvent} */(e);
- switch (e.command.id) {
- case 'undo-command':
- e.canExecute = this.$.toolbar.canUndo();
- break;
- case 'clear-all-command':
- e.canExecute = this.$.toolbar.canClearAll();
- break;
- case 'find-command':
- e.canExecute = true;
- break;
- }
- },
+ // TODO(calamity): Replace these event listeners with data bound properties.
+ listeners: {
+ 'cr-menu-tap': 'onMenuTap_',
+ 'history-checkbox-select': 'checkboxSelected',
+ 'unselect-all': 'unselectAll',
+ 'delete-selected': 'deleteSelected',
+ 'search-domain': 'searchDomain_',
+ 'history-close-drawer': 'closeDrawer_',
+ },
- /**
- * @param {Event} e
- * @private
- */
- onCommand_: function(e) {
- if (e.command.id == 'clear-all-command')
- downloads.ActionService.getInstance().clearAll();
- else if (e.command.id == 'undo-command')
- downloads.ActionService.getInstance().undo();
- else if (e.command.id == 'find-command')
- this.$.toolbar.onFindCommand();
- },
+ /** @override */
+ ready: function() {
+ this.grouped_ = loadTimeData.getBoolean('groupByDomain');
- /** @private */
- onListScroll_: function() {
- var list = this.$['downloads-list'];
- if (list.scrollHeight - list.scrollTop - list.offsetHeight <= 100) {
- // Approaching the end of the scrollback. Attempt to load more items.
- downloads.ActionService.getInstance().loadMore();
- }
- },
+ cr.ui.decorate('command', cr.ui.Command);
+ document.addEventListener('canExecute', this.onCanExecute_.bind(this));
+ document.addEventListener('command', this.onCommand_.bind(this));
- /** @private */
- onLoad_: function() {
- cr.ui.decorate('command', cr.ui.Command);
- document.addEventListener('canExecute', this.onCanExecute_.bind(this));
- document.addEventListener('command', this.onCommand_.bind(this));
+ // Redirect legacy search URLs to URLs compatible with material history.
+ if (window.location.hash) {
+ window.location.href = window.location.href.split('#')[0] + '?' +
+ window.location.hash.substr(1);
+ }
+ },
- downloads.ActionService.getInstance().loadMore();
- },
+ /** @private */
+ onMenuTap_: function() {
+ var drawer = this.$$('#drawer');
+ if (drawer)
+ drawer.toggle();
+ },
- /**
- * @param {number} index
- * @private
- */
- removeItem_: function(index) {
- this.splice('items_', index, 1);
- this.updateHideDates_(index, index);
- this.onListScroll_();
- },
+ /**
+ * Listens for history-item being selected or deselected (through checkbox)
+ * and changes the view of the top toolbar.
+ * @param {{detail: {countAddition: number}}} e
+ */
+ checkboxSelected: function(e) {
+ var toolbar = /** @type {HistoryToolbarElement} */ (this.$.toolbar);
+ toolbar.count += e.detail.countAddition;
+ },
- /**
- * @param {number} start
- * @param {number} end
- * @private
- */
- updateHideDates_: function(start, end) {
- for (var i = start; i <= end; ++i) {
- var current = this.items_[i];
- if (!current)
- continue;
- var prev = this.items_[i - 1];
- current.hideDate = !!prev && prev.date_string == current.date_string;
- }
- },
+ /**
+ * Listens for call to cancel selection and loops through all items to set
+ * checkbox to be unselected.
+ * @private
+ */
+ unselectAll: function() {
+ var listContainer =
+ /** @type {HistoryListContainerElement} */ (this.$['history']);
+ var toolbar = /** @type {HistoryToolbarElement} */ (this.$.toolbar);
+ listContainer.unselectAllItems(toolbar.count);
+ toolbar.count = 0;
+ },
- /**
- * @param {number} index
- * @param {!downloads.Data} data
- * @private
- */
- updateItem_: function(index, data) {
- this.set('items_.' + index, data);
- this.updateHideDates_(index, index);
- var list = /** @type {!IronListElement} */(this.$['downloads-list']);
- list.updateSizeForItem(index);
- },
- });
+ deleteSelected: function() {
+ this.$.history.deleteSelectedWithPrompt();
+ },
- Manager.clearAll = function() {
- Manager.get().clearAll_();
- };
+ /**
+ * @param {HistoryQuery} info An object containing information about the
+ * query.
+ * @param {!Array<HistoryEntry>} results A list of results.
+ */
+ historyResult: function(info, results) {
+ this.set('queryState_.querying', false);
+ this.set('queryResult_.info', info);
+ this.set('queryResult_.results', results);
+ var listContainer =
+ /** @type {HistoryListContainerElement} */ (this.$['history']);
+ listContainer.historyResult(info, results);
+ },
- /** @return {!downloads.Manager} */
- Manager.get = function() {
- return /** @type {!downloads.Manager} */(
- queryRequiredElement('downloads-manager'));
- };
+ /**
+ * Fired when the user presses 'More from this site'.
+ * @param {{detail: {domain: string}}} e
+ */
+ searchDomain_: function(e) { this.$.toolbar.setSearchTerm(e.detail.domain); },
- Manager.insertItems = function(index, list) {
- Manager.get().insertItems_(index, list);
- };
+ /**
+ * @param {Event} e
+ * @private
+ */
+ onCanExecute_: function(e) {
+ e = /** @type {cr.ui.CanExecuteEvent} */(e);
+ switch (e.command.id) {
+ case 'find-command':
+ e.canExecute = true;
+ break;
+ case 'slash-command':
+ e.canExecute = !this.$.toolbar.searchBar.isSearchFocused();
+ break;
+ case 'delete-command':
+ e.canExecute = this.$.toolbar.count > 0;
+ break;
+ }
+ },
- Manager.onLoad = function() {
- Manager.get().onLoad_();
- };
+ /**
+ * @param {string} searchTerm
+ * @private
+ */
+ searchTermChanged_: function(searchTerm) {
+ this.set('queryParams_.q', searchTerm || null);
+ this.$['history'].queryHistory(false);
+ },
- Manager.removeItem = function(index) {
- Manager.get().removeItem_(index);
- };
+ /**
+ * @param {string} searchQuery
+ * @private
+ */
+ searchQueryParamChanged_: function(searchQuery) {
+ this.$.toolbar.setSearchTerm(searchQuery || '');
+ },
- Manager.updateItem = function(index, data) {
- Manager.get().updateItem_(index, data);
- };
+ /**
+ * @param {Event} e
+ * @private
+ */
+ onCommand_: function(e) {
+ if (e.command.id == 'find-command' || e.command.id == 'slash-command')
+ this.$.toolbar.showSearchField();
+ if (e.command.id == 'delete-command')
+ this.deleteSelected();
+ },
- return {Manager: Manager};
-});
-// 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.
+ /**
+ * @param {!Array<!ForeignSession>} sessionList Array of objects describing
+ * the sessions from other devices.
+ * @param {boolean} isTabSyncEnabled Is tab sync enabled for this profile?
+ */
+ setForeignSessions: function(sessionList, isTabSyncEnabled) {
+ if (!isTabSyncEnabled)
+ return;
+
+ this.set('queryResult_.sessionList', sessionList);
+ },
+
+ /**
+ * Update sign in state of synced device manager after user logs in or out.
+ * @param {boolean} isUserSignedIn
+ */
+ updateSignInState: function(isUserSignedIn) {
+ var syncedDeviceManagerElem =
+ /** @type {HistorySyncedDeviceManagerElement} */this
+ .$$('history-synced-device-manager');
+ if (syncedDeviceManagerElem)
+ syncedDeviceManagerElem.updateSignInState(isUserSignedIn);
+ },
+
+ /**
+ * @param {string} selectedPage
+ * @return {boolean}
+ * @private
+ */
+ syncedTabsSelected_: function(selectedPage) {
+ return selectedPage == 'syncedTabs';
+ },
+
+ /**
+ * @param {boolean} querying
+ * @param {boolean} incremental
+ * @param {string} searchTerm
+ * @return {boolean} Whether a loading spinner should be shown (implies the
+ * backend is querying a new search term).
+ * @private
+ */
+ shouldShowSpinner_: function(querying, incremental, searchTerm) {
+ return querying && !incremental && searchTerm != '';
+ },
+
+ /**
+ * @param {string} page
+ * @private
+ */
+ routeDataChanged_: function(page) {
+ this.selectedPage_ = page;
+ },
+
+ /**
+ * @param {string} selectedPage
+ * @private
+ */
+ selectedPageChanged_: function(selectedPage) {
+ this.set('routeData_.page', selectedPage);
+ },
+
+ /**
+ * This computed binding is needed to make the iron-pages selector update when
+ * the synced-device-manager is instantiated for the first time. Otherwise the
+ * fallback selection will continue to be used after the corresponding item is
+ * added as a child of iron-pages.
+ * @param {string} selectedPage
+ * @param {Array} items
+ * @return {string}
+ * @private
+ */
+ getSelectedPage_: function(selectedPage, items) {
+ return selectedPage;
+ },
-window.addEventListener('load', downloads.Manager.onLoad);
+ /** @private */
+ closeDrawer_: function() {
+ var drawer = this.$$('#drawer');
+ if (drawer)
+ drawer.close();
+ },
+});

Powered by Google App Engine
This is Rietveld 408576698