Index: chrome/browser/resources/md_history/crisper.js |
diff --git a/chrome/browser/resources/md_downloads/crisper.js b/chrome/browser/resources/md_history/crisper.js |
similarity index 69% |
copy from chrome/browser/resources/md_downloads/crisper.js |
copy to chrome/browser/resources/md_history/crisper.js |
index 2d17f3a17ee86921ff069832066d8986932d700a..7cc12a4a7bf6d6fa6477725e07b1d50d512dddee 100644 |
--- a/chrome/browser/resources/md_downloads/crisper.js |
+++ b/chrome/browser/resources/md_history/crisper.js |
@@ -608,3750 +608,3198 @@ var cr = cr || function() { |
// Use of this source code is governed by a BSD-style license that can be |
// found in the LICENSE file. |
-cr.define('cr.ui', function() { |
- |
- /** |
- * Decorates elements as an instance of a class. |
- * @param {string|!Element} source The way to find the element(s) to decorate. |
- * If this is a string then {@code querySeletorAll} is used to find the |
- * elements to decorate. |
- * @param {!Function} constr The constructor to decorate with. The constr |
- * needs to have a {@code decorate} function. |
- */ |
- function decorate(source, constr) { |
- var elements; |
- if (typeof source == 'string') |
- elements = cr.doc.querySelectorAll(source); |
- else |
- elements = [source]; |
- |
- for (var i = 0, el; el = elements[i]; i++) { |
- if (!(el instanceof constr)) |
- constr.decorate(el); |
- } |
- } |
- |
- /** |
- * Helper function for creating new element for define. |
- */ |
- function createElementHelper(tagName, opt_bag) { |
- // Allow passing in ownerDocument to create in a different document. |
- var doc; |
- if (opt_bag && opt_bag.ownerDocument) |
- doc = opt_bag.ownerDocument; |
- else |
- doc = cr.doc; |
- return doc.createElement(tagName); |
- } |
+// <include src="../../../../ui/webui/resources/js/assert.js"> |
- /** |
- * Creates the constructor for a UI element class. |
- * |
- * Usage: |
- * <pre> |
- * var List = cr.ui.define('list'); |
- * List.prototype = { |
- * __proto__: HTMLUListElement.prototype, |
- * decorate: function() { |
- * ... |
- * }, |
- * ... |
- * }; |
- * </pre> |
- * |
- * @param {string|Function} tagNameOrFunction The tagName or |
- * function to use for newly created elements. If this is a function it |
- * needs to return a new element when called. |
- * @return {function(Object=):Element} The constructor function which takes |
- * an optional property bag. The function also has a static |
- * {@code decorate} method added to it. |
- */ |
- function define(tagNameOrFunction) { |
- var createFunction, tagName; |
- if (typeof tagNameOrFunction == 'function') { |
- createFunction = tagNameOrFunction; |
- tagName = ''; |
- } else { |
- createFunction = createElementHelper; |
- tagName = tagNameOrFunction; |
- } |
+/** |
+ * 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; |
+} |
- /** |
- * Creates a new UI element constructor. |
- * @param {Object=} opt_propertyBag Optional bag of properties to set on the |
- * object after created. The property {@code ownerDocument} is special |
- * cased and it allows you to create the element in a different |
- * document than the default. |
- * @constructor |
- */ |
- function f(opt_propertyBag) { |
- var el = createFunction(tagName, opt_propertyBag); |
- f.decorate(el); |
- for (var propertyName in opt_propertyBag) { |
- el[propertyName] = opt_propertyBag[propertyName]; |
- } |
- return el; |
- } |
+// 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; |
+} |
- /** |
- * Decorates an element as a UI element class. |
- * @param {!Element} el The element to decorate. |
- */ |
- f.decorate = function(el) { |
- el.__proto__ = f.prototype; |
- el.decorate(); |
- }; |
+/** |
+ * 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); |
+} |
- return f; |
+/** |
+ * 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 + '")'; |
+} |
- /** |
- * Input elements do not grow and shrink with their content. This is a simple |
- * (and not very efficient) way of handling shrinking to content with support |
- * for min width and limited by the width of the parent element. |
- * @param {!HTMLElement} el The element to limit the width for. |
- * @param {!HTMLElement} parentEl The parent element that should limit the |
- * size. |
- * @param {number} min The minimum width. |
- * @param {number=} opt_scale Optional scale factor to apply to the width. |
- */ |
- function limitInputWidth(el, parentEl, min, opt_scale) { |
- // Needs a size larger than borders |
- el.style.width = '10px'; |
- var doc = el.ownerDocument; |
- var win = doc.defaultView; |
- var computedStyle = win.getComputedStyle(el); |
- var parentComputedStyle = win.getComputedStyle(parentEl); |
- var rtl = computedStyle.direction == 'rtl'; |
- |
- // To get the max width we get the width of the treeItem minus the position |
- // of the input. |
- var inputRect = el.getBoundingClientRect(); // box-sizing |
- var parentRect = parentEl.getBoundingClientRect(); |
- var startPos = rtl ? parentRect.right - inputRect.right : |
- inputRect.left - parentRect.left; |
+/** |
+ * 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; |
+} |
- // Add up border and padding of the input. |
- var inner = parseInt(computedStyle.borderLeftWidth, 10) + |
- parseInt(computedStyle.paddingLeft, 10) + |
- parseInt(computedStyle.paddingRight, 10) + |
- parseInt(computedStyle.borderRightWidth, 10); |
+/** |
+ * 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); |
- // We also need to subtract the padding of parent to prevent it to overflow. |
- var parentPadding = rtl ? parseInt(parentComputedStyle.paddingLeft, 10) : |
- parseInt(parentComputedStyle.paddingRight, 10); |
+ var newQuery = ''; |
+ for (var q in query) { |
+ newQuery += (newQuery ? '&' : '?') + q + '=' + query[q]; |
+ } |
- var max = parentEl.clientWidth - startPos - inner - parentPadding; |
- if (opt_scale) |
- max *= opt_scale; |
+ return location.origin + location.pathname + newQuery + location.hash; |
+} |
- function limit() { |
- if (el.scrollWidth > max) { |
- el.style.width = max + 'px'; |
- } else { |
- el.style.width = 0; |
- var sw = el.scrollWidth; |
- if (sw < min) { |
- el.style.width = min + 'px'; |
- } else { |
- el.style.width = sw + 'px'; |
- } |
- } |
- } |
+/** |
+ * @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); |
+ })); |
+} |
- el.addEventListener('input', limit); |
- limit(); |
+/** |
+ * 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; |
+} |
- /** |
- * Takes a number and spits out a value CSS will be happy with. To avoid |
- * subpixel layout issues, the value is rounded to the nearest integral value. |
- * @param {number} pixels The number of pixels. |
- * @return {string} e.g. '16px'. |
- */ |
- function toCssPx(pixels) { |
- if (!window.isFinite(pixels)) |
- console.error('Pixel value is not a number: ' + pixels); |
- return Math.round(pixels) + 'px'; |
+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); |
+} |
- /** |
- * Users complain they occasionaly use doubleclicks instead of clicks |
- * (http://crbug.com/140364). To fix it we freeze click handling for |
- * the doubleclick time interval. |
- * @param {MouseEvent} e Initial click event. |
- */ |
- function swallowDoubleClick(e) { |
- var doc = e.target.ownerDocument; |
- var counter = Math.min(1, e.detail); |
- function swallow(e) { |
- e.stopPropagation(); |
+/** |
+ * 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(); |
- } |
- function onclick(e) { |
- if (e.detail > counter) { |
- counter = e.detail; |
- // Swallow the click since it's a click inside the doubleclick timeout. |
- swallow(e); |
- } else { |
- // Stop tracking clicks and let regular handling. |
- doc.removeEventListener('dblclick', swallow, true); |
- doc.removeEventListener('click', onclick, true); |
- } |
- } |
- // The following 'click' event (if e.type == 'mouseup') mustn't be taken |
- // into account (it mustn't stop tracking clicks). Start event listening |
- // after zero timeout. |
- setTimeout(function() { |
- doc.addEventListener('click', onclick, true); |
- doc.addEventListener('dblclick', swallow, true); |
- }, 0); |
- } |
+ }; |
- return { |
- decorate: decorate, |
- define: define, |
- limitInputWidth: limitInputWidth, |
- toCssPx: toCssPx, |
- swallowDoubleClick: swallowDoubleClick |
+ // Disable dragging. |
+ document.ondragstart = function(e) { |
+ if (!(opt_allowDragStart && opt_allowDragStart.call(this, e))) |
+ e.preventDefault(); |
}; |
-}); |
-// 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. |
+} |
/** |
- * @fileoverview A command is an abstraction of an action a user can do in the |
- * UI. |
- * |
- * When the focus changes in the document for each command a canExecute event |
- * is dispatched on the active element. By listening to this event you can |
- * enable and disable the command by setting the event.canExecute property. |
- * |
- * When a command is executed a command event is dispatched on the active |
- * element. Note that you should stop the propagation after you have handled the |
- * command if there might be other command listeners higher up in the DOM tree. |
+ * 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). |
*/ |
- |
-cr.define('cr.ui', function() { |
- |
- /** |
- * This is used to identify keyboard shortcuts. |
- * @param {string} shortcut The text used to describe the keys for this |
- * keyboard shortcut. |
- * @constructor |
- */ |
- function KeyboardShortcut(shortcut) { |
- var mods = {}; |
- var ident = ''; |
- shortcut.split('|').forEach(function(part) { |
- var partLc = part.toLowerCase(); |
- switch (partLc) { |
- case 'alt': |
- case 'ctrl': |
- case 'meta': |
- case 'shift': |
- mods[partLc + 'Key'] = true; |
- break; |
- default: |
- if (ident) |
- throw Error('Invalid shortcut'); |
- ident = part; |
- } |
+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(); |
+ }); |
+} |
- this.ident_ = ident; |
- this.mods_ = mods; |
- } |
+/** |
+ * Check the directionality of the page. |
+ * @return {boolean} True if Chrome is running an RTL UI. |
+ */ |
+function isRTL() { |
+ return document.documentElement.dir == 'rtl'; |
+} |
- KeyboardShortcut.prototype = { |
- /** |
- * Whether the keyboard shortcut object matches a keyboard event. |
- * @param {!Event} e The keyboard event object. |
- * @return {boolean} Whether we found a match or not. |
- */ |
- matchesEvent: function(e) { |
- if (e.key == this.ident_) { |
- // All keyboard modifiers needs to match. |
- var mods = this.mods_; |
- return ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'].every(function(k) { |
- return e[k] == !!mods[k]; |
- }); |
- } |
- return false; |
- } |
- }; |
+/** |
+ * 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); |
+} |
- /** |
- * Creates a new command element. |
- * @constructor |
- * @extends {HTMLElement} |
- */ |
- var Command = cr.ui.define('command'); |
+/** |
+ * 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); |
+} |
- Command.prototype = { |
- __proto__: HTMLElement.prototype, |
+// 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; |
- /** |
- * Initializes the command. |
- */ |
- decorate: function() { |
- CommandManager.init(assert(this.ownerDocument)); |
+ var el = e.target; |
+ if (el.nodeType == Node.ELEMENT_NODE && |
+ el.webkitMatchesSelector('A, A *')) { |
+ while (el.tagName != 'A') { |
+ el = el.parentElement; |
+ } |
- if (this.hasAttribute('shortcut')) |
- this.shortcut = this.getAttribute('shortcut'); |
- }, |
+ 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(); |
+ } |
+ } |
+}); |
- /** |
- * Executes the command by dispatching a command event on the given element. |
- * If |element| isn't given, the active element is used instead. |
- * If the command is {@code disabled} this does nothing. |
- * @param {HTMLElement=} opt_element Optional element to dispatch event on. |
- */ |
- execute: function(opt_element) { |
- if (this.disabled) |
- return; |
- var doc = this.ownerDocument; |
- if (doc.activeElement) { |
- var e = new Event('command', {bubbles: true}); |
- e.command = this; |
+/** |
+ * 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); |
- (opt_element || doc.activeElement).dispatchEvent(e); |
- } |
- }, |
+ if (url.indexOf('?') == -1) |
+ return url + '?' + param; |
+ return url + '&' + param; |
+} |
- /** |
- * Call this when there have been changes that might change whether the |
- * command can be executed or not. |
- * @param {Node=} opt_node Node for which to actuate command state. |
- */ |
- canExecuteChange: function(opt_node) { |
- dispatchCanExecuteEvent(this, |
- opt_node || this.ownerDocument.activeElement); |
- }, |
- |
- /** |
- * The keyboard shortcut that triggers the command. This is a string |
- * consisting of a key (as reported by WebKit in keydown) as |
- * well as optional key modifiers joinded with a '|'. |
- * |
- * Multiple keyboard shortcuts can be provided by separating them by |
- * whitespace. |
- * |
- * For example: |
- * "F1" |
- * "Backspace|Meta" for Apple command backspace. |
- * "a|Ctrl" for Control A |
- * "Delete Backspace|Meta" for Delete and Command Backspace |
- * |
- * @type {string} |
- */ |
- shortcut_: '', |
- get shortcut() { |
- return this.shortcut_; |
- }, |
- set shortcut(shortcut) { |
- var oldShortcut = this.shortcut_; |
- if (shortcut !== oldShortcut) { |
- this.keyboardShortcuts_ = shortcut.split(/\s+/).map(function(shortcut) { |
- return new KeyboardShortcut(shortcut); |
- }); |
- |
- // Set this after the keyboardShortcuts_ since that might throw. |
- this.shortcut_ = shortcut; |
- cr.dispatchPropertyChange(this, 'shortcut', this.shortcut_, |
- oldShortcut); |
- } |
- }, |
- |
- /** |
- * Whether the event object matches the shortcut for this command. |
- * @param {!Event} e The key event object. |
- * @return {boolean} Whether it matched or not. |
- */ |
- matchesEvent: function(e) { |
- if (!this.keyboardShortcuts_) |
- return false; |
- |
- return this.keyboardShortcuts_.some(function(keyboardShortcut) { |
- return keyboardShortcut.matchesEvent(e); |
- }); |
- }, |
- }; |
- |
- /** |
- * The label of the command. |
- */ |
- cr.defineProperty(Command, 'label', cr.PropertyKind.ATTR); |
- |
- /** |
- * Whether the command is disabled or not. |
- */ |
- cr.defineProperty(Command, 'disabled', cr.PropertyKind.BOOL_ATTR); |
+/** |
+ * 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; |
+} |
- /** |
- * Whether the command is hidden or not. |
- */ |
- cr.defineProperty(Command, 'hidden', cr.PropertyKind.BOOL_ATTR); |
+/** |
+ * 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; |
- /** |
- * Whether the command is checked or not. |
- */ |
- cr.defineProperty(Command, 'checked', cr.PropertyKind.BOOL_ATTR); |
+ // Give an additional 50ms buffer for the animation to complete. |
+ opt_timeOut += 50; |
+ } |
- /** |
- * The flag that prevents the shortcut text from being displayed on menu. |
- * |
- * If false, the keyboard shortcut text (eg. "Ctrl+X" for the cut command) |
- * is displayed in menu when the command is assosiated with a menu item. |
- * Otherwise, no text is displayed. |
- */ |
- cr.defineProperty(Command, 'hideShortcutText', cr.PropertyKind.BOOL_ATTR); |
+ 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); |
+} |
- /** |
- * Dispatches a canExecute event on the target. |
- * @param {!cr.ui.Command} command The command that we are testing for. |
- * @param {EventTarget} target The target element to dispatch the event on. |
- */ |
- function dispatchCanExecuteEvent(command, target) { |
- var e = new CanExecuteEvent(command); |
- target.dispatchEvent(e); |
- command.disabled = !e.canExecute; |
- } |
+/** |
+ * 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; |
+} |
- /** |
- * The command managers for different documents. |
- */ |
- var commandManagers = {}; |
+/** |
+ * 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; |
+} |
- /** |
- * Keeps track of the focused element and updates the commands when the focus |
- * changes. |
- * @param {!Document} doc The document that we are managing the commands for. |
- * @constructor |
- */ |
- function CommandManager(doc) { |
- doc.addEventListener('focus', this.handleFocus_.bind(this), true); |
- // Make sure we add the listener to the bubbling phase so that elements can |
- // prevent the command. |
- doc.addEventListener('keydown', this.handleKeyDown_.bind(this), false); |
- } |
+/** |
+ * 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; |
+} |
- /** |
- * Initializes a command manager for the document as needed. |
- * @param {!Document} doc The document to manage the commands for. |
- */ |
- CommandManager.init = function(doc) { |
- var uid = cr.getUid(doc); |
- if (!(uid in commandManagers)) { |
- commandManagers[uid] = new CommandManager(doc); |
- } |
- }; |
+/** |
+ * 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; |
+} |
- CommandManager.prototype = { |
+/** |
+ * 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, '&') |
+ .replace(/</g, '<') |
+ .replace(/>/g, '>') |
+ .replace(/"/g, '"') |
+ .replace(/'/g, '''); |
+} |
- /** |
- * Handles focus changes on the document. |
- * @param {Event} e The focus event object. |
- * @private |
- * @suppress {checkTypes} |
- * TODO(vitalyp): remove the suppression. |
- */ |
- handleFocus_: function(e) { |
- var target = e.target; |
+/** |
+ * 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'; |
+} |
- // Ignore focus on a menu button or command item. |
- if (target.menu || target.command) |
- return; |
+/** |
+ * 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'); |
+} |
- var commands = Array.prototype.slice.call( |
- target.ownerDocument.querySelectorAll('command')); |
+// <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); |
- commands.forEach(function(command) { |
- dispatchCanExecuteEvent(command, target); |
- }); |
- }, |
+ // A-Z |
+ if (this.keyCode >= 0x41 && this.keyCode <= 0x5a) { |
+ var result = String.fromCharCode(this.keyCode).toLowerCase(); |
+ if (this.shiftKey) |
+ result = result.toUpperCase(); |
+ return result; |
+ } |
- /** |
- * Handles the keydown event and routes it to the right command. |
- * @param {!Event} e The keydown event. |
- */ |
- handleKeyDown_: function(e) { |
- var target = e.target; |
- var commands = Array.prototype.slice.call( |
- target.ownerDocument.querySelectorAll('command')); |
- |
- for (var i = 0, command; command = commands[i]; i++) { |
- if (command.matchesEvent(e)) { |
- // When invoking a command via a shortcut, we have to manually check |
- // if it can be executed, since focus might not have been changed |
- // what would have updated the command's state. |
- command.canExecuteChange(); |
- |
- if (!command.disabled) { |
- e.preventDefault(); |
- // We do not want any other element to handle this. |
- e.stopPropagation(); |
- command.execute(); |
- return; |
- } |
- } |
+ // 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'; |
} |
- }; |
- |
- /** |
- * The event type used for canExecute events. |
- * @param {!cr.ui.Command} command The command that we are evaluating. |
- * @extends {Event} |
- * @constructor |
- * @class |
- */ |
- function CanExecuteEvent(command) { |
- var e = new Event('canExecute', {bubbles: true, cancelable: true}); |
- e.__proto__ = CanExecuteEvent.prototype; |
- e.command = command; |
- return e; |
- } |
- |
- CanExecuteEvent.prototype = { |
- __proto__: Event.prototype, |
- |
- /** |
- * The current command |
- * @type {cr.ui.Command} |
- */ |
- command: null, |
- |
- /** |
- * Whether the target can execute the command. Setting this also stops the |
- * propagation and prevents the default. Callers can tell if an event has |
- * been handled via |this.defaultPrevented|. |
- * @type {boolean} |
- */ |
- canExecute_: false, |
- get canExecute() { |
- return this.canExecute_; |
- }, |
- set canExecute(canExecute) { |
- this.canExecute_ = !!canExecute; |
- this.stopPropagation(); |
- this.preventDefault(); |
- } |
- }; |
- |
- // Export |
- return { |
- Command: Command, |
- CanExecuteEvent: CanExecuteEvent |
- }; |
-}); |
-// Copyright (c) 2012 The Chromium Authors. All rights reserved. |
+ }); |
+} else { |
+ window.console.log("KeyboardEvent.Key polyfill not required"); |
+} |
+// </if> /* is_ios */ |
+// 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. |
-// <include src="../../../../ui/webui/resources/js/assert.js"> |
- |
-/** |
- * 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; |
-} |
+// Globals: |
+/** @const */ var RESULTS_PER_PAGE = 150; |
-// 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. |
+ * Amount of time between pageviews that we consider a 'break' in browsing, |
+ * measured in milliseconds. |
+ * @const |
*/ |
-function getSVGElement(id) { |
- var el = document.getElementById(id); |
- return el ? assertInstanceof(el, Element) : null; |
-} |
+var BROWSING_GAP_TIME = 15 * 60 * 1000; |
/** |
- * 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. |
+ * Maximum length of a history item title. Anything longer than this will be |
+ * cropped to fit within this limit. This value is large enough that it will not |
+ * be noticeable in a 960px wide history-item. |
+ * @const |
*/ |
-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); |
-} |
+var TITLE_MAX_LENGTH = 300; |
/** |
- * Generates a CSS url string. |
- * @param {string} s The URL to generate the CSS url for. |
- * @return {string} The CSS url string. |
+ * @enum {number} |
*/ |
-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 + '")'; |
-} |
+var HistoryRange = { |
+ ALL_TIME: 0, |
+ WEEK: 1, |
+ MONTH: 2 |
+}; |
+// Types: |
/** |
- * 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 |
+ * @typedef {{groupedOffset: number, |
+ * incremental: boolean, |
+ * querying: boolean, |
+ * range: HistoryRange, |
+ * searchTerm: string}} |
*/ |
-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; |
-} |
+var QueryState; |
/** |
- * 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. |
+ * @typedef {{info: ?HistoryQuery, |
+ * results: ?Array<!HistoryEntry>, |
+ * sessionList: ?Array<!ForeignSession>}} |
*/ |
-function setQueryParam(location, key, value) { |
- var query = parseQueryParams(location); |
- query[encodeURIComponent(key)] = encodeURIComponent(value); |
+var QueryResult; |
- var newQuery = ''; |
- for (var q in query) { |
- newQuery += (newQuery ? '&' : '?') + q + '=' + query[q]; |
- } |
+/** @constructor |
+ * @extends {MouseEvent} */ |
+var DomRepeatClickEvent = function() { |
+ this.model = null; |
+}; |
+// 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. |
- return location.origin + location.pathname + newQuery + location.hash; |
-} |
+cr.define('cr.ui', function() { |
-/** |
- * @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); |
- })); |
-} |
+ /** |
+ * Decorates elements as an instance of a class. |
+ * @param {string|!Element} source The way to find the element(s) to decorate. |
+ * If this is a string then {@code querySeletorAll} is used to find the |
+ * elements to decorate. |
+ * @param {!Function} constr The constructor to decorate with. The constr |
+ * needs to have a {@code decorate} function. |
+ */ |
+ function decorate(source, constr) { |
+ var elements; |
+ if (typeof source == 'string') |
+ elements = cr.doc.querySelectorAll(source); |
+ else |
+ elements = [source]; |
-/** |
- * 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; |
+ for (var i = 0, el; el = elements[i]; i++) { |
+ if (!(el instanceof constr)) |
+ constr.decorate(el); |
+ } |
} |
- return last ? node : 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); |
-} |
+ /** |
+ * Helper function for creating new element for define. |
+ */ |
+ function createElementHelper(tagName, opt_bag) { |
+ // Allow passing in ownerDocument to create in a different document. |
+ var doc; |
+ if (opt_bag && opt_bag.ownerDocument) |
+ doc = opt_bag.ownerDocument; |
+ else |
+ doc = cr.doc; |
+ return doc.createElement(tagName); |
+ } |
-/** |
- * 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(); |
- }; |
+ /** |
+ * Creates the constructor for a UI element class. |
+ * |
+ * Usage: |
+ * <pre> |
+ * var List = cr.ui.define('list'); |
+ * List.prototype = { |
+ * __proto__: HTMLUListElement.prototype, |
+ * decorate: function() { |
+ * ... |
+ * }, |
+ * ... |
+ * }; |
+ * </pre> |
+ * |
+ * @param {string|Function} tagNameOrFunction The tagName or |
+ * function to use for newly created elements. If this is a function it |
+ * needs to return a new element when called. |
+ * @return {function(Object=):Element} The constructor function which takes |
+ * an optional property bag. The function also has a static |
+ * {@code decorate} method added to it. |
+ */ |
+ function define(tagNameOrFunction) { |
+ var createFunction, tagName; |
+ if (typeof tagNameOrFunction == 'function') { |
+ createFunction = tagNameOrFunction; |
+ tagName = ''; |
+ } else { |
+ createFunction = createElementHelper; |
+ tagName = tagNameOrFunction; |
+ } |
- // Disable dragging. |
- document.ondragstart = function(e) { |
- if (!(opt_allowDragStart && opt_allowDragStart.call(this, e))) |
- e.preventDefault(); |
- }; |
-} |
+ /** |
+ * Creates a new UI element constructor. |
+ * @param {Object=} opt_propertyBag Optional bag of properties to set on the |
+ * object after created. The property {@code ownerDocument} is special |
+ * cased and it allows you to create the element in a different |
+ * document than the default. |
+ * @constructor |
+ */ |
+ function f(opt_propertyBag) { |
+ var el = createFunction(tagName, opt_propertyBag); |
+ f.decorate(el); |
+ for (var propertyName in opt_propertyBag) { |
+ el[propertyName] = opt_propertyBag[propertyName]; |
+ } |
+ return el; |
+ } |
-/** |
- * 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(); |
- }); |
-} |
+ /** |
+ * Decorates an element as a UI element class. |
+ * @param {!Element} el The element to decorate. |
+ */ |
+ f.decorate = function(el) { |
+ el.__proto__ = f.prototype; |
+ el.decorate(); |
+ }; |
-/** |
- * Check the directionality of the page. |
- * @return {boolean} True if Chrome is running an RTL UI. |
- */ |
-function isRTL() { |
- return document.documentElement.dir == 'rtl'; |
-} |
+ return f; |
+ } |
-/** |
- * 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); |
-} |
+ /** |
+ * Input elements do not grow and shrink with their content. This is a simple |
+ * (and not very efficient) way of handling shrinking to content with support |
+ * for min width and limited by the width of the parent element. |
+ * @param {!HTMLElement} el The element to limit the width for. |
+ * @param {!HTMLElement} parentEl The parent element that should limit the |
+ * size. |
+ * @param {number} min The minimum width. |
+ * @param {number=} opt_scale Optional scale factor to apply to the width. |
+ */ |
+ function limitInputWidth(el, parentEl, min, opt_scale) { |
+ // Needs a size larger than borders |
+ el.style.width = '10px'; |
+ var doc = el.ownerDocument; |
+ var win = doc.defaultView; |
+ var computedStyle = win.getComputedStyle(el); |
+ var parentComputedStyle = win.getComputedStyle(parentEl); |
+ var rtl = computedStyle.direction == 'rtl'; |
-/** |
- * 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); |
-} |
+ // To get the max width we get the width of the treeItem minus the position |
+ // of the input. |
+ var inputRect = el.getBoundingClientRect(); // box-sizing |
+ var parentRect = parentEl.getBoundingClientRect(); |
+ var startPos = rtl ? parentRect.right - inputRect.right : |
+ inputRect.left - parentRect.left; |
-// 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; |
+ // Add up border and padding of the input. |
+ var inner = parseInt(computedStyle.borderLeftWidth, 10) + |
+ parseInt(computedStyle.paddingLeft, 10) + |
+ parseInt(computedStyle.paddingRight, 10) + |
+ parseInt(computedStyle.borderRightWidth, 10); |
- var el = e.target; |
- if (el.nodeType == Node.ELEMENT_NODE && |
- el.webkitMatchesSelector('A, A *')) { |
- while (el.tagName != 'A') { |
- el = el.parentElement; |
+ // We also need to subtract the padding of parent to prevent it to overflow. |
+ var parentPadding = rtl ? parseInt(parentComputedStyle.paddingLeft, 10) : |
+ parseInt(parentComputedStyle.paddingRight, 10); |
+ |
+ var max = parentEl.clientWidth - startPos - inner - parentPadding; |
+ if (opt_scale) |
+ max *= opt_scale; |
+ |
+ function limit() { |
+ if (el.scrollWidth > max) { |
+ el.style.width = max + 'px'; |
+ } else { |
+ el.style.width = 0; |
+ var sw = el.scrollWidth; |
+ if (sw < min) { |
+ el.style.width = min + 'px'; |
+ } else { |
+ el.style.width = sw + 'px'; |
+ } |
+ } |
} |
- 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 |
- ]); |
+ el.addEventListener('input', limit); |
+ limit(); |
+ } |
+ |
+ /** |
+ * Takes a number and spits out a value CSS will be happy with. To avoid |
+ * subpixel layout issues, the value is rounded to the nearest integral value. |
+ * @param {number} pixels The number of pixels. |
+ * @return {string} e.g. '16px'. |
+ */ |
+ function toCssPx(pixels) { |
+ if (!window.isFinite(pixels)) |
+ console.error('Pixel value is not a number: ' + pixels); |
+ return Math.round(pixels) + 'px'; |
+ } |
+ |
+ /** |
+ * Users complain they occasionaly use doubleclicks instead of clicks |
+ * (http://crbug.com/140364). To fix it we freeze click handling for |
+ * the doubleclick time interval. |
+ * @param {MouseEvent} e Initial click event. |
+ */ |
+ function swallowDoubleClick(e) { |
+ var doc = e.target.ownerDocument; |
+ var counter = Math.min(1, e.detail); |
+ function swallow(e) { |
+ e.stopPropagation(); |
e.preventDefault(); |
} |
+ function onclick(e) { |
+ if (e.detail > counter) { |
+ counter = e.detail; |
+ // Swallow the click since it's a click inside the doubleclick timeout. |
+ swallow(e); |
+ } else { |
+ // Stop tracking clicks and let regular handling. |
+ doc.removeEventListener('dblclick', swallow, true); |
+ doc.removeEventListener('click', onclick, true); |
+ } |
+ } |
+ // The following 'click' event (if e.type == 'mouseup') mustn't be taken |
+ // into account (it mustn't stop tracking clicks). Start event listening |
+ // after zero timeout. |
+ setTimeout(function() { |
+ doc.addEventListener('click', onclick, true); |
+ doc.addEventListener('dblclick', swallow, true); |
+ }, 0); |
} |
-}); |
-/** |
- * 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); |
- |
- if (url.indexOf('?') == -1) |
- return url + '?' + param; |
- return url + '&' + param; |
-} |
- |
-/** |
- * 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; |
-} |
+ return { |
+ decorate: decorate, |
+ define: define, |
+ limitInputWidth: limitInputWidth, |
+ toCssPx: toCssPx, |
+ swallowDoubleClick: swallowDoubleClick |
+ }; |
+}); |
+// 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. |
/** |
- * 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. |
+ * @fileoverview A command is an abstraction of an action a user can do in the |
+ * UI. |
+ * |
+ * When the focus changes in the document for each command a canExecute event |
+ * is dispatched on the active element. By listening to this event you can |
+ * enable and disable the command by setting the event.canExecute property. |
+ * |
+ * When a command is executed a command event is dispatched on the active |
+ * element. Note that you should stop the propagation after you have handled the |
+ * command if there might be other command listeners higher up in the DOM tree. |
*/ |
-function ensureTransitionEndEvent(el, opt_timeOut) { |
- if (opt_timeOut === undefined) { |
- var style = getComputedStyle(el); |
- opt_timeOut = parseFloat(style.transitionDuration) * 1000; |
- |
- // Give an additional 50ms buffer for the animation to complete. |
- opt_timeOut += 50; |
- } |
- var fired = false; |
- el.addEventListener('webkitTransitionEnd', function f(e) { |
- el.removeEventListener('webkitTransitionEnd', f); |
- fired = true; |
- }); |
- window.setTimeout(function() { |
- if (!fired) |
- cr.dispatchSimpleEvent(el, 'webkitTransitionEnd', true); |
- }, opt_timeOut); |
-} |
+cr.define('cr.ui', function() { |
-/** |
- * 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; |
-} |
+ /** |
+ * This is used to identify keyboard shortcuts. |
+ * @param {string} shortcut The text used to describe the keys for this |
+ * keyboard shortcut. |
+ * @constructor |
+ */ |
+ function KeyboardShortcut(shortcut) { |
+ var mods = {}; |
+ var ident = ''; |
+ shortcut.split('|').forEach(function(part) { |
+ var partLc = part.toLowerCase(); |
+ switch (partLc) { |
+ case 'alt': |
+ case 'ctrl': |
+ case 'meta': |
+ case 'shift': |
+ mods[partLc + 'Key'] = true; |
+ break; |
+ default: |
+ if (ident) |
+ throw Error('Invalid shortcut'); |
+ ident = part; |
+ } |
+ }); |
-/** |
- * 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; |
-} |
+ this.ident_ = ident; |
+ this.mods_ = mods; |
+ } |
-/** |
- * 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; |
-} |
+ KeyboardShortcut.prototype = { |
+ /** |
+ * Whether the keyboard shortcut object matches a keyboard event. |
+ * @param {!Event} e The keyboard event object. |
+ * @return {boolean} Whether we found a match or not. |
+ */ |
+ matchesEvent: function(e) { |
+ if (e.key == this.ident_) { |
+ // All keyboard modifiers needs to match. |
+ var mods = this.mods_; |
+ return ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'].every(function(k) { |
+ return e[k] == !!mods[k]; |
+ }); |
+ } |
+ return false; |
+ } |
+ }; |
-/** |
- * 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; |
-} |
+ /** |
+ * Creates a new command element. |
+ * @constructor |
+ * @extends {HTMLElement} |
+ */ |
+ var Command = cr.ui.define('command'); |
-/** |
- * 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, '&') |
- .replace(/</g, '<') |
- .replace(/>/g, '>') |
- .replace(/"/g, '"') |
- .replace(/'/g, '''); |
-} |
+ Command.prototype = { |
+ __proto__: HTMLElement.prototype, |
-/** |
- * 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'; |
-} |
+ /** |
+ * Initializes the command. |
+ */ |
+ decorate: function() { |
+ CommandManager.init(assert(this.ownerDocument)); |
-/** |
- * 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'); |
-} |
+ if (this.hasAttribute('shortcut')) |
+ this.shortcut = this.getAttribute('shortcut'); |
+ }, |
-// <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); |
+ /** |
+ * Executes the command by dispatching a command event on the given element. |
+ * If |element| isn't given, the active element is used instead. |
+ * If the command is {@code disabled} this does nothing. |
+ * @param {HTMLElement=} opt_element Optional element to dispatch event on. |
+ */ |
+ execute: function(opt_element) { |
+ if (this.disabled) |
+ return; |
+ var doc = this.ownerDocument; |
+ if (doc.activeElement) { |
+ var e = new Event('command', {bubbles: true}); |
+ e.command = this; |
- // A-Z |
- if (this.keyCode >= 0x41 && this.keyCode <= 0x5a) { |
- var result = String.fromCharCode(this.keyCode).toLowerCase(); |
- if (this.shiftKey) |
- result = result.toUpperCase(); |
- return result; |
+ (opt_element || doc.activeElement).dispatchEvent(e); |
} |
- |
- // 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' |
- }, |
- |
- /** |
- * True if this element is currently notifying its descedant elements of |
- * resize. |
- */ |
- _notifyingDescendant: { |
- type: Boolean, |
- value: false |
- } |
- }, |
- |
- listeners: { |
- 'iron-request-resize-notifications': '_onIronRequestResizeNotifications' |
- }, |
- |
- 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); |
- }, |
- |
- 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(); |
- } |
- }, |
- |
- detached: function() { |
- if (this._parentResizable) { |
- this._parentResizable.stopResizeNotificationsFor(this); |
- } else { |
- window.removeEventListener('resize', this._boundNotifyResize); |
- } |
- |
- this._parentResizable = null; |
- }, |
+ }, |
/** |
- * Can be called to manually notify a resizable and its descendant |
- * resizables of a resize change. |
+ * Call this when there have been changes that might change whether the |
+ * command can be executed or not. |
+ * @param {Node=} opt_node Node for which to actuate command state. |
*/ |
- notifyResize: function() { |
- if (!this.isAttached) { |
- return; |
- } |
- |
- this._interestedResizables.forEach(function(resizable) { |
- if (this.resizerShouldNotify(resizable)) { |
- this._notifyDescendant(resizable); |
- } |
- }, this); |
- |
- this._fireResize(); |
+ canExecuteChange: function(opt_node) { |
+ dispatchCanExecuteEvent(this, |
+ opt_node || this.ownerDocument.activeElement); |
}, |
/** |
- * Used to assign the closest resizable ancestor to this resizable |
- * if the ancestor detects a request for notifications. |
+ * The keyboard shortcut that triggers the command. This is a string |
+ * consisting of a key (as reported by WebKit in keydown) as |
+ * well as optional key modifiers joinded with a '|'. |
+ * |
+ * Multiple keyboard shortcuts can be provided by separating them by |
+ * whitespace. |
+ * |
+ * For example: |
+ * "F1" |
+ * "Backspace|Meta" for Apple command backspace. |
+ * "a|Ctrl" for Control A |
+ * "Delete Backspace|Meta" for Delete and Command Backspace |
+ * |
+ * @type {string} |
*/ |
- assignParentResizable: function(parentResizable) { |
- this._parentResizable = parentResizable; |
+ shortcut_: '', |
+ get shortcut() { |
+ return this.shortcut_; |
}, |
+ set shortcut(shortcut) { |
+ var oldShortcut = this.shortcut_; |
+ if (shortcut !== oldShortcut) { |
+ this.keyboardShortcuts_ = shortcut.split(/\s+/).map(function(shortcut) { |
+ return new KeyboardShortcut(shortcut); |
+ }); |
- /** |
- * 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); |
- |
- if (index > -1) { |
- this._interestedResizables.splice(index, 1); |
- this.unlisten(target, 'iron-resize', '_onDescendantIronResize'); |
+ // Set this after the keyboardShortcuts_ since that might throw. |
+ this.shortcut_ = shortcut; |
+ cr.dispatchPropertyChange(this, 'shortcut', this.shortcut_, |
+ oldShortcut); |
} |
}, |
/** |
- * 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. |
+ * Whether the event object matches the shortcut for this command. |
+ * @param {!Event} e The key event object. |
+ * @return {boolean} Whether it matched or not. |
*/ |
- resizerShouldNotify: function(element) { return true; }, |
- |
- _onDescendantIronResize: function(event) { |
- if (this._notifyingDescendant) { |
- event.stopPropagation(); |
- return; |
- } |
- |
- // 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(); |
- } |
- }, |
+ matchesEvent: function(e) { |
+ if (!this.keyboardShortcuts_) |
+ return false; |
- _fireResize: function() { |
- this.fire('iron-resize', null, { |
- node: this, |
- bubbles: false |
+ return this.keyboardShortcuts_.some(function(keyboardShortcut) { |
+ return keyboardShortcut.matchesEvent(e); |
}); |
}, |
+ }; |
- _onIronRequestResizeNotifications: function(event) { |
- var target = event.path ? event.path[0] : event.target; |
+ /** |
+ * The label of the command. |
+ */ |
+ cr.defineProperty(Command, 'label', cr.PropertyKind.ATTR); |
- if (target === this) { |
- return; |
- } |
+ /** |
+ * Whether the command is disabled or not. |
+ */ |
+ cr.defineProperty(Command, 'disabled', cr.PropertyKind.BOOL_ATTR); |
- if (this._interestedResizables.indexOf(target) === -1) { |
- this._interestedResizables.push(target); |
- this.listen(target, 'iron-resize', '_onDescendantIronResize'); |
- } |
+ /** |
+ * Whether the command is hidden or not. |
+ */ |
+ cr.defineProperty(Command, 'hidden', cr.PropertyKind.BOOL_ATTR); |
- target.assignParentResizable(this); |
- this._notifyDescendant(target); |
+ /** |
+ * Whether the command is checked or not. |
+ */ |
+ cr.defineProperty(Command, 'checked', cr.PropertyKind.BOOL_ATTR); |
- event.stopPropagation(); |
- }, |
+ /** |
+ * The flag that prevents the shortcut text from being displayed on menu. |
+ * |
+ * If false, the keyboard shortcut text (eg. "Ctrl+X" for the cut command) |
+ * is displayed in menu when the command is assosiated with a menu item. |
+ * Otherwise, no text is displayed. |
+ */ |
+ cr.defineProperty(Command, 'hideShortcutText', cr.PropertyKind.BOOL_ATTR); |
- _parentResizableChanged: function(parentResizable) { |
- if (parentResizable) { |
- window.removeEventListener('resize', this._boundNotifyResize); |
- } |
- }, |
+ /** |
+ * Dispatches a canExecute event on the target. |
+ * @param {!cr.ui.Command} command The command that we are testing for. |
+ * @param {EventTarget} target The target element to dispatch the event on. |
+ */ |
+ function dispatchCanExecuteEvent(command, target) { |
+ var e = new CanExecuteEvent(command); |
+ target.dispatchEvent(e); |
+ command.disabled = !e.canExecute; |
+ } |
- _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; |
- } |
+ /** |
+ * The command managers for different documents. |
+ */ |
+ var commandManagers = {}; |
- this._notifyingDescendant = true; |
- descendant.notifyResize(); |
- this._notifyingDescendant = false; |
+ /** |
+ * Keeps track of the focused element and updates the commands when the focus |
+ * changes. |
+ * @param {!Document} doc The document that we are managing the commands for. |
+ * @constructor |
+ */ |
+ function CommandManager(doc) { |
+ doc.addEventListener('focus', this.handleFocus_.bind(this), true); |
+ // Make sure we add the listener to the bubbling phase so that elements can |
+ // prevent the command. |
+ doc.addEventListener('keydown', this.handleKeyDown_.bind(this), false); |
+ } |
+ |
+ /** |
+ * Initializes a command manager for the document as needed. |
+ * @param {!Document} doc The document to manage the commands for. |
+ */ |
+ CommandManager.init = function(doc) { |
+ var uid = cr.getUid(doc); |
+ if (!(uid in commandManagers)) { |
+ commandManagers[uid] = new CommandManager(doc); |
} |
}; |
-(function() { |
- 'use strict'; |
- |
- /** |
- * 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' |
- }; |
- /** |
- * 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: '*' |
- }; |
+ CommandManager.prototype = { |
/** |
- * 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. |
+ * Handles focus changes on the document. |
+ * @param {Event} e The focus event object. |
+ * @private |
+ * @suppress {checkTypes} |
+ * TODO(vitalyp): remove the suppression. |
*/ |
- var MODIFIER_KEYS = { |
- 'shift': 'shiftKey', |
- 'ctrl': 'ctrlKey', |
- 'alt': 'altKey', |
- 'meta': 'metaKey' |
- }; |
+ handleFocus_: function(e) { |
+ var target = e.target; |
- /** |
- * 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*]/; |
+ // Ignore focus on a menu button or command item. |
+ if (target.menu || target.command) |
+ return; |
- /** |
- * Matches a keyIdentifier string. |
- */ |
- var IDENT_CHAR = /U\+/; |
+ var commands = Array.prototype.slice.call( |
+ target.ownerDocument.querySelectorAll('command')); |
- /** |
- * Matches arrow keys in Gecko 27.0+ |
- */ |
- var ARROW_KEY = /^arrow/; |
+ commands.forEach(function(command) { |
+ dispatchCanExecuteEvent(command, target); |
+ }); |
+ }, |
/** |
- * Matches space keys everywhere (notably including IE10's exceptional name |
- * `spacebar`). |
+ * Handles the keydown event and routes it to the right command. |
+ * @param {!Event} e The keydown event. |
*/ |
- var SPACE_KEY = /^space(bar)?/; |
+ handleKeyDown_: function(e) { |
+ var target = e.target; |
+ var commands = Array.prototype.slice.call( |
+ target.ownerDocument.querySelectorAll('command')); |
- /** |
- * Matches ESC key. |
- * |
- * Value from: http://w3c.github.io/uievents-key/#key-Escape |
- */ |
- var ESC_KEY = /^escape$/; |
+ for (var i = 0, command; command = commands[i]; i++) { |
+ if (command.matchesEvent(e)) { |
+ // When invoking a command via a shortcut, we have to manually check |
+ // if it can be executed, since focus might not have been changed |
+ // what would have updated the command's state. |
+ command.canExecuteChange(); |
- /** |
- * 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; |
+ if (!command.disabled) { |
+ e.preventDefault(); |
+ // We do not want any other element to handle this. |
+ e.stopPropagation(); |
+ command.execute(); |
+ return; |
} |
- } else if (ARROW_KEY.test(lKey)) { |
- validKey = lKey.replace('arrow', ''); |
- } else if (lKey == 'multiply') { |
- // numpad '*' can map to Multiply on IE/Windows |
- validKey = '*'; |
- } else { |
- validKey = lKey; |
} |
} |
- return validKey; |
} |
+ }; |
- function transformKeyIdentifier(keyIdent) { |
- var validKey = ''; |
- if (keyIdent) { |
- if (keyIdent in KEY_IDENTIFIER) { |
- validKey = KEY_IDENTIFIER[keyIdent]; |
- } else if (IDENT_CHAR.test(keyIdent)) { |
- keyIdent = parseInt(keyIdent.replace('U+', '0x'), 16); |
- validKey = String.fromCharCode(keyIdent).toLowerCase(); |
- } else { |
- validKey = keyIdent.toLowerCase(); |
- } |
- } |
- return validKey; |
- } |
+ /** |
+ * The event type used for canExecute events. |
+ * @param {!cr.ui.Command} command The command that we are evaluating. |
+ * @extends {Event} |
+ * @constructor |
+ * @class |
+ */ |
+ function CanExecuteEvent(command) { |
+ var e = new Event('canExecute', {bubbles: true, cancelable: true}); |
+ e.__proto__ = CanExecuteEvent.prototype; |
+ e.command = command; |
+ return e; |
+ } |
- 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; |
- } |
+ CanExecuteEvent.prototype = { |
+ __proto__: Event.prototype, |
/** |
- * 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 |
+ * The current command |
+ * @type {cr.ui.Command} |
*/ |
- 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) || ''; |
- } |
+ command: null, |
- 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) |
- ); |
+ /** |
+ * Whether the target can execute the command. Setting this also stops the |
+ * propagation and prevents the default. Callers can tell if an event has |
+ * been handled via |this.defaultPrevented|. |
+ * @type {boolean} |
+ */ |
+ canExecute_: false, |
+ get canExecute() { |
+ return this.canExecute_; |
+ }, |
+ set canExecute(canExecute) { |
+ this.canExecute_ = !!canExecute; |
+ this.stopPropagation(); |
+ this.preventDefault(); |
} |
+ }; |
- function parseKeyComboString(keyComboString) { |
- if (keyComboString.length === 1) { |
- return { |
- combo: keyComboString, |
- key: keyComboString, |
- event: 'keydown' |
- }; |
- } |
- return keyComboString.split('+').reduce(function(parsedKeyCombo, keyComboPart) { |
- var eventParts = keyComboPart.split(':'); |
- var keyName = eventParts[0]; |
- var event = eventParts[1]; |
- |
- if (keyName in MODIFIER_KEYS) { |
- parsedKeyCombo[MODIFIER_KEYS[keyName]] = true; |
- parsedKeyCombo.hasModifiers = true; |
- } else { |
- parsedKeyCombo.key = keyName; |
- parsedKeyCombo.event = event || 'keydown'; |
- } |
- |
- return parsedKeyCombo; |
- }, { |
- combo: keyComboString.split(':').shift() |
- }); |
- } |
- |
- function parseEventString(eventString) { |
- return eventString.trim().split(' ').map(function(keyComboString) { |
- return parseKeyComboString(keyComboString); |
- }); |
- } |
+ // Export |
+ return { |
+ Command: Command, |
+ CanExecuteEvent: CanExecuteEvent |
+ }; |
+}); |
+Polymer({ |
+ is: 'app-drawer', |
- /** |
- * `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 = { |
properties: { |
/** |
- * The EventTarget that will be firing relevant KeyboardEvents. Set it to |
- * `null` to disable the listeners. |
- * @type {?EventTarget} |
+ * The opened state of the drawer. |
*/ |
- keyEventTarget: { |
- type: Object, |
- value: function() { |
- return this; |
- } |
+ opened: { |
+ type: Boolean, |
+ value: false, |
+ notify: true, |
+ reflectToAttribute: true |
}, |
/** |
- * If true, this property will cause the implementing element to |
- * automatically stop propagation on any handled KeyboardEvents. |
+ * The drawer does not have a scrim and cannot be swiped close. |
*/ |
- stopKeyboardEventPropagation: { |
+ persistent: { |
type: Boolean, |
- value: false |
+ value: false, |
+ reflectToAttribute: true |
}, |
- _boundKeyHandlers: { |
- type: Array, |
- value: function() { |
- return []; |
- } |
+ /** |
+ * 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' |
}, |
- // We use this due to a limitation in IE10 where instances will have |
- // own properties of everything on the "prototype". |
- _imperativeKeyBindings: { |
- type: Object, |
- value: function() { |
- return {}; |
- } |
+ /** |
+ * The computed, read-only position of the drawer on the screen ('left' or 'right'). |
+ */ |
+ position: { |
+ type: String, |
+ readOnly: true, |
+ value: 'left', |
+ reflectToAttribute: true |
+ }, |
+ |
+ /** |
+ * Create an area at the edge of the screen to swipe open the drawer. |
+ */ |
+ swipeOpen: { |
+ type: Boolean, |
+ value: false, |
+ reflectToAttribute: true |
+ }, |
+ |
+ /** |
+ * Trap keyboard focus when the drawer is opened and not persistent. |
+ */ |
+ noFocusTrap: { |
+ type: Boolean, |
+ value: false |
} |
}, |
observers: [ |
- '_resetKeyEventListeners(keyEventTarget, _boundKeyHandlers)' |
+ 'resetLayout(position)', |
+ '_resetPosition(align, isAttached)' |
], |
+ _translateOffset: 0, |
- /** |
- * To be used to express what combination of keys will trigger the relative |
- * callback. e.g. `keyBindings: { 'esc': '_onEscPressed'}` |
- * @type {Object} |
- */ |
- keyBindings: {}, |
+ _trackDetails: null, |
- registered: function() { |
- this._prepKeyBindings(); |
+ _drawerState: 0, |
+ |
+ _boundEscKeydownHandler: null, |
+ |
+ _firstTabStop: null, |
+ |
+ _lastTabStop: null, |
+ |
+ ready: function() { |
+ // Set the scroll direction so you can vertically scroll inside the drawer. |
+ this.setScrollDirection('y'); |
+ |
+ // 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'); |
}, |
attached: function() { |
- this._listenKeyEventListeners(); |
+ // 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)) |
+ }); |
}, |
detached: function() { |
- this._unlistenKeyEventListeners(); |
+ document.removeEventListener('keydown', this._boundEscKeydownHandler); |
}, |
/** |
- * 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. |
+ * Opens the drawer. |
*/ |
- addOwnKeyBinding: function(eventString, handlerName) { |
- this._imperativeKeyBindings[eventString] = handlerName; |
- this._prepKeyBindings(); |
- this._resetKeyEventListeners(); |
+ open: function() { |
+ this.opened = true; |
}, |
/** |
- * When called, will remove all imperatively-added key bindings. |
+ * Closes the drawer. |
*/ |
- removeOwnKeyBindings: function() { |
- this._imperativeKeyBindings = {}; |
- this._prepKeyBindings(); |
- this._resetKeyEventListeners(); |
+ close: function() { |
+ this.opened = false; |
}, |
/** |
- * Returns true if a keyboard event matches `eventString`. |
+ * Toggles the drawer open and close. |
+ */ |
+ toggle: function() { |
+ this.opened = !this.opened; |
+ }, |
+ |
+ /** |
+ * Gets the width of the drawer. |
* |
- * @param {KeyboardEvent} event |
- * @param {string} eventString |
- * @return {boolean} |
+ * @return {number} The width of the drawer in pixels. |
*/ |
- keyboardEventMatchesKeys: function(event, eventString) { |
- var keyCombos = parseEventString(eventString); |
- for (var i = 0; i < keyCombos.length; ++i) { |
- if (keyComboMatchesEvent(keyCombos[i], event)) { |
- return true; |
- } |
- } |
- return false; |
+ getWidth: function() { |
+ return this.$.contentContainer.offsetWidth; |
}, |
- _collectKeyBindings: function() { |
- var keyBindings = this.behaviors.map(function(behavior) { |
- return behavior.keyBindings; |
- }); |
+ /** |
+ * 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); |
+ }, |
- if (keyBindings.indexOf(this.keyBindings) === -1) { |
- keyBindings.push(this.keyBindings); |
+ _isRTL: function() { |
+ return window.getComputedStyle(this).direction === 'rtl'; |
+ }, |
+ |
+ _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); |
+ }, |
- return keyBindings; |
+ _escKeydownHandler: function(event) { |
+ var ESC_KEYCODE = 27; |
+ if (event.keyCode === ESC_KEYCODE) { |
+ // Prevent any side effects if app-drawer closes. |
+ event.preventDefault(); |
+ this.close(); |
+ } |
}, |
- _prepKeyBindings: function() { |
- this._keyBindings = {}; |
+ _track: function(event) { |
+ if (this.persistent) { |
+ return; |
+ } |
- this._collectKeyBindings().forEach(function(keyBindings) { |
- for (var eventString in keyBindings) { |
- this._addKeyBinding(eventString, keyBindings[eventString]); |
- } |
- }, this); |
+ // Disable user selection on desktop. |
+ event.preventDefault(); |
- for (var eventString in this._imperativeKeyBindings) { |
- this._addKeyBinding(eventString, this._imperativeKeyBindings[eventString]); |
+ switch (event.detail.state) { |
+ case 'start': |
+ this._trackStart(event); |
+ break; |
+ case 'track': |
+ this._trackMove(event); |
+ break; |
+ case 'end': |
+ this._trackEnd(event); |
+ break; |
} |
+ }, |
- // 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; |
- }) |
+ _trackStart: function(event) { |
+ this._drawerState = this._DRAWER_STATE.TRACKING; |
+ |
+ // Disable transitions since style attributes will reflect user track events. |
+ this._setTransitionDuration('0s'); |
+ this.style.visibility = 'visible'; |
+ |
+ var rect = this.$.contentContainer.getBoundingClientRect(); |
+ if (this.position === 'left') { |
+ this._translateOffset = rect.left; |
+ } else { |
+ this._translateOffset = rect.right - window.innerWidth; |
} |
+ |
+ this._trackDetails = []; |
}, |
- _addKeyBinding: function(eventString, handlerName) { |
- parseEventString(eventString).forEach(function(keyCombo) { |
- this._keyBindings[keyCombo.event] = |
- this._keyBindings[keyCombo.event] || []; |
+ _trackMove: function(event) { |
+ this._translateDrawer(event.detail.dx + this._translateOffset); |
- this._keyBindings[keyCombo.event].push([ |
- keyCombo, |
- handlerName |
- ]); |
- }, this); |
+ // 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() |
+ }); |
}, |
- _resetKeyEventListeners: function() { |
- this._unlistenKeyEventListeners(); |
+ _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); |
- if (this.isAttached) { |
- this._listenKeyEventListeners(); |
+ 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; |
+ |
+ this._flingDrawer(event, trackDetails); |
+ if (this._drawerState === this._DRAWER_STATE.FLINGING) { |
+ return; |
+ } |
} |
- }, |
- _listenKeyEventListeners: function() { |
- if (!this.keyEventTarget) { |
- return; |
+ // 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'; |
} |
- 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]); |
+ // Trigger app-drawer-transitioned now since there will be no transitionend event. |
+ if (isInEndState) { |
+ this._resetDrawerState(); |
+ } |
- this.keyEventTarget.addEventListener(eventName, boundKeyHandler); |
- }, this); |
+ this._setTransitionDuration(''); |
+ this._resetDrawerTranslate(); |
+ this.style.visibility = ''; |
}, |
- _unlistenKeyEventListeners: function() { |
- var keyHandlerTuple; |
- var keyEventTarget; |
- var eventName; |
- var boundKeyHandler; |
+ _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; |
- 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]; |
+ 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; |
+ } |
+ } |
- keyEventTarget.removeEventListener(eventName, boundKeyHandler); |
+ if (trackDetail) { |
+ var dx = event.detail.dx - trackDetail.dx; |
+ var dt = (now - trackDetail.timeStamp) || 1; |
+ return dx / dt; |
} |
+ return 0; |
}, |
- _onKeyBindingEvent: function(keyBindings, event) { |
- if (this.stopKeyboardEventPropagation) { |
- event.stopPropagation(); |
- } |
+ _flingDrawer: function(event, trackDetails) { |
+ var velocity = this._calculateVelocity(event, trackDetails); |
- // if event has been already prevented, don't do anything |
- if (event.defaultPrevented) { |
+ // Do not fling if velocity is not above a threshold. |
+ if (Math.abs(velocity) < this._MIN_FLING_THRESHOLD) { |
return; |
} |
- for (var i = 0; i < keyBindings.length; i++) { |
- var keyCombo = keyBindings[i][0]; |
- var handlerName = keyBindings[i][1]; |
- if (keyComboMatchesEvent(keyCombo, event)) { |
- this._triggerKeyHandler(keyCombo, handlerName, event); |
- // exit the loop if eventDefault was prevented |
- if (event.defaultPrevented) { |
- return; |
- } |
- } |
+ this._drawerState = this._DRAWER_STATE.FLINGING; |
+ |
+ 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; |
} |
- }, |
- _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(); |
+ // 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'; |
} |
- } |
- }; |
- })(); |
-/** |
- * `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: { |
+ // 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); |
- /** |
- * 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; |
+ this._resetDrawerTranslate(); |
+ }, |
+ |
+ _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 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 = ''; |
+ } |
+ |
+ this._resetDrawerState(); |
} |
- } |
- }, |
+ }, |
- observers: [ |
- '_scrollTargetChanged(scrollTarget, isAttached)' |
- ], |
+ _setTransitionDuration: function(duration) { |
+ this.$.contentContainer.style.transitionDuration = duration; |
+ this.$.scrim.style.transitionDuration = duration; |
+ }, |
- _scrollTargetChanged: function(scrollTarget, isAttached) { |
- var eventTarget; |
+ _setTransitionTimingFunction: function(timingFunction) { |
+ this.$.contentContainer.style.transitionTimingFunction = timingFunction; |
+ this.$.scrim.style.transitionTimingFunction = timingFunction; |
+ }, |
- if (this._oldScrollTarget) { |
- eventTarget = this._oldScrollTarget === this._doc ? window : this._oldScrollTarget; |
- eventTarget.removeEventListener('scroll', this._boundScrollHandler); |
- this._oldScrollTarget = null; |
- } |
+ _translateDrawer: function(x) { |
+ var drawerWidth = this.getWidth(); |
- if (!isAttached) { |
- return; |
- } |
- // Support element id references |
- if (scrollTarget === 'document') { |
+ 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.scrollTarget = this._doc; |
+ this.translate3d(x + 'px', '0', '0', this.$.contentContainer); |
+ }, |
- } else if (typeof scrollTarget === 'string') { |
+ _resetDrawerTranslate: function() { |
+ this.$.scrim.style.opacity = ''; |
+ this.transform('', this.$.contentContainer); |
+ }, |
- this.scrollTarget = this.domHost ? this.domHost.$[scrollTarget] : |
- Polymer.dom(this.ownerDocument).querySelector('#' + scrollTarget); |
+ _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; |
+ } |
- } else if (this._isValidScrollTarget()) { |
+ 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 = ''; |
+ } |
- eventTarget = scrollTarget === this._doc ? window : scrollTarget; |
- this._boundScrollHandler = this._boundScrollHandler || this._scrollHandler.bind(this); |
- this._oldScrollTarget = scrollTarget; |
+ // Don't fire the event on initial load. |
+ if (oldState !== this._DRAWER_STATE.INIT) { |
+ this.fire('app-drawer-transitioned'); |
+ } |
+ } |
+ }, |
- eventTarget.addEventListener('scroll', this._boundScrollHandler); |
- } |
- }, |
+ _setKeyboardFocusTrap: function() { |
+ if (this.noFocusTrap) { |
+ return; |
+ } |
- /** |
- * Runs on every scroll event. Consumer of this behavior may override this method. |
- * |
- * @protected |
- */ |
- _scrollHandler: function scrollHandler() {}, |
+ // 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; |
+ } |
- /** |
- * The default scroll target. Consumers of this behavior may want to customize |
- * the default scroll target. |
- * |
- * @type {Element} |
- */ |
- get _defaultScrollTarget() { |
- return this._doc; |
- }, |
+ // 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(); |
+ } |
+ }, |
- /** |
- * Shortcut for the document element |
- * |
- * @type {Element} |
- */ |
- get _doc() { |
- return this.ownerDocument.documentElement; |
- }, |
+ _tabKeydownHandler: function(event) { |
+ if (this.noFocusTrap) { |
+ return; |
+ } |
- /** |
- * 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; |
- }, |
+ 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(); |
+ } |
+ } |
+ } |
+ }, |
- /** |
- * 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; |
- }, |
+ _MIN_FLING_THRESHOLD: 0.2, |
- /** |
- * 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; |
- } |
- }, |
+ _MIN_TRANSITION_VELOCITY: 1.2, |
- /** |
- * 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; |
- } |
- }, |
+ _FLING_TIMING_FUNCTION: 'cubic-bezier(0.667, 1, 0.667, 1)', |
- /** |
- * 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; |
- } |
- }, |
+ _FLING_INITIAL_SLOPE: 1.5, |
- /** |
- * Gets the width of the scroll target. |
- * |
- * @type {number} |
- */ |
- get _scrollTargetWidth() { |
- if (this._isValidScrollTarget()) { |
- return this.scrollTarget === this._doc ? window.innerWidth : this.scrollTarget.offsetWidth; |
+ _DRAWER_STATE: { |
+ INIT: 0, |
+ OPENED: 1, |
+ OPENED_PERSISTENT: 2, |
+ CLOSED: 3, |
+ TRACKING: 4, |
+ FLINGING: 5 |
} |
- 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; |
- }, |
+ /** |
+ * Fired when the layout of app-drawer has changed. |
+ * |
+ * @event app-drawer-reset-layout |
+ */ |
- /** |
- * Returns true if the scroll target is a valid HTMLElement. |
- * |
- * @return {boolean} |
- */ |
- _isValidScrollTarget: function() { |
- return this.scrollTarget instanceof HTMLElement; |
- } |
- }; |
+ /** |
+ * Fired when app-drawer has finished transitioning. |
+ * |
+ * @event app-drawer-transitioned |
+ */ |
+ }); |
(function() { |
+ 'use strict'; |
- 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-location', |
+ properties: { |
+ /** |
+ * The pathname component of the URL. |
+ */ |
+ path: { |
+ type: String, |
+ notify: true, |
+ value: function() { |
+ return window.decodeURIComponent(window.location.pathname); |
+ } |
+ }, |
+ /** |
+ * The query string portion of the URL. |
+ */ |
+ query: { |
+ type: String, |
+ notify: true, |
+ value: function() { |
+ return window.decodeURIComponent(window.location.search.slice(1)); |
+ } |
+ }, |
+ /** |
+ * The hash component of the URL. |
+ */ |
+ hash: { |
+ type: String, |
+ notify: true, |
+ value: function() { |
+ 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 |
+ }, |
- Polymer({ |
+ /** |
+ * 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: '' |
+ }, |
- is: 'iron-list', |
+ /** |
+ * urlSpaceRegex, but coerced into a regexp. |
+ * |
+ * @type {RegExp} |
+ */ |
+ _urlSpaceRegExp: { |
+ computed: '_makeRegExp(urlSpaceRegex)' |
+ }, |
- properties: { |
+ _lastChangedAt: { |
+ type: Number |
+ }, |
- /** |
- * 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 |
+ _initialized: { |
+ type: Boolean, |
+ value: false |
+ } |
}, |
- |
- /** |
- * The max count of physical items the pool can extend to. |
- */ |
- maxPhysicalCount: { |
- type: Number, |
- value: 500 |
+ 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); |
- /** |
- * 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' |
+ this._initialized = true; |
+ this._urlChanged(); |
}, |
- |
- /** |
- * The name of the variable to add to the binding scope with the index |
- * for the row. |
- */ |
- indexAs: { |
- type: String, |
- value: 'index' |
+ detached: function() { |
+ 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}); |
}, |
- |
/** |
- * The name of the variable to add to the binding scope to indicate |
- * if the row is selected. |
+ * A necessary evil so that links work as expected. Does its best to |
+ * bail out early if possible. |
+ * |
+ * @param {MouseEvent} event . |
*/ |
- selectedAs: { |
- type: String, |
- value: 'selected' |
+ _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 true, the list is rendered as a grid. Grid items must have |
- * fixed width and height set via CSS. e.g. |
+ * 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. |
* |
- * ```html |
- * <iron-list grid> |
- * <template> |
- * <div style="width: 100px; height: 100px;"> 100x100 </div> |
- * </template> |
- * </iron-list> |
- * ``` |
+ * @param {MouseEvent} event . |
+ * @return {string?} . |
*/ |
- grid: { |
- type: Boolean, |
- value: false, |
- reflectToAttribute: true |
+ _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; |
+ } |
+ } |
+ |
+ // If there's no link there's nothing to do. |
+ if (!anchor) { |
+ return null; |
+ } |
+ |
+ // 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; |
+ } |
+ |
+ 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); |
+ } |
+ |
+ var origin; |
+ |
+ // IE Polyfill |
+ if (window.location.origin) { |
+ origin = window.location.origin; |
+ } else { |
+ origin = window.location.protocol + '//' + window.location.hostname; |
+ |
+ if (window.location.port) { |
+ origin += ':' + window.location.port; |
+ } |
+ } |
+ |
+ if (url.origin !== origin) { |
+ return null; |
+ } |
+ var normalizedHref = url.pathname + url.search + url.hash; |
+ |
+ // 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'; |
- /** |
- * 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: { |
+ Polymer({ |
+ is: 'iron-query-params', |
+ properties: { |
+ paramsString: { |
+ type: String, |
+ notify: true, |
+ observer: 'paramsStringChanged', |
+ }, |
+ paramsObject: { |
+ type: Object, |
+ notify: true, |
+ value: function() { |
+ 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 = {}; |
+ |
+ // Work around a bug in decodeURIComponent where + is not |
+ // converted to spaces: |
+ paramString = (paramString || '').replace(/\+/g, '%20'); |
+ |
+ 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] || ''); |
+ } |
+ } |
+ return params; |
+ } |
+ }); |
+'use strict'; |
+ /** |
+ * Provides bidirectional mapping between `path` and `queryParams` and a |
+ * app-route compatible `route` object. |
+ * |
+ * For more information, see the docs for `app-route-converter`. |
+ * |
+ * @polymerBehavior |
+ */ |
+ Polymer.AppRouteConverterBehavior = { |
+ properties: { |
/** |
- * When `multiSelection` is false, this is the currently selected item, or `null` |
- * if no item is selected. |
+ * A model representing the deserialized path through the route tree, as |
+ * well as the current queryParams. |
+ * |
+ * A route object is the kernel of the routing system. It is intended to |
+ * be fed into consuming elements such as `app-route`. |
+ * |
+ * @type {?Object} |
*/ |
- selectedItem: { |
+ route: { |
type: Object, |
notify: true |
}, |
/** |
- * When `multiSelection` is true, this is an array that contains the selected items. |
+ * A set of key/value pairs that are universally accessible to branches of |
+ * the route tree. |
+ * |
+ * @type {?Object} |
*/ |
- selectedItems: { |
+ queryParams: { |
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. |
+ * The serialized path through the route tree. This corresponds to the |
+ * `window.location.pathname` value, and will update to reflect changes |
+ * to that value. |
*/ |
- multiSelection: { |
- type: Boolean, |
- value: false |
+ path: { |
+ type: String, |
+ notify: true, |
} |
}, |
observers: [ |
- '_itemsChanged(items.*)', |
- '_selectionEnabledChanged(selectionEnabled)', |
- '_multiSelectionChanged(multiSelection)', |
- '_setOverflow(scrollTarget)' |
+ '_locationChanged(path, queryParams)', |
+ '_routeChanged(route.prefix, route.path)', |
+ '_routeQueryParamsChanged(route.__queryParams)' |
], |
- behaviors: [ |
- Polymer.Templatizer, |
- Polymer.IronResizableBehavior, |
- Polymer.IronA11yKeysBehavior, |
- Polymer.IronScrollTargetBehavior |
- ], |
- |
- keyBindings: { |
- 'up': '_didMoveUp', |
- 'down': '_didMoveDown', |
- 'enter': '_didEnter' |
+ created: function() { |
+ this.linkPaths('route.__queryParams', 'queryParams'); |
+ this.linkPaths('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. |
+ * Handler called when the path or queryParams change. |
*/ |
- _ratio: 0.5, |
+ _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 |
+ }; |
+ }, |
/** |
- * The padding-top value for the list. |
+ * Handler called when the route prefix and route path change. |
*/ |
- _scrollerPaddingTop: 0, |
+ _routeChanged: function() { |
+ if (!this.route) { |
+ return; |
+ } |
- /** |
- * This value is the same as `scrollTop`. |
- */ |
- _scrollPosition: 0, |
+ this.path = this.route.prefix + this.route.path; |
+ }, |
/** |
- * The sum of the heights of all the tiles in the DOM. |
+ * Handler called when the route queryParams change. |
+ * |
+ * @param {Object} queryParams A set of key/value pairs that are |
+ * universally accessible to branches of the route tree. |
*/ |
- _physicalSize: 0, |
- |
- /** |
- * The average `offsetHeight` of the tiles observed till now. |
- */ |
- _physicalAverage: 0, |
+ _routeQueryParamsChanged: function(queryParams) { |
+ if (!this.route) { |
+ return; |
+ } |
+ this.queryParams = queryParams; |
+ } |
+ }; |
+'use strict'; |
- /** |
- * The number of tiles which `offsetHeight` > 0 observed until now. |
- */ |
- _physicalAverageCount: 0, |
+ Polymer({ |
+ is: 'app-location', |
- /** |
- * The Y position of the item rendered in the `_physicalStart` |
- * tile relative to the scrolling list. |
- */ |
- _physicalTop: 0, |
+ properties: { |
+ /** |
+ * A model representing the deserialized path through the route tree, as |
+ * well as the current queryParams. |
+ */ |
+ route: { |
+ type: Object, |
+ notify: true |
+ }, |
- /** |
- * The number of items in the list. |
- */ |
- _virtualCount: 0, |
+ /** |
+ * 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 |
- /** |
- * A map between an item key and its physical item index |
- */ |
- _physicalIndexForKey: null, |
+ * the `path` for generating a `route`. |
+ */ |
+ useHashAsPath: { |
+ type: Boolean, |
+ value: false |
+ }, |
- /** |
- * The estimated scroll height based on `_physicalAverage` |
- */ |
- _estScrollHeight: 0, |
+ /** |
+ * 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 |
+ }, |
- /** |
- * The scroll height of the dom node |
- */ |
- _scrollHeight: 0, |
+ /** |
+ * A set of key/value pairs that are universally accessible to branches |
+ * of the route tree. |
+ */ |
+ __queryParams: { |
+ type: Object |
+ }, |
- /** |
- * The height of the list. This is referred as the viewport in the context of list. |
- */ |
- _viewportHeight: 0, |
+ /** |
+ * The pathname component of the current URL. |
+ */ |
+ __path: { |
+ type: String |
+ }, |
- /** |
- * The width of the list. This is referred as the viewport in the context of list. |
- */ |
- _viewportWidth: 0, |
+ /** |
+ * The query string portion of the current URL. |
+ */ |
+ __query: { |
+ type: String |
+ }, |
- /** |
- * An array of DOM nodes that are currently in the tree |
- * @type {?Array<!TemplatizerNode>} |
- */ |
- _physicalItems: null, |
+ /** |
+ * The hash portion of the current URL. |
+ */ |
+ __hash: { |
+ type: String |
+ }, |
- /** |
- * An array of heights for each item in `_physicalItems` |
- * @type {?Array<number>} |
- */ |
- _physicalSizes: null, |
+ /** |
+ * The route path, which will be either the hash or the path, depending |
+ * on useHashAsPath. |
+ */ |
+ path: { |
+ type: String, |
+ observer: '__onPathChanged' |
+ } |
+ }, |
- /** |
- * A cached value for the first visible index. |
- * See `firstVisibleIndex` |
- * @type {?number} |
- */ |
- _firstVisibleIndexVal: null, |
+ behaviors: [Polymer.AppRouteConverterBehavior], |
- /** |
- * A cached value for the last visible index. |
- * See `lastVisibleIndex` |
- * @type {?number} |
- */ |
- _lastVisibleIndexVal: null, |
+ observers: [ |
+ '__computeRoutePath(useHashAsPath, __hash, __path)' |
+ ], |
- /** |
- * A Polymer collection for the items. |
- * @type {?Polymer.Collection} |
- */ |
- _collection: null, |
+ __computeRoutePath: function() { |
+ this.path = this.useHashAsPath ? this.__hash : this.__path; |
+ }, |
- /** |
- * True if the current item list was rendered for the first time |
- * after attached. |
- */ |
- _itemsRendered: false, |
+ __onPathChanged: function() { |
+ if (!this._readied) { |
+ return; |
+ } |
- /** |
- * The page that is currently rendered. |
- */ |
- _lastPage: null, |
+ if (this.useHashAsPath) { |
+ this.__hash = this.path; |
+ } else { |
+ this.__path = this.path; |
+ } |
+ } |
+ }); |
+'use strict'; |
- /** |
- * The max number of pages to render. One page is equivalent to the height of the list. |
- */ |
- _maxPages: 3, |
+ Polymer({ |
+ is: 'app-route', |
- /** |
- * The currently focused physical item. |
- */ |
- _focusedItem: null, |
+ properties: { |
+ /** |
+ * The URL component managed by this element. |
+ */ |
+ route: { |
+ type: Object, |
+ notify: true |
+ }, |
- /** |
- * The index of the `_focusedItem`. |
- */ |
- _focusedIndex: -1, |
+ /** |
+ * The pattern of slash-separated segments to match `path` against. |
+ * |
+ * 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. |
+ */ |
+ pattern: { |
+ type: String |
+ }, |
- /** |
- * The the item that is focused if it is moved offscreen. |
- * @private {?TemplatizerNode} |
- */ |
- _offscreenFocusedItem: null, |
+ /** |
+ * The parameterized values that are extracted from the route as |
+ * described by `pattern`. |
+ */ |
+ data: { |
+ type: Object, |
+ value: function() {return {};}, |
+ notify: true |
+ }, |
- /** |
- * The item that backfills the `_offscreenFocusedItem` in the physical items |
- * list when that item is moved offscreen. |
- */ |
- _focusBackfillItem: null, |
+ /** |
+ * @type {?Object} |
+ */ |
+ queryParams: { |
+ type: Object, |
+ value: function() { |
+ return {}; |
+ }, |
+ notify: true |
+ }, |
- /** |
- * The maximum items per row |
- */ |
- _itemsPerRow: 1, |
+ /** |
+ * The part of `path` NOT consumed by `pattern`. |
+ */ |
+ tail: { |
+ type: Object, |
+ value: function() {return {path: null, prefix: null, __queryParams: null};}, |
+ notify: true |
+ }, |
- /** |
- * The width of each grid item |
- */ |
- _itemWidth: 0, |
+ active: { |
+ type: Boolean, |
+ notify: true, |
+ readOnly: true |
+ }, |
- /** |
- * The height of the row in grid layout. |
- */ |
- _rowHeight: 0, |
+ _queryParamsUpdating: { |
+ type: Boolean, |
+ value: false |
+ }, |
+ /** |
+ * @type {?string} |
+ */ |
+ _matched: { |
+ type: String, |
+ value: '' |
+ } |
+ }, |
- /** |
- * The bottom of the physical content. |
- */ |
- get _physicalBottom() { |
- return this._physicalTop + this._physicalSize; |
+ observers: [ |
+ '__tryToMatch(route.path, pattern)', |
+ '__updatePathOnDataChange(data.*)', |
+ '__tailPathChanged(tail.path)', |
+ '__routeQueryParamsChanged(route.__queryParams)', |
+ '__tailQueryParamsChanged(tail.__queryParams)', |
+ '__queryParamsChanged(queryParams.*)' |
+ ], |
+ |
+ created: function() { |
+ this.linkPaths('route.__queryParams', 'tail.__queryParams'); |
+ this.linkPaths('tail.__queryParams', 'route.__queryParams'); |
}, |
/** |
- * The bottom of the scroll. |
+ * Deal with the query params object being assigned to wholesale. |
+ * @export |
*/ |
- get _scrollBottom() { |
- return this._scrollPosition + this._viewportHeight; |
+ __routeQueryParamsChanged: function(queryParams) { |
+ if (queryParams && this.tail) { |
+ this.set('tail.__queryParams', queryParams); |
+ |
+ if (!this.active || this._queryParamsUpdating) { |
+ return; |
+ } |
+ |
+ // 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; |
+ } |
+ } |
+ |
+ if (!anythingChanged) { |
+ return; |
+ } |
+ this._queryParamsUpdating = true; |
+ this.set('queryParams', copyOfQueryParams); |
+ this._queryParamsUpdating = false; |
+ } |
}, |
/** |
- * The n-th item rendered in the last physical item. |
+ * @export |
*/ |
- get _virtualEnd() { |
- return this._virtualStart + this._physicalCount - 1; |
+ __tailQueryParamsChanged: function(queryParams) { |
+ if (queryParams && this.route) { |
+ this.set('route.__queryParams', queryParams); |
+ } |
}, |
/** |
- * The height of the physical content that isn't on the screen. |
+ * @export |
*/ |
- get _hiddenContentSize() { |
- var size = this.grid ? this._physicalRows * this._rowHeight : this._physicalSize; |
- return size - this._viewportHeight; |
+ __queryParamsChanged: function(changes) { |
+ if (!this.active || this._queryParamsUpdating) { |
+ return; |
+ } |
+ |
+ this.set('route.__' + changes.path, changes.value); |
}, |
- /** |
- * The maximum scroll top value. |
- */ |
- get _maxScrollTop() { |
- return this._estScrollHeight - this._viewportHeight + this._scrollerPaddingTop; |
+ __resetProperties: function() { |
+ this._setActive(false); |
+ this._matched = null; |
+ //this.tail = { path: null, prefix: null, queryParams: null }; |
+ //this.data = {}; |
}, |
/** |
- * The lowest n-th value for an item such that it can be rendered in `_physicalStart`. |
+ * @export |
*/ |
- _minVirtualStart: 0, |
+ __tryToMatch: function() { |
+ if (!this.route) { |
+ return; |
+ } |
+ var path = this.route.path; |
+ var pattern = this.pattern; |
+ if (!pattern) { |
+ return; |
+ } |
- /** |
- * The largest n-th value for an item such that it can be rendered in `_physicalStart`. |
- */ |
- get _maxVirtualStart() { |
- return Math.max(0, this._virtualCount - this._physicalCount); |
- }, |
+ if (!path) { |
+ this.__resetProperties(); |
+ return; |
+ } |
- /** |
- * The n-th item rendered in the `_physicalStart` tile. |
- */ |
- _virtualStartVal: 0, |
+ var remainingPieces = path.split('/'); |
+ var patternPieces = pattern.split('/'); |
- set _virtualStart(val) { |
- this._virtualStartVal = Math.min(this._maxVirtualStart, Math.max(this._minVirtualStart, val)); |
- }, |
+ var matched = []; |
+ var namedMatches = {}; |
- get _virtualStart() { |
- return this._virtualStartVal || 0; |
- }, |
+ for (var i=0; i < patternPieces.length; i++) { |
+ var patternPiece = patternPieces[i]; |
+ if (!patternPiece && patternPiece !== '') { |
+ break; |
+ } |
+ var pathPiece = remainingPieces.shift(); |
- /** |
- * The k-th tile that is at the top of the scrolling list. |
- */ |
- _physicalStartVal: 0, |
+ // We don't match this path. |
+ if (!pathPiece && pathPiece !== '') { |
+ this.__resetProperties(); |
+ return; |
+ } |
+ matched.push(pathPiece); |
- set _physicalStart(val) { |
- this._physicalStartVal = val % this._physicalCount; |
- if (this._physicalStartVal < 0) { |
- this._physicalStartVal = this._physicalCount + this._physicalStartVal; |
+ if (patternPiece.charAt(0) == ':') { |
+ namedMatches[patternPiece.slice(1)] = pathPiece; |
+ } else if (patternPiece !== pathPiece) { |
+ this.__resetProperties(); |
+ return; |
+ } |
} |
- 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; |
- }, |
+ this._matched = matched.join('/'); |
- get _physicalCount() { |
- return this._physicalCountVal; |
- }, |
+ // Properties that must be updated atomically. |
+ var propertyUpdates = {}; |
- /** |
- * The k-th tile that is at the bottom of the scrolling list. |
- */ |
- _physicalEnd: 0, |
+ //this.active |
+ if (!this.active) { |
+ propertyUpdates.active = true; |
+ } |
- /** |
- * 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; |
+ // 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 |
+ }; |
} |
- return this._viewportHeight * this._maxPages; |
- }, |
- get _optPhysicalCount() { |
- return this._estRowsInView * this._itemsPerRow * this._maxPages; |
- }, |
+ // this.data |
+ propertyUpdates.data = namedMatches; |
+ this._dataInUrl = {}; |
+ for (var key in namedMatches) { |
+ this._dataInUrl[key] = namedMatches[key]; |
+ } |
- /** |
- * True if the current list is visible. |
- */ |
- get _isVisible() { |
- return this.scrollTarget && Boolean(this.scrollTarget.offsetWidth || this.scrollTarget.offsetHeight); |
+ this.__setMulti(propertyUpdates); |
}, |
/** |
- * Gets the index of the first visible item in the viewport. |
- * |
- * @type {number} |
+ * @export |
*/ |
- 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; |
+ __tailPathChanged: function() { |
+ if (!this.active) { |
+ return; |
} |
- return this._firstVisibleIndexVal; |
+ var tailPath = this.tail.path; |
+ var newPath = this._matched; |
+ if (tailPath) { |
+ if (tailPath.charAt(0) !== '/') { |
+ tailPath = '/' + tailPath; |
+ } |
+ newPath += tailPath; |
+ } |
+ this.set('route.path', newPath); |
}, |
/** |
- * Gets the index of the last visible item in the viewport. |
- * |
- * @type {number} |
+ * @export |
*/ |
- 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); |
+ __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); |
+ }, |
+ |
+ __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 { |
- 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); |
- }); |
+ interp.push(values.tail.path); |
} |
} |
- return this._lastVisibleIndexVal; |
+ return interp.join('/'); |
}, |
- get _defaultScrollTarget() { |
- return this; |
- }, |
- get _virtualRowCount() { |
- return Math.ceil(this._virtualCount / this._itemsPerRow); |
- }, |
+ __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]); |
+ } |
- get _estRowsInView() { |
- return Math.ceil(this._viewportHeight / this._rowHeight); |
- }, |
+ for (var property in setObj) { |
+ this._pathEffector(property, this[property]); |
+ this._notifyPathUp(property, this[property]); |
+ } |
+ } |
+ }); |
+Polymer({ |
- get _physicalRows() { |
- return Math.ceil(this._physicalCount / this._itemsPerRow); |
- }, |
+ is: 'iron-media-query', |
- ready: function() { |
- this.addEventListener('focus', this._didFocus.bind(this), true); |
+ properties: { |
+ |
+ /** |
+ * 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 |
+ } |
}, |
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'); |
+ this.style.display = 'none'; |
+ this.queryChanged(); |
}, |
detached: function() { |
- this._itemsRendered = false; |
- this.unlisten(this, 'iron-resize', '_resizeHandler'); |
+ this._remove(); |
}, |
- /** |
- * 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' : ''; |
+ _add: function() { |
+ if (this._mq) { |
+ this._mq.addListener(this._boundMQHandler); |
+ } |
}, |
- /** |
- * 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(); |
+ _remove: function() { |
+ if (this._mq) { |
+ this._mq.removeListener(this._boundMQHandler); |
} |
+ this._mq = null; |
}, |
- /** |
- * 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); |
+ queryChanged: function() { |
+ this._remove(); |
+ var query = this.query; |
+ if (!query) { |
+ return; |
} |
- // scroll up |
- else if (delta < 0) { |
- var topSpace = scrollTop - this._physicalTop; |
- var virtualStart = this._virtualStart; |
- |
- recycledTileSet = []; |
+ if (!this.full && query[0] !== '(') { |
+ query = '(' + query + ')'; |
+ } |
+ this._mq = window.matchMedia(query); |
+ this._add(); |
+ this.queryHandler(this._mq); |
+ }, |
- kth = this._physicalEnd; |
- currentRatio = topSpace / hiddenContentSize; |
+ queryHandler: function(mq) { |
+ this._setQueryMatches(mq.matches); |
+ } |
- // 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; |
- } |
+ }); |
+/** |
+ * `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' |
+ }, |
- movingUp = recycledTileSet; |
- recycledTiles = -recycledTiles; |
+ /** |
+ * True if this element is currently notifying its descedant elements of |
+ * resize. |
+ */ |
+ _notifyingDescendant: { |
+ type: Boolean, |
+ value: false |
} |
- // 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; |
+ listeners: { |
+ 'iron-request-resize-notifications': '_onIronRequestResizeNotifications' |
+ }, |
- // 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 |
- ) { |
+ 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); |
+ }, |
- tileHeight = this._getPhysicalSizeIncrement(kth); |
- currentRatio += tileHeight / hiddenContentSize; |
+ attached: function() { |
+ this.fire('iron-request-resize-notifications', null, { |
+ node: this, |
+ bubbles: true, |
+ cancelable: true |
+ }); |
- this._physicalTop += tileHeight; |
- recycledTileSet.push(kth); |
- recycledTiles++; |
- kth = (kth + 1) % this._physicalCount; |
- } |
+ if (!this._parentResizable) { |
+ window.addEventListener('resize', this._boundNotifyResize); |
+ this.notifyResize(); |
} |
+ }, |
- 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(); |
- } |
+ detached: function() { |
+ if (this._parentResizable) { |
+ this._parentResizable.stopResizeNotificationsFor(this); |
} else { |
- this._virtualStart = this._virtualStart + recycledTiles; |
- this._physicalStart = this._physicalStart + recycledTiles; |
- this._update(recycledTileSet, movingUp); |
+ window.removeEventListener('resize', this._boundNotifyResize); |
} |
+ |
+ this._parentResizable = null; |
}, |
/** |
- * Update the list of items, starting from the `_virtualStart` item. |
- * @param {!Array<number>=} itemSet |
- * @param {!Array<number>=} movingUp |
+ * Can be called to manually notify a resizable and its descendant |
+ * resizables of a resize change. |
*/ |
- _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); |
- } |
+ notifyResize: function() { |
+ if (!this.isAttached) { |
+ return; |
} |
- // update the position of the items |
- this._positionItems(); |
- // set the scroller size |
- this._updateScrollerSize(); |
- // increase the pool of physical items |
- this._increasePoolIfNeeded(); |
+ |
+ this._interestedResizables.forEach(function(resizable) { |
+ if (this.resizerShouldNotify(resizable)) { |
+ this._notifyDescendant(resizable); |
+ } |
+ }, this); |
+ |
+ this._fireResize(); |
}, |
/** |
- * Creates a pool of DOM elements and attaches them to the local dom. |
+ * Used to assign the closest resizable ancestor to this resizable |
+ * if the ancestor detects a request for notifications. |
*/ |
- _createPool: function(size) { |
- var physicalItems = new Array(size); |
+ assignParentResizable: function(parentResizable) { |
+ this._parentResizable = parentResizable; |
+ }, |
- this._ensureTemplatized(); |
+ /** |
+ * 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); |
- 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); |
+ if (index > -1) { |
+ this._interestedResizables.splice(index, 1); |
+ this.unlisten(target, 'iron-resize', '_onDescendantIronResize'); |
} |
- return physicalItems; |
}, |
/** |
- * Increases the pool of physical items only if needed. |
+ * 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. |
* |
- * @return {boolean} True if the pool was increased. |
+ * @param {HTMLElement} element A candidate descendant element that |
+ * implements `IronResizableBehavior`. |
+ * @return {boolean} True if the `element` should be notified of resize. |
*/ |
- _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); |
+ resizerShouldNotify: function(element) { return true; }, |
- 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)); |
+ _onDescendantIronResize: function(event) { |
+ if (this._notifyingDescendant) { |
+ event.stopPropagation(); |
+ return; |
} |
- this._lastPage = currentPage; |
+ // 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 true; |
+ _fireResize: function() { |
+ this.fire('iron-resize', null, { |
+ node: this, |
+ bubbles: false |
+ }); |
}, |
- /** |
- * 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; |
+ _onIronRequestResizeNotifications: function(event) { |
+ var target = event.path ? event.path[0] : event.target; |
- if (delta <= 0) { |
+ if (target === this) { |
return; |
} |
- [].push.apply(this._physicalItems, this._createPool(delta)); |
- [].push.apply(this._physicalSizes, new Array(delta)); |
+ if (this._interestedResizables.indexOf(target) === -1) { |
+ this._interestedResizables.push(target); |
+ this.listen(target, 'iron-resize', '_onDescendantIronResize'); |
+ } |
- this._physicalCount = prevPhysicalCount + delta; |
+ target.assignParentResizable(this); |
+ this._notifyDescendant(target); |
- // 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(); |
+ event.stopPropagation(); |
}, |
- /** |
- * 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; |
- |
- if (this.isAttached && !this._itemsRendered && this._isVisible && requiresUpdate) { |
- this._lastPage = 0; |
- this._update(); |
- this._itemsRendered = true; |
+ _parentResizableChanged: function(parentResizable) { |
+ if (parentResizable) { |
+ window.removeEventListener('resize', this._boundNotifyResize); |
} |
}, |
- /** |
- * 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._instanceProps = props; |
- this._userTemplate = Polymer.dom(this).querySelector('template'); |
- |
- if (this._userTemplate) { |
- this.templatize(this._userTemplate); |
- } else { |
- console.warn('iron-list requires a template to be provided in light-dom'); |
- } |
+ _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; |
} |
- }, |
- /** |
- * Implements extension point from Templatizer mixin. |
- */ |
- _getStampedChildren: function() { |
- return this._physicalItems; |
- }, |
+ this._notifyingDescendant = true; |
+ descendant.notifyResize(); |
+ this._notifyingDescendant = false; |
+ } |
+ }; |
+/** |
+ * @param {!Function} selectCallback |
+ * @constructor |
+ */ |
+ Polymer.IronSelection = function(selectCallback) { |
+ this.selection = []; |
+ this.selectCallback = selectCallback; |
+ }; |
+ |
+ Polymer.IronSelection.prototype = { |
/** |
- * 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. |
+ * 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. |
*/ |
- _forwardInstancePath: function(inst, path, value) { |
- if (path.indexOf(this.as + '.') === 0) { |
- this.notifyPath('items.' + inst.__key__ + '.' + |
- path.slice(this.as.length + 1), value); |
- } |
+ get: function() { |
+ return this.multi ? this.selection.slice() : this.selection[0]; |
}, |
/** |
- * Implements extension point from Templatizer mixin |
- * Called as side-effect of a host property change, responsible for |
- * notifying parent path change on each row. |
+ * Clears all the selection except the ones indicated. |
+ * |
+ * @method clear |
+ * @param {Array} excludes items to be excluded. |
*/ |
- _forwardParentProp: function(prop, value) { |
- if (this._physicalItems) { |
- this._physicalItems.forEach(function(item) { |
- item._templateInstance[prop] = value; |
- }, this); |
- } |
+ clear: function(excludes) { |
+ this.selection.slice().forEach(function(item) { |
+ if (!excludes || excludes.indexOf(item) < 0) { |
+ this.setItemSelected(item, false); |
+ } |
+ }, this); |
}, |
/** |
- * Implements extension point from Templatizer |
- * Called as side-effect of a host path change, responsible for |
- * notifying parent.<path> path change on each row. |
+ * 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. |
*/ |
- _forwardParentPath: function(path, value) { |
- if (this._physicalItems) { |
- this._physicalItems.forEach(function(item) { |
- item._templateInstance.notifyPath(path, value, true); |
- }, this); |
- } |
+ isSelected: function(item) { |
+ return this.selection.indexOf(item) >= 0; |
}, |
/** |
- * Called as a side effect of a host items.<key>.<path> path change, |
- * responsible for notifying item.<path> changes. |
+ * 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. |
*/ |
- _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; |
+ 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); |
} |
} |
- } else if (this.selectedItem === currentItem) { |
- this.set('selectedItem', value); |
+ if (this.selectCallback) { |
+ this.selectCallback(item, isSelected); |
+ } |
} |
- el._templateInstance[this.as] = value; |
} |
}, |
/** |
- * Called when the items have changed. That is, ressignments |
- * to `items`, splices or updates to a single item. |
+ * 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. |
*/ |
- _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._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); |
- } |
- |
- this._physicalStart = 0; |
- |
- } else if (change.path === 'items.splices') { |
- |
- this._adjustVirtualIndex(change.value.indexSplices); |
- this._virtualCount = this.items ? this.items.length : 0; |
- |
- } else { |
- // update a single item |
- this._forwardItemPath(change.path.split('.').slice(1).join('.'), change.value); |
- return; |
+ select: function(item) { |
+ if (this.multi) { |
+ this.toggle(item); |
+ } else if (this.get() !== item) { |
+ this.setItemSelected(this.get(), false); |
+ this.setItemSelected(item, true); |
} |
- |
- this._itemsRendered = false; |
- this._debounceTemplate(this._render); |
}, |
/** |
- * @param {!Array<!PolymerSplice>} splices |
+ * Toggles the selection state for `item`. |
+ * |
+ * @method toggle |
+ * @param {*} item The item to toggle. |
*/ |
- _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); |
+ toggle: function(item) { |
+ this.setItemSelected(item, !this.isSelected(item)); |
+ } |
- this._virtualStart = this._virtualStart + delta; |
+ }; |
+/** @polymerBehavior */ |
+ Polymer.IronSelectableBehavior = { |
- if (this._focusedIndex >= 0) { |
- this._focusedIndex = this._focusedIndex + delta; |
- } |
- } |
- }, this); |
- }, |
+ /** |
+ * 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 |
+ */ |
- _removeItem: function(item) { |
- this.$.selector.deselect(item); |
- // remove the current focused item |
- if (this._focusedItem && this._focusedItem._templateInstance[this.as] === item) { |
- this._removeFocusedItem(); |
- } |
- }, |
+ /** |
+ * Fired when an item is selected |
+ * |
+ * @event iron-select |
+ */ |
- /** |
- * 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; |
+ /** |
+ * Fired when an item is deselected |
+ * |
+ * @event iron-deselect |
+ */ |
- 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; |
+ /** |
+ * 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 |
+ */ |
- 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; |
- } |
- } |
- } |
- }, |
+ properties: { |
- /** |
- * 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; |
- }, |
+ /** |
+ * 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 |
+ }, |
- /** |
- * 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]; |
+ /** |
+ * Gets or sets the selected element. The default is to use the index of the item. |
+ * @type {string|number} |
+ */ |
+ selected: { |
+ type: String, |
+ notify: true |
+ }, |
- 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); |
- }, |
+ /** |
+ * Returns the currently selected item. |
+ * |
+ * @type {?Object} |
+ */ |
+ selectedItem: { |
+ type: Object, |
+ readOnly: true, |
+ notify: true |
+ }, |
- /** |
- * 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(); |
+ /** |
+ * 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' |
+ }, |
- var newPhysicalSize = 0; |
- var oldPhysicalSize = 0; |
- var prevAvgCount = this._physicalAverageCount; |
- var prevPhysicalAvg = this._physicalAverage; |
+ /** |
+ * This is a CSS selector string. If this is set, only items that match the CSS selector |
+ * are selectable. |
+ */ |
+ selectable: String, |
- this._iterateItems(function(pidx, vidx) { |
+ /** |
+ * The class to set on elements when selected. |
+ */ |
+ selectedClass: { |
+ type: String, |
+ value: 'iron-selected' |
+ }, |
- oldPhysicalSize += this._physicalSizes[pidx] || 0; |
- this._physicalSizes[pidx] = this._physicalItems[pidx].offsetHeight; |
- newPhysicalSize += this._physicalSizes[pidx]; |
- this._physicalAverageCount += this._physicalSizes[pidx] ? 1 : 0; |
+ /** |
+ * The attribute to set on elements when selected. |
+ */ |
+ selectedAttribute: { |
+ type: String, |
+ value: null |
+ }, |
- }, itemSet); |
+ /** |
+ * Default fallback if the selection based on selected with `attrForSelected` |
+ * is not found. |
+ */ |
+ fallbackSelection: { |
+ type: String, |
+ value: null |
+ }, |
- 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; |
+ /** |
+ * 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 |
+ }; |
+ } |
} |
+ }, |
- // update the average if we measured something |
- if (this._physicalAverageCount !== prevAvgCount) { |
- this._physicalAverage = Math.round( |
- ((prevPhysicalAvg * prevAvgCount) + newPhysicalSize) / |
- this._physicalAverageCount); |
+ observers: [ |
+ '_updateAttrForSelected(attrForSelected)', |
+ '_updateSelected(selected)', |
+ '_checkFallback(fallbackSelection)' |
+ ], |
+ |
+ 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); |
}, |
- _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; |
+ detached: function() { |
+ if (this._observer) { |
+ Polymer.dom(this).unobserveNodes(this._observer); |
+ } |
+ this._removeListener(this.activateEvent); |
}, |
/** |
- * Updates the position of the physical items. |
+ * Returns the index of the given item. |
+ * |
+ * @method indexOf |
+ * @param {Object} item |
+ * @returns Returns the index of the item |
*/ |
- _positionItems: function() { |
- this._adjustScrollPosition(); |
+ indexOf: function(item) { |
+ return this.items.indexOf(item); |
+ }, |
- var y = this._physicalTop; |
- |
- if (this.grid) { |
- var totalItemWidth = this._itemsPerRow * this._itemWidth; |
- var rowOffset = (this._viewportWidth - totalItemWidth) / 2; |
- |
- this._iterateItems(function(pidx, vidx) { |
- |
- var modulus = vidx % this._itemsPerRow; |
- var x = Math.floor((modulus * this._itemWidth) + rowOffset); |
- |
- this.translate3d(x + 'px', y + 'px', 0, this._physicalItems[pidx]); |
- |
- if (this._shouldRenderNextRow(vidx)) { |
- y += this._rowHeight; |
- } |
- |
- }); |
- } else { |
- this._iterateItems(function(pidx, vidx) { |
- |
- this.translate3d(0, y + 'px', 0, this._physicalItems[pidx]); |
- y += this._physicalSizes[pidx]; |
- |
- }); |
- } |
- }, |
- |
- _getPhysicalSizeIncrement: function(pidx) { |
- if (!this.grid) { |
- return this._physicalSizes[pidx]; |
- } |
- if (this._computeVidx(pidx) % this._itemsPerRow !== this._itemsPerRow - 1) { |
- return 0; |
- } |
- return this._rowHeight; |
- }, |
+ /** |
+ * Selects the given value. |
+ * |
+ * @method select |
+ * @param {string|number} value the value to select. |
+ */ |
+ select: function(value) { |
+ this.selected = value; |
+ }, |
/** |
- * Returns, based on the current index, |
- * whether or not the next index will need |
- * to be rendered on a new row. |
+ * Selects the previous item. |
* |
- * @param {number} vidx Virtual index |
- * @return {boolean} |
+ * @method selectPrevious |
*/ |
- _shouldRenderNextRow: function(vidx) { |
- return vidx % this._itemsPerRow === this._itemsPerRow - 1; |
+ selectPrevious: function() { |
+ var length = this.items.length; |
+ var index = (Number(this._valueToIndex(this.selected)) - 1 + length) % length; |
+ this.selected = this._indexToValue(index); |
}, |
/** |
- * Adjusts the scroll position when it was overestimated. |
+ * Selects the next item. |
+ * |
+ * @method selectNext |
*/ |
- _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); |
- } |
- } |
+ selectNext: function() { |
+ var index = (Number(this._valueToIndex(this.selected)) + 1) % this.items.length; |
+ this.selected = this._indexToValue(index); |
}, |
/** |
- * Sets the position of the scroll. |
+ * Selects the item at the given index. |
+ * |
+ * @method selectIndex |
*/ |
- _resetScrollPosition: function(pos) { |
- if (this.scrollTarget) { |
- this._scrollTop = pos; |
- this._scrollPosition = this._scrollTop; |
- } |
+ selectIndex: function(index) { |
+ this.select(this._indexToValue(index)); |
}, |
/** |
- * Sets the scroll height, that's the height of the content, |
+ * Force a synchronous update of the `items` property. |
* |
- * @param {boolean=} forceUpdate If true, updates the height no matter what. |
+ * 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. |
*/ |
- _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); |
- } |
- |
- forceUpdate = forceUpdate || this._scrollHeight === 0; |
- forceUpdate = forceUpdate || this._scrollPosition >= this._estScrollHeight - this._physicalSize; |
- forceUpdate = forceUpdate || this.grid && this.$.items.style.height < this._estScrollHeight; |
- |
- // 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; |
- } |
+ forceSynchronousItemUpdate: function() { |
+ this._updateItems(); |
}, |
- /** |
- * 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)); |
+ get _shouldUpdateSelection() { |
+ return this.selected != null; |
}, |
- /** |
- * 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; |
+ _checkFallback: function() { |
+ if (this._shouldUpdateSelection) { |
+ this._updateSelected(); |
} |
+ }, |
- Polymer.dom.flush(); |
+ _addListener: function(eventName) { |
+ this.listen(this, eventName, '_activateHandler'); |
+ }, |
- 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(); |
+ _removeListener: function(eventName) { |
+ this.unlisten(this, eventName, '_activateHandler'); |
+ }, |
- // estimate new physical offset |
- var estPhysicalTop = Math.floor(this._virtualStart / this._itemsPerRow) * this._physicalAverage; |
- this._physicalTop = estPhysicalTop; |
+ _activateEventChanged: function(eventName, old) { |
+ this._removeListener(old); |
+ this._addListener(eventName); |
+ }, |
- var currentTopItem = this._physicalStart; |
- var currentVirtualItem = this._virtualStart; |
- var targetOffsetTop = 0; |
- var hiddenContentSize = this._hiddenContentSize; |
+ _updateItems: function() { |
+ var nodes = Polymer.dom(this).queryDistributedElements(this.selectable || '*'); |
+ nodes = Array.prototype.filter.call(nodes, this._bindFilterItem); |
+ this._setItems(nodes); |
+ }, |
- // 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++; |
+ _updateAttrForSelected: function() { |
+ if (this._shouldUpdateSelection) { |
+ this.selected = this._indexToValue(this.indexOf(this.selectedItem)); |
} |
- // 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; |
+ _updateSelected: function() { |
+ this._selectSelected(this.selected); |
}, |
- /** |
- * 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; |
+ _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; |
} |
- // 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 (this._itemsRendered && this._physicalItems && this._isVisible) { |
- this._resetAverage(); |
- this.scrollToIndex(this.firstVisibleIndex); |
- } |
- }.bind(this), 1)); |
}, |
- _getModelFromItem: function(item) { |
- var key = this._collection.getKey(item); |
- var pidx = this._physicalIndexForKey[key]; |
+ _filterItem: function(node) { |
+ return !this._excludedLocalNames[node.localName]; |
+ }, |
- if (pidx != null) { |
- return this._physicalItems[pidx]._templateInstance; |
- } |
- return null; |
+ _valueToItem: function(value) { |
+ return (value == null) ? null : this.items[this._valueToIndex(value)]; |
}, |
- /** |
- * 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'); |
+ _valueToIndex: function(value) { |
+ if (this.attrForSelected) { |
+ for (var i = 0, item; item = this.items[i]; i++) { |
+ if (this._valueForItem(item) == value) { |
+ return i; |
} |
- return item; |
} |
- throw new TypeError('<item> should be a valid item'); |
+ } else { |
+ return Number(value); |
} |
- return item; |
}, |
- /** |
- * Select the list item at the given index. |
- * |
- * @method selectItem |
- * @param {(Object|number)} item The item object or its index |
- */ |
- selectItem: function(item) { |
- item = this._getNormalizedItem(item); |
- var model = this._getModelFromItem(item); |
- |
- if (!this.multiSelection && this.selectedItem) { |
- this.deselectItem(this.selectedItem); |
- } |
- if (model) { |
- model[this.selectedAs] = true; |
+ _indexToValue: function(index) { |
+ if (this.attrForSelected) { |
+ var item = this.items[index]; |
+ if (item) { |
+ return this._valueForItem(item); |
+ } |
+ } else { |
+ return index; |
} |
- this.$.selector.select(item); |
- this.updateSizeForItem(item); |
}, |
- /** |
- * Deselects the given item list if it is already selected. |
- * |
- |
- * @method deselect |
- * @param {(Object|number)} item The item object or its index |
- */ |
- deselectItem: function(item) { |
- item = this._getNormalizedItem(item); |
- var model = this._getModelFromItem(item); |
+ _valueForItem: function(item) { |
+ var propValue = item[Polymer.CaseMap.dashToCamelCase(this.attrForSelected)]; |
+ return propValue != undefined ? propValue : item.getAttribute(this.attrForSelected); |
+ }, |
- if (model) { |
- model[this.selectedAs] = false; |
+ _applySelection: function(item, isSelected) { |
+ if (this.selectedClass) { |
+ this.toggleClass(this.selectedClass, isSelected, item); |
} |
- this.$.selector.deselect(item); |
- this.updateSizeForItem(item); |
+ if (this.selectedAttribute) { |
+ this.toggleAttribute(this.selectedAttribute, isSelected, item); |
+ } |
+ this._selectionChange(); |
+ this.fire('iron-' + (isSelected ? 'select' : 'deselect'), {item: item}); |
}, |
- /** |
- * Select or deselect a given item depending on whether the item |
- * has already been selected. |
- * |
- * @method toggleSelectionForItem |
- * @param {(Object|number)} item The item object or its index |
- */ |
- toggleSelectionForItem: function(item) { |
- item = this._getNormalizedItem(item); |
- if (/** @type {!ArraySelectorElement} */ (this.$.selector).isSelected(item)) { |
- this.deselectItem(item); |
- } else { |
- this.selectItem(item); |
- } |
+ _selectionChange: function() { |
+ this._setSelectedItem(this._selection.get()); |
}, |
- /** |
- * 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; |
- } |
- } |
+ // observe items change under the given node. |
+ _observeItems: function(node) { |
+ return Polymer.dom(node).observeNodes(function(mutation) { |
+ this._updateItems(); |
- if (Array.isArray(this.selectedItems)) { |
- this.selectedItems.forEach(unselect, this); |
- } else if (this.selectedItem) { |
- unselect.call(this, this.selectedItem); |
- } |
+ if (this._shouldUpdateSelection) { |
+ this._updateSelected(); |
+ } |
- /** @type {!ArraySelectorElement} */ (this.$.selector).clearSelection(); |
+ // 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 |
+ }); |
+ }); |
}, |
- /** |
- * 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'); |
+ _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; |
+ } |
}, |
- /** |
- * 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; |
+ _itemActivate: function(value, item) { |
+ if (!this.fire('iron-activate', |
+ {selected: value, item: item}, {cancelable: true}).defaultPrevented) { |
+ this.select(value); |
} |
- this.toggleSelectionForItem(model[this.as]); |
- }, |
+ } |
- _multiSelectionChanged: function(multiSelection) { |
- this.clearSelection(); |
- this.$.selector.multi = multiSelection; |
- }, |
+ }; |
+Polymer({ |
- /** |
- * 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]; |
+ is: 'iron-pages', |
- if (pidx != null) { |
- this._updateMetrics([pidx]); |
- this._positionItems(); |
- } |
- }, |
+ behaviors: [ |
+ Polymer.IronResizableBehavior, |
+ Polymer.IronSelectableBehavior |
+ ], |
- /** |
- * 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; |
+ properties: { |
- 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(); |
+ // 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 |
} |
- } 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; |
- }, |
- _isIndexVisible: function(idx) { |
- return idx >= this.firstVisibleIndex && idx <= this.lastVisibleIndex; |
- }, |
+ }, |
- _getPhysicalIndex: function(idx) { |
- return this._physicalIndexForKey[this._collection.getKey(this._getNormalizedItem(idx))]; |
- }, |
+ observers: [ |
+ '_selectedPageChanged(selected)' |
+ ], |
- _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); |
+ _selectedPageChanged: function(selected, old) { |
+ this.async(this.notifyResize); |
} |
+ }); |
+(function() { |
- var physicalItem = this._physicalItems[this._getPhysicalIndex(idx)]; |
- var model = physicalItem._templateInstance; |
- var focusable; |
+ // monostate data |
+ var metaDatas = {}; |
+ var metaArrays = {}; |
+ var singleton = null; |
- // 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(); |
- }, |
+ Polymer.IronMeta = Polymer({ |
- _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); |
- } |
- }, |
- |
- _restoreFocusedItem: function() { |
- var pidx, fidx = this._focusedIndex; |
- |
- 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); |
- |
- 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); |
- } |
- }, |
- |
- _didFocus: function(e) { |
- var targetModel = this.modelForElement(e.target); |
- var focusedModel = this._focusedItem ? this._focusedItem._templateInstance : null; |
- var hasOffscreenFocusedItem = this._offscreenFocusedItem !== null; |
- var fidx = this._focusedIndex; |
- |
- if (!targetModel || !focusedModel) { |
- return; |
- } |
- if (focusedModel === targetModel) { |
- // if the user focused the same item, then bring it into view if it's not visible |
- if (!this._isIndexVisible(fidx)) { |
- this.scrollToIndex(fidx); |
- } |
- } else { |
- this._restoreFocusedItem(); |
- // restore tabIndex for the currently focused item |
- focusedModel.tabIndex = -1; |
- // set the tabIndex for the next focused item |
- targetModel.tabIndex = 0; |
- fidx = targetModel[this.indexAs]; |
- this._focusedIndex = fidx; |
- this._focusedItem = this._physicalItems[this._getPhysicalIndex(fidx)]; |
- |
- if (hasOffscreenFocusedItem && !this._offscreenFocusedItem) { |
- this._update(); |
- } |
- } |
- }, |
- |
- _didMoveUp: function() { |
- this._focusPhysicalItem(this._focusedIndex - 1); |
- }, |
- |
- _didMoveDown: function(e) { |
- // disable scroll when pressing the down key |
- e.detail.keyboardEvent.preventDefault(); |
- this._focusPhysicalItem(this._focusedIndex + 1); |
- }, |
- |
- _didEnter: function(e) { |
- this._focusPhysicalItem(this._focusedIndex); |
- this._selectionHandler(e.detail.keyboardEvent); |
- } |
- }); |
- |
-})(); |
-// Copyright 2015 The Chromium Authors. All rights reserved. |
-// Use of this source code is governed by a BSD-style license that can be |
-// found in the LICENSE file. |
- |
-cr.define('downloads', function() { |
- /** |
- * @param {string} chromeSendName |
- * @return {function(string):void} A chrome.send() callback with curried name. |
- */ |
- function chromeSendWithId(chromeSendName) { |
- return function(id) { chrome.send(chromeSendName, [id]); }; |
- } |
- |
- /** @constructor */ |
- function ActionService() { |
- /** @private {Array<string>} */ |
- this.searchTerms_ = []; |
- } |
- |
- /** |
- * @param {string} s |
- * @return {string} |s| without whitespace at the beginning or end. |
- */ |
- function trim(s) { return s.trim(); } |
- |
- /** |
- * @param {string|undefined} value |
- * @return {boolean} Whether |value| is truthy. |
- */ |
- function truthy(value) { return !!value; } |
- |
- /** |
- * @param {string} searchText Input typed by the user into a search box. |
- * @return {Array<string>} A list of terms extracted from |searchText|. |
- */ |
- ActionService.splitTerms = function(searchText) { |
- // Split quoted terms (e.g., 'The "lazy" dog' => ['The', 'lazy', 'dog']). |
- return searchText.split(/"([^"]*)"/).map(trim).filter(truthy); |
- }; |
- |
- ActionService.prototype = { |
- /** @param {string} id ID of the download to cancel. */ |
- cancel: chromeSendWithId('cancel'), |
- |
- /** Instructs the browser to clear all finished downloads. */ |
- clearAll: function() { |
- if (loadTimeData.getBoolean('allowDeletingHistory')) { |
- chrome.send('clearAll'); |
- this.search(''); |
- } |
- }, |
- |
- /** @param {string} id ID of the dangerous download to discard. */ |
- discardDangerous: chromeSendWithId('discardDangerous'), |
- |
- /** @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(); |
- }, |
- |
- /** @param {string} id ID of the download that the user started dragging. */ |
- drag: chromeSendWithId('drag'), |
- |
- /** Loads more downloads with the current search terms. */ |
- loadMore: function() { |
- chrome.send('getDownloads', this.searchTerms_); |
- }, |
- |
- /** |
- * @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; |
- |
- this.searchTerms_ = searchTerms; |
- this.loadMore(); |
- }, |
- |
- /** |
- * 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'), |
- }; |
- |
- cr.addSingletonGetter(ActionService); |
- |
- 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. |
- |
-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', |
- }; |
- |
- /** |
- * 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', |
- }; |
- |
- 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. |
- |
-// 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. |
- |
-/** |
- * @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); |
- } |
- }); |
- |
- function preventDefault(e) { |
- e.preventDefault(); |
- } |
- |
- function removePreventDefault() { |
- document.removeEventListener('selectstart', preventDefault); |
- document.removeEventListener('mouseup', removePreventDefault); |
- } |
- |
- this.addEventListener('mousedown', function() { |
- // This handlers strives to match the behavior of <a href="...">. |
- |
- // While the mouse is down, prevent text selection from dragging. |
- document.addEventListener('selectstart', preventDefault); |
- document.addEventListener('mouseup', removePreventDefault); |
- |
- // If focus started via mouse press, don't show an outline. |
- if (document.activeElement != this) |
- this.classList.add('no-outline'); |
- }); |
- |
- this.addEventListener('blur', function() { |
- this.classList.remove('no-outline'); |
- }); |
- }, |
- |
- /** @type {boolean} */ |
- set disabled(disabled) { |
- if (disabled) |
- HTMLAnchorElement.prototype.setAttribute.call(this, 'disabled', ''); |
- else |
- HTMLAnchorElement.prototype.removeAttribute.call(this, 'disabled'); |
- this.tabIndex = disabled ? -1 : 0; |
- }, |
- get disabled() { |
- return this.hasAttribute('disabled'); |
- }, |
- |
- /** @override */ |
- setAttribute: function(attr, val) { |
- if (attr.toLowerCase() == 'disabled') |
- this.disabled = true; |
- else |
- HTMLAnchorElement.prototype.setAttribute.apply(this, arguments); |
- }, |
- |
- /** @override */ |
- removeAttribute: function(attr) { |
- if (attr.toLowerCase() == 'disabled') |
- this.disabled = false; |
- else |
- HTMLAnchorElement.prototype.removeAttribute.apply(this, arguments); |
- }, |
- }, |
- |
- extends: 'a', |
-}); |
-(function() { |
- |
- // monostate data |
- var metaDatas = {}; |
- var metaArrays = {}; |
- var singleton = null; |
- |
- Polymer.IronMeta = Polymer({ |
- |
- is: 'iron-meta', |
+ is: 'iron-meta', |
properties: { |
@@ -4717,2407 +4165,1982 @@ Polymer({ |
}); |
/** |
+ * The `iron-iconset-svg` element allows users to define their own icon sets |
+ * that contain svg icons. The svg icon elements should be children of the |
+ * `iron-iconset-svg` element. Multiple icons should be given distinct id's. |
+ * |
+ * Using svg elements to create icons has a few advantages over traditional |
+ * bitmap graphics like jpg or png. Icons that use svg are vector based so |
+ * they are resolution independent and should look good on any device. They |
+ * are stylable via css. Icons can be themed, colorized, and even animated. |
+ * |
+ * Example: |
+ * |
+ * <iron-iconset-svg name="my-svg-icons" size="24"> |
+ * <svg> |
+ * <defs> |
+ * <g id="shape"> |
+ * <rect x="12" y="0" width="12" height="24" /> |
+ * <circle cx="12" cy="12" r="12" /> |
+ * </g> |
+ * </defs> |
+ * </svg> |
+ * </iron-iconset-svg> |
+ * |
+ * This will automatically register the icon set "my-svg-icons" to the iconset |
+ * database. To use these icons from within another element, make a |
+ * `iron-iconset` element and call the `byId` method |
+ * to retrieve a given iconset. To apply a particular icon inside an |
+ * element use the `applyIcon` method. For example: |
+ * |
+ * iconset.applyIcon(iconNode, 'car'); |
+ * |
+ * @element iron-iconset-svg |
* @demo demo/index.html |
- * @polymerBehavior |
+ * @implements {Polymer.Iconset} |
*/ |
- Polymer.IronControlState = { |
+ Polymer({ |
+ is: 'iron-iconset-svg', |
properties: { |
/** |
- * If true, the element currently has focus. |
+ * The name of the iconset. |
*/ |
- focused: { |
- type: Boolean, |
- value: false, |
- notify: true, |
- readOnly: true, |
- reflectToAttribute: true |
- }, |
+ name: { |
+ type: String, |
+ observer: '_nameChanged' |
+ }, |
/** |
- * If true, the user cannot interact with this element. |
+ * The size of an individual icon. Note that icons must be square. |
*/ |
- disabled: { |
- type: Boolean, |
- value: false, |
- notify: true, |
- observer: '_disabledChanged', |
- reflectToAttribute: true |
- }, |
- |
- _oldTabIndex: { |
- type: Number |
- }, |
- |
- _boundFocusBlurHandler: { |
- type: Function, |
- value: function() { |
- return this._focusBlurHandler.bind(this); |
- } |
+ size: { |
+ type: Number, |
+ value: 24 |
} |
}, |
- observers: [ |
- '_changedControlState(focused, disabled)' |
- ], |
- |
- ready: function() { |
- this.addEventListener('focus', this._boundFocusBlurHandler, true); |
- this.addEventListener('blur', this._boundFocusBlurHandler, true); |
+ attached: function() { |
+ this.style.display = 'none'; |
}, |
- _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`. |
- |
- 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 |
- }); |
- } |
- } |
+ /** |
+ * Construct an array of all icon names in this iconset. |
+ * |
+ * @return {!Array} Array of icon names. |
+ */ |
+ getIconNames: function() { |
+ this._icons = this._createIconMap(); |
+ return Object.keys(this._icons).map(function(n) { |
+ return this.name + ':' + n; |
+ }, this); |
}, |
- _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; |
+ /** |
+ * Applies an icon to the given element. |
+ * |
+ * An svg icon is prepended to the element's shadowRoot if it exists, |
+ * otherwise to the element itself. |
+ * |
+ * @method applyIcon |
+ * @param {Element} element Element to which the icon is applied. |
+ * @param {string} iconName Name of the icon to apply. |
+ * @return {?Element} The svg element which renders the icon. |
+ */ |
+ applyIcon: function(element, iconName) { |
+ // insert svg element into shadow root, if it exists |
+ element = element.root || element; |
+ // Remove old svg element |
+ this.removeIcon(element); |
+ // install new svg element |
+ var svg = this._cloneIcon(iconName); |
+ if (svg) { |
+ var pde = Polymer.dom(element); |
+ pde.insertBefore(svg, pde.childNodes[0]); |
+ return element._svgIcon = svg; |
} |
+ return null; |
}, |
- _changedControlState: function() { |
- // _controlStateChanged is abstract, follow-on behaviors may implement it |
- if (this._controlStateChanged) { |
- this._controlStateChanged(); |
- } |
- } |
- |
- }; |
-/** |
- * @demo demo/index.html |
- * @polymerBehavior Polymer.IronButtonState |
- */ |
- Polymer.IronButtonStateImpl = { |
- |
- properties: { |
- |
- /** |
- * If true, the user is currently holding down the button. |
- */ |
- pressed: { |
- type: Boolean, |
- readOnly: true, |
- value: false, |
- reflectToAttribute: true, |
- observer: '_pressedChanged' |
- }, |
- |
- /** |
- * If true, the button toggles the active state with each tap or press |
- * of the spacebar. |
- */ |
- toggles: { |
- type: Boolean, |
- value: false, |
- reflectToAttribute: true |
- }, |
- |
- /** |
- * If true, the button is a toggle and is currently in the active state. |
- */ |
- active: { |
- type: Boolean, |
- value: false, |
- notify: true, |
- reflectToAttribute: true |
- }, |
- |
- /** |
- * 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 |
- }, |
- |
- /** |
- * 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' |
+ /** |
+ * Remove an icon from the given element by undoing the changes effected |
+ * by `applyIcon`. |
+ * |
+ * @param {Element} element The element from which the icon is removed. |
+ */ |
+ removeIcon: function(element) { |
+ // Remove old svg element |
+ if (element._svgIcon) { |
+ Polymer.dom(element).removeChild(element._svgIcon); |
+ element._svgIcon = null; |
} |
}, |
- listeners: { |
- down: '_downHandler', |
- up: '_upHandler', |
- tap: '_tapHandler' |
- }, |
- |
- observers: [ |
- '_detectKeyboardFocus(focused)', |
- '_activeChanged(active, ariaActiveAttribute)' |
- ], |
- |
- keyBindings: { |
- 'enter:keydown': '_asyncClick', |
- 'space:keydown': '_spaceKeyDownHandler', |
- 'space:keyup': '_spaceKeyUpHandler', |
- }, |
- |
- _mouseEventRe: /^mouse/, |
- |
- _tapHandler: function() { |
- if (this.toggles) { |
- // a tap is needed to toggle the active state |
- this._userActivate(!this.active); |
- } else { |
- this.active = false; |
- } |
+ /** |
+ * |
+ * When name is changed, register iconset metadata |
+ * |
+ */ |
+ _nameChanged: function() { |
+ new Polymer.IronMeta({type: 'iconset', key: this.name, value: this}); |
+ this.async(function() { |
+ this.fire('iron-iconset-added', this, {node: window}); |
+ }); |
}, |
- _detectKeyboardFocus: function(focused) { |
- this._setReceivedFocusFromKeyboard(!this.pointerDown && focused); |
+ /** |
+ * Create a map of child SVG elements by id. |
+ * |
+ * @return {!Object} Map of id's to SVG elements. |
+ */ |
+ _createIconMap: function() { |
+ // Objects chained to Object.prototype (`{}`) have members. Specifically, |
+ // on FF there is a `watch` method that confuses the icon map, so we |
+ // need to use a null-based object here. |
+ var icons = Object.create(null); |
+ Polymer.dom(this).querySelectorAll('[id]') |
+ .forEach(function(icon) { |
+ icons[icon.id] = icon; |
+ }); |
+ return icons; |
}, |
- // 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'); |
- } |
- }, |
- |
- _downHandler: function(event) { |
- this._setPointerDown(true); |
- this._setPressed(true); |
- this._setReceivedFocusFromKeyboard(false); |
- }, |
- |
- _upHandler: function() { |
- this._setPointerDown(false); |
- this._setPressed(false); |
+ /** |
+ * Produce installable clone of the SVG element matching `id` in this |
+ * iconset, or `undefined` if there is no matching element. |
+ * |
+ * @return {Element} Returns an installable clone of the SVG element |
+ * matching `id`. |
+ */ |
+ _cloneIcon: function(id) { |
+ // create the icon map on-demand, since the iconset itself has no discrete |
+ // signal to know when it's children are fully parsed |
+ this._icons = this._icons || this._createIconMap(); |
+ return this._prepareSvgClone(this._icons[id], this.size); |
}, |
/** |
- * @param {!KeyboardEvent} event . |
+ * @param {Element} sourceSvg |
+ * @param {number} size |
+ * @return {Element} |
*/ |
- _spaceKeyDownHandler: function(event) { |
- var keyboardEvent = event.detail.keyboardEvent; |
- var target = Polymer.dom(keyboardEvent).localTarget; |
+ _prepareSvgClone: function(sourceSvg, size) { |
+ if (sourceSvg) { |
+ var content = sourceSvg.cloneNode(true), |
+ svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), |
+ viewBox = content.getAttribute('viewBox') || '0 0 ' + size + ' ' + size; |
+ svg.setAttribute('viewBox', viewBox); |
+ svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); |
+ // TODO(dfreedm): `pointer-events: none` works around https://crbug.com/370136 |
+ // TODO(sjmiles): inline style may not be ideal, but avoids requiring a shadow-root |
+ svg.style.cssText = 'pointer-events: none; display: block; width: 100%; height: 100%;'; |
+ svg.appendChild(content).removeAttribute('id'); |
+ return svg; |
+ } |
+ return null; |
+ } |
- // 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; |
+ }); |
+(function() { |
+ 'use strict'; |
- keyboardEvent.preventDefault(); |
- keyboardEvent.stopImmediatePropagation(); |
- this._setPressed(true); |
- }, |
+ /** |
+ * 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' |
+ }; |
/** |
- * @param {!KeyboardEvent} event . |
+ * 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 |
*/ |
- _spaceKeyUpHandler: function(event) { |
- var keyboardEvent = event.detail.keyboardEvent; |
- var target = Polymer.dom(keyboardEvent).localTarget; |
+ 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: '*' |
+ }; |
- // 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; |
+ /** |
+ * 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 (this.pressed) { |
- this._asyncClick(); |
- } |
- this._setPressed(false); |
- }, |
+ /** |
+ * 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*]/; |
- // 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); |
- }, |
+ /** |
+ * Matches a keyIdentifier string. |
+ */ |
+ var IDENT_CHAR = /U\+/; |
- // any of these changes are considered a change to button state |
+ /** |
+ * Matches arrow keys in Gecko 27.0+ |
+ */ |
+ var ARROW_KEY = /^arrow/; |
- _pressedChanged: function(pressed) { |
- this._changedButtonState(); |
- }, |
+ /** |
+ * Matches space keys everywhere (notably including IE10's exceptional name |
+ * `spacebar`). |
+ */ |
+ var SPACE_KEY = /^space(bar)?/; |
- _ariaActiveAttributeChanged: function(value, oldValue) { |
- if (oldValue && oldValue != value && this.hasAttribute(oldValue)) { |
- this.removeAttribute(oldValue); |
- } |
- }, |
+ /** |
+ * Matches ESC key. |
+ * |
+ * Value from: http://w3c.github.io/uievents-key/#key-Escape |
+ */ |
+ var ESC_KEY = /^escape$/; |
- _activeChanged: function(active, ariaActiveAttribute) { |
- if (this.toggles) { |
- this.setAttribute(this.ariaActiveAttribute, |
- active ? 'true' : 'false'); |
- } else { |
- this.removeAttribute(this.ariaActiveAttribute); |
+ /** |
+ * Transforms the key. |
+ * @param {string} key The KeyBoardEvent.key |
+ * @param {Boolean} [noSpecialChars] Limits the transformation to |
+ * alpha-numeric characters. |
+ */ |
+ function transformKey(key, noSpecialChars) { |
+ var validKey = ''; |
+ if (key) { |
+ var lKey = key.toLowerCase(); |
+ if (lKey === ' ' || SPACE_KEY.test(lKey)) { |
+ validKey = 'space'; |
+ } else if (ESC_KEY.test(lKey)) { |
+ validKey = 'esc'; |
+ } else if (lKey.length == 1) { |
+ if (!noSpecialChars || KEY_CHAR.test(lKey)) { |
+ validKey = lKey; |
+ } |
+ } else if (ARROW_KEY.test(lKey)) { |
+ validKey = lKey.replace('arrow', ''); |
+ } else if (lKey == 'multiply') { |
+ // numpad '*' can map to Multiply on IE/Windows |
+ validKey = '*'; |
+ } else { |
+ validKey = lKey; |
+ } |
} |
- this._changedButtonState(); |
- }, |
+ return validKey; |
+ } |
- _controlStateChanged: function() { |
- if (this.disabled) { |
- this._setPressed(false); |
- } else { |
- this._changedButtonState(); |
+ 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(); |
+ } |
} |
- }, |
- |
- // provide hook for follow-on behaviors to react to button-state |
+ return validKey; |
+ } |
- _changedButtonState: function() { |
- if (this._buttonStateChanged) { |
- this._buttonStateChanged(); // abstract |
+ 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; |
} |
- }; |
- |
- /** @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); |
- }, |
- |
- now: window.performance && window.performance.now ? |
- window.performance.now.bind(window.performance) : Date.now |
- }; |
- |
/** |
- * @param {HTMLElement} element |
- * @constructor |
+ * 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 ElementMetrics(element) { |
- this.element = element; |
- this.width = this.boundingRect.width; |
- this.height = this.boundingRect.height; |
- |
- this.size = Math.max(this.width, this.height); |
+ 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) || ''; |
} |
- ElementMetrics.prototype = { |
- get boundingRect () { |
- return this.element.getBoundingClientRect(); |
- }, |
- |
- 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); |
+ 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) |
+ ); |
+ } |
- return Math.max(topLeft, topRight, bottomLeft, bottomRight); |
+ function parseKeyComboString(keyComboString) { |
+ if (keyComboString.length === 1) { |
+ return { |
+ combo: keyComboString, |
+ key: keyComboString, |
+ event: 'keydown' |
+ }; |
} |
- }; |
- |
- /** |
- * @param {HTMLElement} element |
- * @constructor |
- */ |
- function Ripple(element) { |
- this.element = element; |
- this.color = window.getComputedStyle(element).color; |
+ return keyComboString.split('+').reduce(function(parsedKeyCombo, keyComboPart) { |
+ var eventParts = keyComboPart.split(':'); |
+ var keyName = eventParts[0]; |
+ var event = eventParts[1]; |
- 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); |
+ if (keyName in MODIFIER_KEYS) { |
+ parsedKeyCombo[MODIFIER_KEYS[keyName]] = true; |
+ parsedKeyCombo.hasModifiers = true; |
+ } else { |
+ parsedKeyCombo.key = keyName; |
+ parsedKeyCombo.event = event || 'keydown'; |
+ } |
- this.resetInteractionState(); |
+ return parsedKeyCombo; |
+ }, { |
+ combo: keyComboString.split(':').shift() |
+ }); |
} |
- Ripple.MAX_RADIUS = 300; |
+ function parseEventString(eventString) { |
+ return eventString.trim().split(' ').map(function(keyComboString) { |
+ return parseKeyComboString(keyComboString); |
+ }); |
+ } |
- Ripple.prototype = { |
- get recenters() { |
- return this.element.recenters; |
- }, |
+ /** |
+ * `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 = { |
+ 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; |
+ } |
+ }, |
- get center() { |
- return this.element.center; |
- }, |
+ /** |
+ * If true, this property will cause the implementing element to |
+ * automatically stop propagation on any handled KeyboardEvents. |
+ */ |
+ stopKeyboardEventPropagation: { |
+ type: Boolean, |
+ value: false |
+ }, |
- get mouseDownElapsed() { |
- var elapsed; |
+ _boundKeyHandlers: { |
+ type: Array, |
+ value: function() { |
+ return []; |
+ } |
+ }, |
- if (!this.mouseDownStart) { |
- return 0; |
+ // 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 {}; |
+ } |
} |
+ }, |
- elapsed = Utility.now() - this.mouseDownStart; |
+ observers: [ |
+ '_resetKeyEventListeners(keyEventTarget, _boundKeyHandlers)' |
+ ], |
- if (this.mouseUpStart) { |
- elapsed -= this.mouseUpElapsed; |
- } |
- return elapsed; |
- }, |
+ /** |
+ * To be used to express what combination of keys will trigger the relative |
+ * callback. e.g. `keyBindings: { 'esc': '_onEscPressed'}` |
+ * @type {Object} |
+ */ |
+ keyBindings: {}, |
- get mouseUpElapsed() { |
- return this.mouseUpStart ? |
- Utility.now () - this.mouseUpStart : 0; |
+ registered: function() { |
+ this._prepKeyBindings(); |
}, |
- get mouseDownElapsedSeconds() { |
- return this.mouseDownElapsed / 1000; |
+ attached: function() { |
+ this._listenKeyEventListeners(); |
}, |
- get mouseUpElapsedSeconds() { |
- return this.mouseUpElapsed / 1000; |
+ detached: function() { |
+ this._unlistenKeyEventListeners(); |
}, |
- get mouseInteractionSeconds() { |
- return this.mouseDownElapsedSeconds + this.mouseUpElapsedSeconds; |
+ /** |
+ * 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(); |
}, |
- get initialOpacity() { |
- return this.element.initialOpacity; |
+ /** |
+ * When called, will remove all imperatively-added key bindings. |
+ */ |
+ removeOwnKeyBindings: function() { |
+ this._imperativeKeyBindings = {}; |
+ this._prepKeyBindings(); |
+ this._resetKeyEventListeners(); |
}, |
- get opacityDecayVelocity() { |
- return this.element.opacityDecayVelocity; |
+ /** |
+ * 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; |
+ } |
+ } |
+ return false; |
}, |
- 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; |
- |
- var duration = 1.1 - 0.2 * (waveRadius / Ripple.MAX_RADIUS); |
- var timeNow = this.mouseInteractionSeconds / duration; |
- var size = waveRadius * (1 - Math.pow(80, -timeNow)); |
- |
- return Math.abs(size); |
- }, |
+ _collectKeyBindings: function() { |
+ var keyBindings = this.behaviors.map(function(behavior) { |
+ return behavior.keyBindings; |
+ }); |
- get opacity() { |
- if (!this.mouseUpStart) { |
- return this.initialOpacity; |
+ if (keyBindings.indexOf(this.keyBindings) === -1) { |
+ keyBindings.push(this.keyBindings); |
} |
- return Math.max( |
- 0, |
- this.initialOpacity - this.mouseUpElapsedSeconds * this.opacityDecayVelocity |
- ); |
+ return keyBindings; |
}, |
- 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; |
+ _prepKeyBindings: function() { |
+ this._keyBindings = {}; |
- return Math.max( |
- 0, |
- Math.min(outerOpacity, waveOpacity) |
- ); |
- }, |
+ this._collectKeyBindings().forEach(function(keyBindings) { |
+ for (var eventString in keyBindings) { |
+ this._addKeyBinding(eventString, keyBindings[eventString]); |
+ } |
+ }, this); |
- get isOpacityFullyDecayed() { |
- return this.opacity < 0.01 && |
- this.radius >= Math.min(this.maxRadius, Ripple.MAX_RADIUS); |
- }, |
+ for (var eventString in this._imperativeKeyBindings) { |
+ this._addKeyBinding(eventString, this._imperativeKeyBindings[eventString]); |
+ } |
- get isRestingAtMaxRadius() { |
- return this.opacity >= this.initialOpacity && |
- this.radius >= Math.min(this.maxRadius, Ripple.MAX_RADIUS); |
+ // 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; |
+ }) |
+ } |
}, |
- get isAnimationComplete() { |
- return this.mouseUpStart ? |
- this.isOpacityFullyDecayed : this.isRestingAtMaxRadius; |
- }, |
+ _addKeyBinding: function(eventString, handlerName) { |
+ parseEventString(eventString).forEach(function(keyCombo) { |
+ this._keyBindings[keyCombo.event] = |
+ this._keyBindings[keyCombo.event] || []; |
- get translationFraction() { |
- return Math.min( |
- 1, |
- this.radius / this.containerMetrics.size * 2 / Math.sqrt(2) |
- ); |
+ this._keyBindings[keyCombo.event].push([ |
+ keyCombo, |
+ handlerName |
+ ]); |
+ }, this); |
}, |
- get xNow() { |
- if (this.xEnd) { |
- return this.xStart + this.translationFraction * (this.xEnd - this.xStart); |
- } |
- |
- return this.xStart; |
- }, |
+ _resetKeyEventListeners: function() { |
+ this._unlistenKeyEventListeners(); |
- get yNow() { |
- if (this.yEnd) { |
- return this.yStart + this.translationFraction * (this.yEnd - this.yStart); |
+ if (this.isAttached) { |
+ this._listenKeyEventListeners(); |
} |
- |
- return this.yStart; |
- }, |
- |
- get isMouseDown() { |
- return this.mouseDownStart && !this.mouseUpStart; |
}, |
- resetInteractionState: function() { |
- this.maxRadius = 0; |
- this.mouseDownStart = 0; |
- this.mouseUpStart = 0; |
+ _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.xStart = 0; |
- this.yStart = 0; |
- this.xEnd = 0; |
- this.yEnd = 0; |
- this.slideDistance = 0; |
+ this._boundKeyHandlers.push([this.keyEventTarget, eventName, boundKeyHandler]); |
- this.containerMetrics = new ElementMetrics(this.element); |
+ this.keyEventTarget.addEventListener(eventName, boundKeyHandler); |
+ }, this); |
}, |
- 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); |
+ _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]; |
- // 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)'; |
+ keyEventTarget.removeEventListener(eventName, boundKeyHandler); |
+ } |
}, |
- /** @param {Event=} event */ |
- downAction: function(event) { |
- var xCenter = this.containerMetrics.width / 2; |
- var yCenter = this.containerMetrics.height / 2; |
- |
- this.resetInteractionState(); |
- this.mouseDownStart = Utility.now(); |
+ _onKeyBindingEvent: function(keyBindings, event) { |
+ if (this.stopKeyboardEventPropagation) { |
+ event.stopPropagation(); |
+ } |
- 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 event has been already prevented, don't do anything |
+ if (event.defaultPrevented) { |
+ return; |
} |
- if (this.recenters) { |
- this.xEnd = xCenter; |
- this.yEnd = yCenter; |
- this.slideDistance = Utility.distance( |
- this.xStart, this.yStart, this.xEnd, this.yEnd |
- ); |
+ 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; |
+ } |
+ } |
} |
+ }, |
- this.maxRadius = this.containerMetrics.furthestCornerDistanceFrom( |
- this.xStart, |
- this.yStart |
- ); |
+ _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 |
+ */ |
+ Polymer.IronControlState = { |
- this.waveContainer.style.top = |
- (this.containerMetrics.height - this.containerMetrics.size) / 2 + 'px'; |
- this.waveContainer.style.left = |
- (this.containerMetrics.width - this.containerMetrics.size) / 2 + 'px'; |
+ properties: { |
- this.waveContainer.style.width = this.containerMetrics.size + 'px'; |
- this.waveContainer.style.height = this.containerMetrics.size + 'px'; |
+ /** |
+ * If true, the element currently has focus. |
+ */ |
+ focused: { |
+ type: Boolean, |
+ value: false, |
+ notify: true, |
+ readOnly: true, |
+ reflectToAttribute: true |
}, |
- /** @param {Event=} event */ |
- upAction: function(event) { |
- if (!this.isMouseDown) { |
- return; |
- } |
+ /** |
+ * If true, the user cannot interact with this element. |
+ */ |
+ disabled: { |
+ type: Boolean, |
+ value: false, |
+ notify: true, |
+ observer: '_disabledChanged', |
+ reflectToAttribute: true |
+ }, |
- this.mouseUpStart = Utility.now(); |
+ _oldTabIndex: { |
+ type: Number |
}, |
- remove: function() { |
- Polymer.dom(this.waveContainer.parentNode).removeChild( |
- this.waveContainer |
- ); |
+ _boundFocusBlurHandler: { |
+ type: Function, |
+ value: function() { |
+ return this._focusBlurHandler.bind(this); |
+ } |
} |
- }; |
- Polymer({ |
- is: 'paper-ripple', |
+ }, |
- behaviors: [ |
- Polymer.IronA11yKeysBehavior |
- ], |
+ observers: [ |
+ '_changedControlState(focused, disabled)' |
+ ], |
- properties: { |
- /** |
- * The initial opacity set on the wave. |
- * |
- * @attribute initialOpacity |
- * @type number |
- * @default 0.25 |
- */ |
- initialOpacity: { |
- type: Number, |
- value: 0.25 |
- }, |
+ ready: function() { |
+ this.addEventListener('focus', this._boundFocusBlurHandler, true); |
+ this.addEventListener('blur', this._boundFocusBlurHandler, true); |
+ }, |
- /** |
- * How fast (opacity per second) the wave fades out. |
- * |
- * @attribute opacityDecayVelocity |
- * @type number |
- * @default 0.8 |
- */ |
- opacityDecayVelocity: { |
- type: Number, |
- value: 0.8 |
- }, |
- |
- /** |
- * If true, ripples will exhibit a gravitational pull towards |
- * the center of their container as they fade away. |
- * |
- * @attribute recenters |
- * @type boolean |
- * @default false |
- */ |
- recenters: { |
- type: Boolean, |
- value: false |
- }, |
- |
- /** |
- * If true, ripples will center inside its container |
- * |
- * @attribute recenters |
- * @type boolean |
- * @default false |
- */ |
- center: { |
- type: Boolean, |
- value: false |
- }, |
- |
- /** |
- * A list of the visual ripples. |
- * |
- * @attribute ripples |
- * @type Array |
- * @default [] |
- */ |
- ripples: { |
- type: Array, |
- value: function() { |
- return []; |
- } |
- }, |
- |
- /** |
- * True when there are visible ripples animating within the |
- * element. |
- */ |
- animating: { |
- type: Boolean, |
- readOnly: true, |
- reflectToAttribute: true, |
- value: false |
- }, |
- |
- /** |
- * If true, the ripple will remain in the "down" state until `holdDown` |
- * is set to false again. |
- */ |
- holdDown: { |
- type: Boolean, |
- value: false, |
- observer: '_holdDownChanged' |
- }, |
- |
- /** |
- * 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 |
- }, |
- |
- _animating: { |
- type: Boolean |
- }, |
+ _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`. |
- _boundAnimate: { |
- type: Function, |
- value: function() { |
- return this.animate.bind(this); |
- } |
+ 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 target () { |
- return this.keyEventTarget; |
- }, |
+ } |
+ }, |
- keyBindings: { |
- 'enter:keydown': '_onEnterKeydown', |
- 'space:keydown': '_onSpaceKeydown', |
- 'space:keyup': '_onSpaceKeyup' |
- }, |
+ _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; |
+ } |
+ }, |
- 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; |
- } |
- var keyEventTarget = /** @type {!EventTarget} */ (this.keyEventTarget); |
- this.listen(keyEventTarget, 'up', 'uiUpAction'); |
- this.listen(keyEventTarget, 'down', 'uiDownAction'); |
- }, |
+ _changedControlState: function() { |
+ // _controlStateChanged is abstract, follow-on behaviors may implement it |
+ if (this._controlStateChanged) { |
+ this._controlStateChanged(); |
+ } |
+ } |
- detached: function() { |
- this.unlisten(this.keyEventTarget, 'up', 'uiUpAction'); |
- this.unlisten(this.keyEventTarget, 'down', 'uiDownAction'); |
- this.keyEventTarget = null; |
- }, |
+ }; |
+/** |
+ * @demo demo/index.html |
+ * @polymerBehavior Polymer.IronButtonState |
+ */ |
+ Polymer.IronButtonStateImpl = { |
- get shouldKeepAnimating () { |
- for (var index = 0; index < this.ripples.length; ++index) { |
- if (!this.ripples[index].isAnimationComplete) { |
- return true; |
- } |
- } |
+ properties: { |
- return false; |
+ /** |
+ * If true, the user is currently holding down the button. |
+ */ |
+ pressed: { |
+ type: Boolean, |
+ readOnly: true, |
+ value: false, |
+ reflectToAttribute: true, |
+ observer: '_pressedChanged' |
}, |
- simulatedRipple: function() { |
- this.downAction(null); |
- |
- // Please see polymer/polymer#1305 |
- this.async(function() { |
- this.upAction(); |
- }, 1); |
+ /** |
+ * If true, the button toggles the active state with each tap or press |
+ * of the spacebar. |
+ */ |
+ toggles: { |
+ type: Boolean, |
+ value: false, |
+ reflectToAttribute: true |
}, |
/** |
- * Provokes a ripple down effect via a UI event, |
- * respecting the `noink` property. |
- * @param {Event=} event |
+ * If true, the button is a toggle and is currently in the active state. |
*/ |
- uiDownAction: function(event) { |
- if (!this.noink) { |
- this.downAction(event); |
- } |
+ active: { |
+ type: Boolean, |
+ value: false, |
+ notify: true, |
+ reflectToAttribute: true |
}, |
/** |
- * Provokes a ripple down effect via a UI event, |
- * *not* respecting the `noink` property. |
- * @param {Event=} 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). |
*/ |
- 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(); |
- } |
+ pointerDown: { |
+ type: Boolean, |
+ readOnly: true, |
+ value: false |
}, |
/** |
- * Provokes a ripple up effect via a UI event, |
- * respecting the `noink` property. |
- * @param {Event=} event |
+ * True if the input device that caused the element to receive focus |
+ * was a keyboard. |
*/ |
- uiUpAction: function(event) { |
- if (!this.noink) { |
- this.upAction(event); |
- } |
+ receivedFocusFromKeyboard: { |
+ type: Boolean, |
+ readOnly: true |
}, |
/** |
- * Provokes a ripple up effect via a UI event, |
- * *not* respecting the `noink` property. |
- * @param {Event=} event |
+ * The aria attribute to be set if the button is a toggle and in the |
+ * active state. |
*/ |
- upAction: function(event) { |
- if (this.holdDown) { |
- return; |
- } |
+ ariaActiveAttribute: { |
+ type: String, |
+ value: 'aria-pressed', |
+ observer: '_ariaActiveAttributeChanged' |
+ } |
+ }, |
- this.ripples.forEach(function(ripple) { |
- ripple.upAction(event); |
- }); |
+ listeners: { |
+ down: '_downHandler', |
+ up: '_upHandler', |
+ tap: '_tapHandler' |
+ }, |
- this._animating = true; |
- this.animate(); |
- }, |
+ observers: [ |
+ '_detectKeyboardFocus(focused)', |
+ '_activeChanged(active, ariaActiveAttribute)' |
+ ], |
- onAnimationComplete: function() { |
- this._animating = false; |
- this.$.background.style.backgroundColor = null; |
- this.fire('transitionend'); |
- }, |
+ keyBindings: { |
+ 'enter:keydown': '_asyncClick', |
+ 'space:keydown': '_spaceKeyDownHandler', |
+ 'space:keyup': '_spaceKeyUpHandler', |
+ }, |
- addRipple: function() { |
- var ripple = new Ripple(this); |
+ _mouseEventRe: /^mouse/, |
- Polymer.dom(this.$.waves).appendChild(ripple.waveContainer); |
- this.$.background.style.backgroundColor = ripple.color; |
- this.ripples.push(ripple); |
+ _tapHandler: function() { |
+ if (this.toggles) { |
+ // a tap is needed to toggle the active state |
+ this._userActivate(!this.active); |
+ } else { |
+ this.active = false; |
+ } |
+ }, |
- this._setAnimating(true); |
+ _detectKeyboardFocus: function(focused) { |
+ this._setReceivedFocusFromKeyboard(!this.pointerDown && focused); |
+ }, |
- return ripple; |
- }, |
+ // 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'); |
+ } |
+ }, |
- removeRipple: function(ripple) { |
- var rippleIndex = this.ripples.indexOf(ripple); |
+ _downHandler: function(event) { |
+ this._setPointerDown(true); |
+ this._setPressed(true); |
+ this._setReceivedFocusFromKeyboard(false); |
+ }, |
- if (rippleIndex < 0) { |
- return; |
- } |
+ _upHandler: function() { |
+ this._setPointerDown(false); |
+ this._setPressed(false); |
+ }, |
- this.ripples.splice(rippleIndex, 1); |
+ /** |
+ * @param {!KeyboardEvent} event . |
+ */ |
+ _spaceKeyDownHandler: function(event) { |
+ var keyboardEvent = event.detail.keyboardEvent; |
+ var target = Polymer.dom(keyboardEvent).localTarget; |
- ripple.remove(); |
+ // 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.ripples.length) { |
- this._setAnimating(false); |
- } |
- }, |
+ keyboardEvent.preventDefault(); |
+ keyboardEvent.stopImmediatePropagation(); |
+ this._setPressed(true); |
+ }, |
- animate: function() { |
- if (!this._animating) { |
- return; |
- } |
- var index; |
- var ripple; |
+ /** |
+ * @param {!KeyboardEvent} event . |
+ */ |
+ _spaceKeyUpHandler: 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(); |
+ if (this.pressed) { |
+ this._asyncClick(); |
+ } |
+ this._setPressed(false); |
+ }, |
- this.$.background.style.opacity = ripple.outerOpacity; |
+ // 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); |
+ }, |
- if (ripple.isOpacityFullyDecayed && !ripple.isRestingAtMaxRadius) { |
- this.removeRipple(ripple); |
- } |
- } |
+ // any of these changes are considered a change to button state |
- if (!this.shouldKeepAnimating && this.ripples.length === 0) { |
- this.onAnimationComplete(); |
- } else { |
- window.requestAnimationFrame(this._boundAnimate); |
- } |
- }, |
+ _pressedChanged: function(pressed) { |
+ this._changedButtonState(); |
+ }, |
- _onEnterKeydown: function() { |
- this.uiDownAction(); |
- this.async(this.uiUpAction, 1); |
- }, |
+ _ariaActiveAttributeChanged: function(value, oldValue) { |
+ if (oldValue && oldValue != value && this.hasAttribute(oldValue)) { |
+ this.removeAttribute(oldValue); |
+ } |
+ }, |
- _onSpaceKeydown: function() { |
- this.uiDownAction(); |
- }, |
+ _activeChanged: function(active, ariaActiveAttribute) { |
+ if (this.toggles) { |
+ this.setAttribute(this.ariaActiveAttribute, |
+ active ? 'true' : 'false'); |
+ } else { |
+ this.removeAttribute(this.ariaActiveAttribute); |
+ } |
+ this._changedButtonState(); |
+ }, |
- _onSpaceKeyup: function() { |
- this.uiUpAction(); |
- }, |
+ _controlStateChanged: function() { |
+ if (this.disabled) { |
+ this._setPressed(false); |
+ } else { |
+ this._changedButtonState(); |
+ } |
+ }, |
- // 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(); |
- } |
+ // provide hook for follow-on behaviors to react to button-state |
+ |
+ _changedButtonState: function() { |
+ if (this._buttonStateChanged) { |
+ this._buttonStateChanged(); // abstract |
} |
+ } |
- /** |
- 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 = { |
- properties: { |
- /** |
- * If true, the element will not produce a ripple effect when interacted |
- * with via the pointer. |
- */ |
- noink: { |
- type: Boolean, |
- observer: '_noinkChanged' |
+ /** @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); |
}, |
- /** |
- * @type {Element|undefined} |
- */ |
- _rippleContainer: { |
- type: Object, |
- } |
- }, |
+ now: window.performance && window.performance.now ? |
+ window.performance.now.bind(window.performance) : Date.now |
+ }; |
/** |
- * Ensures a `<paper-ripple>` element is available when the element is |
- * focused. |
+ * @param {HTMLElement} element |
+ * @constructor |
*/ |
- _buttonStateChanged: function() { |
- if (this.focused) { |
- this.ensureRipple(); |
- } |
- }, |
+ function ElementMetrics(element) { |
+ this.element = element; |
+ this.width = this.boundingRect.width; |
+ this.height = this.boundingRect.height; |
- /** |
- * 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.size = Math.max(this.width, this.height); |
+ } |
- /** |
- * 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); |
- } |
- } |
- } |
- }, |
+ ElementMetrics.prototype = { |
+ get boundingRect () { |
+ return this.element.getBoundingClientRect(); |
+ }, |
- /** |
- * 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; |
- }, |
+ 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); |
- /** |
- * Returns true if this element currently contains a ripple effect. |
- * @return {boolean} |
- */ |
- hasRipple: function() { |
- return Boolean(this._ripple); |
- }, |
+ return Math.max(topLeft, topRight, bottomLeft, bottomRight); |
+ } |
+ }; |
/** |
- * 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. |
+ * @param {HTMLElement} element |
+ * @constructor |
*/ |
- _createRipple: function() { |
- return /** @type {!PaperRippleElement} */ ( |
- document.createElement('paper-ripple')); |
- }, |
- |
- _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 |
- } |
- }, |
+ function Ripple(element) { |
+ this.element = element; |
+ this.color = window.getComputedStyle(element).color; |
- observers: [ |
- '_calculateElevation(focused, disabled, active, pressed, receivedFocusFromKeyboard)', |
- '_computeKeyboardClass(receivedFocusFromKeyboard)' |
- ], |
+ 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); |
- hostAttributes: { |
- role: 'button', |
- tabindex: '0', |
- animated: true |
- }, |
+ this.resetInteractionState(); |
+ } |
- _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); |
- }, |
+ Ripple.MAX_RADIUS = 300; |
- _computeKeyboardClass: function(receivedFocusFromKeyboard) { |
- this.toggleClass('keyboard-focus', receivedFocusFromKeyboard); |
- }, |
+ Ripple.prototype = { |
+ get recenters() { |
+ return this.element.recenters; |
+ }, |
- /** |
- * In addition to `IronButtonState` behavior, when space key goes down, |
- * create a ripple down effect. |
- * |
- * @param {!KeyboardEvent} event . |
- */ |
- _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(); |
- } |
- }, |
+ get center() { |
+ return this.element.center; |
+ }, |
- /** |
- * 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(); |
- } |
- } |
- }; |
+ get mouseDownElapsed() { |
+ var elapsed; |
- /** @polymerBehavior */ |
- Polymer.PaperButtonBehavior = [ |
- Polymer.IronButtonState, |
- Polymer.IronControlState, |
- Polymer.PaperRippleBehavior, |
- Polymer.PaperButtonBehaviorImpl |
- ]; |
-Polymer({ |
- is: 'paper-button', |
+ if (!this.mouseDownStart) { |
+ return 0; |
+ } |
- behaviors: [ |
- Polymer.PaperButtonBehavior |
- ], |
+ elapsed = Utility.now() - this.mouseDownStart; |
- properties: { |
- /** |
- * If true, the button should be styled with a shadow. |
- */ |
- raised: { |
- type: Boolean, |
- reflectToAttribute: true, |
- value: false, |
- observer: '_calculateElevation' |
+ if (this.mouseUpStart) { |
+ elapsed -= this.mouseUpElapsed; |
} |
+ |
+ return elapsed; |
}, |
- _calculateElevation: function() { |
- if (!this.raised) { |
- this._setElevation(0); |
- } else { |
- Polymer.PaperButtonBehaviorImpl._calculateElevation.apply(this); |
- } |
- } |
+ get mouseUpElapsed() { |
+ return this.mouseUpStart ? |
+ Utility.now () - this.mouseUpStart : 0; |
+ }, |
- /** |
- Fired when the animation finishes. |
- This is useful if you want to wait until |
- the ripple animation finishes to perform some action. |
+ get mouseDownElapsedSeconds() { |
+ return this.mouseDownElapsed / 1000; |
+ }, |
- @event transitionend |
- Event param: {{node: Object}} detail Contains the animated node. |
- */ |
- }); |
-Polymer({ |
- is: 'paper-icon-button-light', |
- extends: 'button', |
+ get mouseUpElapsedSeconds() { |
+ return this.mouseUpElapsed / 1000; |
+ }, |
- behaviors: [ |
- Polymer.PaperRippleBehavior |
- ], |
+ get mouseInteractionSeconds() { |
+ return this.mouseDownElapsedSeconds + this.mouseUpElapsedSeconds; |
+ }, |
- listeners: { |
- 'down': '_rippleDown', |
- 'up': '_rippleUp', |
- 'focus': '_rippleDown', |
- 'blur': '_rippleUp', |
+ get initialOpacity() { |
+ return this.element.initialOpacity; |
}, |
- _rippleDown: function() { |
- this.getRipple().downAction(); |
+ get opacityDecayVelocity() { |
+ return this.element.opacityDecayVelocity; |
}, |
- _rippleUp: function() { |
- this.getRipple().upAction(); |
+ 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; |
+ |
+ var duration = 1.1 - 0.2 * (waveRadius / Ripple.MAX_RADIUS); |
+ var timeNow = this.mouseInteractionSeconds / duration; |
+ var size = waveRadius * (1 - Math.pow(80, -timeNow)); |
+ |
+ return Math.abs(size); |
}, |
- /** |
- * @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'); |
+ get opacity() { |
+ if (!this.mouseUpStart) { |
+ return this.initialOpacity; |
} |
- } |
- }); |
-/** |
- * `iron-range-behavior` provides the behavior for something with a minimum to maximum range. |
- * |
- * @demo demo/index.html |
- * @polymerBehavior |
- */ |
- Polymer.IronRangeBehavior = { |
- properties: { |
+ return Math.max( |
+ 0, |
+ this.initialOpacity - this.mouseUpElapsedSeconds * this.opacityDecayVelocity |
+ ); |
+ }, |
- /** |
- * The number that represents the current value. |
- */ |
- value: { |
- type: Number, |
- value: 0, |
- notify: true, |
- reflectToAttribute: true |
- }, |
+ 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; |
- /** |
- * The number that indicates the minimum value of the range. |
- */ |
- min: { |
- type: Number, |
- value: 0, |
- notify: true |
- }, |
+ return Math.max( |
+ 0, |
+ Math.min(outerOpacity, waveOpacity) |
+ ); |
+ }, |
- /** |
- * The number that indicates the maximum value of the range. |
- */ |
- max: { |
- type: Number, |
- value: 100, |
- notify: true |
- }, |
+ get isOpacityFullyDecayed() { |
+ return this.opacity < 0.01 && |
+ this.radius >= Math.min(this.maxRadius, Ripple.MAX_RADIUS); |
+ }, |
- /** |
- * Specifies the value granularity of the range's value. |
- */ |
- step: { |
- type: Number, |
- value: 1, |
- notify: true |
- }, |
+ get isRestingAtMaxRadius() { |
+ return this.opacity >= this.initialOpacity && |
+ this.radius >= Math.min(this.maxRadius, Ripple.MAX_RADIUS); |
+ }, |
- /** |
- * Returns the ratio of the value. |
- */ |
- ratio: { |
- type: Number, |
- value: 0, |
- readOnly: true, |
- notify: true |
- }, |
- }, |
+ get isAnimationComplete() { |
+ return this.mouseUpStart ? |
+ this.isOpacityFullyDecayed : this.isRestingAtMaxRadius; |
+ }, |
- observers: [ |
- '_update(value, min, max, step)' |
- ], |
+ get translationFraction() { |
+ return Math.min( |
+ 1, |
+ this.radius / this.containerMetrics.size * 2 / Math.sqrt(2) |
+ ); |
+ }, |
- _calcRatio: function(value) { |
- return (this._clampValue(value) - this.min) / (this.max - this.min); |
- }, |
+ get xNow() { |
+ if (this.xEnd) { |
+ return this.xStart + this.translationFraction * (this.xEnd - this.xStart); |
+ } |
- _clampValue: function(value) { |
- return Math.min(this.max, Math.max(this.min, this._calcStep(value))); |
- }, |
+ return this.xStart; |
+ }, |
- _calcStep: function(value) { |
- // polymer/issues/2493 |
- value = parseFloat(value); |
+ get yNow() { |
+ if (this.yEnd) { |
+ return this.yStart + this.translationFraction * (this.yEnd - this.yStart); |
+ } |
- if (!this.step) { |
- return value; |
- } |
+ return this.yStart; |
+ }, |
- 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; |
- } |
- }, |
+ get isMouseDown() { |
+ return this.mouseDownStart && !this.mouseUpStart; |
+ }, |
- _validateValue: function() { |
- var v = this._clampValue(this.value); |
- this.value = this.oldValue = isNaN(v) ? this.oldValue : v; |
- return this.value !== v; |
- }, |
+ resetInteractionState: function() { |
+ this.maxRadius = 0; |
+ this.mouseDownStart = 0; |
+ this.mouseUpStart = 0; |
- _update: function() { |
- this._validateValue(); |
- this._setRatio(this._calcRatio(this.value) * 100); |
- } |
+ this.xStart = 0; |
+ this.yStart = 0; |
+ this.xEnd = 0; |
+ this.yEnd = 0; |
+ this.slideDistance = 0; |
-}; |
-Polymer({ |
- is: 'paper-progress', |
+ this.containerMetrics = new ElementMetrics(this.element); |
+ }, |
- behaviors: [ |
- Polymer.IronRangeBehavior |
- ], |
+ draw: function() { |
+ var scale; |
+ var translateString; |
+ var dx; |
+ var dy; |
- properties: { |
- /** |
- * The number that represents the current secondary progress. |
- */ |
- secondaryProgress: { |
- type: Number, |
- value: 0 |
- }, |
+ this.wave.style.opacity = this.opacity; |
- /** |
- * The secondary ratio |
- */ |
- secondaryRatio: { |
- type: Number, |
- value: 0, |
- readOnly: true |
- }, |
+ scale = this.radius / (this.containerMetrics.size / 2); |
+ dx = this.xNow - (this.containerMetrics.width / 2); |
+ dy = this.yNow - (this.containerMetrics.height / 2); |
- /** |
- * Use an indeterminate progress indicator. |
- */ |
- indeterminate: { |
- type: Boolean, |
- value: false, |
- observer: '_toggleIndeterminate' |
+ |
+ // 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)'; |
}, |
- /** |
- * True if the progress is disabled. |
- */ |
- disabled: { |
- type: Boolean, |
- value: false, |
- reflectToAttribute: true, |
- observer: '_disabledChanged' |
- } |
- }, |
+ /** @param {Event=} event */ |
+ downAction: function(event) { |
+ var xCenter = this.containerMetrics.width / 2; |
+ var yCenter = this.containerMetrics.height / 2; |
- observers: [ |
- '_progressChanged(secondaryProgress, value, min, max)' |
- ], |
+ this.resetInteractionState(); |
+ this.mouseDownStart = Utility.now(); |
- hostAttributes: { |
- role: 'progressbar' |
- }, |
+ 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; |
+ } |
- _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); |
- }, |
+ if (this.recenters) { |
+ this.xEnd = xCenter; |
+ this.yEnd = yCenter; |
+ this.slideDistance = Utility.distance( |
+ this.xStart, this.yStart, this.xEnd, this.yEnd |
+ ); |
+ } |
- _transformProgress: function(progress, ratio) { |
- var transform = 'scaleX(' + (ratio / 100) + ')'; |
- progress.style.transform = progress.style.webkitTransform = transform; |
- }, |
+ this.maxRadius = this.containerMetrics.furthestCornerDistanceFrom( |
+ this.xStart, |
+ this.yStart |
+ ); |
- _mainRatioChanged: function(ratio) { |
- this._transformProgress(this.$.primaryProgress, ratio); |
- }, |
+ this.waveContainer.style.top = |
+ (this.containerMetrics.height - this.containerMetrics.size) / 2 + 'px'; |
+ this.waveContainer.style.left = |
+ (this.containerMetrics.width - this.containerMetrics.size) / 2 + 'px'; |
- _progressChanged: function(secondaryProgress, value, min, max) { |
- secondaryProgress = this._clampValue(secondaryProgress); |
- value = this._clampValue(value); |
+ this.waveContainer.style.width = this.containerMetrics.size + 'px'; |
+ this.waveContainer.style.height = this.containerMetrics.size + 'px'; |
+ }, |
- var secondaryRatio = this._calcRatio(secondaryProgress) * 100; |
- var mainRatio = this._calcRatio(value) * 100; |
+ /** @param {Event=} event */ |
+ upAction: function(event) { |
+ if (!this.isMouseDown) { |
+ return; |
+ } |
- this._setSecondaryRatio(secondaryRatio); |
- this._transformProgress(this.$.secondaryProgress, secondaryRatio); |
- this._transformProgress(this.$.primaryProgress, mainRatio); |
+ this.mouseUpStart = Utility.now(); |
+ }, |
- this.secondaryProgress = secondaryProgress; |
+ remove: function() { |
+ Polymer.dom(this.waveContainer.parentNode).removeChild( |
+ this.waveContainer |
+ ); |
+ } |
+ }; |
- this.setAttribute('aria-valuenow', value); |
- this.setAttribute('aria-valuemin', min); |
- this.setAttribute('aria-valuemax', max); |
- }, |
+ Polymer({ |
+ is: 'paper-ripple', |
- _disabledChanged: function(disabled) { |
- this.setAttribute('aria-disabled', disabled ? 'true' : 'false'); |
- }, |
+ behaviors: [ |
+ Polymer.IronA11yKeysBehavior |
+ ], |
- _hideSecondaryProgress: function(secondaryRatio) { |
- return secondaryRatio === 0; |
- } |
- }); |
-/** |
- * The `iron-iconset-svg` element allows users to define their own icon sets |
- * that contain svg icons. The svg icon elements should be children of the |
- * `iron-iconset-svg` element. Multiple icons should be given distinct id's. |
- * |
- * Using svg elements to create icons has a few advantages over traditional |
- * bitmap graphics like jpg or png. Icons that use svg are vector based so |
- * they are resolution independent and should look good on any device. They |
- * are stylable via css. Icons can be themed, colorized, and even animated. |
- * |
- * Example: |
- * |
- * <iron-iconset-svg name="my-svg-icons" size="24"> |
- * <svg> |
- * <defs> |
- * <g id="shape"> |
- * <rect x="12" y="0" width="12" height="24" /> |
- * <circle cx="12" cy="12" r="12" /> |
- * </g> |
- * </defs> |
- * </svg> |
- * </iron-iconset-svg> |
- * |
- * This will automatically register the icon set "my-svg-icons" to the iconset |
- * database. To use these icons from within another element, make a |
- * `iron-iconset` element and call the `byId` method |
- * to retrieve a given iconset. To apply a particular icon inside an |
- * element use the `applyIcon` method. For example: |
- * |
- * iconset.applyIcon(iconNode, 'car'); |
- * |
- * @element iron-iconset-svg |
- * @demo demo/index.html |
- * @implements {Polymer.Iconset} |
- */ |
- Polymer({ |
- is: 'iron-iconset-svg', |
- |
- properties: { |
- |
- /** |
- * The name of the iconset. |
- */ |
- name: { |
- type: String, |
- observer: '_nameChanged' |
- }, |
- |
- /** |
- * The size of an individual icon. Note that icons must be square. |
- */ |
- size: { |
- type: Number, |
- value: 24 |
- } |
- |
- }, |
- |
- attached: function() { |
- this.style.display = 'none'; |
- }, |
- |
- /** |
- * Construct an array of all icon names in this iconset. |
- * |
- * @return {!Array} Array of icon names. |
- */ |
- getIconNames: function() { |
- this._icons = this._createIconMap(); |
- return Object.keys(this._icons).map(function(n) { |
- return this.name + ':' + n; |
- }, this); |
- }, |
- |
- /** |
- * Applies an icon to the given element. |
- * |
- * An svg icon is prepended to the element's shadowRoot if it exists, |
- * otherwise to the element itself. |
- * |
- * @method applyIcon |
- * @param {Element} element Element to which the icon is applied. |
- * @param {string} iconName Name of the icon to apply. |
- * @return {?Element} The svg element which renders the icon. |
- */ |
- applyIcon: function(element, iconName) { |
- // insert svg element into shadow root, if it exists |
- element = element.root || element; |
- // Remove old svg element |
- this.removeIcon(element); |
- // install new svg element |
- var svg = this._cloneIcon(iconName); |
- if (svg) { |
- var pde = Polymer.dom(element); |
- pde.insertBefore(svg, pde.childNodes[0]); |
- return element._svgIcon = svg; |
- } |
- return null; |
- }, |
+ properties: { |
+ /** |
+ * The initial opacity set on the wave. |
+ * |
+ * @attribute initialOpacity |
+ * @type number |
+ * @default 0.25 |
+ */ |
+ initialOpacity: { |
+ type: Number, |
+ value: 0.25 |
+ }, |
- /** |
- * Remove an icon from the given element by undoing the changes effected |
- * by `applyIcon`. |
- * |
- * @param {Element} element The element from which the icon is removed. |
- */ |
- removeIcon: function(element) { |
- // Remove old svg element |
- if (element._svgIcon) { |
- Polymer.dom(element).removeChild(element._svgIcon); |
- element._svgIcon = null; |
- } |
- }, |
+ /** |
+ * How fast (opacity per second) the wave fades out. |
+ * |
+ * @attribute opacityDecayVelocity |
+ * @type number |
+ * @default 0.8 |
+ */ |
+ opacityDecayVelocity: { |
+ type: Number, |
+ value: 0.8 |
+ }, |
- /** |
- * |
- * When name is changed, register iconset metadata |
- * |
- */ |
- _nameChanged: function() { |
- new Polymer.IronMeta({type: 'iconset', key: this.name, value: this}); |
- this.async(function() { |
- this.fire('iron-iconset-added', this, {node: window}); |
- }); |
- }, |
+ /** |
+ * If true, ripples will exhibit a gravitational pull towards |
+ * the center of their container as they fade away. |
+ * |
+ * @attribute recenters |
+ * @type boolean |
+ * @default false |
+ */ |
+ recenters: { |
+ type: Boolean, |
+ value: false |
+ }, |
- /** |
- * Create a map of child SVG elements by id. |
- * |
- * @return {!Object} Map of id's to SVG elements. |
- */ |
- _createIconMap: function() { |
- // Objects chained to Object.prototype (`{}`) have members. Specifically, |
- // on FF there is a `watch` method that confuses the icon map, so we |
- // need to use a null-based object here. |
- var icons = Object.create(null); |
- Polymer.dom(this).querySelectorAll('[id]') |
- .forEach(function(icon) { |
- icons[icon.id] = icon; |
- }); |
- return icons; |
- }, |
+ /** |
+ * If true, ripples will center inside its container |
+ * |
+ * @attribute recenters |
+ * @type boolean |
+ * @default false |
+ */ |
+ center: { |
+ type: Boolean, |
+ value: false |
+ }, |
- /** |
- * Produce installable clone of the SVG element matching `id` in this |
- * iconset, or `undefined` if there is no matching element. |
- * |
- * @return {Element} Returns an installable clone of the SVG element |
- * matching `id`. |
- */ |
- _cloneIcon: function(id) { |
- // create the icon map on-demand, since the iconset itself has no discrete |
- // signal to know when it's children are fully parsed |
- this._icons = this._icons || this._createIconMap(); |
- return this._prepareSvgClone(this._icons[id], this.size); |
- }, |
+ /** |
+ * A list of the visual ripples. |
+ * |
+ * @attribute ripples |
+ * @type Array |
+ * @default [] |
+ */ |
+ ripples: { |
+ type: Array, |
+ value: function() { |
+ return []; |
+ } |
+ }, |
- /** |
- * @param {Element} sourceSvg |
- * @param {number} size |
- * @return {Element} |
- */ |
- _prepareSvgClone: function(sourceSvg, size) { |
- if (sourceSvg) { |
- var content = sourceSvg.cloneNode(true), |
- svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), |
- viewBox = content.getAttribute('viewBox') || '0 0 ' + size + ' ' + size; |
- svg.setAttribute('viewBox', viewBox); |
- svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); |
- // TODO(dfreedm): `pointer-events: none` works around https://crbug.com/370136 |
- // TODO(sjmiles): inline style may not be ideal, but avoids requiring a shadow-root |
- svg.style.cssText = 'pointer-events: none; display: block; width: 100%; height: 100%;'; |
- svg.appendChild(content).removeAttribute('id'); |
- return svg; |
- } |
- return null; |
- } |
+ /** |
+ * True when there are visible ripples animating within the |
+ * element. |
+ */ |
+ animating: { |
+ type: Boolean, |
+ readOnly: true, |
+ reflectToAttribute: true, |
+ value: false |
+ }, |
- }); |
-// 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. |
+ /** |
+ * If true, the ripple will remain in the "down" state until `holdDown` |
+ * is set to false again. |
+ */ |
+ holdDown: { |
+ type: Boolean, |
+ value: false, |
+ observer: '_holdDownChanged' |
+ }, |
-cr.define('downloads', function() { |
- var Item = Polymer({ |
- is: 'downloads-item', |
+ /** |
+ * 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: { |
- data: { |
- type: Object, |
- }, |
+ _animating: { |
+ type: Boolean |
+ }, |
- completelyOnDisk_: { |
- computed: 'computeCompletelyOnDisk_(' + |
- 'data.state, data.file_externally_removed)', |
- type: Boolean, |
- value: true, |
+ _boundAnimate: { |
+ type: Function, |
+ value: function() { |
+ return this.animate.bind(this); |
+ } |
+ } |
}, |
- controlledBy_: { |
- computed: 'computeControlledBy_(data.by_ext_id, data.by_ext_name)', |
- type: String, |
- value: '', |
+ get target () { |
+ return this.keyEventTarget; |
}, |
- isActive_: { |
- computed: 'computeIsActive_(' + |
- 'data.state, data.file_externally_removed)', |
- type: Boolean, |
- value: true, |
+ keyBindings: { |
+ 'enter:keydown': '_onEnterKeydown', |
+ 'space:keydown': '_onSpaceKeydown', |
+ 'space:keyup': '_onSpaceKeyup' |
}, |
- isDangerous_: { |
- computed: 'computeIsDangerous_(data.state)', |
- type: Boolean, |
- value: false, |
+ 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; |
+ } |
+ var keyEventTarget = /** @type {!EventTarget} */ (this.keyEventTarget); |
+ this.listen(keyEventTarget, 'up', 'uiUpAction'); |
+ this.listen(keyEventTarget, 'down', 'uiDownAction'); |
}, |
- isMalware_: { |
- computed: 'computeIsMalware_(isDangerous_, data.danger_type)', |
- type: Boolean, |
- value: false, |
+ detached: function() { |
+ this.unlisten(this.keyEventTarget, 'up', 'uiUpAction'); |
+ this.unlisten(this.keyEventTarget, 'down', 'uiDownAction'); |
+ this.keyEventTarget = null; |
}, |
- isInProgress_: { |
- computed: 'computeIsInProgress_(data.state)', |
- type: Boolean, |
- value: false, |
- }, |
+ get shouldKeepAnimating () { |
+ for (var index = 0; index < this.ripples.length; ++index) { |
+ if (!this.ripples[index].isAnimationComplete) { |
+ return true; |
+ } |
+ } |
- pauseOrResumeText_: { |
- computed: 'computePauseOrResumeText_(isInProgress_, data.resume)', |
- type: String, |
+ return false; |
}, |
- showCancel_: { |
- computed: 'computeShowCancel_(data.state)', |
- type: Boolean, |
- value: false, |
- }, |
+ simulatedRipple: function() { |
+ this.downAction(null); |
- showProgress_: { |
- computed: 'computeShowProgress_(showCancel_, data.percent)', |
- type: Boolean, |
- value: false, |
+ // Please see polymer/polymer#1305 |
+ this.async(function() { |
+ this.upAction(); |
+ }, 1); |
}, |
- }, |
- 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)', |
- ], |
+ /** |
+ * 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); |
+ } |
+ }, |
- ready: function() { |
- this.content = this.$.content; |
- }, |
+ /** |
+ * 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; |
+ } |
- /** @private */ |
- computeClass_: function() { |
- var classes = []; |
+ var ripple = this.addRipple(); |
- if (this.isActive_) |
- classes.push('is-active'); |
+ ripple.downAction(event); |
- if (this.isDangerous_) |
- classes.push('dangerous'); |
+ if (!this._animating) { |
+ this._animating = true; |
+ this.animate(); |
+ } |
+ }, |
- if (this.showProgress_) |
- classes.push('show-progress'); |
+ /** |
+ * 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); |
+ } |
+ }, |
- return classes.join(' '); |
- }, |
+ /** |
+ * Provokes a ripple up effect via a UI event, |
+ * *not* respecting the `noink` property. |
+ * @param {Event=} event |
+ */ |
+ upAction: function(event) { |
+ if (this.holdDown) { |
+ return; |
+ } |
- /** @private */ |
- computeCompletelyOnDisk_: function() { |
- return this.data.state == downloads.States.COMPLETE && |
- !this.data.file_externally_removed; |
- }, |
+ this.ripples.forEach(function(ripple) { |
+ ripple.upAction(event); |
+ }); |
- /** @private */ |
- computeControlledBy_: function() { |
- if (!this.data.by_ext_id || !this.data.by_ext_name) |
- return ''; |
+ this._animating = true; |
+ this.animate(); |
+ }, |
- var url = 'chrome://extensions#' + this.data.by_ext_id; |
- var name = this.data.by_ext_name; |
- return loadTimeData.getStringF('controlledByUrl', url, name); |
- }, |
+ onAnimationComplete: function() { |
+ this._animating = false; |
+ this.$.background.style.backgroundColor = null; |
+ this.fire('transitionend'); |
+ }, |
- /** @private */ |
- computeDangerIcon_: function() { |
- if (!this.isDangerous_) |
- return ''; |
+ addRipple: function() { |
+ var ripple = new Ripple(this); |
- 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'; |
- } |
- }, |
+ Polymer.dom(this.$.waves).appendChild(ripple.waveContainer); |
+ this.$.background.style.backgroundColor = ripple.color; |
+ this.ripples.push(ripple); |
- /** @private */ |
- computeDate_: function() { |
- assert(typeof this.data.hideDate == 'boolean'); |
- if (this.data.hideDate) |
- return ''; |
- return assert(this.data.since_string || this.data.date_string); |
- }, |
+ this._setAnimating(true); |
- /** @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; |
+ return ripple; |
+ }, |
- case downloads.States.IN_PROGRESS: |
- case downloads.States.PAUSED: // Fallthrough. |
- return data.progress_status_text; |
- } |
+ removeRipple: function(ripple) { |
+ var rippleIndex = this.ripples.indexOf(ripple); |
- return ''; |
- }, |
+ if (rippleIndex < 0) { |
+ return; |
+ } |
- /** @private */ |
- computeIsActive_: function() { |
- return this.data.state != downloads.States.CANCELLED && |
- this.data.state != downloads.States.INTERRUPTED && |
- !this.data.file_externally_removed; |
- }, |
+ this.ripples.splice(rippleIndex, 1); |
- /** @private */ |
- computeIsDangerous_: function() { |
- return this.data.state == downloads.States.DANGEROUS; |
- }, |
+ ripple.remove(); |
- /** @private */ |
- computeIsInProgress_: function() { |
- return this.data.state == downloads.States.IN_PROGRESS; |
- }, |
+ if (!this.ripples.length) { |
+ this._setAnimating(false); |
+ } |
+ }, |
- /** @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); |
- }, |
+ animate: function() { |
+ if (!this._animating) { |
+ return; |
+ } |
+ var index; |
+ var ripple; |
- /** @private */ |
- computePauseOrResumeText_: function() { |
- if (this.isInProgress_) |
- return loadTimeData.getString('controlPause'); |
- if (this.data.resume) |
- return loadTimeData.getString('controlResume'); |
- return ''; |
- }, |
+ for (index = 0; index < this.ripples.length; ++index) { |
+ ripple = this.ripples[index]; |
- /** @private */ |
- computeRemoveStyle_: function() { |
- var canDelete = loadTimeData.getBoolean('allowDeletingHistory'); |
- var hideRemove = this.isDangerous_ || this.showCancel_ || !canDelete; |
- return hideRemove ? 'visibility: hidden' : ''; |
- }, |
+ ripple.draw(); |
- /** @private */ |
- computeShowCancel_: function() { |
- return this.data.state == downloads.States.IN_PROGRESS || |
- this.data.state == downloads.States.PAUSED; |
- }, |
+ this.$.background.style.opacity = ripple.outerOpacity; |
- /** @private */ |
- computeShowProgress_: function() { |
- return this.showCancel_ && this.data.percent >= -1; |
- }, |
+ if (ripple.isOpacityFullyDecayed && !ripple.isRestingAtMaxRadius) { |
+ this.removeRipple(ripple); |
+ } |
+ } |
- /** @private */ |
- computeTag_: function() { |
- switch (this.data.state) { |
- case downloads.States.CANCELLED: |
- return loadTimeData.getString('statusCancelled'); |
+ if (!this.shouldKeepAnimating && this.ripples.length === 0) { |
+ this.onAnimationComplete(); |
+ } else { |
+ window.requestAnimationFrame(this._boundAnimate); |
+ } |
+ }, |
- case downloads.States.INTERRUPTED: |
- return this.data.last_reason_text; |
+ _onEnterKeydown: function() { |
+ this.uiDownAction(); |
+ this.async(this.uiUpAction, 1); |
+ }, |
- case downloads.States.COMPLETE: |
- return this.data.file_externally_removed ? |
- loadTimeData.getString('statusRemoved') : ''; |
- } |
+ _onSpaceKeydown: function() { |
+ this.uiDownAction(); |
+ }, |
- return ''; |
- }, |
+ _onSpaceKeyup: function() { |
+ this.uiUpAction(); |
+ }, |
- /** @private */ |
- isIndeterminate_: function() { |
- return this.data.percent == -1; |
- }, |
+ // 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(); |
+ } |
+ } |
- /** @private */ |
- observeControlledBy_: function() { |
- this.$['controlled-by'].innerHTML = this.controlledBy_; |
- }, |
+ /** |
+ Fired when the animation finishes. |
+ This is useful if you want to wait until |
+ the ripple animation finishes to perform some action. |
- /** @private */ |
- observeIsDangerous_: function() { |
- if (!this.data) |
- return; |
+ @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' |
+ }, |
- 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; |
+ /** |
+ * @type {Element|undefined} |
+ */ |
+ _rippleContainer: { |
+ type: Object, |
} |
}, |
- /** @private */ |
- onCancelTap_: function() { |
- downloads.ActionService.getInstance().cancel(this.data.id); |
- }, |
- |
- /** @private */ |
- onDiscardDangerousTap_: function() { |
- downloads.ActionService.getInstance().discardDangerous(this.data.id); |
+ /** |
+ * Ensures a `<paper-ripple>` element is available when the element is |
+ * focused. |
+ */ |
+ _buttonStateChanged: function() { |
+ if (this.focused) { |
+ this.ensureRipple(); |
+ } |
}, |
/** |
- * @private |
- * @param {Event} e |
+ * In addition to the functionality provided in `IronButtonState`, ensures |
+ * a ripple effect is created when the element is in a `pressed` state. |
*/ |
- onDragStart_: function(e) { |
- e.preventDefault(); |
- downloads.ActionService.getInstance().drag(this.data.id); |
+ _downHandler: function(event) { |
+ Polymer.IronButtonStateImpl._downHandler.call(this, event); |
+ if (this.pressed) { |
+ this.ensureRipple(event); |
+ } |
}, |
/** |
- * @param {Event} e |
- * @private |
+ * 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. |
*/ |
- onFileLinkTap_: function(e) { |
- e.preventDefault(); |
- downloads.ActionService.getInstance().openFile(this.data.id); |
- }, |
- |
- /** @private */ |
- onPauseOrResumeTap_: function() { |
- if (this.isInProgress_) |
- downloads.ActionService.getInstance().pause(this.data.id); |
- else |
- downloads.ActionService.getInstance().resume(this.data.id); |
- }, |
- |
- /** @private */ |
- onRemoveTap_: function() { |
- downloads.ActionService.getInstance().remove(this.data.id); |
+ 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); |
+ } |
+ } |
+ } |
}, |
- /** @private */ |
- onRetryTap_: function() { |
- downloads.ActionService.getInstance().download(this.data.url); |
+ /** |
+ * 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; |
}, |
- /** @private */ |
- onSaveDangerousTap_: function() { |
- downloads.ActionService.getInstance().saveDangerous(this.data.id); |
+ /** |
+ * Returns true if this element currently contains a ripple effect. |
+ * @return {boolean} |
+ */ |
+ hasRipple: function() { |
+ return Boolean(this._ripple); |
}, |
- /** @private */ |
- onShowTap_: function() { |
- downloads.ActionService.getInstance().show(this.data.id); |
+ /** |
+ * 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')); |
}, |
- }); |
- return {Item: Item}; |
-}); |
-/** @polymerBehavior Polymer.PaperItemBehavior */ |
- Polymer.PaperItemBehaviorImpl = { |
- hostAttributes: { |
- role: 'option', |
- tabindex: '0' |
+ _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 |
+ } |
+ }, |
- /** @polymerBehavior */ |
- Polymer.PaperItemBehavior = [ |
- Polymer.IronButtonState, |
- Polymer.IronControlState, |
- Polymer.PaperItemBehaviorImpl |
- ]; |
-Polymer({ |
- is: 'paper-item', |
- |
- behaviors: [ |
- Polymer.PaperItemBehavior |
- ] |
- }); |
-/** |
- * @param {!Function} selectCallback |
- * @constructor |
- */ |
- Polymer.IronSelection = function(selectCallback) { |
- this.selection = []; |
- this.selectCallback = selectCallback; |
- }; |
- |
- Polymer.IronSelection.prototype = { |
+ observers: [ |
+ '_calculateElevation(focused, disabled, active, pressed, receivedFocusFromKeyboard)', |
+ '_computeKeyboardClass(receivedFocusFromKeyboard)' |
+ ], |
- /** |
- * 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]; |
+ hostAttributes: { |
+ role: 'button', |
+ tabindex: '0', |
+ animated: true |
}, |
- /** |
- * 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); |
+ _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); |
}, |
- /** |
- * 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; |
+ _computeKeyboardClass: function(receivedFocusFromKeyboard) { |
+ this.toggleClass('keyboard-focus', receivedFocusFromKeyboard); |
}, |
/** |
- * Sets the selection state for a given item to either selected or deselected. |
+ * In addition to `IronButtonState` behavior, when space key goes down, |
+ * create a ripple down effect. |
* |
- * @method setItemSelected |
- * @param {*} item The item to select. |
- * @param {boolean} isSelected True for selected, false for deselected. |
+ * @param {!KeyboardEvent} event . |
*/ |
- 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); |
- } |
- } |
+ _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(); |
} |
}, |
/** |
- * 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. |
+ * In addition to `IronButtonState` behavior, when space key goes up, |
+ * create a ripple up effect. |
* |
- * @method select |
- * @param {*} item The item to select. |
+ * @param {!KeyboardEvent} event . |
*/ |
- select: function(item) { |
- if (this.multi) { |
- this.toggle(item); |
- } else if (this.get() !== item) { |
- this.setItemSelected(this.get(), false); |
- this.setItemSelected(item, true); |
+ _spaceKeyUpHandler: function(event) { |
+ Polymer.IronButtonStateImpl._spaceKeyUpHandler.call(this, event); |
+ if (this.hasRipple()) { |
+ this._ripple.uiUpAction(); |
} |
- }, |
- |
- /** |
- * 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 |
- */ |
+ /** @polymerBehavior */ |
+ Polymer.PaperButtonBehavior = [ |
+ Polymer.IronButtonState, |
+ Polymer.IronControlState, |
+ Polymer.PaperRippleBehavior, |
+ Polymer.PaperButtonBehaviorImpl |
+ ]; |
+Polymer({ |
+ is: 'paper-button', |
- /** |
- * Fired when an item is selected |
- * |
- * @event iron-select |
- */ |
+ behaviors: [ |
+ Polymer.PaperButtonBehavior |
+ ], |
- /** |
- * Fired when an item is deselected |
- * |
- * @event iron-deselect |
- */ |
+ properties: { |
+ /** |
+ * If true, the button should be styled with a shadow. |
+ */ |
+ raised: { |
+ type: Boolean, |
+ reflectToAttribute: true, |
+ value: false, |
+ observer: '_calculateElevation' |
+ } |
+ }, |
+ |
+ _calculateElevation: function() { |
+ if (!this.raised) { |
+ this._setElevation(0); |
+ } else { |
+ Polymer.PaperButtonBehaviorImpl._calculateElevation.apply(this); |
+ } |
+ } |
/** |
- * 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 |
- */ |
+ Fired when the animation finishes. |
+ This is useful if you want to wait until |
+ the ripple animation finishes to perform some action. |
- properties: { |
+ @event transitionend |
+ Event param: {{node: Object}} detail Contains the animated node. |
+ */ |
+ }); |
+/** |
+ * `Polymer.PaperInkyFocusBehavior` implements a ripple when the element has keyboard focus. |
+ * |
+ * @polymerBehavior Polymer.PaperInkyFocusBehavior |
+ */ |
+ Polymer.PaperInkyFocusBehaviorImpl = { |
+ observers: [ |
+ '_focusedChanged(receivedFocusFromKeyboard)' |
+ ], |
- /** |
- * 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 |
+ _focusedChanged: function(receivedFocusFromKeyboard) { |
+ if (receivedFocusFromKeyboard) { |
+ this.ensureRipple(); |
+ } |
+ if (this.hasRipple()) { |
+ this._ripple.holdDown = receivedFocusFromKeyboard; |
+ } |
+ }, |
+ |
+ _createRipple: function() { |
+ var ripple = Polymer.PaperRippleBehavior._createRipple(); |
+ ripple.id = 'ink'; |
+ ripple.setAttribute('center', ''); |
+ ripple.classList.add('circle'); |
+ return ripple; |
+ } |
+ }; |
+ |
+ /** @polymerBehavior Polymer.PaperInkyFocusBehavior */ |
+ Polymer.PaperInkyFocusBehavior = [ |
+ Polymer.IronButtonState, |
+ Polymer.IronControlState, |
+ Polymer.PaperRippleBehavior, |
+ Polymer.PaperInkyFocusBehaviorImpl |
+ ]; |
+Polymer({ |
+ is: 'paper-icon-button', |
+ |
+ hostAttributes: { |
+ role: 'button', |
+ tabindex: '0' |
}, |
- /** |
- * Gets or sets the selected element. The default is to use the index of the item. |
- * @type {string|number} |
- */ |
- selected: { |
- type: String, |
- notify: true |
+ behaviors: [ |
+ Polymer.PaperInkyFocusBehavior |
+ ], |
+ |
+ properties: { |
+ /** |
+ * The URL of an image for the icon. If the src property is specified, |
+ * the icon property should not be. |
+ */ |
+ src: { |
+ type: String |
+ }, |
+ |
+ /** |
+ * 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 |
+ }, |
+ |
+ /** |
+ * Specifies the alternate text for the button, for accessibility. |
+ */ |
+ alt: { |
+ type: String, |
+ observer: "_altChanged" |
+ } |
}, |
- /** |
- * Returns the currently selected item. |
- * |
- * @type {?Object} |
- */ |
- selectedItem: { |
- type: Object, |
- readOnly: true, |
- notify: true |
+ _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); |
+ } |
+ } |
+ }); |
+Polymer({ |
+ is: 'paper-tab', |
+ |
+ behaviors: [ |
+ Polymer.IronControlState, |
+ Polymer.IronButtonState, |
+ Polymer.PaperRippleBehavior |
+ ], |
+ |
+ properties: { |
+ |
+ /** |
+ * 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 |
+ } |
+ |
}, |
- /** |
- * 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' |
+ hostAttributes: { |
+ role: 'tab' |
}, |
- /** |
- * This is a CSS selector string. If this is set, only items that match the CSS selector |
- * are selectable. |
- */ |
- selectable: String, |
+ listeners: { |
+ down: '_updateNoink', |
+ tap: '_onTap' |
+ }, |
- /** |
- * The class to set on elements when selected. |
- */ |
- selectedClass: { |
- type: String, |
- value: 'iron-selected' |
+ attached: function() { |
+ this._updateNoink(); |
+ }, |
+ |
+ get _parentNoink () { |
+ var parent = Polymer.dom(this).parentNode; |
+ return !!parent && !!parent.noink; |
+ }, |
+ |
+ _updateNoink: function() { |
+ this.noink = !!this.noink || !!this._parentNoink; |
}, |
+ _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(); |
+ } |
+ } |
+ |
+ }); |
+/** @polymerBehavior Polymer.IronMultiSelectableBehavior */ |
+ Polymer.IronMultiSelectableBehaviorImpl = { |
+ properties: { |
+ |
/** |
- * The attribute to set on elements when selected. |
+ * If true, multiple selections are allowed. |
*/ |
- selectedAttribute: { |
- type: String, |
- value: null |
+ multi: { |
+ type: Boolean, |
+ value: false, |
+ observer: 'multiChanged' |
}, |
/** |
- * Default fallback if the selection based on selected with `attrForSelected` |
- * is not found. |
+ * Gets or sets the selected elements. This is used instead of `selected` when `multi` |
+ * is true. |
*/ |
- fallbackSelection: { |
- type: String, |
- value: null |
+ selectedValues: { |
+ type: Array, |
+ notify: true |
}, |
/** |
- * The list of items from which a selection can be made. |
+ * Returns an array of currently selected items. |
*/ |
- items: { |
+ selectedItems: { |
type: Array, |
readOnly: true, |
- notify: true, |
- value: function() { |
- return []; |
- } |
+ notify: true |
}, |
- /** |
- * 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 |
- }; |
- } |
- } |
}, |
observers: [ |
- '_updateAttrForSelected(attrForSelected)', |
- '_updateSelected(selected)', |
- '_checkFallback(fallbackSelection)' |
- ], |
- |
- 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); |
- }, |
- |
- detached: function() { |
- if (this._observer) { |
- Polymer.dom(this).unobserveNodes(this._observer); |
- } |
- this._removeListener(this.activateEvent); |
- }, |
- |
- /** |
- * Returns the index of the given item. |
- * |
- * @method indexOf |
- * @param {Object} item |
- * @returns Returns the index of the item |
- */ |
- indexOf: function(item) { |
- return this.items.indexOf(item); |
- }, |
- |
- /** |
- * Selects the given value. |
- * |
- * @method select |
- * @param {string|number} value the value to select. |
- */ |
- select: function(value) { |
- this.selected = value; |
- }, |
- |
- /** |
- * Selects the previous item. |
- * |
- * @method selectPrevious |
- */ |
- selectPrevious: function() { |
- var length = this.items.length; |
- var index = (Number(this._valueToIndex(this.selected)) - 1 + length) % length; |
- this.selected = this._indexToValue(index); |
- }, |
- |
- /** |
- * 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); |
- }, |
- |
- _updateItems: function() { |
- var nodes = Polymer.dom(this).queryDistributedElements(this.selectable || '*'); |
- nodes = Array.prototype.filter.call(nodes, this._bindFilterItem); |
- this._setItems(nodes); |
- }, |
- |
- _updateAttrForSelected: function() { |
- if (this._shouldUpdateSelection) { |
- this.selected = this._indexToValue(this.indexOf(this.selectedItem)); |
- } |
- }, |
- |
- _updateSelected: function() { |
- this._selectSelected(this.selected); |
- }, |
- |
- _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 { |
- return Number(value); |
- } |
- }, |
- |
- _indexToValue: function(index) { |
- if (this.attrForSelected) { |
- var item = this.items[index]; |
- if (item) { |
- return this._valueForItem(item); |
- } |
- } else { |
- return index; |
- } |
- }, |
- |
- _valueForItem: function(item) { |
- var propValue = item[Polymer.CaseMap.dashToCamelCase(this.attrForSelected)]; |
- return propValue != undefined ? propValue : item.getAttribute(this.attrForSelected); |
- }, |
- |
- _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}); |
- }, |
- |
- _selectionChange: function() { |
- this._setSelectedItem(this._selection.get()); |
- }, |
- |
- // observe items change under the given node. |
- _observeItems: function(node) { |
- return Polymer.dom(node).observeNodes(function(mutation) { |
- this._updateItems(); |
- |
- if (this._shouldUpdateSelection) { |
- this._updateSelected(); |
- } |
- |
- // 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 |
- }); |
- }); |
- }, |
- |
- _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; |
- } |
- }, |
- |
- _itemActivate: function(value, item) { |
- if (!this.fire('iron-activate', |
- {selected: value, item: item}, {cancelable: true}).defaultPrevented) { |
- this.select(value); |
- } |
- } |
- |
- }; |
-/** @polymerBehavior Polymer.IronMultiSelectableBehavior */ |
- Polymer.IronMultiSelectableBehaviorImpl = { |
- properties: { |
- |
- /** |
- * If true, multiple selections are allowed. |
- */ |
- multi: { |
- type: Boolean, |
- value: false, |
- observer: 'multiChanged' |
- }, |
- |
- /** |
- * Gets or sets the selected elements. This is used instead of `selected` when `multi` |
- * is true. |
- */ |
- selectedValues: { |
- type: Array, |
- notify: true |
- }, |
- |
- /** |
- * Returns an array of currently selected items. |
- */ |
- selectedItems: { |
- type: Array, |
- readOnly: true, |
- notify: true |
- }, |
- |
- }, |
- |
- observers: [ |
- '_updateSelected(selectedValues.splices)' |
+ '_updateSelected(selectedValues.splices)' |
], |
/** |
@@ -7539,4382 +6562,9086 @@ Polymer({ |
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. |
+ * `Polymer.IronMenubarBehavior` implements accessible menubar behavior. |
+ * |
+ * @polymerBehavior Polymer.IronMenubarBehavior |
+ */ |
+ Polymer.IronMenubarBehaviorImpl = { |
-The element will only be sized and/or positioned if it has not already been sized and/or positioned |
-by CSS. |
+ hostAttributes: { |
+ 'role': 'menubar' |
+ }, |
-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` |
+ keyBindings: { |
+ 'left': '_onLeftKey', |
+ 'right': '_onRightKey' |
+ }, |
-`Polymer.IronFitBehavior` can position an element into another element using |
-`verticalAlign` and `horizontalAlign`. This will override the element's css position. |
+ _onUpKey: function(event) { |
+ this.focusedItem.click(); |
+ event.detail.keyboardEvent.preventDefault(); |
+ }, |
- <div class="container"> |
- <iron-fit-impl vertical-align="top" horizontal-align="auto"> |
- Positioned into the container |
- </iron-fit-impl> |
- </div> |
+ _onDownKey: function(event) { |
+ this.focusedItem.click(); |
+ event.detail.keyboardEvent.preventDefault(); |
+ }, |
-Use `noOverlap` to position the element around another element without overlapping it. |
+ get _isRTL() { |
+ return window.getComputedStyle(this)['direction'] === 'rtl'; |
+ }, |
- <div class="container"> |
- <iron-fit-impl no-overlap vertical-align="auto" horizontal-align="auto"> |
- Positioned around the container |
- </iron-fit-impl> |
- </div> |
+ _onLeftKey: function(event) { |
+ if (this._isRTL) { |
+ this._focusNext(); |
+ } else { |
+ this._focusPrevious(); |
+ } |
+ event.detail.keyboardEvent.preventDefault(); |
+ }, |
-@demo demo/index.html |
-@polymerBehavior |
-*/ |
+ _onRightKey: function(event) { |
+ if (this._isRTL) { |
+ this._focusPrevious(); |
+ } else { |
+ this._focusNext(); |
+ } |
+ event.detail.keyboardEvent.preventDefault(); |
+ }, |
- Polymer.IronFitBehavior = { |
+ _onKeydown: function(event) { |
+ if (this.keyboardEventMatchesKeys(event, 'up down left right esc')) { |
+ return; |
+ } |
- properties: { |
+ // all other keys focus the menu item starting with that character |
+ this._focusWithKeyboardEvent(event); |
+ } |
- /** |
- * 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; |
+ }; |
+ |
+ /** @polymerBehavior Polymer.IronMenubarBehavior */ |
+ Polymer.IronMenubarBehavior = [ |
+ Polymer.IronMenuBehavior, |
+ Polymer.IronMenubarBehaviorImpl |
+ ]; |
+Polymer({ |
+ is: 'paper-tabs', |
+ |
+ behaviors: [ |
+ Polymer.IronResizableBehavior, |
+ Polymer.IronMenubarBehavior |
+ ], |
+ |
+ 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 |
} |
}, |
- /** |
- * The element to fit `this` into. |
- */ |
- fitInto: { |
- type: Object, |
- value: window |
+ hostAttributes: { |
+ role: 'tablist' |
}, |
- /** |
- * Will position the element around the positionTarget without overlapping it. |
- */ |
- noOverlap: { |
- type: Boolean |
+ listeners: { |
+ 'iron-resize': '_onTabSizingChanged', |
+ 'iron-items-changed': '_onTabSizingChanged', |
+ 'iron-select': '_onIronSelect', |
+ 'iron-deselect': '_onIronDeselect' |
}, |
- /** |
- * 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 |
+ keyBindings: { |
+ 'left:keyup right:keyup': '_onArrowKeyup' |
}, |
- /** |
- * The orientation against which to align the element horizontally |
- * relative to the `positionTarget`. Possible values are "left", "right", "auto". |
- */ |
- horizontalAlign: { |
- type: String |
+ 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); |
}, |
- /** |
- * The orientation against which to align the element vertically |
- * relative to the `positionTarget`. Possible values are "top", "bottom", "auto". |
- */ |
- verticalAlign: { |
- type: String |
+ ready: function() { |
+ this.setScrollDirection('y', this.$.tabsContainer); |
}, |
- /** |
- * 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 |
+ detached: function() { |
+ this._cancelPendingActivation(); |
+ }, |
+ |
+ _noinkChanged: function(noink) { |
+ var childTabs = Polymer.dom(this).querySelectorAll('paper-tab'); |
+ childTabs.forEach(noink ? this._setNoinkAttribute : this._removeNoinkAttribute); |
+ }, |
+ |
+ _setNoinkAttribute: function(element) { |
+ element.setAttribute('noink', ''); |
+ }, |
+ |
+ _removeNoinkAttribute: function(element) { |
+ element.removeAttribute('noink'); |
+ }, |
+ |
+ _computeScrollButtonClass: function(hideThisButton, scrollable, hideScrollButtons) { |
+ if (!scrollable || hideScrollButtons) { |
+ return 'hidden'; |
+ } |
+ |
+ if (hideThisButton) { |
+ return 'not-visible'; |
+ } |
+ |
+ return ''; |
+ }, |
+ |
+ _computeTabsContentClass: function(scrollable, fitContainer) { |
+ return scrollable ? 'scrollable' + (fitContainer ? ' fit-container' : '') : ' fit-container'; |
+ }, |
+ |
+ _computeSelectionBarClass: function(noBar, alignBottom) { |
+ if (noBar) { |
+ return 'hidden'; |
+ } else if (alignBottom) { |
+ return 'align-bottom'; |
+ } |
+ |
+ return ''; |
+ }, |
+ |
+ // TODO(cdata): Add `track` response back in when gesture lands. |
+ |
+ _onTabSizingChanged: function() { |
+ this.debounce('_onTabSizingChanged', function() { |
+ this._scroll(); |
+ this._tabChanged(this.selectedItem); |
+ }, 10); |
+ }, |
+ |
+ _onIronSelect: function(event) { |
+ this._tabChanged(event.detail.item, this._previousTab); |
+ this._previousTab = event.detail.item; |
+ this.cancelDebouncer('tab-changed'); |
+ }, |
+ |
+ _onIronDeselect: function(event) { |
+ this.debounce('tab-changed', function() { |
+ this._tabChanged(null, this._previousTab); |
+ this._previousTab = null; |
+ // See polymer/polymer#1305 |
+ }, 1); |
+ }, |
+ |
+ _activateHandler: function() { |
+ // Cancel item activations scheduled by keyboard events when any other |
+ // action causes an item to be activated (e.g. clicks). |
+ this._cancelPendingActivation(); |
+ |
+ Polymer.IronMenuBehaviorImpl._activateHandler.apply(this, arguments); |
}, |
/** |
- * The same as setting margin-left and margin-right css properties. |
- * @deprecated |
+ * Activates an item after a delay (in milliseconds). |
*/ |
- horizontalOffset: { |
- type: Number, |
- value: 0, |
- notify: true |
+ _scheduleActivation: function(item, delay) { |
+ this._pendingActivationItem = item; |
+ this._pendingActivationTimeout = this.async( |
+ this._bindDelayedActivationHandler, delay); |
}, |
/** |
- * The same as setting margin-top and margin-bottom css properties. |
- * @deprecated |
+ * Activates the last item given to `_scheduleActivation`. |
*/ |
- verticalOffset: { |
- type: Number, |
- value: 0, |
- notify: true |
+ _delayedActivationHandler: function() { |
+ var item = this._pendingActivationItem; |
+ this._pendingActivationItem = undefined; |
+ this._pendingActivationTimeout = undefined; |
+ item.fire(this.activateEvent, null, { |
+ bubbles: true, |
+ cancelable: true |
+ }); |
}, |
/** |
- * Set to true to auto-fit on attach. |
+ * Cancels a previously scheduled item activation made with |
+ * `_scheduleActivation`. |
*/ |
- autoFitOnAttach: { |
- type: Boolean, |
- value: false |
+ _cancelPendingActivation: function() { |
+ if (this._pendingActivationTimeout !== undefined) { |
+ this.cancelAsync(this._pendingActivationTimeout); |
+ this._pendingActivationItem = undefined; |
+ this._pendingActivationTimeout = undefined; |
+ } |
}, |
- /** @type {?Object} */ |
- _fitInfo: { |
- type: Object |
- } |
- }, |
+ _onArrowKeyup: function(event) { |
+ if (this.autoselect) { |
+ this._scheduleActivation(this.focusedItem, this.autoselectDelay); |
+ } |
+ }, |
- get _fitWidth() { |
- var fitWidth; |
- if (this.fitInto === window) { |
- fitWidth = this.fitInto.innerWidth; |
- } else { |
- fitWidth = this.fitInto.getBoundingClientRect().width; |
- } |
- return fitWidth; |
- }, |
+ _onBlurCapture: function(event) { |
+ // Cancel a scheduled item activation (if any) when that item is |
+ // blurred. |
+ if (event.target === this._pendingActivationItem) { |
+ this._cancelPendingActivation(); |
+ } |
+ }, |
- get _fitHeight() { |
- var fitHeight; |
- if (this.fitInto === window) { |
- fitHeight = this.fitInto.innerHeight; |
- } else { |
- fitHeight = this.fitInto.getBoundingClientRect().height; |
- } |
- return fitHeight; |
- }, |
+ get _tabContainerScrollSize () { |
+ return Math.max( |
+ 0, |
+ this.$.tabsContainer.scrollWidth - |
+ this.$.tabsContainer.offsetWidth |
+ ); |
+ }, |
- get _fitLeft() { |
- var fitLeft; |
- if (this.fitInto === window) { |
- fitLeft = 0; |
- } else { |
- fitLeft = this.fitInto.getBoundingClientRect().left; |
- } |
- return fitLeft; |
- }, |
+ _scroll: function(e, detail) { |
+ if (!this.scrollable) { |
+ return; |
+ } |
- get _fitTop() { |
- var fitTop; |
- if (this.fitInto === window) { |
- fitTop = 0; |
- } else { |
- fitTop = this.fitInto.getBoundingClientRect().top; |
- } |
- return fitTop; |
- }, |
+ var ddx = (detail && -detail.ddx) || 0; |
+ this._affectScroll(ddx); |
+ }, |
- /** |
- * The element that should be used to position the element, |
- * if no position target is configured. |
- */ |
- get _defaultPositionTarget() { |
- var parent = Polymer.dom(this).parentNode; |
+ _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); |
+ }, |
- if (parent && parent.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { |
- parent = parent.host; |
- } |
+ _affectScroll: function(dx) { |
+ this.$.tabsContainer.scrollLeft += dx; |
- return parent; |
- }, |
+ var scrollLeft = this.$.tabsContainer.scrollLeft; |
- /** |
- * 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'; |
- } |
- } |
- return this.horizontalAlign; |
- }, |
+ this._leftHidden = scrollLeft === 0; |
+ this._rightHidden = scrollLeft === this._tabContainerScrollSize; |
+ }, |
- 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(); |
- } |
- } |
- }, |
+ _onLeftScrollButtonDown: function() { |
+ this._scrollToLeft(); |
+ this._holdJob = setInterval(this._scrollToLeft.bind(this), this._holdDelay); |
+ }, |
- /** |
- * Positions and fits the element into the `fitInto` element. |
- */ |
- fit: function() { |
- this.position(); |
- this.constrain(); |
- this.center(); |
- }, |
+ _onRightScrollButtonDown: function() { |
+ this._scrollToRight(); |
+ this._holdJob = setInterval(this._scrollToRight.bind(this), this._holdDelay); |
+ }, |
- /** |
- * Memoize information needed to position and size the target element. |
- * @suppress {deprecated} |
- */ |
- _discoverInfo: function() { |
- if (this._fitInfo) { |
- return; |
- } |
- var target = window.getComputedStyle(this); |
- var sizer = window.getComputedStyle(this.sizingTarget); |
+ _onScrollButtonUp: function() { |
+ clearInterval(this._holdJob); |
+ this._holdJob = null; |
+ }, |
- 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 |
+ _scrollToLeft: function() { |
+ this._affectScroll(-this._step); |
+ }, |
+ |
+ _scrollToRight: function() { |
+ this._affectScroll(this._step); |
+ }, |
+ |
+ _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; |
} |
- }; |
- // 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'; |
- } |
- }, |
+ var r = this.$.tabsContent.getBoundingClientRect(); |
+ var w = r.width; |
+ var tabRect = tab.getBoundingClientRect(); |
+ var tabOffsetLeft = tabRect.left - r.left; |
- /** |
- * 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]; |
- } |
- for (var property in info.inlineStyle) { |
- this.style[property] = info.inlineStyle[property]; |
- } |
+ this._pos = { |
+ width: this._calcPercent(tabRect.width, w), |
+ left: this._calcPercent(tabOffsetLeft, w) |
+ }; |
- this._fitInfo = null; |
- }, |
+ 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; |
+ } |
- /** |
- * 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; |
- }, |
+ var oldRect = old.getBoundingClientRect(); |
+ var oldIndex = this.items.indexOf(old); |
+ var index = this.items.indexOf(tab); |
+ var m = 5; |
- /** |
- * 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; |
- } |
- this._discoverInfo(); |
+ // bar animation: expand |
+ this.$.selectionBar.classList.add('expand'); |
- 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 moveRight = oldIndex < index; |
+ var isRTL = this._isRTL; |
+ if (isRTL) { |
+ moveRight = !moveRight; |
+ } |
- var rect = this.getBoundingClientRect(); |
- var positionRect = this.__getNormalizedRect(this.positionTarget); |
- var fitRect = this.__getNormalizedRect(this.fitInto); |
+ 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); |
+ } |
- var margin = this._fitInfo.margin; |
+ if (this.scrollable) { |
+ this._scrollToSelectedIfNeeded(tabRect.width, tabOffsetLeft); |
+ } |
+ }, |
- // 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 |
- }; |
+ _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; |
+ } |
+ } |
+ }, |
- var position = this.__getPosition(this._localeHorizontalAlign, this.verticalAlign, size, positionRect, fitRect); |
+ _calcPercent: function(w, w0) { |
+ return 100 * w / w0; |
+ }, |
- var left = position.left + margin.left; |
- var top = position.top + margin.top; |
+ _positionBar: function(width, left) { |
+ width = width || 0; |
+ left = left || 0; |
- // 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); |
+ this._width = width; |
+ this._left = left; |
+ this.transform( |
+ 'translateX(' + left + '%) scaleX(' + (width / 100) + ')', |
+ this.$.selectionBar); |
+ }, |
- 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; |
+ _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'); |
} |
} |
+ }); |
+(function() { |
+ 'use strict'; |
- this.sizingTarget.style.maxWidth = (right - left) + 'px'; |
- this.sizingTarget.style.maxHeight = (bottom - top) + 'px'; |
+ Polymer.IronA11yAnnouncer = Polymer({ |
+ is: 'iron-a11y-announcer', |
- // Remove the offset caused by any stacking context. |
- this.style.left = (left - rect.left) + 'px'; |
- this.style.top = (top - rect.top) + 'px'; |
- }, |
+ properties: { |
- /** |
- * 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(); |
+ /** |
+ * 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' |
+ }, |
- 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'; |
- } |
+ _text: { |
+ type: String, |
+ value: '' |
+ } |
+ }, |
- // 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'); |
+ created: function() { |
+ if (!Polymer.IronA11yAnnouncer.instance) { |
+ Polymer.IronA11yAnnouncer.instance = this; |
+ } |
+ |
+ 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); |
+ } |
+ } |
+ }); |
+ |
+ Polymer.IronA11yAnnouncer.instance = null; |
+ |
+ Polymer.IronA11yAnnouncer.requestAvailability = function() { |
+ if (!Polymer.IronA11yAnnouncer.instance) { |
+ Polymer.IronA11yAnnouncer.instance = document.createElement('iron-a11y-announcer'); |
+ } |
+ |
+ document.body.appendChild(Polymer.IronA11yAnnouncer.instance); |
+ }; |
+ })(); |
+/** |
+ * Singleton IronMeta instance. |
+ */ |
+ Polymer.IronValidatableBehaviorMeta = null; |
+ |
+ /** |
+ * `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: { |
+ |
+ /** |
+ * Name of the validator to use. |
+ */ |
+ validator: { |
+ type: String |
+ }, |
+ |
+ /** |
+ * True if the last call to `validate` is invalid. |
+ */ |
+ invalid: { |
+ notify: true, |
+ reflectToAttribute: true, |
+ type: Boolean, |
+ value: false |
+ }, |
+ |
+ /** |
+ * This property is deprecated and should not be used. Use the global |
+ * validator meta singleton, `Polymer.IronValidatableBehaviorMeta` instead. |
+ */ |
+ _validatorMeta: { |
+ type: Object |
+ }, |
+ |
+ /** |
+ * 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. |
+ */ |
+ validatorType: { |
+ type: String, |
+ value: 'validator' |
+ }, |
+ |
+ _validator: { |
+ type: Object, |
+ computed: '__computeValidator(validator)' |
} |
- if (!info.sizedBy.width) { |
- this.__sizeDimension(rect, info.positionedBy.horizontally, 'left', 'right', 'Width'); |
+ }, |
+ |
+ observers: [ |
+ '_invalidChanged(invalid)' |
+ ], |
+ |
+ registered: function() { |
+ Polymer.IronValidatableBehaviorMeta = new Polymer.IronMeta({type: 'validator'}); |
+ }, |
+ |
+ _invalidChanged: function() { |
+ if (this.invalid) { |
+ this.setAttribute('aria-invalid', 'true'); |
+ } else { |
+ this.removeAttribute('aria-invalid'); |
} |
}, |
/** |
- * @protected |
- * @deprecated |
+ * @return {boolean} True if the validator `validator` exists. |
*/ |
- _sizeDimension: function(rect, positionedBy, start, end, extent) { |
- this.__sizeDimension(rect, positionedBy, start, end, extent); |
+ hasValidator: function() { |
+ return this._validator != null; |
}, |
/** |
- * @private |
+ * 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. |
*/ |
- __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'; |
+ validate: function(value) { |
+ this.invalid = !this._getValidity(value); |
+ return !this.invalid; |
}, |
/** |
- * Centers horizontally and vertically if not already positioned. This also sets |
- * `position:fixed`. |
+ * 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. |
+ * |
+ * @param {Object} value The value to be validated. |
+ * @return {boolean} True if `value` is valid. |
*/ |
- center: function() { |
- if (this.horizontalAlign || this.verticalAlign) { |
- return; |
- } |
- this._discoverInfo(); |
- 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'; |
+ _getValidity: function(value) { |
+ if (this.hasValidator()) { |
+ return this._validator.validate(value); |
} |
+ return true; |
}, |
- __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(); |
- }, |
+ __computeValidator: function() { |
+ return Polymer.IronValidatableBehaviorMeta && |
+ Polymer.IronValidatableBehaviorMeta.byKey(this.validator); |
+ } |
+ }; |
+/* |
+`<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]"> |
+ |
+@hero hero.svg |
+@demo demo/index.html |
+*/ |
+ |
+ Polymer({ |
+ |
+ is: 'iron-input', |
+ |
+ extends: 'input', |
+ |
+ behaviors: [ |
+ Polymer.IronValidatableBehavior |
+ ], |
+ |
+ properties: { |
+ |
+ /** |
+ * Use this property instead of `value` for two-way data binding. |
+ */ |
+ bindValue: { |
+ observer: '_bindValueChanged', |
+ type: String |
+ }, |
+ |
+ /** |
+ * 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 |
+ }, |
+ |
+ /** |
+ * 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" |
+ }, |
+ |
+ _previousValidInput: { |
+ type: String, |
+ value: '' |
+ }, |
+ |
+ _patternAlreadyChecked: { |
+ type: Boolean, |
+ value: false |
+ } |
+ |
+ }, |
+ |
+ listeners: { |
+ 'input': '_onInput', |
+ 'keypress': '_onKeypress' |
+ }, |
+ |
+ /** @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; |
+ } |
+ }, |
+ |
+ created: function() { |
+ Polymer.IronA11yAnnouncer.requestAvailability(); |
+ }, |
+ |
+ _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; |
+ }, |
+ |
+ _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; |
+ }, |
+ |
+ 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; |
+ }, |
+ |
+ ready: function() { |
+ this.bindValue = this.value; |
+ }, |
+ |
+ /** |
+ * @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}); |
+ }, |
+ |
+ _allowedPatternChanged: function() { |
+ // Force to prevent invalid input when an `allowed-pattern` is set |
+ this.preventInvalidInput = this.allowedPattern ? true : false; |
+ }, |
+ |
+ _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; |
+ } |
+ } |
+ |
+ this.bindValue = this.value; |
+ this._previousValidInput = this.value; |
+ this._patternAlreadyChecked = false; |
+ }, |
+ |
+ _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. |
+ |
+ // 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); |
+ }, |
+ |
+ _onKeypress: function(event) { |
+ if (!this.preventInvalidInput && this.type !== 'number') { |
+ return; |
+ } |
+ var regexp = this._patternRegExp; |
+ if (!regexp) { |
+ return; |
+ } |
+ |
+ // Handle special keys and backspace |
+ if (event.metaKey || event.ctrlKey || event.altKey) |
+ return; |
+ |
+ // 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.'); |
+ } |
+ }, |
+ |
+ _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; |
+ }, |
+ |
+ /** |
+ * 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(); |
+ |
+ // 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); |
+ } |
+ } |
+ |
+ this.invalid = !valid; |
+ this.fire('iron-input-validate'); |
+ return valid; |
+ }, |
+ |
+ _announceInvalidCharacter: function(message) { |
+ this.fire('iron-announce', { text: message }); |
+ } |
+ }); |
+ |
+ /* |
+ The `iron-input-validate` event is fired whenever `validate()` is called. |
+ @event iron-input-validate |
+ */ |
+Polymer({ |
+ is: 'paper-input-container', |
+ |
+ properties: { |
+ /** |
+ * Set to true to disable the floating label. The label disappears when the input value is |
+ * not null. |
+ */ |
+ noLabelFloat: { |
+ type: Boolean, |
+ value: false |
+ }, |
+ |
+ /** |
+ * Set to true to always float the floating label. |
+ */ |
+ alwaysFloatLabel: { |
+ type: Boolean, |
+ value: false |
+ }, |
+ |
+ /** |
+ * The attribute to listen for value changes on. |
+ */ |
+ attrForValue: { |
+ type: String, |
+ value: 'bind-value' |
+ }, |
+ |
+ /** |
+ * Set to true to auto-validate the input value when it changes. |
+ */ |
+ autoValidate: { |
+ type: Boolean, |
+ value: false |
+ }, |
+ |
+ /** |
+ * 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 |
+ }, |
+ |
+ /** |
+ * True if the input has focus. |
+ */ |
+ focused: { |
+ readOnly: true, |
+ type: Boolean, |
+ value: false, |
+ notify: 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. |
+ }, |
+ |
+ _inputHasContent: { |
+ type: Boolean, |
+ value: false |
+ }, |
+ |
+ _inputSelector: { |
+ type: String, |
+ value: 'input,textarea,.paper-input-input' |
+ }, |
+ |
+ _boundOnFocus: { |
+ type: Function, |
+ value: function() { |
+ return this._onFocus.bind(this); |
+ } |
+ }, |
+ |
+ _boundOnBlur: { |
+ type: Function, |
+ value: function() { |
+ return this._onBlur.bind(this); |
+ } |
+ }, |
+ |
+ _boundOnInput: { |
+ type: Function, |
+ value: function() { |
+ return this._onInput.bind(this); |
+ } |
+ }, |
+ |
+ _boundValueChanged: { |
+ type: Function, |
+ value: function() { |
+ return this._onValueChanged.bind(this); |
+ } |
+ } |
+ }, |
+ |
+ listeners: { |
+ 'addon-attached': '_onAddonAttached', |
+ 'iron-input-validate': '_onIronInputValidate' |
+ }, |
+ |
+ get _valueChangedEvent() { |
+ return this.attrForValue + '-changed'; |
+ }, |
+ |
+ get _propertyForValue() { |
+ return Polymer.CaseMap.dashToCamelCase(this.attrForValue); |
+ }, |
+ |
+ get _inputElement() { |
+ return Polymer.dom(this).querySelector(this._inputSelector); |
+ }, |
+ |
+ get _inputElementValue() { |
+ return this._inputElement[this._propertyForValue] || this._inputElement.value; |
+ }, |
+ |
+ ready: function() { |
+ if (!this._addons) { |
+ this._addons = []; |
+ } |
+ this.addEventListener('focus', this._boundOnFocus, true); |
+ this.addEventListener('blur', this._boundOnBlur, true); |
+ }, |
+ |
+ attached: function() { |
+ if (this.attrForValue) { |
+ this._inputElement.addEventListener(this._valueChangedEvent, this._boundValueChanged); |
+ } else { |
+ this.addEventListener('input', this._onInput); |
+ } |
+ |
+ // Only validate when attached if the input already has a value. |
+ if (this._inputElementValue != '') { |
+ this._handleValueAndAutoValidate(this._inputElement); |
+ } else { |
+ this._handleValue(this._inputElement); |
+ } |
+ }, |
+ |
+ _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); |
+ } |
+ } |
+ }, |
+ |
+ _onFocus: function() { |
+ this._setFocused(true); |
+ }, |
+ |
+ _onBlur: function() { |
+ this._setFocused(false); |
+ this._handleValueAndAutoValidate(this._inputElement); |
+ }, |
+ |
+ _onInput: function(event) { |
+ this._handleValueAndAutoValidate(event.target); |
+ }, |
+ |
+ _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; |
+ } |
+ |
+ this.updateAddons({ |
+ inputElement: inputElement, |
+ value: value, |
+ invalid: this.invalid |
+ }); |
+ }, |
+ |
+ _handleValueAndAutoValidate: function(inputElement) { |
+ if (this.autoValidate) { |
+ var valid; |
+ if (inputElement.validate) { |
+ valid = inputElement.validate(this._inputElementValue); |
+ } else { |
+ valid = inputElement.checkValidity(); |
+ } |
+ this.invalid = !valid; |
+ } |
+ |
+ // Call this last to notify the add-ons. |
+ this._handleValue(inputElement); |
+ }, |
+ |
+ _onIronInputValidate: function(event) { |
+ this.invalid = this._inputElement.invalid; |
+ }, |
+ |
+ _invalidChanged: function() { |
+ if (this._addons) { |
+ this.updateAddons({invalid: this.invalid}); |
+ } |
+ }, |
+ |
+ /** |
+ * Call this to update the state of add-ons. |
+ * @param {Object} state Add-on state. |
+ */ |
+ updateAddons: function(state) { |
+ for (var addon, index = 0; addon = this._addons[index]; index++) { |
+ addon.update(state); |
+ } |
+ }, |
+ |
+ _computeInputContentClass: function(noLabelFloat, alwaysFloatLabel, focused, invalid, _inputHasContent) { |
+ var cls = 'input-content'; |
+ if (!noLabelFloat) { |
+ var label = this.querySelector('label'); |
+ |
+ 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'; |
+ } |
+ } |
+ return cls; |
+ }, |
+ |
+ _computeUnderlineClass: function(focused, invalid) { |
+ var cls = 'underline'; |
+ if (invalid) { |
+ cls += ' is-invalid'; |
+ } else if (focused) { |
+ cls += ' is-highlighted' |
+ } |
+ return cls; |
+ }, |
+ |
+ _computeAddOnContentClass: function(focused, invalid) { |
+ var cls = 'add-on-content'; |
+ if (invalid) { |
+ cls += ' is-invalid'; |
+ } else if (focused) { |
+ cls += ' is-highlighted' |
+ } |
+ 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 |
+ } |
+ }, |
+ |
+ __computeContainerClasses: function(active, coolingDown) { |
+ return [ |
+ active || coolingDown ? 'active' : '', |
+ coolingDown ? 'cooldown' : '' |
+ ].join(' '); |
+ }, |
+ |
+ __activeChanged: function(active, old) { |
+ this.__setAriaHidden(!active); |
+ this.__coolingDown = !active && old; |
+ }, |
+ |
+ __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); |
+ } |
+ }, |
+ |
+ __setAriaHidden: function(hidden) { |
+ var attr = 'aria-hidden'; |
+ if (hidden) { |
+ this.setAttribute(attr, 'true'); |
+ } else { |
+ this.removeAttribute(attr); |
+ } |
+ }, |
+ |
+ __reset: function() { |
+ this.active = false; |
+ this.__coolingDown = false; |
+ } |
+ }; |
+Polymer({ |
+ is: 'paper-spinner-lite', |
+ |
+ 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. |
+ |
+/** |
+ * 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: '', |
+ }, |
+ |
+ clearLabel: { |
+ type: String, |
+ value: '', |
+ }, |
+ |
+ showingSearch: { |
+ type: Boolean, |
+ value: false, |
+ notify: true, |
+ observer: 'showingSearchChanged_', |
+ reflectToAttribute: true |
+ }, |
+ |
+ /** @private */ |
+ lastValue_: { |
+ type: String, |
+ value: '', |
+ }, |
+ }, |
+ |
+ /** |
+ * @abstract |
+ * @return {!HTMLInputElement} The input field element the behavior should |
+ * use. |
+ */ |
+ getSearchInput: function() {}, |
+ |
+ /** |
+ * @return {string} The value of the search field. |
+ */ |
+ getValue: function() { |
+ return this.getSearchInput().value; |
+ }, |
+ |
+ /** |
+ * 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); |
+ }, |
+ |
+ showAndFocus: function() { |
+ this.showingSearch = true; |
+ this.focus_(); |
+ }, |
+ |
+ /** @private */ |
+ focus_: function() { |
+ this.getSearchInput().focus(); |
+ }, |
+ |
+ onSearchTermSearch: function() { |
+ this.onValueChanged_(this.getValue()); |
+ }, |
+ |
+ /** |
+ * 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; |
+ |
+ this.fire('search-changed', newValue); |
+ this.lastValue_ = newValue; |
+ }, |
+ |
+ onSearchTermKeydown: function(e) { |
+ if (e.key == 'Escape') |
+ this.showingSearch = false; |
+ }, |
+ |
+ /** @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, |
+ }, |
+ |
+ // 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, |
+ }, |
+ |
+ listeners: { |
+ 'tap': 'showSearch_', |
+ 'searchInput.bind-value-changed': 'onBindValueChanged_', |
+ }, |
+ |
+ /** @return {!HTMLInputElement} */ |
+ getSearchInput: function() { |
+ return this.$.searchInput; |
+ }, |
+ |
+ /** @return {boolean} */ |
+ isSearchFocused: function() { |
+ return this.$.searchTerm.focused; |
+ }, |
+ |
+ /** |
+ * @param {boolean} narrow |
+ * @return {number} |
+ * @private |
+ */ |
+ computeIconTabIndex_: function(narrow) { |
+ return narrow ? 0 : -1; |
+ }, |
+ |
+ /** |
+ * @param {boolean} spinnerActive |
+ * @param {boolean} showingSearch |
+ * @return {boolean} |
+ * @private |
+ */ |
+ isSpinnerShown_: function(spinnerActive, showingSearch) { |
+ return spinnerActive && showingSearch; |
+ }, |
+ |
+ /** @private */ |
+ onInputBlur_: function() { |
+ if (!this.hasSearchText_) |
+ this.showingSearch = false; |
+ }, |
+ |
+ /** |
+ * 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; |
+ }, |
+ |
+ /** |
+ * @param {Event} e |
+ * @private |
+ */ |
+ showSearch_: function(e) { |
+ if (e.target != this.$.clearSearch) |
+ this.showingSearch = true; |
+ }, |
+ |
+ /** |
+ * @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. |
+ |
+Polymer({ |
+ is: 'cr-toolbar', |
+ |
+ properties: { |
+ // Name to display in the toolbar, in titlecase. |
+ pageName: String, |
+ |
+ // Prompt text to display in the search field. |
+ searchPrompt: String, |
+ |
+ // Tooltip to display on the clear search button. |
+ clearLabel: String, |
+ |
+ // 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 |
+ }, |
+ |
+ /** @private */ |
+ narrow_: { |
+ type: Boolean, |
+ reflectToAttribute: true |
+ }, |
+ |
+ /** @private */ |
+ showingSearch_: { |
+ type: Boolean, |
+ reflectToAttribute: true, |
+ }, |
+ }, |
+ |
+ /** @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. |
+ count: { |
+ type: Number, |
+ value: 0, |
+ observer: 'changeToolbarView_' |
+ }, |
+ |
+ // True if 1 or more history items are selected. When this value changes |
+ // the background colour changes. |
+ itemsSelected_: { |
+ type: Boolean, |
+ value: false, |
+ reflectToAttribute: true |
+ }, |
+ |
+ // The most recent term entered in the search field. Updated incrementally |
+ // as the user types. |
+ searchTerm: { |
+ type: String, |
+ notify: true, |
+ }, |
+ |
+ // True if the backend is processing and a spinner should be shown in the |
+ // toolbar. |
+ spinnerActive: { |
+ type: Boolean, |
+ value: false |
+ }, |
+ |
+ hasDrawer: { |
+ type: Boolean, |
+ observer: 'hasDrawerChanged_', |
+ reflectToAttribute: true, |
+ }, |
+ |
+ // Whether domain-grouped history is enabled. |
+ isGroupedMode: { |
+ type: Boolean, |
+ reflectToAttribute: true, |
+ }, |
+ |
+ // The period to search over. Matches BrowsingHistoryHandler::Range. |
+ groupedRange: { |
+ type: Number, |
+ value: 0, |
+ reflectToAttribute: true, |
+ notify: true |
+ }, |
+ |
+ // The start time of the query range. |
+ queryStartTime: String, |
+ |
+ // The end time of the query range. |
+ queryEndTime: String, |
+ }, |
+ |
+ /** |
+ * Changes the toolbar background color depending on whether any history items |
+ * are currently selected. |
+ * @private |
+ */ |
+ changeToolbarView_: function() { |
+ this.itemsSelected_ = this.count > 0; |
+ }, |
+ |
+ /** |
+ * 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; |
+ |
+ this.searchTerm = search; |
+ var searchField = /** @type {!CrToolbarElement} */(this.$['main-toolbar']) |
+ .getSearchField(); |
+ searchField.showAndFocus(); |
+ searchField.setValue(search); |
+ }, |
+ |
+ /** |
+ * @param {!CustomEvent} event |
+ * @private |
+ */ |
+ onSearchChanged_: function(event) { |
+ this.searchTerm = /** @type {string} */ (event.detail); |
+ }, |
+ |
+ onClearSelectionTap_: function() { |
+ this.fire('unselect-all'); |
+ }, |
+ |
+ onDeleteTap_: function() { |
+ this.fire('delete-selected'); |
+ }, |
+ |
+ get searchBar() { |
+ return this.$['main-toolbar'].getSearchField(); |
+ }, |
+ |
+ showSearchField: function() { |
+ /** @type {!CrToolbarElement} */(this.$['main-toolbar']) |
+ .getSearchField() |
+ .showAndFocus(); |
+ }, |
+ |
+ /** |
+ * If the user is a supervised user the delete button is not shown. |
+ * @private |
+ */ |
+ deletingAllowed_: function() { |
+ return loadTimeData.getBoolean('allowDeletingHistory'); |
+ }, |
+ |
+ numberOfItemsSelected_: function(count) { |
+ return count > 0 ? loadTimeData.getStringF('itemsSelected', count) : ''; |
+ }, |
+ |
+ getHistoryInterval_: function(queryStartTime, queryEndTime) { |
+ // TODO(calamity): Fix the format of these dates. |
+ return loadTimeData.getStringF( |
+ 'historyInterval', queryStartTime, queryEndTime); |
+ }, |
+ |
+ /** @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. |
+ |
+/** |
+ * @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', |
+ |
+ cancel: function() { |
+ this.fire('cancel'); |
+ HTMLDialogElement.prototype.close.call(this, ''); |
+ }, |
+ |
+ /** |
+ * @param {string=} opt_returnValue |
+ * @override |
+ */ |
+ close: function(opt_returnValue) { |
+ HTMLDialogElement.prototype.close.call(this, 'success'); |
+ }, |
+ |
+ /** @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. |
+ |
+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, |
+ value: function() { |
+ return 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 |
+ }, |
+ |
+ /** |
+ * The orientation against which to align the element vertically |
+ * relative to the `positionTarget`. Possible values are "top", "bottom", "auto". |
+ */ |
+ verticalAlign: { |
+ type: String |
+ }, |
+ |
+ /** |
+ * 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 |
+ }, |
+ |
+ /** |
+ * The same as setting margin-left and margin-right css properties. |
+ * @deprecated |
+ */ |
+ horizontalOffset: { |
+ type: Number, |
+ value: 0, |
+ notify: true |
+ }, |
+ |
+ /** |
+ * The same as setting margin-top and margin-bottom css properties. |
+ * @deprecated |
+ */ |
+ verticalOffset: { |
+ type: Number, |
+ value: 0, |
+ notify: true |
+ }, |
+ |
+ /** |
+ * Set to true to auto-fit on attach. |
+ */ |
+ autoFitOnAttach: { |
+ type: Boolean, |
+ value: false |
+ }, |
+ |
+ /** @type {?Object} */ |
+ _fitInfo: { |
+ type: Object |
+ } |
+ }, |
+ |
+ get _fitWidth() { |
+ var fitWidth; |
+ if (this.fitInto === window) { |
+ fitWidth = this.fitInto.innerWidth; |
+ } else { |
+ fitWidth = this.fitInto.getBoundingClientRect().width; |
+ } |
+ return fitWidth; |
+ }, |
+ |
+ get _fitHeight() { |
+ var fitHeight; |
+ if (this.fitInto === window) { |
+ fitHeight = this.fitInto.innerHeight; |
+ } else { |
+ fitHeight = this.fitInto.getBoundingClientRect().height; |
+ } |
+ return fitHeight; |
+ }, |
+ |
+ get _fitLeft() { |
+ var fitLeft; |
+ if (this.fitInto === window) { |
+ fitLeft = 0; |
+ } else { |
+ fitLeft = this.fitInto.getBoundingClientRect().left; |
+ } |
+ return fitLeft; |
+ }, |
+ |
+ get _fitTop() { |
+ var fitTop; |
+ if (this.fitInto === window) { |
+ fitTop = 0; |
+ } else { |
+ fitTop = this.fitInto.getBoundingClientRect().top; |
+ } |
+ 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; |
+ |
+ if (parent && parent.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { |
+ parent = parent.host; |
+ } |
+ |
+ return parent; |
+ }, |
+ |
+ /** |
+ * 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'; |
+ } |
+ } |
+ 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(); |
+ } |
+ } |
+ }, |
+ |
+ /** |
+ * Positions and fits the element into the `fitInto` element. |
+ */ |
+ fit: function() { |
+ this.position(); |
+ this.constrain(); |
+ this.center(); |
+ }, |
+ |
+ /** |
+ * Memoize information needed to position and size the target element. |
+ * @suppress {deprecated} |
+ */ |
+ _discoverInfo: function() { |
+ if (this._fitInfo) { |
+ return; |
+ } |
+ 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 |
+ } |
+ }; |
+ |
+ // 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'; |
+ } |
+ }, |
+ |
+ /** |
+ * 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]; |
+ } |
+ for (var property in info.inlineStyle) { |
+ this.style[property] = info.inlineStyle[property]; |
+ } |
+ |
+ this._fitInfo = null; |
+ }, |
+ |
+ /** |
+ * 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; |
+ }, |
+ |
+ /** |
+ * 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; |
+ } |
+ 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); |
+ |
+ var margin = this._fitInfo.margin; |
+ |
+ // 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 |
+ }; |
+ |
+ var position = this.__getPosition(this._localeHorizontalAlign, this.verticalAlign, size, positionRect, fitRect); |
+ |
+ var left = position.left + margin.left; |
+ var top = position.top + margin.top; |
+ |
+ // 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); |
+ |
+ 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; |
+ } |
+ } |
+ |
+ 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'; |
+ }, |
+ |
+ /** |
+ * 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(); |
+ |
+ 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'); |
+ } |
+ }, |
+ |
+ /** |
+ * @protected |
+ * @deprecated |
+ */ |
+ _sizeDimension: function(rect, positionedBy, start, end, extent) { |
+ this.__sizeDimension(rect, positionedBy, start, end, extent); |
+ }, |
+ |
+ /** |
+ * @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'; |
+ }, |
+ |
+ /** |
+ * Centers horizontally and vertically if not already positioned. This also sets |
+ * `position:fixed`. |
+ */ |
+ center: function() { |
+ if (this.horizontalAlign || this.verticalAlign) { |
+ return; |
+ } |
+ this._discoverInfo(); |
+ |
+ 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'; |
+ } |
+ }, |
+ |
+ __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(); |
+ }, |
+ |
+ __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; |
+ }, |
+ |
+ |
+ __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 |
+ }]; |
+ |
+ 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; |
+ } |
+ |
+ // 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]; |
+ |
+ // 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; |
+ } |
+ |
+ // 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); |
+ |
+ // 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; |
+ } |
+ |
+ 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; |
+ } |
+ } |
+ |
+ return position; |
+ } |
+ |
+ }; |
+(function() { |
+'use strict'; |
+ |
+ Polymer({ |
+ |
+ is: 'iron-overlay-backdrop', |
+ |
+ properties: { |
+ |
+ /** |
+ * Returns true if the backdrop is opened. |
+ */ |
+ opened: { |
+ reflectToAttribute: true, |
+ type: Boolean, |
+ value: false, |
+ observer: '_openedChanged' |
+ } |
+ |
+ }, |
+ |
+ listeners: { |
+ 'transitionend': '_onTransitionend' |
+ }, |
+ |
+ created: function() { |
+ // Used to cancel previous requestAnimationFrame calls when opened changes. |
+ this.__openedRaf = null; |
+ }, |
+ |
+ attached: function() { |
+ this.opened && this._openedChanged(this.opened); |
+ }, |
+ |
+ /** |
+ * Appends the backdrop to document body if needed. |
+ */ |
+ prepare: function() { |
+ if (this.opened && !this.parentNode) { |
+ Polymer.dom(document.body).appendChild(this); |
+ } |
+ }, |
+ |
+ /** |
+ * Shows the backdrop. |
+ */ |
+ open: function() { |
+ this.opened = true; |
+ }, |
+ |
+ /** |
+ * 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); |
+ } |
+ }, |
+ |
+ _onTransitionend: function(event) { |
+ if (event && event.target === this) { |
+ this.complete(); |
+ } |
+ }, |
+ |
+ /** |
+ * @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(); |
+ } |
+ } |
+ |
+ if (!this.isAttached) { |
+ return; |
+ } |
+ |
+ // 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() { |
+ /** |
+ * Used to keep track of the opened overlays. |
+ * @private {Array<Element>} |
+ */ |
+ 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.IronOverlayManagerClass.prototype = { |
+ |
+ constructor: Polymer.IronOverlayManagerClass, |
+ |
+ /** |
+ * The shared backdrop element. |
+ * @type {!Element} backdropElement |
+ */ |
+ get backdropElement() { |
+ if (!this._backdropElement) { |
+ this._backdropElement = document.createElement('iron-overlay-backdrop'); |
+ } |
+ return this._backdropElement; |
+ }, |
+ |
+ /** |
+ * 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; |
+ }, |
+ |
+ /** |
+ * 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); |
+ } |
+ |
+ // Shift other overlays behind the new on top. |
+ while (i < lastI) { |
+ this._overlays[i] = this._overlays[i + 1]; |
+ i++; |
+ } |
+ this._overlays[lastI] = overlay; |
+ }, |
+ |
+ /** |
+ * 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); |
+ } |
+ }, |
+ |
+ /** |
+ * 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); |
+ |
+ // 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); |
+ } |
+ |
+ // Update z-index and insert overlay. |
+ if (newZ <= minimumZ) { |
+ this._applyOverlayZ(overlay, minimumZ); |
+ } |
+ this._overlays.splice(insertionIndex, 0, overlay); |
+ |
+ 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(); |
+ }, |
+ |
+ /** |
+ * Returns the current overlay. |
+ * @return {Element|undefined} |
+ */ |
+ currentOverlay: function() { |
+ var i = this._overlays.length - 1; |
+ return this._overlays[i]; |
+ }, |
+ |
+ /** |
+ * Returns the current overlay z-index. |
+ * @return {number} |
+ */ |
+ currentOverlayZ: function() { |
+ return this._getZ(this.currentOverlay()); |
+ }, |
+ |
+ /** |
+ * 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); |
+ }, |
+ |
+ focusOverlay: function() { |
+ var current = /** @type {?} */ (this.currentOverlay()); |
+ if (current) { |
+ current._applyFocus(); |
+ } |
+ }, |
+ |
+ /** |
+ * 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; |
+ }, |
+ |
+ /** |
+ * @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; |
+ }, |
+ |
+ /** |
+ * Returns the z-index for the backdrop. |
+ * @return {number} |
+ */ |
+ backdropZ: function() { |
+ return this._getZ(this._overlayWithBackdrop()) - 1; |
+ }, |
+ |
+ /** |
+ * 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]; |
+ } |
+ } |
+ }, |
+ |
+ /** |
+ * 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. |
+ */ |
+ canceled: { |
+ observer: '_canceledChanged', |
+ readOnly: true, |
+ type: Boolean, |
+ value: false |
+ }, |
+ |
+ /** |
+ * 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 |
+ }, |
+ |
+ /** |
+ * 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 |
+ }, |
+ |
+ /** |
+ * Set to true to disable canceling the overlay with the ESC key. |
+ */ |
+ noCancelOnEscKey: { |
+ type: Boolean, |
+ value: false |
+ }, |
+ |
+ /** |
+ * Set to true to disable canceling the overlay by clicking outside it. |
+ */ |
+ noCancelOnOutsideClick: { |
+ type: Boolean, |
+ value: false |
+ }, |
+ |
+ /** |
+ * 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 |
+ }, |
+ |
+ /** |
+ * Set to true to enable restoring of focus when overlay is closed. |
+ */ |
+ restoreFocusOnClose: { |
+ type: Boolean, |
+ value: false |
+ }, |
+ |
+ /** |
+ * Set to true to keep overlay always on top. |
+ */ |
+ alwaysOnTop: { |
+ type: Boolean |
+ }, |
+ |
+ /** |
+ * Shortcut to access to the overlay manager. |
+ * @private |
+ * @type {Polymer.IronOverlayManagerClass} |
+ */ |
+ _manager: { |
+ type: Object, |
+ value: Polymer.IronOverlayManager |
+ }, |
+ |
+ /** |
+ * The node being focused. |
+ * @type {?Node} |
+ */ |
+ _focusedChild: { |
+ 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); |
+ } |
+ // 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 |
+ }, |
+ |
+ /** |
+ * 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({ |
- __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; |
- }, |
+ is: 'fade-in-animation', |
+ behaviors: [ |
+ Polymer.NeonAnimationBehavior |
+ ], |
- __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 |
- }]; |
+ configure: function(config) { |
+ var node = config.node; |
+ this._effect = new KeyframeEffect(node, [ |
+ {'opacity': '0'}, |
+ {'opacity': '1'} |
+ ], this.timingFromConfig(config)); |
+ return this._effect; |
+ } |
- 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; |
- } |
+ }); |
+Polymer({ |
- // Consider auto as null for coding convenience. |
- vAlign = vAlign === 'auto' ? null : vAlign; |
- hAlign = hAlign === 'auto' ? null : hAlign; |
+ is: 'fade-out-animation', |
- var position; |
- for (var i = 0; i < positions.length; i++) { |
- var pos = positions[i]; |
+ behaviors: [ |
+ Polymer.NeonAnimationBehavior |
+ ], |
- // 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; |
- } |
+ configure: function(config) { |
+ var node = config.node; |
+ this._effect = new KeyframeEffect(node, [ |
+ {'opacity': '1'}, |
+ {'opacity': '0'} |
+ ], this.timingFromConfig(config)); |
+ return this._effect; |
+ } |
- // 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); |
+ }); |
+Polymer({ |
+ is: 'paper-menu-grow-height-animation', |
- // 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; |
- } |
+ behaviors: [ |
+ Polymer.NeonAnimationBehavior |
+ ], |
- 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; |
- } |
- } |
+ configure: function(config) { |
+ var node = config.node; |
+ var rect = node.getBoundingClientRect(); |
+ var height = rect.height; |
- return position; |
+ this._effect = new KeyframeEffect(node, [{ |
+ height: (height / 2) + 'px' |
+ }, { |
+ height: height + 'px' |
+ }], this.timingFromConfig(config)); |
+ |
+ return this._effect; |
} |
+ }); |
- }; |
-(function() { |
-'use strict'; |
+ 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', |
- is: 'iron-overlay-backdrop', |
+ behaviors: [ |
+ Polymer.NeonAnimationBehavior |
+ ], |
- properties: { |
+ configure: function(config) { |
+ var node = config.node; |
+ var rect = node.getBoundingClientRect(); |
+ var width = rect.width; |
- /** |
- * Returns true if the backdrop is opened. |
- */ |
- opened: { |
- reflectToAttribute: true, |
- type: Boolean, |
- value: false, |
- observer: '_openedChanged' |
- } |
+ this._effect = new KeyframeEffect(node, [{ |
+ width: width + 'px' |
+ }, { |
+ width: width - (width / 20) + 'px' |
+ }], this.timingFromConfig(config)); |
- }, |
+ return this._effect; |
+ } |
+ }); |
- listeners: { |
- 'transitionend': '_onTransitionend' |
- }, |
+ Polymer({ |
+ is: 'paper-menu-shrink-height-animation', |
- created: function() { |
- // Used to cancel previous requestAnimationFrame calls when opened changes. |
- this.__openedRaf = null; |
- }, |
+ behaviors: [ |
+ Polymer.NeonAnimationBehavior |
+ ], |
- attached: function() { |
- this.opened && this._openedChanged(this.opened); |
- }, |
+ configure: function(config) { |
+ var node = config.node; |
+ var rect = node.getBoundingClientRect(); |
+ var height = rect.height; |
+ var top = rect.top; |
- /** |
- * Appends the backdrop to document body if needed. |
- */ |
- prepare: function() { |
- if (this.opened && !this.parentNode) { |
- Polymer.dom(document.body).appendChild(this); |
- } |
- }, |
+ this.setPrefixedProperty(node, 'transformOrigin', '0 0'); |
- /** |
- * Shows the backdrop. |
- */ |
- open: function() { |
- this.opened = true; |
- }, |
+ this._effect = new KeyframeEffect(node, [{ |
+ height: height + 'px', |
+ transform: 'translateY(0)' |
+ }, { |
+ height: height / 2 + 'px', |
+ transform: 'translateY(-20px)' |
+ }], this.timingFromConfig(config)); |
- /** |
- * Hides the backdrop. |
- */ |
- close: function() { |
- this.opened = false; |
- }, |
+ 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. |
- /** |
- * Removes the backdrop from document body if needed. |
- */ |
- complete: function() { |
- if (!this.opened && this.parentNode === document.body) { |
- Polymer.dom(this.parentNode).removeChild(this); |
- } |
- }, |
+/** Same as paper-menu-button's custom easing cubic-bezier param. */ |
+var SLIDE_CUBIC_BEZIER = 'cubic-bezier(0.3, 0.95, 0.5, 1)'; |
- _onTransitionend: function(event) { |
- if (event && event.target === this) { |
- this.complete(); |
- } |
+Polymer({ |
+ is: 'cr-shared-menu', |
+ |
+ behaviors: [Polymer.IronA11yKeysBehavior], |
+ |
+ properties: { |
+ menuOpen: { |
+ type: Boolean, |
+ observer: 'menuOpenChanged_', |
+ value: false, |
}, |
/** |
- * @param {boolean} opened |
- * @private |
+ * 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} |
*/ |
- _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(); |
- } |
+ itemData: { |
+ type: Object, |
+ value: null, |
+ }, |
+ |
+ /** @override */ |
+ keyEventTarget: { |
+ type: Object, |
+ value: function() { |
+ return this.$.menu; |
} |
+ }, |
- if (!this.isAttached) { |
- return; |
+ 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 |
+ } |
+ }]; |
} |
+ }, |
- // Always cancel previous requestAnimationFrame. |
- if (this.__openedRaf) { |
- window.cancelAnimationFrame(this.__openedRaf); |
- this.__openedRaf = null; |
+ closeAnimationConfig: { |
+ type: Object, |
+ value: function() { |
+ return [{ |
+ name: 'fade-out-animation', |
+ timing: { |
+ duration: 150 |
+ } |
+ }]; |
} |
- // 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 |
+ 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} |
*/ |
- Polymer.IronOverlayManagerClass = function() { |
- /** |
- * Used to keep track of the opened overlays. |
- * @private {Array<Element>} |
- */ |
- this._overlays = []; |
+ lastAnchor_: null, |
- /** |
- * iframes have a default z-index of 100, |
- * so this default should be at least that. |
- * @private {number} |
- */ |
- this._minimumZ = 101; |
+ /** |
+ * The first focusable child in the menu's light DOM. |
+ * @private {?Element} |
+ */ |
+ firstFocus_: null, |
- /** |
- * Memoized backdrop element. |
- * @private {Element|null} |
- */ |
- this._backdropElement = null; |
+ /** |
+ * The last focusable child in the menu's light DOM. |
+ * @private {?Element} |
+ */ |
+ lastFocus_: null, |
- // Enable document-wide tap recognizer. |
- Polymer.Gestures.add(document, 'tap', this._onCaptureClick.bind(this)); |
+ /** @override */ |
+ attached: function() { |
+ window.addEventListener('resize', this.closeMenu.bind(this)); |
+ }, |
- document.addEventListener('focus', this._onCaptureFocus.bind(this), true); |
- document.addEventListener('keydown', this._onCaptureKeyDown.bind(this), true); |
- }; |
+ /** 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; |
+ }, |
- Polymer.IronOverlayManagerClass.prototype = { |
+ /** |
+ * 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; |
- constructor: Polymer.IronOverlayManagerClass, |
+ if (this.menuOpen) |
+ this.closeMenu(); |
- /** |
- * The shared backdrop element. |
- * @type {!Element} backdropElement |
- */ |
- get backdropElement() { |
- if (!this._backdropElement) { |
- this._backdropElement = document.createElement('iron-overlay-backdrop'); |
- } |
- return this._backdropElement; |
- }, |
+ this.itemData = itemData; |
+ this.lastAnchor_ = anchor; |
+ this.$.dropdown.restoreFocusOnClose = true; |
- /** |
- * 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; |
- }, |
+ 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]; |
+ } |
- /** |
- * 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); |
- } |
+ // Move the menu to the anchor. |
+ this.$.dropdown.positionTarget = anchor; |
+ this.menuOpen = true; |
+ }, |
- // Shift other overlays behind the new on top. |
- while (i < lastI) { |
- this._overlays[i] = this._overlays[i + 1]; |
- i++; |
- } |
- this._overlays[lastI] = overlay; |
- }, |
+ /** |
+ * 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); |
+ }, |
- /** |
- * 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); |
- } |
- }, |
+ /** |
+ * 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; |
- /** |
- * 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); |
+ 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_; |
- // 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); |
- } |
+ if (!toFocus) |
+ return; |
- // Update z-index and insert overlay. |
- if (newZ <= minimumZ) { |
- this._applyOverlayZ(overlay, minimumZ); |
- } |
- this._overlays.splice(insertionIndex, 0, overlay); |
+ e.preventDefault(); |
+ toFocus.focus(); |
+ }, |
- this.trackBackdrop(); |
+ /** |
+ * 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'; |
}, |
/** |
- * @param {!Element} overlay |
+ * `maxWidth` or `maxHeight`. |
+ * @private |
*/ |
- removeOverlay: function(overlay) { |
- var i = this._overlays.indexOf(overlay); |
- if (i === -1) { |
- return; |
- } |
- this._overlays.splice(i, 1); |
- |
- this.trackBackdrop(); |
+ get _dimensionMax() { |
+ return this.horizontal ? 'maxWidth' : 'maxHeight'; |
}, |
/** |
- * Returns the current overlay. |
- * @return {Element|undefined} |
+ * `max-width` or `max-height`. |
+ * @private |
*/ |
- currentOverlay: function() { |
- var i = this._overlays.length - 1; |
- return this._overlays[i]; |
+ get _dimensionMaxCss() { |
+ return this.horizontal ? 'max-width' : 'max-height'; |
}, |
- /** |
- * Returns the current overlay z-index. |
- * @return {number} |
- */ |
- currentOverlayZ: function() { |
- return this._getZ(this.currentOverlay()); |
+ hostAttributes: { |
+ role: 'group', |
+ 'aria-hidden': 'true', |
+ 'aria-expanded': 'false' |
}, |
- /** |
- * 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); |
+ listeners: { |
+ transitionend: '_transitionEnd' |
}, |
- focusOverlay: function() { |
- var current = /** @type {?} */ (this.currentOverlay()); |
- if (current) { |
- current._applyFocus(); |
- } |
+ attached: function() { |
+ // It will take care of setting correct classes and styles. |
+ this._transitionEnd(); |
}, |
/** |
- * Updates the backdrop z-index. |
+ * Toggle the opened state. |
+ * |
+ * @method toggle |
*/ |
- 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; |
+ toggle: function() { |
+ this.opened = !this.opened; |
}, |
- /** |
- * @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; |
+ show: function() { |
+ this.opened = true; |
}, |
- /** |
- * Returns the z-index for the backdrop. |
- * @return {number} |
- */ |
- backdropZ: function() { |
- return this._getZ(this._overlayWithBackdrop()) - 1; |
+ hide: function() { |
+ this.opened = false; |
}, |
/** |
- * Returns the first opened overlay that has a backdrop. |
- * @return {Element|undefined} |
- * @private |
+ * 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. |
*/ |
- _overlayWithBackdrop: function() { |
- for (var i = 0; i < this._overlays.length; i++) { |
- if (this._overlays[i].withBackdrop) { |
- return this._overlays[i]; |
- } |
+ updateSize: function(size, animated) { |
+ // No change! |
+ var curSize = this.style[this._dimensionMax]; |
+ if (curSize === size || (size === 'auto' && !curSize)) { |
+ return; |
} |
- }, |
- /** |
- * 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; |
- } |
+ 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; |
} |
- return z; |
}, |
/** |
- * @param {!Element} element |
- * @param {number|string} z |
- * @private |
+ * 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 |
*/ |
- _setZ: function(element, z) { |
- element.style.zIndex = z; |
+ enableTransition: function(enabled) { |
+ Polymer.Base._warn('`enableTransition()` is deprecated, use `noAnimation` instead.'); |
+ this.noAnimation = !enabled; |
}, |
- /** |
- * @param {!Element} overlay |
- * @param {number} aboveZ |
- * @private |
- */ |
- _applyOverlayZ: function(overlay, aboveZ) { |
- this._setZ(overlay, aboveZ + 2); |
+ _updateTransition: function(enabled) { |
+ this.style.transitionDuration = (enabled && !this.noAnimation) ? '' : '0s'; |
}, |
- /** |
- * 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]; |
- } |
- } |
+ _horizontalChanged: function() { |
+ this.style.transitionProperty = this._dimensionMaxCss; |
+ var otherDimension = this._dimensionMax === 'maxWidth' ? 'maxHeight' : 'maxWidth'; |
+ this.style[otherDimension] = ''; |
+ this.updateSize(this.opened ? 'auto' : '0px', false); |
}, |
- /** |
- * 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); |
+ _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(); |
} |
}, |
- /** |
- * 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); |
+ _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(); |
}, |
/** |
- * Ensures TAB and ESC keyboard events are delegated to the right overlay. |
- * @param {!Event} event |
+ * Simplistic heuristic to detect if element has a parent with display: none |
+ * |
* @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); |
- } |
+ get _isDisplayed() { |
+ var rect = this.getBoundingClientRect(); |
+ for (var prop in rect) { |
+ if (rect[prop] !== 0) return true; |
} |
+ return false; |
}, |
- /** |
- * 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; |
+ _calcSize: function() { |
+ return this.getBoundingClientRect()[this.dimension] + 'px'; |
} |
- }; |
- |
- 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. |
+ Polymer.IronFormElementBehavior enables a custom element to be included |
+ in an `iron-form`. |
-### 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 = { |
+ @demo demo/index.html |
+ @polymerBehavior |
+ */ |
+ Polymer.IronFormElementBehavior = { |
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. |
+ * Fired when the element is added to an `iron-form`. |
+ * |
+ * @event iron-form-element-register |
*/ |
- canceled: { |
- observer: '_canceledChanged', |
- readOnly: true, |
- type: Boolean, |
- value: false |
- }, |
/** |
- * Set to true to display a backdrop behind the overlay. It traps the focus |
- * within the light DOM of the overlay. |
+ * Fired when the element is removed from an `iron-form`. |
+ * |
+ * @event iron-form-element-unregister |
*/ |
- withBackdrop: { |
- observer: '_withBackdropChanged', |
- type: Boolean |
- }, |
/** |
- * Set to true to disable auto-focusing the overlay or child nodes with |
- * the `autofocus` attribute` when the overlay is opened. |
+ * The name of this element. |
*/ |
- noAutoFocus: { |
- type: Boolean, |
- value: false |
+ name: { |
+ type: String |
}, |
/** |
- * Set to true to disable canceling the overlay with the ESC key. |
+ * The value for this element. |
*/ |
- noCancelOnEscKey: { |
- type: Boolean, |
- value: false |
+ value: { |
+ notify: true, |
+ type: String |
}, |
/** |
- * Set to true to disable canceling the overlay by clicking outside it. |
+ * 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. |
*/ |
- noCancelOnOutsideClick: { |
+ required: { |
type: Boolean, |
value: false |
}, |
/** |
- * 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`. |
+ * The form that the element is registered to. |
*/ |
- closingReason: { |
- // was a getter before, but needs to be a property so other |
- // behaviors can override this. |
+ _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: { |
/** |
- * Set to true to enable restoring of focus when overlay is closed. |
+ * Fired when the checked state changes. |
+ * |
+ * @event iron-change |
*/ |
- restoreFocusOnClose: { |
- type: Boolean, |
- value: false |
- }, |
/** |
- * Set to true to keep overlay always on top. |
+ * Gets or sets the state, `true` is checked and `false` is unchecked. |
*/ |
- alwaysOnTop: { |
- type: Boolean |
+ checked: { |
+ type: Boolean, |
+ value: false, |
+ reflectToAttribute: true, |
+ notify: true, |
+ observer: '_checkedChanged' |
}, |
/** |
- * Shortcut to access to the overlay manager. |
- * @private |
- * @type {Polymer.IronOverlayManagerClass} |
+ * If true, the button toggles the active state with each tap or press |
+ * of the spacebar. |
*/ |
- _manager: { |
- type: Object, |
- value: Polymer.IronOverlayManager |
+ toggles: { |
+ type: Boolean, |
+ value: true, |
+ reflectToAttribute: true |
}, |
- /** |
- * The node being focused. |
- * @type {?Node} |
- */ |
- _focusedChild: { |
- type: Object |
+ /* Overriden from Polymer.IronFormElementBehavior */ |
+ value: { |
+ type: String, |
+ value: 'on', |
+ observer: '_valueChanged' |
} |
- |
}, |
- listeners: { |
- 'iron-resize': '_onIronResize' |
+ 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; |
}, |
/** |
- * The backdrop element. |
- * @type {Element} |
+ * 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. |
*/ |
- get backdropElement() { |
- return this._manager.backdropElement; |
+ _getValidity: function(_value) { |
+ return this.disabled || !this.required || this.checked; |
}, |
/** |
- * Returns the node to give focus to. |
- * @type {Node} |
+ * Update the aria-required label when `required` is changed. |
*/ |
- get _focusNode() { |
- return this._focusedChild || Polymer.dom(this).querySelector('[autofocus]') || this; |
+ _requiredChanged: function() { |
+ if (this.required) { |
+ this.setAttribute('aria-required', 'true'); |
+ } else { |
+ this.removeAttribute('aria-required'); |
+ } |
}, |
/** |
- * 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 |
+ * Fire `iron-changed` when the checked state changes. |
*/ |
- 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]' |
- ]; |
+ _checkedChanged: function() { |
+ this.active = this.checked; |
+ this.fire('iron-change'); |
+ }, |
- // Elements that cannot be focused if they have [disabled] attribute. |
- var FOCUSABLE_WITHOUT_DISABLED = [ |
- 'input', |
- 'select', |
- 'textarea', |
- 'button' |
- ]; |
+ /** |
+ * Reset value to 'on' if it is set to `undefined`. |
+ */ |
+ _valueChanged: function() { |
+ if (this.value === undefined || this.value === null) { |
+ this.value = 'on'; |
+ } |
+ } |
+ }; |
- // 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"])'; |
+ /** @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'); |
+ } |
+ } |
+ }, |
- 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); |
+ /** |
+ * Synchronizes the element's `active` and `checked` state. |
+ */ |
+ _buttonStateChanged: function() { |
+ Polymer.PaperRippleBehavior._buttonStateChanged.call(this); |
+ if (this.disabled) { |
+ return; |
} |
- // Sort by tabindex. |
- return focusables.sort(function (a, b) { |
- if (a.tabIndex === b.tabIndex) { |
- return 0; |
+ 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' |
} |
- if (a.tabIndex === 0 || a.tabIndex > b.tabIndex) { |
- return 1; |
+ }, |
+ |
+ _computeCheckboxClass: function(checked, invalid) { |
+ var className = ''; |
+ if (checked) { |
+ className += 'checked '; |
} |
- return -1; |
- }); |
- }, |
+ if (invalid) { |
+ className += 'invalid'; |
+ } |
+ return className; |
+ }, |
- 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(); |
- }, |
+ _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. |
- 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); |
- }, |
+/** |
+ * @fileoverview Defines a singleton object, md_history.BrowserService, which |
+ * provides access to chrome.send APIs. |
+ */ |
- detached: function() { |
- Polymer.dom(this).unobserveNodes(this._observer); |
- this._observer = null; |
- if (this.__raf) { |
- window.cancelAnimationFrame(this.__raf); |
- this.__raf = null; |
+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); }); |
} |
- this._manager.removeOverlay(this); |
+ |
+ 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; |
}, |
/** |
- * Toggle the opened state of the overlay. |
+ * @param {!string} url |
*/ |
- toggle: function() { |
- this._setCanceled(false); |
- this.opened = !this.opened; |
+ removeBookmark: function(url) { |
+ chrome.send('removeBookmark', [url]); |
}, |
/** |
- * Open the overlay. |
+ * @param {string} sessionTag |
*/ |
- open: function() { |
- this._setCanceled(false); |
- this.opened = true; |
+ openForeignSessionAllTabs: function(sessionTag) { |
+ chrome.send('openForeignSession', [sessionTag]); |
}, |
/** |
- * Close the overlay. |
+ * @param {string} sessionTag |
+ * @param {number} windowId |
+ * @param {number} tabId |
+ * @param {MouseEvent} e |
*/ |
- close: function() { |
- this._setCanceled(false); |
- this.opened = false; |
+ 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 |
+ ]); |
}, |
/** |
- * Cancels the overlay. |
- * @param {Event=} event The original event |
+ * @param {string} sessionTag |
*/ |
- cancel: function(event) { |
- var cancelEvent = this.fire('iron-overlay-canceled', event, {cancelable: true}); |
- if (cancelEvent.defaultPrevented) { |
- return; |
- } |
- |
- this._setCanceled(true); |
- this.opened = false; |
+ deleteForeignSession: function(sessionTag) { |
+ chrome.send('deleteForeignSession', [sessionTag]); |
}, |
- _ensureSetup: function() { |
- if (this._overlaySetup) { |
- return; |
- } |
- this._overlaySetup = true; |
- this.style.outline = 'none'; |
- this.style.display = 'none'; |
+ openClearBrowsingData: function() { |
+ chrome.send('clearBrowsingData'); |
}, |
/** |
- * Called when `opened` changes. |
- * @param {boolean=} opened |
- * @protected |
+ * @param {boolean} successful |
+ * @private |
*/ |
- _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) { |
+ resolveDelete_: function(successful) { |
+ if (this.pendingDeleteItems_ == null || |
+ this.pendingDeletePromise_ == null) { |
return; |
} |
- this.__isAnimating = true; |
+ if (successful) |
+ this.pendingDeletePromise_.resolve(this.pendingDeleteItems_); |
+ else |
+ this.pendingDeletePromise_.reject(this.pendingDeleteItems_); |
- // Use requestAnimationFrame for non-blocking rendering. |
- this.__onNextAnimationFrame(this.__openedChanged); |
+ this.pendingDeleteItems_ = null; |
+ this.pendingDeletePromise_ = null; |
}, |
+ }; |
- _canceledChanged: function() { |
- this.closingReason = this.closingReason || {}; |
- this.closingReason.canceled = this.canceled; |
- }, |
+ cr.addSingletonGetter(BrowserService); |
- _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(); |
- } |
- }, |
+ return {BrowserService: BrowserService}; |
+}); |
- /** |
- * tasks which must occur before opening; e.g. making the element visible. |
- * @protected |
- */ |
- _prepareRenderOpened: function() { |
- // Store focused node. |
- this.__restoreFocusNode = this._manager.deepActiveElement; |
+/** |
+ * Called by the history backend when deletion was succesful. |
+ */ |
+function deleteComplete() { |
+ md_history.BrowserService.getInstance().resolveDelete_(true); |
+} |
- // 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(); |
+/** |
+ * 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. |
- // 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(); |
- } |
- }, |
+Polymer({ |
+ is: 'history-searched-label', |
- /** |
- * Tasks which cause the overlay to actually open; typically play an animation. |
- * @protected |
- */ |
- _renderOpened: function() { |
- this._finishRenderOpened(); |
- }, |
+ properties: { |
+ // The text to show in this label. |
+ title: String, |
- /** |
- * Tasks which cause the overlay to actually close; typically play an animation. |
- * @protected |
- */ |
- _renderClosed: function() { |
- this._finishRenderClosed(); |
- }, |
+ // The search term to bold within the title. |
+ searchTerm: String, |
+ }, |
- /** |
- * Tasks to be performed at the end of open action. Will fire `iron-overlay-opened`. |
- * @protected |
- */ |
- _finishRenderOpened: function() { |
- this.notifyResize(); |
- this.__isAnimating = false; |
+ observers: ['setSearchedTextToBold_(title, searchTerm)'], |
- // Store it so we don't query too much. |
- var focusableNodes = this._focusableNodes; |
- this.__firstFocusableNode = focusableNodes[0]; |
- this.__lastFocusableNode = focusableNodes[focusableNodes.length - 1]; |
+ /** |
+ * 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; |
- this.fire('iron-overlay-opened'); |
- }, |
+ if (this.searchTerm == '' || this.searchTerm == null) { |
+ titleElem.textContent = titleText; |
+ return; |
+ } |
- /** |
- * 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 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. |
- _preparePositioning: function() { |
- this.style.transition = this.style.webkitTransition = 'none'; |
- this.style.transform = this.style.webkitTransform = 'none'; |
- this.style.display = ''; |
- }, |
+cr.define('md_history', function() { |
+ var HistoryItem = Polymer({ |
+ is: 'history-item', |
- _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; |
- }, |
+ 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_'}, |
- /** |
- * 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(); |
- } |
- } |
- }, |
+ // Search term used to obtain this history-item. |
+ searchTerm: {type: String}, |
- /** |
- * Cancels (closes) the overlay. Call when click happens outside the overlay. |
- * @param {!Event} event |
- * @protected |
- */ |
- _onCaptureClick: function(event) { |
- if (!this.noCancelOnOutsideClick) { |
- this.cancel(event); |
- } |
- }, |
+ selected: {type: Boolean, notify: true}, |
- /** |
- * 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]; |
- } |
+ 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} |
}, |
/** |
- * Handles the ESC key event and cancels (closes) the overlay. |
- * @param {!Event} event |
- * @protected |
+ * When a history-item is selected the toolbar is notified and increases |
+ * or decreases its count of selected items accordingly. |
+ * @private |
*/ |
- _onCaptureEsc: function(event) { |
- if (!this.noCancelOnEscKey) { |
- this.cancel(event); |
- } |
+ onCheckboxSelected_: function() { |
+ this.fire('history-checkbox-select', { |
+ countAddition: this.$.checkbox.checked ? 1 : -1 |
+ }); |
}, |
/** |
- * Handles TAB key events to track focus changes. |
- * Will wrap focus for overlays withBackdrop. |
- * @param {!Event} event |
- * @protected |
+ * Remove bookmark of current item when bookmark-star is clicked. |
+ * @private |
*/ |
- _onCaptureTab: function(event) { |
- if (!this.withBackdrop) { |
+ onRemoveBookmarkTap_: function() { |
+ if (!this.item.starred) |
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(); |
- } |
+ 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); |
}, |
/** |
- * Refits if the overlay is opened and not animating. |
- * @protected |
+ * Fires a custom event when the menu button is clicked. Sends the details |
+ * of the history item and where the menu should appear. |
*/ |
- _onIronResize: function() { |
- if (this.opened && !this.__isAnimating) { |
- this.__onNextAnimationFrame(this.refit); |
- } |
+ 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(); |
}, |
/** |
- * Will call notifyResize if overlay is opened. |
- * Can be overridden in order to avoid multiple observers on the same node. |
- * @protected |
+ * Set the favicon image, based on the URL of the history item. |
+ * @private |
*/ |
- _onNodesChange: function() { |
- if (this.opened && !this.__isAnimating) { |
- this.notifyResize(); |
- } |
+ showIcon_: function() { |
+ this.$.icon.style.backgroundImage = |
+ cr.icon.getFaviconImageSet(this.item.url); |
+ }, |
+ |
+ selectionNotAllowed_: function() { |
+ return !loadTimeData.getBoolean('allowDeletingHistory'); |
}, |
/** |
- * Tasks executed when opened changes: prepare for the opening, move the |
- * focus, update the manager, render opened/closed. |
+ * 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 |
*/ |
- __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(); |
+ cardTitle_: function(numberOfItems, historyDate, search) { |
+ if (!search) |
+ return this.item.dateRelativeDay; |
- this._renderClosed(); |
- } |
+ var resultId = numberOfItems == 1 ? 'searchResult' : 'searchResults'; |
+ return loadTimeData.getStringF('foundSearchResults', numberOfItems, |
+ loadTimeData.getString(resultId), search); |
}, |
/** |
- * 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 |
+ * Crop long item titles to reduce their effect on layout performance. See |
+ * crbug.com/621347. |
+ * @param {string} title |
+ * @return {string} |
*/ |
- __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); |
- }); |
+ 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; |
}; |
- /** @polymerBehavior */ |
- Polymer.IronOverlayBehavior = [Polymer.IronFitBehavior, Polymer.IronResizableBehavior, Polymer.IronOverlayBehaviorImpl]; |
+ 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. |
+ |
+/** |
+ * @typedef {{domain: string, |
+ * visits: !Array<HistoryEntry>, |
+ * rendered: boolean, |
+ * expanded: boolean}} |
+ */ |
+var HistoryDomain; |
+ |
+/** |
+ * @typedef {{title: string, |
+ * domains: !Array<HistoryDomain>}} |
+ */ |
+var HistoryGroup; |
+ |
+// TODO(calamity): Support selection by refactoring selection out of |
+// history-list and into history-app. |
+Polymer({ |
+ is: 'history-grouped-list', |
+ |
+ 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)' |
+ ], |
/** |
- * Fired after the overlay opens. |
- * @event iron-overlay-opened |
+ * 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) |
+ }]; |
+ } |
+ }, |
/** |
- * 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). |
+ * @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); |
+ }, |
/** |
- * 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). |
+ * 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); |
+ }, |
+ |
+ hasResults_: function(historyDataLength) { |
+ return historyDataLength > 0; |
+ }, |
+ |
+ getWebsiteIconStyle_: function(domain) { |
+ return 'background-image: ' + |
+ cr.icon.getFaviconImageSet(domain.visits[0].url); |
+ }, |
+ |
+ getDropdownIcon_: function(expanded) { |
+ return expanded ? 'cr:expand-less' : 'cr:expand-more'; |
+ }, |
+ |
+ noResultsMessage_: function(searchedTerm) { |
+ var messageId = searchedTerm !== '' ? 'noSearchResults' : 'noResults'; |
+ return loadTimeData.getString(messageId); |
+ }, |
+}); |
/** |
- * `Polymer.NeonAnimatableBehavior` is implemented by elements containing animations for use with |
- * elements implementing `Polymer.NeonAnimationRunnerBehavior`. |
+ * `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.NeonAnimatableBehavior = { |
+ Polymer.IronScrollTargetBehavior = { |
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 |
+ * 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; |
+ } |
} |
- |
}, |
- _entryAnimationChanged: function() { |
- this.animationConfig = this.animationConfig || {}; |
- this.animationConfig['entry'] = [{ |
- name: this.entryAnimation, |
- node: this |
- }]; |
- }, |
+ observers: [ |
+ '_scrollTargetChanged(scrollTarget, isAttached)' |
+ ], |
- _exitAnimationChanged: function() { |
- this.animationConfig = this.animationConfig || {}; |
- this.animationConfig['exit'] = [{ |
- name: this.exitAnimation, |
- node: this |
- }]; |
- }, |
+ _scrollTargetChanged: function(scrollTarget, isAttached) { |
+ var eventTarget; |
- _copyProperties: function(config1, config2) { |
- // shallowly copy properties from config2 to config1 |
- for (var property in config2) { |
- config1[property] = config2[property]; |
+ if (this._oldScrollTarget) { |
+ eventTarget = this._oldScrollTarget === this._doc ? window : this._oldScrollTarget; |
+ eventTarget.removeEventListener('scroll', this._boundScrollHandler); |
+ this._oldScrollTarget = null; |
} |
- }, |
- |
- _cloneConfig: function(config) { |
- var clone = { |
- isClone: true |
- }; |
- this._copyProperties(clone, config); |
- return clone; |
- }, |
- _getAnimationConfigRecursive: function(type, map, allConfigs) { |
- if (!this.animationConfig) { |
+ if (!isAttached) { |
return; |
} |
+ // Support element id references |
+ if (scrollTarget === 'document') { |
- 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; |
- } |
+ this.scrollTarget = this._doc; |
- // type is optional |
- var thisConfig; |
- if (type) { |
- thisConfig = this.animationConfig[type]; |
- } else { |
- thisConfig = this.animationConfig; |
- } |
+ } else if (typeof scrollTarget === 'string') { |
- if (!Array.isArray(thisConfig)) { |
- thisConfig = [thisConfig]; |
- } |
+ this.scrollTarget = this.domHost ? this.domHost.$[scrollTarget] : |
+ Polymer.dom(this.ownerDocument).querySelector('#' + scrollTarget); |
- // 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); |
- } |
- } |
- } |
+ } 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); |
} |
}, |
/** |
- * 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. |
+ * Runs on every scroll event. Consumer of this behavior may override this method. |
+ * |
+ * @protected |
*/ |
- 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; |
- } |
+ _scrollHandler: function scrollHandler() {}, |
- }; |
-/** |
- * `Polymer.NeonAnimationRunnerBehavior` adds a method to run animations. |
- * |
- * @polymerBehavior Polymer.NeonAnimationRunnerBehavior |
- */ |
- Polymer.NeonAnimationRunnerBehaviorImpl = { |
+ /** |
+ * The default scroll target. Consumers of this behavior may want to customize |
+ * the default scroll target. |
+ * |
+ * @type {Element} |
+ */ |
+ get _defaultScrollTarget() { |
+ return this._doc; |
+ }, |
- _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; |
+ /** |
+ * Shortcut for the document element |
+ * |
+ * @type {Element} |
+ */ |
+ get _doc() { |
+ return this.ownerDocument.documentElement; |
}, |
- _shouldComplete: function(activeEntries) { |
- var finished = true; |
- for (var i = 0; i < activeEntries.length; i++) { |
- if (activeEntries[i].animation.playState != 'finished') { |
- finished = false; |
- break; |
- } |
+ /** |
+ * 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 finished; |
+ return 0; |
}, |
- _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(); |
+ /** |
+ * 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; |
}, |
/** |
- * Plays an animation with an optional `type`. |
- * @param {string=} type |
- * @param {!Object=} cookie |
+ * Sets the number of pixels that the content of an element is scrolled upward. |
+ * |
+ * @type {number} |
*/ |
- 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]; |
+ set _scrollTop(top) { |
+ if (this.scrollTarget === this._doc) { |
+ window.scrollTo(window.pageXOffset, top); |
+ } else if (this._isValidScrollTarget()) { |
+ this.scrollTarget.scrollTop = top; |
} |
+ }, |
- var activeEntries = this._configureAnimations(configs); |
- |
- if (activeEntries.length == 0) { |
- this.fire('neon-animation-finish', cookie, {bubbles: false}); |
- return; |
+ /** |
+ * Sets the number of pixels that the content of an element is scrolled to the left. |
+ * |
+ * @type {number} |
+ */ |
+ set _scrollLeft(left) { |
+ if (this.scrollTarget === this._doc) { |
+ window.scrollTo(left, window.pageYOffset); |
+ } else if (this._isValidScrollTarget()) { |
+ this.scrollTarget.scrollLeft = left; |
} |
+ }, |
- this._active[type] = activeEntries; |
+ /** |
+ * 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; |
+ } |
+ }, |
- 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); |
+ /** |
+ * 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; |
}, |
/** |
- * Cancels the currently running animations. |
+ * Gets the height of the scroll target. |
+ * |
+ * @type {number} |
*/ |
- cancelAnimation: function() { |
- for (var k in this._animations) { |
- this._animations[k].cancel(); |
+ get _scrollTargetHeight() { |
+ if (this._isValidScrollTarget()) { |
+ return this.scrollTarget === this._doc ? window.innerHeight : this.scrollTarget.offsetHeight; |
} |
- this._animations = {}; |
+ return 0; |
+ }, |
+ |
+ /** |
+ * Returns true if the scroll target is a valid HTMLElement. |
+ * |
+ * @return {boolean} |
+ */ |
+ _isValidScrollTarget: function() { |
+ return this.scrollTarget instanceof HTMLElement; |
} |
}; |
+(function() { |
- /** @polymerBehavior Polymer.NeonAnimationRunnerBehavior */ |
- Polymer.NeonAnimationRunnerBehavior = [ |
- Polymer.NeonAnimatableBehavior, |
- Polymer.NeonAnimationRunnerBehaviorImpl |
- ]; |
-/** |
- * Use `Polymer.NeonAnimationBehavior` to implement an animation. |
- * @polymerBehavior |
- */ |
- Polymer.NeonAnimationBehavior = { |
+ 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: { |
/** |
- * Defines the animation timing. |
+ * An array containing items determining how many instances of the template |
+ * to stamp and that that each template instance should bind to. |
*/ |
- animationTiming: { |
+ 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, |
- value: function() { |
- return { |
- duration: 500, |
- easing: 'cubic-bezier(0.4, 0, 0.2, 1)', |
- fill: 'both' |
- } |
- } |
+ 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' |
}, |
/** |
- * Can be used to determine that elements implement this behavior. |
+ * 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. |
*/ |
- isNeonAnimation: true, |
+ _ratio: 0.5, |
/** |
- * Do any animation configuration here. |
+ * The padding-top value for the list. |
*/ |
- // configure: function(config) { |
- // }, |
+ _scrollerPaddingTop: 0, |
/** |
- * Returns the animation timing by mixing in properties from `config` to the defaults defined |
- * by the animation. |
+ * This value is the same as `scrollTop`. |
*/ |
- timingFromConfig: function(config) { |
- if (config.timing) { |
- for (var property in config.timing) { |
- this.animationTiming[property] = config.timing[property]; |
- } |
- } |
- return this.animationTiming; |
- }, |
+ _scrollPosition: 0, |
/** |
- * Sets `transform` and `transformOrigin` properties along with the prefixed versions. |
+ * The sum of the heights of all the tiles in the DOM. |
*/ |
- 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; |
- }, |
+ _physicalSize: 0, |
/** |
- * Called when the animation finishes. |
+ * The average `offsetHeight` of the tiles observed till now. |
*/ |
- complete: function() {} |
- |
- }; |
-Polymer({ |
+ _physicalAverage: 0, |
- is: 'opaque-animation', |
+ /** |
+ * The number of tiles which `offsetHeight` > 0 observed until now. |
+ */ |
+ _physicalAverageCount: 0, |
- behaviors: [ |
- Polymer.NeonAnimationBehavior |
- ], |
+ /** |
+ * The Y position of the item rendered in the `_physicalStart` |
+ * tile relative to the scrolling list. |
+ */ |
+ _physicalTop: 0, |
- 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; |
- }, |
+ /** |
+ * The number of items in the list. |
+ */ |
+ _virtualCount: 0, |
- complete: function(config) { |
- config.node.style.opacity = ''; |
- } |
+ /** |
+ * A map between an item key and its physical item index |
+ */ |
+ _physicalIndexForKey: null, |
- }); |
-(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 estimated scroll height based on `_physicalAverage` |
+ */ |
+ _estScrollHeight: 0, |
/** |
- * The IronDropdownScrollManager is intended to provide a central source |
- * of authority and control over which elements in a document are currently |
- * allowed to scroll. |
+ * The scroll height of the dom node |
*/ |
+ _scrollHeight: 0, |
- Polymer.IronDropdownScrollManager = { |
+ /** |
+ * The height of the list. This is referred as the viewport in the context of list. |
+ */ |
+ _viewportHeight: 0, |
- /** |
- * 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]; |
- }, |
+ /** |
+ * The width of the list. This is referred as the viewport in the context of list. |
+ */ |
+ _viewportWidth: 0, |
- /** |
- * 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; |
+ /** |
+ * An array of DOM nodes that are currently in the tree |
+ * @type {?Array<!TemplatizerNode>} |
+ */ |
+ _physicalItems: null, |
- if (currentLockingElement === undefined) |
- return false; |
+ /** |
+ * An array of heights for each item in `_physicalItems` |
+ * @type {?Array<number>} |
+ */ |
+ _physicalSizes: null, |
- var scrollLocked; |
+ /** |
+ * A cached value for the first visible index. |
+ * See `firstVisibleIndex` |
+ * @type {?number} |
+ */ |
+ _firstVisibleIndexVal: null, |
- if (this._hasCachedLockedElement(element)) { |
- return true; |
- } |
+ /** |
+ * A cached value for the last visible index. |
+ * See `lastVisibleIndex` |
+ * @type {?number} |
+ */ |
+ _lastVisibleIndexVal: null, |
- if (this._hasCachedUnlockedElement(element)) { |
- return false; |
- } |
+ /** |
+ * A Polymer collection for the items. |
+ * @type {?Polymer.Collection} |
+ */ |
+ _collection: null, |
- scrollLocked = !!currentLockingElement && |
- currentLockingElement !== element && |
- !this._composedTreeContains(currentLockingElement, element); |
+ /** |
+ * True if the current item list was rendered for the first time |
+ * after attached. |
+ */ |
+ _itemsRendered: false, |
- if (scrollLocked) { |
- this._lockedElementCache.push(element); |
- } else { |
- this._unlockedElementCache.push(element); |
- } |
+ /** |
+ * The page that is currently rendered. |
+ */ |
+ _lastPage: null, |
- return scrollLocked; |
- }, |
+ /** |
+ * The max number of pages to render. One page is equivalent to the height of the list. |
+ */ |
+ _maxPages: 3, |
- /** |
- * 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; |
- } |
+ /** |
+ * The currently focused physical item. |
+ */ |
+ _focusedItem: null, |
- if (this._lockingElements.length === 0) { |
- this._lockScrollInteractions(); |
- } |
+ /** |
+ * The index of the `_focusedItem`. |
+ */ |
+ _focusedIndex: -1, |
- this._lockingElements.push(element); |
+ /** |
+ * The the item that is focused if it is moved offscreen. |
+ * @private {?TemplatizerNode} |
+ */ |
+ _offscreenFocusedItem: null, |
- this._lockedElementCache = []; |
- this._unlockedElementCache = []; |
- }, |
+ /** |
+ * The item that backfills the `_offscreenFocusedItem` in the physical items |
+ * list when that item is moved offscreen. |
+ */ |
+ _focusBackfillItem: null, |
- /** |
- * 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); |
+ /** |
+ * The maximum items per row |
+ */ |
+ _itemsPerRow: 1, |
- if (index === -1) { |
- return; |
- } |
+ /** |
+ * The width of each grid item |
+ */ |
+ _itemWidth: 0, |
- this._lockingElements.splice(index, 1); |
+ /** |
+ * The height of the row in grid layout. |
+ */ |
+ _rowHeight: 0, |
- this._lockedElementCache = []; |
- this._unlockedElementCache = []; |
+ /** |
+ * The bottom of the physical content. |
+ */ |
+ get _physicalBottom() { |
+ return this._physicalTop + this._physicalSize; |
+ }, |
- if (this._lockingElements.length === 0) { |
- this._unlockScrollInteractions(); |
- } |
- }, |
+ /** |
+ * The bottom of the scroll. |
+ */ |
+ get _scrollBottom() { |
+ return this._scrollPosition + this._viewportHeight; |
+ }, |
- _lockingElements: [], |
+ /** |
+ * The n-th item rendered in the last physical item. |
+ */ |
+ get _virtualEnd() { |
+ return this._virtualStart + this._physicalCount - 1; |
+ }, |
- _lockedElementCache: null, |
+ /** |
+ * 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; |
+ }, |
- _unlockedElementCache: null, |
+ /** |
+ * The maximum scroll top value. |
+ */ |
+ get _maxScrollTop() { |
+ return this._estScrollHeight - this._viewportHeight + this._scrollerPaddingTop; |
+ }, |
- _hasCachedLockedElement: function(element) { |
- return this._lockedElementCache.indexOf(element) > -1; |
- }, |
+ /** |
+ * The lowest n-th value for an item such that it can be rendered in `_physicalStart`. |
+ */ |
+ _minVirtualStart: 0, |
- _hasCachedUnlockedElement: function(element) { |
- return this._unlockedElementCache.indexOf(element) > -1; |
- }, |
+ /** |
+ * 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); |
+ }, |
- _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; |
+ /** |
+ * The n-th item rendered in the `_physicalStart` tile. |
+ */ |
+ _virtualStartVal: 0, |
- if (element.contains(child)) { |
- return true; |
- } |
+ set _virtualStart(val) { |
+ this._virtualStartVal = Math.min(this._maxVirtualStart, Math.max(this._minVirtualStart, val)); |
+ }, |
- contentElements = Polymer.dom(element).querySelectorAll('content'); |
+ get _virtualStart() { |
+ return this._virtualStartVal || 0; |
+ }, |
- for (contentIndex = 0; contentIndex < contentElements.length; ++contentIndex) { |
+ /** |
+ * The k-th tile that is at the top of the scrolling list. |
+ */ |
+ _physicalStartVal: 0, |
- distributedNodes = Polymer.dom(contentElements[contentIndex]).getDistributedNodes(); |
+ 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; |
+ }, |
- for (nodeIndex = 0; nodeIndex < distributedNodes.length; ++nodeIndex) { |
+ get _physicalStart() { |
+ return this._physicalStartVal || 0; |
+ }, |
- if (this._composedTreeContains(distributedNodes[nodeIndex], child)) { |
- return true; |
- } |
- } |
- } |
+ /** |
+ * The number of tiles in the DOM. |
+ */ |
+ _physicalCountVal: 0, |
- return false; |
- }, |
+ set _physicalCount(val) { |
+ this._physicalCountVal = val; |
+ this._physicalEnd = (this._physicalStart + this._physicalCount - 1) % this._physicalCount; |
+ }, |
- _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; |
- } |
- }, |
+ get _physicalCount() { |
+ return this._physicalCountVal; |
+ }, |
- _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); |
- }, |
+ /** |
+ * The k-th tile that is at the bottom of the scrolling list. |
+ */ |
+ _physicalEnd: 0, |
- _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); |
- }, |
+ /** |
+ * 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; |
+ }, |
- /** |
- * 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) { |
+ get _optPhysicalCount() { |
+ return this._estRowsInView * this._itemsPerRow * this._maxPages; |
+ }, |
- // 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); |
- } |
+ /** |
+ * True if the current list is visible. |
+ */ |
+ get _isVisible() { |
+ return this.scrollTarget && Boolean(this.scrollTarget.offsetWidth || this.scrollTarget.offsetHeight); |
+ }, |
- // 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); |
- }, |
+ /** |
+ * 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); |
- /** |
- * 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; |
- }, |
+ this._firstVisibleIndexVal = this._iterateItems( |
+ function(pidx, vidx) { |
+ physicalOffset += this._getPhysicalSizeIncrement(pidx); |
- /** |
- * 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; |
- } |
- } |
- }, |
+ 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; |
+ }, |
- /** |
- * 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; |
+ /** |
+ * 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 info; |
} |
- }; |
- })(); |
-(function() { |
- 'use strict'; |
+ return this._lastVisibleIndexVal; |
+ }, |
- Polymer({ |
- is: 'iron-dropdown', |
+ get _defaultScrollTarget() { |
+ return this; |
+ }, |
+ get _virtualRowCount() { |
+ return Math.ceil(this._virtualCount / this._itemsPerRow); |
+ }, |
- behaviors: [ |
- Polymer.IronControlState, |
- Polymer.IronA11yKeysBehavior, |
- Polymer.IronOverlayBehavior, |
- Polymer.NeonAnimationRunnerBehavior |
- ], |
+ get _estRowsInView() { |
+ return Math.ceil(this._viewportHeight / this._rowHeight); |
+ }, |
- 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 |
- }, |
+ get _physicalRows() { |
+ return Math.ceil(this._physicalCount / this._itemsPerRow); |
+ }, |
- /** |
- * 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 |
- }, |
+ ready: function() { |
+ this.addEventListener('focus', this._didFocus.bind(this), true); |
+ }, |
- /** |
- * An animation config. If provided, this will be used to animate the |
- * opening of the dropdown. |
- */ |
- openAnimationConfig: { |
- type: Object |
- }, |
+ 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'); |
+ }, |
- /** |
- * An animation config. If provided, this will be used to animate the |
- * closing of the dropdown. |
- */ |
- closeAnimationConfig: { |
- type: Object |
- }, |
+ detached: function() { |
+ this._itemsRendered = false; |
+ this.unlisten(this, 'iron-resize', '_resizeHandler'); |
+ }, |
- /** |
- * If provided, this will be the element that will be focused when |
- * the dropdown opens. |
- */ |
- focusTarget: { |
- type: Object |
- }, |
+ /** |
+ * 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' : ''; |
+ }, |
- /** |
- * Set to true to disable animations when opening and closing the |
- * dropdown. |
- */ |
- noAnimations: { |
- type: Boolean, |
- value: false |
- }, |
+ /** |
+ * 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); |
- /** |
- * 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 |
- }, |
+ this._viewportHeight = this._scrollTargetHeight; |
+ if (this.grid) { |
+ this._updateGridMetrics(); |
+ } |
+ }, |
- /** |
- * Callback for scroll events. |
- * @type {Function} |
- * @private |
- */ |
- _boundOnCaptureScroll: { |
- type: Function, |
- value: function() { |
- return this._onCaptureScroll.bind(this); |
- } |
- } |
- }, |
+ /** |
+ * 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 = []; |
- listeners: { |
- 'neon-animation-finish': '_onNeonAnimationFinish' |
- }, |
+ // track the last `scrollTop` |
+ this._scrollPosition = scrollTop; |
- observers: [ |
- '_updateOverlayPosition(positionTarget, verticalAlign, horizontalAlign, verticalOffset, horizontalOffset)' |
- ], |
+ // clear cached visible indexes |
+ this._firstVisibleIndexVal = null; |
+ this._lastVisibleIndexVal = null; |
- /** |
- * The element that is contained by the dropdown, if any. |
- */ |
- get containedElement() { |
- return Polymer.dom(this.$.content).getDistributedNodes()[0]; |
- }, |
+ 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 |
+ ) { |
+ |
+ tileHeight = this._getPhysicalSizeIncrement(kth); |
+ currentRatio += tileHeight / hiddenContentSize; |
+ |
+ this._physicalTop += tileHeight; |
+ recycledTileSet.push(kth); |
+ recycledTiles++; |
+ kth = (kth + 1) % this._physicalCount; |
+ } |
+ } |
+ |
+ 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; |
- /** |
- * 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(); |
- } |
- }, |
+ // 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(); |
+ }, |
- /** |
- * 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({ |
+ /** |
+ * 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; |
- is: 'fade-in-animation', |
+ if (this.isAttached && !this._itemsRendered && this._isVisible && requiresUpdate) { |
+ this._lastPage = 0; |
+ this._update(); |
+ this._itemsRendered = true; |
+ } |
+ }, |
- behaviors: [ |
- Polymer.NeonAnimationBehavior |
- ], |
+ /** |
+ * 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; |
- configure: function(config) { |
- var node = config.node; |
- this._effect = new KeyframeEffect(node, [ |
- {'opacity': '0'}, |
- {'opacity': '1'} |
- ], this.timingFromConfig(config)); |
- return this._effect; |
- } |
+ this._instanceProps = props; |
+ this._userTemplate = Polymer.dom(this).querySelector('template'); |
- }); |
-Polymer({ |
+ if (this._userTemplate) { |
+ this.templatize(this._userTemplate); |
+ } else { |
+ console.warn('iron-list requires a template to be provided in light-dom'); |
+ } |
+ } |
+ }, |
- is: 'fade-out-animation', |
+ /** |
+ * Implements extension point from Templatizer mixin. |
+ */ |
+ _getStampedChildren: function() { |
+ return this._physicalItems; |
+ }, |
- behaviors: [ |
- Polymer.NeonAnimationBehavior |
- ], |
+ /** |
+ * 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); |
+ } |
+ }, |
- 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 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); |
+ } |
+ }, |
- }); |
-Polymer({ |
- is: 'paper-menu-grow-height-animation', |
+ /** |
+ * 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); |
+ } |
+ }, |
- behaviors: [ |
- Polymer.NeonAnimationBehavior |
- ], |
+ /** |
+ * 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]; |
- configure: function(config) { |
- var node = config.node; |
- var rect = node.getBoundingClientRect(); |
- var height = rect.height; |
+ 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; |
+ } |
+ }, |
- this._effect = new KeyframeEffect(node, [{ |
- height: (height / 2) + 'px' |
- }, { |
- height: height + 'px' |
- }], this.timingFromConfig(config)); |
+ /** |
+ * 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; |
- return this._effect; |
- } |
- }); |
+ 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); |
+ } |
- Polymer({ |
- is: 'paper-menu-grow-width-animation', |
+ this._physicalStart = 0; |
- behaviors: [ |
- Polymer.NeonAnimationBehavior |
- ], |
+ } else if (change.path === 'items.splices') { |
- configure: function(config) { |
- var node = config.node; |
- var rect = node.getBoundingClientRect(); |
- var width = rect.width; |
+ this._adjustVirtualIndex(change.value.indexSplices); |
+ this._virtualCount = this.items ? this.items.length : 0; |
- this._effect = new KeyframeEffect(node, [{ |
- width: (width / 2) + 'px' |
- }, { |
- width: width + 'px' |
- }], this.timingFromConfig(config)); |
+ } else { |
+ // update a single item |
+ this._forwardItemPath(change.path.split('.').slice(1).join('.'), change.value); |
+ return; |
+ } |
- return this._effect; |
- } |
- }); |
+ this._itemsRendered = false; |
+ this._debounceTemplate(this._render); |
+ }, |
- Polymer({ |
- is: 'paper-menu-shrink-width-animation', |
+ /** |
+ * @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); |
- behaviors: [ |
- Polymer.NeonAnimationBehavior |
- ], |
+ this._virtualStart = this._virtualStart + delta; |
- configure: function(config) { |
- var node = config.node; |
- var rect = node.getBoundingClientRect(); |
- var width = rect.width; |
+ if (this._focusedIndex >= 0) { |
+ this._focusedIndex = this._focusedIndex + delta; |
+ } |
+ } |
+ }, this); |
+ }, |
- this._effect = new KeyframeEffect(node, [{ |
- width: width + 'px' |
- }, { |
- width: width - (width / 20) + 'px' |
- }], this.timingFromConfig(config)); |
+ _removeItem: function(item) { |
+ this.$.selector.deselect(item); |
+ // remove the current focused item |
+ if (this._focusedItem && this._focusedItem._templateInstance[this.as] === item) { |
+ this._removeFocusedItem(); |
+ } |
+ }, |
- return this._effect; |
- } |
- }); |
+ /** |
+ * 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; |
- Polymer({ |
- is: 'paper-menu-shrink-height-animation', |
+ 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; |
- behaviors: [ |
- Polymer.NeonAnimationBehavior |
- ], |
+ 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; |
+ } |
+ } |
+ } |
+ }, |
- configure: function(config) { |
- var node = config.node; |
- var rect = node.getBoundingClientRect(); |
- var height = rect.height; |
- var top = rect.top; |
+ /** |
+ * 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; |
+ }, |
- this.setPrefixedProperty(node, 'transformOrigin', '0 0'); |
+ /** |
+ * 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]; |
- this._effect = new KeyframeEffect(node, [{ |
- height: height + 'px', |
- transform: 'translateY(0)' |
- }, { |
- height: height / 2 + 'px', |
- transform: 'translateY(-20px)' |
- }], this.timingFromConfig(config)); |
+ 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); |
+ }, |
- return this._effect; |
- } |
- }); |
-(function() { |
- 'use strict'; |
+ /** |
+ * 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 config = { |
- ANIMATION_CUBIC_BEZIER: 'cubic-bezier(.3,.95,.5,1)', |
- MAX_ANIMATION_TIME_MS: 400 |
- }; |
+ var newPhysicalSize = 0; |
+ var oldPhysicalSize = 0; |
+ var prevAvgCount = this._physicalAverageCount; |
+ var prevPhysicalAvg = this._physicalAverage; |
- var PaperMenuButton = Polymer({ |
- is: 'paper-menu-button', |
+ this._iterateItems(function(pidx, vidx) { |
- /** |
- * Fired when the dropdown opens. |
- * |
- * @event paper-dropdown-open |
- */ |
+ oldPhysicalSize += this._physicalSizes[pidx] || 0; |
+ this._physicalSizes[pidx] = this._physicalItems[pidx].offsetHeight; |
+ newPhysicalSize += this._physicalSizes[pidx]; |
+ this._physicalAverageCount += this._physicalSizes[pidx] ? 1 : 0; |
- /** |
- * Fired when the dropdown closes. |
- * |
- * @event paper-dropdown-close |
- */ |
+ }, itemSet); |
- behaviors: [ |
- Polymer.IronA11yKeysBehavior, |
- Polymer.IronControlState |
- ], |
+ 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; |
+ } |
- properties: { |
- /** |
- * True if the content is currently displayed. |
- */ |
- opened: { |
- type: Boolean, |
- value: false, |
- notify: true, |
- observer: '_openedChanged' |
- }, |
+ // update the average if we measured something |
+ if (this._physicalAverageCount !== prevAvgCount) { |
+ this._physicalAverage = Math.round( |
+ ((prevPhysicalAvg * prevAvgCount) + newPhysicalSize) / |
+ this._physicalAverageCount); |
+ } |
+ }, |
- /** |
- * The orientation against which to align the menu dropdown |
- * horizontally relative to the dropdown trigger. |
- */ |
- horizontalAlign: { |
- type: String, |
- value: 'left', |
- reflectToAttribute: true |
- }, |
+ _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; |
+ }, |
- /** |
- * The orientation against which to align the menu dropdown |
- * vertically relative to the dropdown trigger. |
- */ |
- verticalAlign: { |
- type: String, |
- value: 'top', |
- reflectToAttribute: true |
- }, |
+ /** |
+ * Updates the position of the physical items. |
+ */ |
+ _positionItems: function() { |
+ this._adjustScrollPosition(); |
- /** |
- * 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 |
- }, |
+ var y = this._physicalTop; |
- /** |
- * 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 |
- }, |
+ if (this.grid) { |
+ var totalItemWidth = this._itemsPerRow * this._itemWidth; |
+ var rowOffset = (this._viewportWidth - totalItemWidth) / 2; |
- /** |
- * 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._iterateItems(function(pidx, vidx) { |
- /** |
- * If true, the dropdown will be positioned so that it doesn't overlap |
- * the button. |
- */ |
- noOverlap: { |
- type: Boolean |
- }, |
+ var modulus = vidx % this._itemsPerRow; |
+ var x = Math.floor((modulus * this._itemWidth) + rowOffset); |
- /** |
- * Set to true to disable animations when opening and closing the |
- * dropdown. |
- */ |
- noAnimations: { |
- type: Boolean, |
- value: false |
- }, |
+ this.translate3d(x + 'px', y + 'px', 0, this._physicalItems[pidx]); |
- /** |
- * Set to true to disable automatically closing the dropdown after |
- * a selection has been made. |
- */ |
- ignoreSelect: { |
- type: Boolean, |
- value: false |
- }, |
+ if (this._shouldRenderNextRow(vidx)) { |
+ y += this._rowHeight; |
+ } |
- /** |
- * 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 |
- }, |
+ }); |
+ } else { |
+ this._iterateItems(function(pidx, vidx) { |
- /** |
- * 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 |
- } |
- }]; |
- } |
- }, |
+ this.translate3d(0, y + 'px', 0, this._physicalItems[pidx]); |
+ y += this._physicalSizes[pidx]; |
- /** |
- * 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' |
- } |
- }]; |
- } |
- }, |
+ }); |
+ } |
+ }, |
- /** |
- * 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 |
- }, |
+ _getPhysicalSizeIncrement: function(pidx) { |
+ if (!this.grid) { |
+ return this._physicalSizes[pidx]; |
+ } |
+ if (this._computeVidx(pidx) % this._itemsPerRow !== this._itemsPerRow - 1) { |
+ return 0; |
+ } |
+ return this._rowHeight; |
+ }, |
+ |
+ /** |
+ * 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; |
+ }, |
- /** |
- * Whether focus should be restored to the button when the menu closes. |
- */ |
- restoreFocusOnClose: { |
- type: Boolean, |
- value: true |
- }, |
+ /** |
+ * Adjusts the scroll position when it was overestimated. |
+ */ |
+ _adjustScrollPosition: function() { |
+ var deltaHeight = this._virtualStart === 0 ? this._physicalTop : |
+ Math.min(this._scrollPosition + this._physicalTop, 0); |
- /** |
- * This is the element intended to be bound as the focus target |
- * for the `iron-dropdown` contained by `paper-menu-button`. |
- */ |
- _dropdownContent: { |
- type: Object |
- } |
- }, |
+ 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); |
+ } |
+ } |
+ }, |
- hostAttributes: { |
- role: 'group', |
- 'aria-haspopup': 'true' |
- }, |
+ /** |
+ * Sets the position of the scroll. |
+ */ |
+ _resetScrollPosition: function(pos) { |
+ if (this.scrollTarget) { |
+ this._scrollTop = pos; |
+ this._scrollPosition = this._scrollTop; |
+ } |
+ }, |
- listeners: { |
- 'iron-activate': '_onIronActivate', |
- 'iron-select': '_onIronSelect' |
- }, |
+ /** |
+ * 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); |
+ } |
- /** |
- * The content element that is contained by the menu button, if any. |
- */ |
- get contentElement() { |
- return Polymer.dom(this.$.content).getDistributedNodes()[0]; |
- }, |
+ forceUpdate = forceUpdate || this._scrollHeight === 0; |
+ forceUpdate = forceUpdate || this._scrollPosition >= this._estScrollHeight - this._physicalSize; |
+ forceUpdate = forceUpdate || this.grid && this.$.items.style.height < this._estScrollHeight; |
- /** |
- * Toggles the drowpdown content between opened and closed. |
- */ |
- toggle: function() { |
- if (this.opened) { |
- this.close(); |
- } else { |
- this.open(); |
- } |
- }, |
+ // 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; |
+ } |
+ }, |
- /** |
- * Make the dropdown content appear as an overlay positioned relative |
- * to the dropdown trigger. |
- */ |
- open: function() { |
- if (this.disabled) { |
- return; |
- } |
+ /** |
+ * 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)); |
+ }, |
- this.$.dropdown.open(); |
- }, |
+ /** |
+ * 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; |
+ } |
- /** |
- * Hide the dropdown content. |
- */ |
- close: function() { |
- this.$.dropdown.close(); |
- }, |
+ Polymer.dom.flush(); |
- /** |
- * 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(); |
- } |
- }, |
+ 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(); |
- /** |
- * 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(); |
- } |
- }, |
+ // estimate new physical offset |
+ var estPhysicalTop = Math.floor(this._virtualStart / this._itemsPerRow) * this._physicalAverage; |
+ this._physicalTop = estPhysicalTop; |
- /** |
- * 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'); |
- } |
- }, |
+ var currentTopItem = this._physicalStart; |
+ var currentVirtualItem = this._virtualStart; |
+ var targetOffsetTop = 0; |
+ var hiddenContentSize = this._hiddenContentSize; |
- /** |
- * 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(); |
- } |
- }, |
+ // 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; |
+ }, |
- __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 (this.hasRipple()) { |
- this._ripple.holdDown = receivedFocusFromKeyboard; |
+ if (pidx != null) { |
+ return this._physicalItems[pidx]._templateInstance; |
} |
+ return null; |
}, |
- _createRipple: function() { |
- var ripple = Polymer.PaperRippleBehavior._createRipple(); |
- ripple.id = 'ink'; |
- ripple.setAttribute('center', ''); |
- ripple.classList.add('circle'); |
- return ripple; |
- } |
- }; |
- |
- /** @polymerBehavior Polymer.PaperInkyFocusBehavior */ |
- Polymer.PaperInkyFocusBehavior = [ |
- Polymer.IronButtonState, |
- Polymer.IronControlState, |
- Polymer.PaperRippleBehavior, |
- Polymer.PaperInkyFocusBehaviorImpl |
- ]; |
-Polymer({ |
- is: 'paper-icon-button', |
+ /** |
+ * 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; |
+ }, |
- hostAttributes: { |
- role: 'button', |
- tabindex: '0' |
- }, |
+ /** |
+ * 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); |
- behaviors: [ |
- Polymer.PaperInkyFocusBehavior |
- ], |
+ if (!this.multiSelection && this.selectedItem) { |
+ this.deselectItem(this.selectedItem); |
+ } |
+ if (model) { |
+ model[this.selectedAs] = true; |
+ } |
+ this.$.selector.select(item); |
+ this.updateSizeForItem(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 |
- }, |
+ /** |
+ * Deselects the given item list if it is already selected. |
+ * |
- /** |
- * 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 |
- }, |
+ * @method deselect |
+ * @param {(Object|number)} item The item object or its index |
+ */ |
+ deselectItem: function(item) { |
+ item = this._getNormalizedItem(item); |
+ var model = this._getModelFromItem(item); |
- /** |
- * Specifies the alternate text for the button, for accessibility. |
- */ |
- alt: { |
- type: String, |
- observer: "_altChanged" |
- } |
- }, |
+ if (model) { |
+ model[this.selectedAs] = false; |
+ } |
+ this.$.selector.deselect(item); |
+ this.updateSizeForItem(item); |
+ }, |
- _altChanged: function(newValue, oldValue) { |
- var label = this.getAttribute('aria-label'); |
+ /** |
+ * 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); |
+ } |
+ }, |
- // Don't stomp over a user-set aria-label. |
- if (!label || oldValue == label) { |
- this.setAttribute('aria-label', newValue); |
+ /** |
+ * 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; |
} |
} |
- }); |
-// 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: '', |
+ if (Array.isArray(this.selectedItems)) { |
+ this.selectedItems.forEach(unselect, this); |
+ } else if (this.selectedItem) { |
+ unselect.call(this, this.selectedItem); |
+ } |
+ |
+ /** @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, |
- type: Boolean, |
- value: false |
+ lowerThreshold: { |
+ type: Number, |
+ value: 100 |
}, |
/** |
- * 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 upper threshold. |
*/ |
- _validatorMeta: { |
- type: Object |
+ upperTriggered: { |
+ 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. |
+ * Read-only value that tracks the triggered state of the lower threshold. |
*/ |
- validatorType: { |
- type: String, |
- value: 'validator' |
+ lowerTriggered: { |
+ type: Boolean, |
+ value: false, |
+ notify: true, |
+ readOnly: true |
}, |
- _validator: { |
- type: Object, |
- computed: '__computeValidator(validator)' |
+ /** |
+ * True if the orientation of the scroller is horizontal. |
+ */ |
+ 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; |
+ }, |
+ |
+ _setOverflow: function(scrollTarget) { |
+ this.style.overflow = scrollTarget === this ? 'auto' : ''; |
+ }, |
+ |
+ _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); |
+ } |
}, |
- _invalidChanged: function() { |
- if (this.invalid) { |
- this.setAttribute('aria-invalid', 'true'); |
- } else { |
- this.removeAttribute('aria-invalid'); |
+ _initCheck: function(horizontal, isAttached) { |
+ if (isAttached) { |
+ this.debounce('_init', function() { |
+ this.clearTriggers(); |
+ this.checkScrollThesholds(); |
+ }); |
} |
}, |
/** |
- * @return {boolean} True if the validator `validator` exists. |
+ * Checks the scroll thresholds. |
+ * This method is automatically called by iron-scroll-threshold. |
+ * |
+ * @method checkScrollThesholds |
*/ |
- hasValidator: function() { |
- return this._validator != null; |
+ 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; |
+ |
+ // 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'); |
+ } |
}, |
/** |
- * 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. |
+ * Clear the upper and lower threshold states. |
+ * |
+ * @method clearTriggers |
+ */ |
+ clearTriggers: function() { |
+ this._setUpperTriggered(false); |
+ this._setLowerTriggered(false); |
+ } |
- * @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. |
+ /** |
+ * Fires when the lower threshold has been reached. |
+ * |
+ * @event lower-threshold |
*/ |
- validate: function(value) { |
- this.invalid = !this._getValidity(value); |
- return !this.invalid; |
- }, |
/** |
- * 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. |
+ * Fires when the upper threshold has been reached. |
* |
- * @param {Object} value The value to be validated. |
- * @return {boolean} True if `value` is valid. |
+ * @event upper-threshold |
*/ |
- _getValidity: function(value) { |
- if (this.hasValidator()) { |
- return this._validator.validate(value); |
- } |
- return true; |
+ }); |
+// 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-list', |
+ |
+ properties: { |
+ // The search term for the current query. Set when the query returns. |
+ searchedTerm: { |
+ type: String, |
+ value: '', |
}, |
- __computeValidator: function() { |
- return Polymer.IronValidatableBehaviorMeta && |
- Polymer.IronValidatableBehaviorMeta.byKey(this.validator); |
- } |
- }; |
-/* |
-`<iron-input>` adds two-way binding and custom validators using `Polymer.IronValidatorBehavior` |
-to `<input>`. |
+ lastSearchedTerm_: String, |
-### Two-way binding |
+ querying: Boolean, |
-By default you can only get notified of changes to an `input`'s `value` due to user input: |
+ // An array of history entries in reverse chronological order. |
+ historyData_: Array, |
- <input value="{{myValue::input}}"> |
+ resultLoadingDisabled_: { |
+ type: Boolean, |
+ value: false, |
+ }, |
+ }, |
-`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. |
+ listeners: { |
+ 'infinite-list.scroll': 'notifyListScroll_', |
+ 'remove-bookmark-stars': 'removeBookmarkStars_', |
+ }, |
- <input is="iron-input" bind-value="{{myValue}}"> |
+ /** @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(); |
+ }, |
-### Custom validators |
+ /** |
+ * Remove bookmark star for history items with matching URLs. |
+ * @param {{detail: !string}} e |
+ * @private |
+ */ |
+ removeBookmarkStars_: function(e) { |
+ var url = e.detail; |
-You can use custom validators that implement `Polymer.IronValidatorBehavior` with `<iron-input>`. |
+ if (this.historyData_ === undefined) |
+ return; |
- <input is="iron-input" validator="my-custom-validator"> |
+ for (var i = 0; i < this.historyData_.length; i++) { |
+ if (this.historyData_[i].url == url) |
+ this.set('historyData_.' + i + '.starred', false); |
+ } |
+ }, |
-### Stopping invalid input |
+ /** |
+ * Disables history result loading when there are no more history results. |
+ */ |
+ disableResultLoading: function() { |
+ this.resultLoadingDisabled_ = true; |
+ }, |
-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. |
+ /** |
+ * 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; |
+ } |
- \x3c!-- only allow characters that match [0-9] --\x3e |
- <input is="iron-input" prevent-invalid-input allowed-pattern="[0-9]"> |
+ 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); |
+ } |
+ }, |
-@hero hero.svg |
-@demo demo/index.html |
-*/ |
+ /** |
+ * Cycle through each entry in historyData_ and set all items to be |
+ * unselected. |
+ * @param {number} overallItemCount The number of checkboxes selected. |
+ */ |
+ unselectAllItems: function(overallItemCount) { |
+ if (this.historyData_ === undefined) |
+ return; |
- Polymer({ |
+ for (var i = 0; i < this.historyData_.length; i++) { |
+ if (this.historyData_[i].selected) { |
+ this.set('historyData_.' + i + '.selected', false); |
+ overallItemCount--; |
+ if (overallItemCount == 0) |
+ break; |
+ } |
+ } |
+ }, |
- is: 'iron-input', |
+ /** |
+ * Remove the given |items| from the list. Expected to be called after the |
+ * items are removed from the backend. |
+ * @param {!Array<!HistoryEntry>} removalList |
+ * @private |
+ */ |
+ removeDeletedHistory_: function(removalList) { |
+ // This set is only for speed. Note that set inclusion for objects is by |
+ // reference, so this relies on the HistoryEntry objects never being copied. |
+ var deletedItems = new Set(removalList); |
+ var splices = []; |
+ |
+ for (var i = this.historyData_.length - 1; i >= 0; i--) { |
+ var item = this.historyData_[i]; |
+ if (deletedItems.has(item)) { |
+ // Removes the selected item from historyData_. Use unshift so |
+ // |splices| ends up in index order. |
+ splices.unshift({ |
+ index: i, |
+ removed: [item], |
+ addedCount: 0, |
+ object: this.historyData_, |
+ type: 'splice' |
+ }); |
+ this.historyData_.splice(i, 1); |
+ } |
+ } |
+ // notifySplices gives better performance than individually splicing as it |
+ // batches all of the updates together. |
+ this.notifySplices('historyData_', splices); |
+ }, |
- extends: 'input', |
+ /** |
+ * 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.historyData_.filter(function(item) { |
+ return item.selected; |
+ }); |
+ md_history.BrowserService.getInstance() |
+ .deleteItems(toBeRemoved) |
+ .then(function(items) { |
+ this.removeDeletedHistory_(items); |
+ this.fire('unselect-all'); |
+ }.bind(this)); |
+ }, |
- behaviors: [ |
- Polymer.IronValidatableBehavior |
- ], |
+ /** |
+ * Called when the page is scrolled to near the bottom of the list. |
+ * @private |
+ */ |
+ loadMoreData_: function() { |
+ if (this.resultLoadingDisabled_ || this.querying) |
+ return; |
- properties: { |
+ this.fire('load-more-history'); |
+ }, |
- /** |
- * Use this property instead of `value` for two-way data binding. |
- */ |
- bindValue: { |
- observer: '_bindValueChanged', |
- type: String |
- }, |
+ /** |
+ * 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); |
+ }, |
- /** |
- * 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 |
- }, |
+ hasResults: function(historyDataLength) { |
+ return historyDataLength > 0; |
+ }, |
- /** |
- * 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" |
- }, |
+ noResultsMessage_: function(searchedTerm, isLoading) { |
+ if (isLoading) |
+ return ''; |
+ var messageId = searchedTerm !== '' ? 'noSearchResults' : 'noResults'; |
+ return loadTimeData.getString(messageId); |
+ }, |
- _previousValidInput: { |
- type: String, |
- value: '' |
- }, |
+ /** |
+ * 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; |
+ }, |
- _patternAlreadyChecked: { |
- type: Boolean, |
- value: false |
- } |
+ /** |
+ * 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; |
+ }, |
- }, |
+ /** |
+ * @param {number} index |
+ * @return {boolean} |
+ * @private |
+ */ |
+ isFirstItem_: function(index) { |
+ return index == 0; |
+ }, |
- listeners: { |
- 'input': '_onInput', |
- 'keypress': '_onKeypress' |
- }, |
+ /** |
+ * @private |
+ */ |
+ notifyListScroll_: function() { |
+ this.fire('history-list-scrolled'); |
+ }, |
+}); |
+// 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. |
- /** @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; |
- } |
- }, |
+Polymer({ |
+ is: 'history-list-container', |
- created: function() { |
- Polymer.IronA11yAnnouncer.requestAvailability(); |
- }, |
+ properties: { |
+ // The path of the currently selected page. |
+ selectedPage_: String, |
- _canDispatchEventOnDisabled: function() { |
- var input = document.createElement('input'); |
- var canDispatch = false; |
- input.disabled = true; |
+ // Whether domain-grouped history is enabled. |
+ grouped: Boolean, |
- input.addEventListener('feature-check-dispatch-event', function() { |
- canDispatch = true; |
- }); |
+ /** @type {!QueryState} */ |
+ queryState: Object, |
- try { |
- input.dispatchEvent(new Event('feature-check-dispatch-event')); |
- } catch(e) {} |
+ /** @type {!QueryResult} */ |
+ queryResult: Object, |
+ }, |
- return canDispatch; |
- }, |
+ observers: [ |
+ 'groupedRangeChanged_(queryState.range)', |
+ ], |
- _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; |
- }, |
+ listeners: { |
+ 'history-list-scrolled': 'closeMenu_', |
+ 'load-more-history': 'loadMoreHistory_', |
+ 'toggle-menu': 'toggleMenu_', |
+ }, |
+ |
+ /** |
+ * @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_(); |
+ |
+ if (this.selectedPage_ == 'grouped-list') { |
+ this.$$('#grouped-list').historyData = results; |
+ return; |
+ } |
+ |
+ var list = /** @type {HistoryListElement} */(this.$['infinite-list']); |
+ list.addNewResults(results); |
+ if (info.finished) |
+ list.disableResultLoading(); |
+ }, |
+ |
+ /** |
+ * 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; |
+ } |
- 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; |
- }, |
+ // Close any open dialog if a new query is initiated. |
+ if (!incremental && this.$.dialog.open) |
+ this.$.dialog.close(); |
- ready: function() { |
- this.bindValue = this.value; |
- }, |
+ this.set('queryState.querying', true); |
+ this.set('queryState.incremental', incremental); |
- /** |
- * @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}); |
- }, |
+ var lastVisitTime = 0; |
+ if (incremental) { |
+ var lastVisit = this.queryResult.results.slice(-1)[0]; |
+ lastVisitTime = lastVisit ? lastVisit.time : 0; |
+ } |
- _allowedPatternChanged: function() { |
- // Force to prevent invalid input when an `allowed-pattern` is set |
- this.preventInvalidInput = this.allowedPattern ? true : false; |
- }, |
+ var maxResults = |
+ queryState.range == HistoryRange.ALL_TIME ? RESULTS_PER_PAGE : 0; |
+ chrome.send('queryHistory', [ |
+ queryState.searchTerm, queryState.groupedOffset, queryState.range, |
+ lastVisitTime, maxResults |
+ ]); |
+ }, |
- _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; |
- } |
- } |
+ unselectAllItems: function(count) { |
+ /** @type {HistoryListElement} */ (this.$['infinite-list']) |
+ .unselectAllItems(count); |
+ }, |
- this.bindValue = this.value; |
- this._previousValidInput = this.value; |
- this._patternAlreadyChecked = 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; |
- _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. |
+ this.$.dialog.showModal(); |
+ }, |
- // 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 |
+ /** |
+ * @param {HistoryRange} range |
+ * @private |
+ */ |
+ groupedRangeChanged_: function(range) { |
+ this.selectedPage_ = this.queryState.range == HistoryRange.ALL_TIME ? |
+ 'infinite-list' : 'grouped-list'; |
- // 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 |
+ this.queryHistory(false); |
+ }, |
- return !anyNonPrintable && !(event.charCode == 0 && mozNonPrintable); |
- }, |
+ /** @private */ |
+ loadMoreHistory_: function() { this.queryHistory(true); }, |
- _onKeypress: function(event) { |
- if (!this.preventInvalidInput && this.type !== 'number') { |
- return; |
- } |
- var regexp = this._patternRegExp; |
- if (!regexp) { |
- return; |
- } |
+ /** |
+ * @param {HistoryQuery} info |
+ * @param {!Array<HistoryEntry>} results |
+ * @private |
+ */ |
+ initializeResults_: function(info, results) { |
+ if (results.length == 0) |
+ return; |
- // Handle special keys and backspace |
- if (event.metaKey || event.ctrlKey || event.altKey) |
- return; |
+ var currentDate = results[0].dateRelativeDay; |
- // Check the pattern either here or in `_onInput`, but not in both. |
- this._patternAlreadyChecked = true; |
+ 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; |
- var thisChar = String.fromCharCode(event.charCode); |
- if (this._isPrintable(event) && !regexp.test(thisChar)) { |
- event.preventDefault(); |
- this._announceInvalidCharacter('Invalid character ' + thisChar + ' not entered.'); |
+ if (results[i].dateRelativeDay != currentDate) { |
+ currentDate = results[i].dateRelativeDay; |
} |
- }, |
+ } |
+ }, |
- _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; |
- }, |
+ /** @private */ |
+ onDialogConfirmTap_: function() { |
+ this.$['infinite-list'].deleteSelected(); |
+ this.$.dialog.close(); |
+ }, |
- /** |
- * 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(); |
+ /** @private */ |
+ onDialogCancelTap_: function() { |
+ this.$.dialog.close(); |
+ }, |
- // 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); |
- } |
- } |
+ /** |
+ * Closes the overflow menu. |
+ * @private |
+ */ |
+ closeMenu_: function() { |
+ /** @type {CrSharedMenuElement} */(this.$.sharedMenu).closeMenu(); |
+ }, |
- this.invalid = !valid; |
- this.fire('iron-input-validate'); |
- return valid; |
- }, |
+ /** |
+ * 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); |
+ }, |
- _announceInvalidCharacter: function(message) { |
- this.fire('iron-announce', { text: message }); |
- } |
- }); |
+ /** @private */ |
+ onMoreFromSiteTap_: function() { |
+ var menu = /** @type {CrSharedMenuElement} */(this.$.sharedMenu); |
+ this.fire('search-domain', {domain: menu.itemData.domain}); |
+ menu.closeMenu(); |
+ }, |
+ |
+ /** @private */ |
+ onRemoveFromHistoryTap_: function() { |
+ var menu = /** @type {CrSharedMenuElement} */(this.$.sharedMenu); |
+ md_history.BrowserService.getInstance() |
+ .deleteItems([menu.itemData]) |
+ .then(function(items) { |
+ this.$['infinite-list'].removeDeletedHistory_(items); |
+ // 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(); |
+ }, |
+}); |
+// 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. |
- /* |
- The `iron-input-validate` event is fired whenever `validate()` is called. |
- @event iron-input-validate |
- */ |
Polymer({ |
- is: 'paper-input-container', |
+ is: 'history-synced-device-card', |
- properties: { |
- /** |
- * Set to true to disable the floating label. The label disappears when the input value is |
- * not null. |
- */ |
- noLabelFloat: { |
- type: Boolean, |
- value: false |
- }, |
+ properties: { |
+ // Name of the synced device. |
+ device: String, |
- /** |
- * Set to true to always float the floating label. |
- */ |
- alwaysFloatLabel: { |
- type: Boolean, |
- value: false |
- }, |
+ // When the device information was last updated. |
+ lastUpdateTime: String, |
- /** |
- * The attribute to listen for value changes on. |
- */ |
- attrForValue: { |
- type: String, |
- value: 'bind-value' |
- }, |
+ /** |
+ * The list of tabs open for this device. |
+ * @type {!Array<!ForeignSessionTab>} |
+ */ |
+ tabs: { |
+ type: Array, |
+ value: function() { return []; }, |
+ observer: 'updateIcons_' |
+ }, |
- /** |
- * Set to true to auto-validate the input value when it changes. |
- */ |
- autoValidate: { |
- type: Boolean, |
- value: false |
- }, |
+ /** |
+ * 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, |
- /** |
- * 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 |
- }, |
+ // Whether the card is open. |
+ cardOpen_: {type: Boolean, value: true}, |
- /** |
- * True if the input has focus. |
- */ |
- focused: { |
- readOnly: true, |
- type: Boolean, |
- value: false, |
- notify: true |
- }, |
+ searchTerm: String, |
- _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. |
- }, |
+ // Internal identifier for the device. |
+ sessionTag: String, |
+ }, |
- _inputHasContent: { |
- type: Boolean, |
- value: false |
- }, |
+ /** |
+ * 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(); |
+ }, |
+ |
+ /** |
+ * 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'; |
+ }, |
+ |
+ /** |
+ * 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'); |
+ |
+ for (var i = 0; i < this.tabs.length; i++) { |
+ icons[i].style.backgroundImage = |
+ cr.icon.getFaviconImageSet(this.tabs[i].url); |
+ } |
+ }); |
+ }, |
+ |
+ /** @private */ |
+ isWindowSeparatorIndex_: function(index, separatorIndexes) { |
+ return this.separatorIndexes.indexOf(index) != -1; |
+ }, |
- _inputSelector: { |
- type: String, |
- value: 'input,textarea,.paper-input-input' |
- }, |
+ /** |
+ * @param {boolean} cardOpen |
+ * @return {string} |
+ */ |
+ getCollapseTitle_: function(cardOpen) { |
+ return cardOpen ? loadTimeData.getString('collapseSessionButton') : |
+ loadTimeData.getString('expandSessionButton'); |
+ }, |
- _boundOnFocus: { |
- type: Function, |
- value: function() { |
- return this._onFocus.bind(this); |
- } |
- }, |
+ /** |
+ * @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. |
- _boundOnBlur: { |
- type: Function, |
- value: function() { |
- return this._onBlur.bind(this); |
- } |
- }, |
+/** |
+ * @typedef {{device: string, |
+ * lastUpdateTime: string, |
+ * separatorIndexes: !Array<number>, |
+ * timestamp: number, |
+ * tabs: !Array<!ForeignSessionTab>, |
+ * tag: string}} |
+ */ |
+var ForeignDeviceInternal; |
- _boundOnInput: { |
- type: Function, |
- value: function() { |
- return this._onInput.bind(this); |
- } |
- }, |
+Polymer({ |
+ is: 'history-synced-device-manager', |
- _boundValueChanged: { |
- type: Function, |
- value: function() { |
- return this._onValueChanged.bind(this); |
- } |
- } |
+ properties: { |
+ /** |
+ * @type {?Array<!ForeignSession>} |
+ */ |
+ sessionList: { |
+ type: Array, |
+ observer: 'updateSyncedDevices' |
}, |
- listeners: { |
- 'addon-attached': '_onAddonAttached', |
- 'iron-input-validate': '_onIronInputValidate' |
+ searchTerm: { |
+ type: String, |
+ observer: 'searchTermChanged' |
}, |
- get _valueChangedEvent() { |
- return this.attrForValue + '-changed'; |
+ /** |
+ * An array of synced devices with synced tab data. |
+ * @type {!Array<!ForeignDeviceInternal>} |
+ */ |
+ syncedDevices_: { |
+ type: Array, |
+ value: function() { return []; } |
}, |
- get _propertyForValue() { |
- return Polymer.CaseMap.dashToCamelCase(this.attrForValue); |
+ /** @private */ |
+ signInState_: { |
+ type: Boolean, |
+ value: loadTimeData.getBoolean('isUserSignedIn'), |
}, |
- get _inputElement() { |
- return Polymer.dom(this).querySelector(this._inputSelector); |
+ /** @private */ |
+ guestSession_: { |
+ type: Boolean, |
+ value: loadTimeData.getBoolean('isGuestSession'), |
}, |
- get _inputElementValue() { |
- return this._inputElement[this._propertyForValue] || this._inputElement.value; |
- }, |
+ /** @private */ |
+ fetchingSyncedTabs_: { |
+ type: Boolean, |
+ value: false, |
+ } |
+ }, |
- ready: function() { |
- if (!this._addons) { |
- this._addons = []; |
- } |
- this.addEventListener('focus', this._boundOnFocus, true); |
- this.addEventListener('blur', this._boundOnBlur, true); |
- }, |
+ listeners: { |
+ 'toggle-menu': 'onToggleMenu_', |
+ }, |
- attached: function() { |
- if (this.attrForValue) { |
- this._inputElement.addEventListener(this._valueChangedEvent, this._boundValueChanged); |
- } else { |
- this.addEventListener('input', this._onInput); |
- } |
+ /** @override */ |
+ attached: function() { |
+ // Update the sign in state. |
+ chrome.send('otherDevicesInitialized'); |
+ }, |
- // Only validate when attached if the input already has a value. |
- if (this._inputElementValue != '') { |
- this._handleValueAndAutoValidate(this._inputElement); |
- } else { |
- this._handleValue(this._inputElement); |
- } |
- }, |
+ /** |
+ * @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; |
+ }); |
- _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); |
+ var windowAdded = false; |
+ if (!this.searchTerm) { |
+ // Add all the tabs if there is no search term. |
+ tabs = tabs.concat(newTabs); |
+ windowAdded = true; |
+ } else { |
+ 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; |
+ } |
} |
} |
- }, |
+ 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, |
+ }; |
+ }, |
- _onFocus: function() { |
- this._setFocused(true); |
- }, |
+ onSignInTap_: function() { |
+ chrome.send('SyncSetupShowSetupUI'); |
+ chrome.send('SyncSetupStartSignIn', [false]); |
+ }, |
- _onBlur: function() { |
- this._setFocused(false); |
- this._handleValueAndAutoValidate(this._inputElement); |
- }, |
+ onToggleMenu_: function(e) { |
+ this.$.menu.toggleMenu(e.detail.target, e.detail.tag); |
+ }, |
- _onInput: function(event) { |
- this._handleValueAndAutoValidate(event.target); |
- }, |
+ onOpenAllTap_: function() { |
+ md_history.BrowserService.getInstance().openForeignSessionAllTabs( |
+ this.$.menu.itemData); |
+ this.$.menu.closeMenu(); |
+ }, |
- _onValueChanged: function(event) { |
- this._handleValueAndAutoValidate(event.target); |
- }, |
+ onDeleteSessionTap_: function() { |
+ md_history.BrowserService.getInstance().deleteForeignSession( |
+ this.$.menu.itemData); |
+ this.$.menu.closeMenu(); |
+ }, |
- _handleValue: function(inputElement) { |
- var value = this._inputElementValue; |
+ /** @private */ |
+ clearDisplayedSyncedDevices_: function() { |
+ this.syncedDevices_ = []; |
+ }, |
- // 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; |
- } |
+ /** |
+ * 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; |
- this.updateAddons({ |
- inputElement: inputElement, |
- value: value, |
- invalid: this.invalid |
- }); |
- }, |
+ return signInState && syncedDevicesLength == 0; |
+ }, |
- _handleValueAndAutoValidate: function(inputElement) { |
- if (this.autoValidate) { |
- var valid; |
- if (inputElement.validate) { |
- valid = inputElement.validate(this._inputElementValue); |
- } else { |
- valid = inputElement.checkValidity(); |
- } |
- this.invalid = !valid; |
- } |
+ /** |
+ * 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; |
+ }, |
- // Call this last to notify the add-ons. |
- this._handleValue(inputElement); |
- }, |
+ /** |
+ * 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'); |
+ }, |
- _onIronInputValidate: function(event) { |
- this.invalid = this._inputElement.invalid; |
- }, |
+ /** |
+ * 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; |
- _invalidChanged: function() { |
- if (this._addons) { |
- this.updateAddons({invalid: this.invalid}); |
- } |
- }, |
+ if (!sessionList) |
+ return; |
- /** |
- * Call this to update the state of add-ons. |
- * @param {Object} state Add-on state. |
- */ |
- updateAddons: function(state) { |
- for (var addon, index = 0; addon = this._addons[index]; index++) { |
- addon.update(state); |
+ // 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])); |
} |
- }, |
+ } |
- _computeInputContentClass: function(noLabelFloat, alwaysFloatLabel, focused, invalid, _inputHasContent) { |
- var cls = 'input-content'; |
- if (!noLabelFloat) { |
- var label = this.querySelector('label'); |
+ // Then, append any new devices. |
+ for (var i = updateCount; i < sessionList.length; i++) { |
+ this.push('syncedDevices_', this.createInternalDevice_(sessionList[i])); |
+ } |
+ }, |
- 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'; |
+ /** |
+ * 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; |
- 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'; |
+ 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); |
+ } |
+}); |
+/** |
+ `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. |
+ |
+ Example: |
+ |
+ <iron-selector selected="0"> |
+ <div>Item 1</div> |
+ <div>Item 2</div> |
+ <div>Item 3</div> |
+ </iron-selector> |
+ |
+ 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`. |
+ |
+ Example: |
+ |
+ <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> |
+ |
+ You can specify a default fallback with `fallbackSelection` in case the `selected` attribute does |
+ not match the `attrForSelected` attribute of any elements. |
+ |
+ Example: |
+ |
+ <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> |
+ |
+ Note: When the selector is multi, the selection will set to `fallbackSelection` iff |
+ the number of matching elements is zero. |
+ |
+ `iron-selector` is not styled. Use the `iron-selected` CSS class to style the selected element. |
+ |
+ Example: |
+ |
+ <style> |
+ .iron-selected { |
+ background: #eee; |
} |
- } |
- return cls; |
- }, |
+ </style> |
- _computeUnderlineClass: function(focused, invalid) { |
- var cls = 'underline'; |
- if (invalid) { |
- cls += ' is-invalid'; |
- } else if (focused) { |
- cls += ' is-highlighted' |
- } |
- return cls; |
- }, |
+ ... |
+ |
+ <iron-selector selected="0"> |
+ <div>Item 1</div> |
+ <div>Item 2</div> |
+ <div>Item 3</div> |
+ </iron-selector> |
+ |
+ @demo demo/index.html |
+ */ |
+ |
+ Polymer({ |
+ |
+ is: 'iron-selector', |
+ |
+ behaviors: [ |
+ Polymer.IronMultiSelectableBehavior |
+ ] |
- _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. |
+// 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 SearchField = Polymer({ |
- is: 'cr-search-field', |
- |
- behaviors: [CrSearchFieldBehavior], |
+Polymer({ |
+ is: 'history-side-bar', |
properties: { |
- value_: String, |
- }, |
+ selectedPage: { |
+ type: String, |
+ notify: true |
+ }, |
- /** @return {!HTMLInputElement} */ |
- getSearchInput: function() { |
- return this.$.searchInput; |
+ route: Object, |
+ |
+ showFooter: Boolean, |
+ |
+ // If true, the sidebar is contained within an app-drawer. |
+ drawer: { |
+ type: Boolean, |
+ reflectToAttribute: true |
+ }, |
}, |
/** @private */ |
- clearSearch_: function() { |
- this.setValue(''); |
- this.getSearchInput().focus(); |
+ onSelectorActivate_: function() { |
+ this.fire('history-close-drawer'); |
}, |
- /** @private */ |
- toggleShowingSearch_: function() { |
- this.showingSearch = !this.showingSearch; |
+ /** |
+ * 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(); |
}, |
+ |
+ /** |
+ * @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 Toolbar = Polymer({ |
- is: 'downloads-toolbar', |
- |
- attached: function() { |
- // isRTL() only works after i18n_template.js runs to set <html dir>. |
- this.overflowAlign_ = isRTL() ? 'left' : 'right'; |
- }, |
- |
- properties: { |
- downloadsShowing: { |
- reflectToAttribute: true, |
- type: Boolean, |
- value: false, |
- observer: 'downloadsShowingChanged_', |
- }, |
+Polymer({ |
+ is: 'history-app', |
- overflowAlign_: { |
- type: String, |
- value: 'right', |
- }, |
- }, |
+ properties: { |
+ showSidebarFooter: Boolean, |
- listeners: { |
- 'paper-dropdown-close': 'onPaperDropdownClose_', |
- 'paper-dropdown-open': 'onPaperDropdownOpen_', |
- }, |
+ // The id of the currently selected page. |
+ selectedPage_: {type: String, value: 'history', observer: 'unselectAll'}, |
- /** @return {boolean} Whether removal can be undone. */ |
- canUndo: function() { |
- return this.$['search-input'] != this.shadowRoot.activeElement; |
- }, |
+ // Whether domain-grouped history is enabled. |
+ grouped_: {type: Boolean, reflectToAttribute: true}, |
- /** @return {boolean} Whether "Clear all" should be allowed. */ |
- canClearAll: function() { |
- return !this.$['search-input'].getValue() && this.downloadsShowing; |
+ /** @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; }, |
+ }; |
+ } |
}, |
- onFindCommand: function() { |
- this.$['search-input'].showAndFocus(); |
+ /** @type {!QueryResult} */ |
+ queryResult_: { |
+ type: Object, |
+ value: function() { |
+ return { |
+ info: null, |
+ results: null, |
+ sessionList: null, |
+ }; |
+ } |
}, |
- /** @private */ |
- closeMoreActions_: function() { |
- this.$.more.close(); |
- }, |
+ // Route data for the current page. |
+ routeData_: Object, |
- /** @private */ |
- downloadsShowingChanged_: function() { |
- this.updateClearAll_(); |
- }, |
+ // The query params for the page. |
+ queryParams_: Object, |
- /** @private */ |
- onClearAllTap_: function() { |
- assert(this.canClearAll()); |
- downloads.ActionService.getInstance().clearAll(); |
- }, |
+ // True if the window is narrow enough for the page to have a drawer. |
+ hasDrawer_: Boolean, |
+ }, |
- /** @private */ |
- onPaperDropdownClose_: function() { |
- window.removeEventListener('resize', assert(this.boundClose_)); |
- }, |
+ observers: [ |
+ // routeData_.page <=> selectedPage |
+ 'routeDataChanged_(routeData_.page)', |
+ 'selectedPageChanged_(selectedPage_)', |
- /** |
- * @param {!Event} e |
- * @private |
- */ |
- onItemBlur_: function(e) { |
- var menu = /** @type {PaperMenuElement} */(this.$$('paper-menu')); |
- if (menu.items.indexOf(e.relatedTarget) >= 0) |
- return; |
+ // queryParams_.q <=> queryState.searchTerm |
+ 'searchTermChanged_(queryState_.searchTerm)', |
+ 'searchQueryParamChanged_(queryParams_.q)', |
- this.$.more.restoreFocusOnClose = false; |
- this.closeMoreActions_(); |
- this.$.more.restoreFocusOnClose = true; |
- }, |
+ ], |
- /** @private */ |
- onPaperDropdownOpen_: function() { |
- this.boundClose_ = this.boundClose_ || this.closeMoreActions_.bind(this); |
- window.addEventListener('resize', this.boundClose_); |
- }, |
+ // 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 {!CustomEvent} event |
- * @private |
- */ |
- onSearchChanged_: function(event) { |
- downloads.ActionService.getInstance().search( |
- /** @type {string} */ (event.detail)); |
- this.updateClearAll_(); |
- }, |
+ /** @override */ |
+ ready: function() { |
+ this.grouped_ = loadTimeData.getBoolean('groupByDomain'); |
- /** @private */ |
- onOpenDownloadsFolderTap_: function() { |
- downloads.ActionService.getInstance().openDownloadsFolder(); |
- }, |
+ cr.ui.decorate('command', cr.ui.Command); |
+ document.addEventListener('canExecute', this.onCanExecute_.bind(this)); |
+ document.addEventListener('command', this.onCommand_.bind(this)); |
- /** @private */ |
- updateClearAll_: function() { |
- this.$$('#actions .clear-all').hidden = !this.canClearAll(); |
- this.$$('paper-menu .clear-all').hidden = !this.canClearAll(); |
- }, |
- }); |
+ // 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); |
+ } |
+ }, |
- return {Toolbar: Toolbar}; |
-}); |
-// 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. |
+ /** @private */ |
+ onMenuTap_: function() { |
+ var drawer = this.$$('#drawer'); |
+ if (drawer) |
+ drawer.toggle(); |
+ }, |
-cr.define('downloads', function() { |
- var Manager = Polymer({ |
- is: 'downloads-manager', |
+ /** |
+ * 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; |
+ }, |
- properties: { |
- hasDownloads_: { |
- observer: 'hasDownloadsChanged_', |
- type: Boolean, |
- }, |
+ /** |
+ * 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; |
+ }, |
- items_: { |
- type: Array, |
- value: function() { return []; }, |
- }, |
- }, |
+ deleteSelected: function() { |
+ this.$.history.deleteSelectedWithPrompt(); |
+ }, |
- hostAttributes: { |
- loading: true, |
- }, |
+ /** |
+ * @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); |
+ }, |
- listeners: { |
- 'downloads-list.scroll': 'onListScroll_', |
- }, |
+ /** |
+ * Fired when the user presses 'More from this site'. |
+ * @param {{detail: {domain: string}}} e |
+ */ |
+ searchDomain_: function(e) { this.$.toolbar.setSearchTerm(e.detail.domain); }, |
- observers: [ |
- 'itemsChanged_(items_.*)', |
- ], |
+ /** |
+ * @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; |
+ } |
+ }, |
- /** @private */ |
- clearAll_: function() { |
- this.set('items_', []); |
- }, |
+ /** |
+ * @param {string} searchTerm |
+ * @private |
+ */ |
+ searchTermChanged_: function(searchTerm) { |
+ this.set('queryParams_.q', searchTerm || null); |
+ this.$['history'].queryHistory(false); |
+ }, |
- /** @private */ |
- hasDownloadsChanged_: function() { |
- if (loadTimeData.getBoolean('allowDeletingHistory')) |
- this.$.toolbar.downloadsShowing = this.hasDownloads_; |
+ /** |
+ * @param {string} searchQuery |
+ * @private |
+ */ |
+ searchQueryParamChanged_: function(searchQuery) { |
+ this.$.toolbar.setSearchTerm(searchQuery || ''); |
+ }, |
- 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); |
- } |
- }, |
+ /** |
+ * @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(); |
+ }, |
- /** |
- * @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'); |
- }, |
+ /** |
+ * @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; |
- /** @private */ |
- itemsChanged_: function() { |
- this.hasDownloads_ = this.items_.length > 0; |
- }, |
+ this.set('queryResult_.sessionList', sessionList); |
+ }, |
- /** |
- * @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; |
- } |
- }, |
+ /** |
+ * 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 {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(); |
- }, |
+ /** |
+ * @param {string} selectedPage |
+ * @return {boolean} |
+ * @private |
+ */ |
+ syncedTabsSelected_: function(selectedPage) { |
+ return selectedPage == 'syncedTabs'; |
+ }, |
- /** @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(); |
- } |
- }, |
+ /** |
+ * @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 != ''; |
+ }, |
- /** @private */ |
- onLoad_: function() { |
- cr.ui.decorate('command', cr.ui.Command); |
- document.addEventListener('canExecute', this.onCanExecute_.bind(this)); |
- document.addEventListener('command', this.onCommand_.bind(this)); |
+ /** |
+ * @param {string} page |
+ * @private |
+ */ |
+ routeDataChanged_: function(page) { |
+ this.selectedPage_ = page; |
+ }, |
- downloads.ActionService.getInstance().loadMore(); |
- }, |
+ /** |
+ * @param {string} selectedPage |
+ * @private |
+ */ |
+ selectedPageChanged_: function(selectedPage) { |
+ this.set('routeData_.page', selectedPage); |
+ }, |
- /** |
- * @param {number} index |
- * @private |
- */ |
- removeItem_: function(index) { |
- this.splice('items_', index, 1); |
- this.updateHideDates_(index, index); |
- this.onListScroll_(); |
- }, |
+ /** |
+ * 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_(selectedPage, items) { |
+ return selectedPage; |
+ }, |
- /** |
- * @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; |
- } |
- }, |
+ /** @private */ |
+ closeDrawer_: function() { |
+ var drawer = this.$$('#drawer'); |
+ if (drawer) |
+ drawer.close(); |
+ }, |
+}); |
+// 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 {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); |
- }, |
- }); |
+// Send the history query immediately. This allows the query to process during |
+// the initial page startup. |
+chrome.send('queryHistory', ['', 0, 0, 0, RESULTS_PER_PAGE]); |
+chrome.send('getForeignSessions'); |
- Manager.clearAll = function() { |
- Manager.get().clearAll_(); |
- }; |
+/** @type {Promise} */ |
+var upgradePromise = null; |
+/** @type {boolean} */ |
+var resultsRendered = false; |
- /** @return {!downloads.Manager} */ |
- Manager.get = function() { |
- return /** @type {!downloads.Manager} */( |
- queryRequiredElement('downloads-manager')); |
- }; |
+/** |
+ * @return {!Promise<!HistoryAppElement>} Resolves once the history-app has been |
+ * fully upgraded. |
+ */ |
+function waitForHistoryApp() { |
+ if (!upgradePromise) { |
+ upgradePromise = new Promise(function(resolve, reject) { |
+ if (window.Polymer && Polymer.isInstance && |
+ Polymer.isInstance(document.querySelector('history-app'))) { |
+ resolve(/** @type {!HistoryAppElement} */( |
+ document.querySelector('history-app'))); |
+ } else { |
+ $('bundle').addEventListener('load', function() { |
+ resolve(/** @type {!HistoryAppElement} */( |
+ document.querySelector('history-app'))); |
+ }); |
+ } |
+ }); |
+ } |
+ return upgradePromise; |
+} |
- Manager.insertItems = function(index, list) { |
- Manager.get().insertItems_(index, list); |
- }; |
+// Chrome Callbacks------------------------------------------------------------- |
- Manager.onLoad = function() { |
- Manager.get().onLoad_(); |
- }; |
+/** |
+ * Our history system calls this function with results from searches. |
+ * @param {HistoryQuery} info An object containing information about the query. |
+ * @param {!Array<HistoryEntry>} results A list of results. |
+ */ |
+function historyResult(info, results) { |
+ waitForHistoryApp().then(function(historyApp) { |
+ historyApp.historyResult(info, results); |
+ document.body.classList.remove('loading'); |
+ |
+ if (!resultsRendered) { |
+ resultsRendered = true; |
+ // requestAnimationFrame allows measurement immediately before the next |
+ // repaint, but after the first page of <iron-list> items has stamped. |
+ requestAnimationFrame(function() { |
+ chrome.send( |
+ 'metricsHandler:recordTime', |
+ ['History.ResultsRenderedTime', window.performance.now()]); |
+ }); |
+ } |
+ }); |
+} |
- Manager.removeItem = function(index) { |
- Manager.get().removeItem_(index); |
- }; |
+/** |
+ * Called by the history backend after receiving results and after discovering |
+ * the existence of other forms of browsing history. |
+ * @param {boolean} hasSyncedResults Whether there are synced results. |
+ * @param {boolean} includeOtherFormsOfBrowsingHistory Whether to include |
+ * a sentence about the existence of other forms of browsing history. |
+ */ |
+function showNotification( |
+ hasSyncedResults, includeOtherFormsOfBrowsingHistory) { |
+ // TODO(msramek): |hasSyncedResults| was used in the old WebUI to show |
+ // the message about other signed-in devices. This message does not exist |
+ // in the MD history anymore, so the parameter is not needed. Remove it |
+ // when WebUI is removed and this becomes the only client of |
+ // BrowsingHistoryHandler. |
+ waitForHistoryApp().then(function(historyApp) { |
+ historyApp.showSidebarFooter = includeOtherFormsOfBrowsingHistory; |
+ }); |
+} |
- Manager.updateItem = function(index, data) { |
- Manager.get().updateItem_(index, data); |
- }; |
+/** |
+ * Receives the synced history data. An empty list means that either there are |
+ * no foreign sessions, or tab sync is disabled for this profile. |
+ * |isTabSyncEnabled| makes it possible to distinguish between the cases. |
+ * |
+ * @param {!Array<!ForeignSession>} sessionList Array of objects describing the |
+ * sessions from other devices. |
+ * @param {boolean} isTabSyncEnabled Is tab sync enabled for this profile? |
+ */ |
+function setForeignSessions(sessionList, isTabSyncEnabled) { |
+ waitForHistoryApp().then(function(historyApp) { |
+ historyApp.setForeignSessions(sessionList, isTabSyncEnabled); |
+ }); |
+} |
- 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. |
+/** |
+ * Called when the history is deleted by someone else. |
+ */ |
+function historyDeleted() { |
+} |
-window.addEventListener('load', downloads.Manager.onLoad); |
+/** |
+ * Called by the history backend after user's sign in state changes. |
+ * @param {boolean} isUserSignedIn Whether user is signed in or not now. |
+ */ |
+function updateSignInState(isUserSignedIn) { |
+ waitForHistoryApp().then(function(historyApp) { |
+ historyApp.updateSignInState(isUserSignedIn); |
+ }); |
+}; |