UNPKG

@openhab-ui/setup-and-maintenance

Version:

Configuration and maintenance interface for openHAB

1,466 lines (1,361 loc) 281 kB
/** * @category Web Components * @customelement nav-breadcrumb * @description A navigation breadcrumb. * This is rendered as a disabled <a> tag if no parent is known. * * A parent is declared via a link tag in the header, like: * <link rel="parent" href="rules.html" data-title="Rule list" data-idkey="uid" /> * * If you do not set the "data-title" attribute, then "Home" will be used. * * The "data-idkey" attribute is used to extract that parameter from the query url. * It will be used for the parent links hash. * So if the page has an url of "http://abc.org?uid=myThing", then the parent link * will be constructed as "http://abc.org/rules.html#uid=myThing". * * @attribute label The label for the current page. Reactive. * * @example <caption>An example</caption> * <nav-breadcrumb label="My page"></nav-breadcrumb> */ class NavBreadcrumb extends HTMLElement { constructor() { super(); } static get observedAttributes() { return ['label']; } attributeChangedCallback(name, oldValue, newValue) { if (name == "label") { this.label = this.getAttribute("label"); this.render(); } } connectedCallback() { this.style.display = "block"; const link = document.querySelector('link[rel="parent"]'); if (link) { let paramAsHash = link.dataset.idkey; if (paramAsHash) { paramAsHash = new URL(window.location).searchParams.get(paramAsHash); if (!paramAsHash) paramAsHash = ""; else paramAsHash = paramAsHash.replace(/:/g, '_'); // Replace potential colons, as they are not valid for IDs } this.parentLink = link.href + "#" + paramAsHash; this.parent = link.dataset.title ? link.dataset.title : "Home"; } if (this.hasAttribute("label")) this.attributeChangedCallback("label"); else { if (!this.label) this.label = document.title; this.render(); } } render() { while (this.firstChild) { this.firstChild.remove(); } this.innerHTML = ` ${this.parentLink ? `<a href="${this.parentLink}">${this.parent}</a> <span>→</span>` : ``} <span>${this.label}</span>`; } } customElements.define('nav-breadcrumb', NavBreadcrumb); /** * @category Web Components * @customelement nav-buttons * @description Prev/Next navigation buttons * * @example <caption>An example</caption> * <nav-buttons></nav-buttons> */ class NavButtons extends HTMLElement { constructor() { super(); } connectedCallback() { this.style.display = "block"; this.prevLink = this.hasAttribute("prevLink") ? this.getAttribute("prevLink") : null; this.nextLink = this.hasAttribute("nextLink") ? this.getAttribute("nextLink") : null; if (!this.prevLink) { const link = document.querySelector('link[rel="prev"]'); if (link) this.prevLink = link.href; else this.prevLink = ""; } if (!this.nextLink) { const link = document.querySelector('link[rel="next"]'); if (link) this.nextLink = link.href; else this.nextLink = ""; } this.prevEnabled = this.prevLink != ""; this.nextEnabled = this.nextLink != ""; while (this.firstChild) { this.firstChild.remove(); } this.innerHTML = ` <a data-no-reload="nav" class="btn btn-primary col-2 mx-2 ${this.prevEnabled ? "" : "disabled"}" href="${this.prevLink}">Back</a> <a data-no-reload="nav" class="btn btn-primary col-2 mx-2 ${this.nextEnabled ? "" : "disabled"}" href="${this.nextLink}">Next</a>`; } } customElements.define('nav-buttons', NavButtons); /** * Class to handle a loaded page */ class Page { constructor(dom) { this.dom = dom; } /** * Performs a querySelector in the page content or document * * @param {string} selector * @param {DocumentElement} context * * @return {Node} */ querySelector(selector, context = this.dom) { const result = context.querySelector(selector); if (!result) { throw new Error(`Not found the target "${selector}"`); } return result; } /** * Performs a querySelector * * @param {string} selector * @param {DocumentElement} context * * @return {Nodelist} */ querySelectorAll(selector, context = this.dom) { const result = context.querySelectorAll(selector); if (!result.length) { throw new Error(`Not found the target "${selector}"`); } return result; } /** * Removes elements in the document * * @param {String} selector * * @return {this} */ removeContent(selector) { this.querySelectorAll(selector, document).forEach(element => element.remove() ); return this; } async timeout(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Replace an element in the document by an element in the page * Optionally, it can execute a callback to the new inserted element * * @param {String} selector * @param {Function|undefined} callback * * @return {this} */ async replaceContent(selector = 'body', options = null) { const content = this.querySelector(selector); if (!options || !options.animationClass) this.querySelector(selector, document).replaceWith(content); else { try { const oldMain = this.querySelector(selector, document); const duration = options.duration || 1; // First insert the new element, just before the old one. // The grid layout will make sure the old one is still at the front oldMain.parentNode.insertBefore(content, oldMain); // Add animation class and remove old after timeout oldMain.classList.add(options.animationClass); await this.timeout(duration * 1000); oldMain.remove(); } catch (e) { console.warn("ANIM failed", e); } } if (options && typeof options.callback === 'function') { options.callback(content); } return this; } /** * Appends the content of an element in the page in other element in the document * Optionally, it can execute a callback for each new inserted elements * * @param {String} selector * @param {Function|undefined} callback * * @return {this} */ appendContent(target = 'body', callback = undefined) { const content = Array.from(this.querySelector(target).childNodes); const fragment = document.createDocumentFragment(); content.forEach(item => fragment.appendChild(item)); this.querySelector(target, document).append(fragment); if (typeof callback === 'function') { content .filter(item => item.nodeType === Node.ELEMENT_NODE) .forEach(callback); } return this; } replaceNavReferences(context = 'head') { const documentContext = this.querySelector(context, document); const pageContext = this.querySelector(context); documentContext.querySelectorAll('link[rel="prev"]').forEach(link => link.remove()); documentContext.querySelectorAll('link[rel="next"]').forEach(link => link.remove()); documentContext.querySelectorAll('link[rel="parent"]').forEach(link => link.remove()); var link; link = pageContext.querySelector('link[rel="prev"]'); if (link) documentContext.append(link); link = pageContext.querySelector('link[rel="next"]'); if (link) documentContext.append(link); link = pageContext.querySelector('link[rel="parent"]'); if (link) documentContext.append(link); return this; } addNewStyles(context = 'head') { const currentPage = this.querySelector(context, document); const newPage = this.querySelector(context); // Inline styles are perfomed immediately currentPage .querySelectorAll('style') .forEach(style => style.remove()); newPage .querySelectorAll('style') .forEach(style => currentPage.append(style)); this.oldLinks = Array.from( currentPage.querySelectorAll('link[rel="stylesheet"]') ); const newLinks = Array.from( newPage.querySelectorAll('link[rel="stylesheet"]') ).filter(newLink => { let found = this.oldLinks.findIndex(oldLink => oldLink.href == newLink.href); if (found != -1) { this.oldLinks.splice(found, 1); return false; } return true; }); // Don't remove stylesheets with the data-keep flag like in: // <link rel="stylesheet" href="css/tutorial.css" type="text/css" data-keep="true" /> this.oldLinks = this.oldLinks.filter(e => !e.dataset.keep); return Promise.all( newLinks.map( link => new Promise((resolve, reject) => { link.addEventListener('load', resolve); link.addEventListener('error', reject); currentPage.append(link); }) ) ).then(() => this); } removeOldStyles(context = 'head') { for (let link of this.oldLinks) { link.remove(); } delete this.oldLinks; return this; } /** * Change the scripts of the current page * * @param {string} context * * @return Promise */ replaceScripts(context = 'head') { const documentContext = this.querySelector(context, document); const pageContext = this.querySelector(context); const oldScripts = Array.from( documentContext.querySelectorAll('script') ); const newScripts = Array.from(pageContext.querySelectorAll('script')); oldScripts.forEach(script => { if (!script.src) { script.remove(); return; } const index = newScripts.findIndex( newScript => newScript.src === script.src ); if (index === -1) { script.remove(); } else { newScripts.splice(index, 1); } }); return Promise.all( newScripts.map( script => new Promise((resolve, reject) => { const scriptElement = document.createElement('script'); scriptElement.type = script.type || 'text/javascript'; scriptElement.defer = script.defer; scriptElement.async = script.async; if (script.src) { scriptElement.src = script.src; scriptElement.addEventListener('load', resolve); scriptElement.addEventListener('error', reject); documentContext.append(scriptElement); return; } scriptElement.innerText = script.innerText; documentContext.append(script); resolve(); }) ) ).then(() => Promise.resolve(this)); } } /** * Class to load an url and generate a page with the result */ class UrlLoader { constructor(url) { this.url = url; this.html = null; this.state = {}; } /** * Performs a fetch to the url and return a promise * * @return {Promise} */ fetch() { return fetch(this.url); } /** * Go natively to the url. Used as fallback */ fallback() { document.location = this.url; } /** * Load the page with the content of the page * * @return {Promise} */ async load(replace = false, state = null) { if (this.html) { const page = new Page(parseHtml(this.html)); this.setState(page.dom.title, replace, state); return page; } const html = await this.fetch() .then(res => { if (res.status < 200 || res.status >= 300) { throw new Error(`The request status code is ${res.status}`); } return res; }) .then(res => res.text()); if (this.html !== false) { this.html = html; } const page = new Page(parseHtml(html)); this.setState(page.dom.title, replace, state); return page; } setState(title, replace = false, state = null) { document.title = title; if (state) { this.state = state; } if (this.url !== document.location.href) { if (replace) { history.replaceState(this.state, null, this.url); } else { history.pushState(this.state, null, this.url); } } else { history.replaceState(this.state, null, this.url); } } } /** * Class to submit a form and generate a page with the result */ class FormLoader extends UrlLoader { constructor(form) { let url = form.action.split('?', 2).shift(); const method = (form.method || 'GET').toUpperCase(); if (method === 'GET') { url += '?' + new URLSearchParams(new FormData(form)); } super(url); this.html = false; this.method = method; this.form = form; } /** * Submit natively the form. Used as fallback */ fallback() { this.form.submit(); } /** * Performs a fetch with the form data and return a promise * * @return {Promise} */ fetch() { const options = { method: this.method }; if (this.method === 'POST') { options.body = new FormData(this.form); } return fetch(this.url, options); } } function parseHtml(html) { html = html.trim().replace(/^\<!DOCTYPE html\>/i, ''); const doc = document.implementation.createHTMLDocument(); doc.documentElement.innerHTML = html; return doc; } /** * Class to handle the navigation history */ class Navigator { constructor(handler) { this.handler = handler; this.fnRequirePageReloadList = [ async (el, url) => !(url && url.indexOf(`${document.location.protocol}//${document.location.host}`) === 0), async (el, url) => url === document.location.href ? null : false, async (el, url) => new URL(url).hash === "#" ? null : false ]; } /** * Add a filter. Depending on the return value a page will be served via * partial html replacement, a complete page reload or the page navigation will * be aborted. * * @param {fnRequirePageReloadPrototype} fnRequirePageReload The filter function accepting two arguments: the element clicked and url * * @return {this} */ addFilter(fnRequirePageReload) { this.fnRequirePageReloadList.push(fnRequirePageReload); return this; } async consultFilters(event, el, url) { for (let fnRequirePageReload of this.fnRequirePageReloadList) { const r = await fnRequirePageReload(el, url); if (r === true) { event.stopPropagation(); // Default handling. Clone the old event and attach a "alreadyHandled" property. // The event will arrive at the a.click handler further down, but not handled // ourself anymore when that property is noticed. const new_e = new event.constructor(event.type, event); new_e.alreadyHandled = true; el.dispatchEvent(new_e); return false; } else if (r === null) return false; } return true; } /** * Init the navigator, attach the events to capture the history changes * * @return {this} */ init() { var handlePopState = (event) => { this.go(document.location.href, event); }; delegate('click', 'a', async (event, link) => { window.removeEventListener('popstate', handlePopState); if (event.alreadyHandled) return; event.preventDefault(); if (await this.consultFilters(event, link, link.href)) this.go(link.href, event); setTimeout(() => window.addEventListener('popstate', handlePopState), 0); }); delegate('submit', 'form', (event, form) => { if (event.alreadyHandled) return; if (this.consultFilters(event, form, resolve(form.action))) this.submit(form, event); }); window.addEventListener('popstate', handlePopState); return this; } /** * Go to other url. * * @param {string} url * @param {Event} event * * @return {Promise|void} */ go(url, event) { return this.load(new UrlLoader(resolve(url)), event); } /** * Submit a form via ajax * * @param {HTMLFormElement} form * @param {Event} event * * @return {Promise} */ submit(form, event) { return this.load(new FormLoader(form), event); } /** * Execute a page loader * * @param {UrlLoader|FormLoader} loader * @param {Event} event * * @return {Promise} */ load(loader, event) { try { return this.handler(loader, event); } catch (err) { console.error(err); loader.fallback(); return Promise.resolve(); } } } const link = document.createElement('a'); function resolve(url) { link.setAttribute('href', url); return link.href; } function delegate(event, selector, callback) { document.addEventListener( event, function (event) { for ( let target = event.target; target && target != this; target = target.parentNode ) { if (target.matches(selector)) { callback.call(target, event, target); break; } } }, true ); } /** * @category Web Components * @customelement nav-ajax-page-load * @description * To avoid flickering of the website-shell, an ajax loading mechanism * is used. This is a progressive enhancement and the page works without * it as well. * * The script only replaces part of the page with the downloaded content. * That is: * - All styles and scripts linked in the body section * - <main>, <footer>, <section.header>, <aside>, <nav> is replaced. * - "prev"/"next"/"parent" ref links in <head> are replaced. * - A "DOMContentLoaded" event is emitted after loading * * A not-found message is shown if loading failed. * * A replacement does not happen if the link points to the same page or ("#"). * * @see https://github.com/oom-components/page-loader * @example <caption>An example</caption> * <nav-ajax-page-load></nav-ajax-page-load> */ class NavAjaxPageLoad extends HTMLElement { constructor() { super(); this.nav = new Navigator((loader, event) => { if (event && event.target && event.target.classList) event.target.classList.add("disabled"); loader.load() .then(page => page.addNewStyles("body")) .then(page => this.checkReload(event.target, "aside") ? page.replaceContent('aside') : page) .then(page => this.checkReload(event.target, "nav") ? page.replaceContent('body>nav') : page) .then(page => page.replaceNavReferences()) .then(page => page.replaceContent('footer')) .then(page => page.replaceContent('section.header')) .then(page => page.replaceContent('main', { animationClass: "bouncyFadeOut", duration: 0.7 })) .then(page => page.removeOldStyles("body")) .then(page => page.replaceScripts("body")) .then(() => this.prepareLoadedContent(event)) .catch(e => { // Connection lost? Check login console.log("Failed to load page:", e); document.querySelector("main").innerHTML = ` <main class='centered m-4'> <section class='card p-4'> <h4>Error loading the page ☹</h4> ${e.message ? e.message : e} </section> </main> `; document.dispatchEvent(new Event('FailedLoading')); }); }); // Perform default action if clicking on a same page link where only the hash differs. // Required for anchor links this.nav.addFilter((el, url) => ((el && el.dataset && !el.dataset.noReload) && new URL(url).pathname == window.location.pathname)); // Abort page request on demand this.nav.addFilter((el, url) => { if (this.hasUnsavedChanges) { const r = window.confirm("You have unsaved changes. Dismiss them?"); if (r) this.hasUnsavedChanges = false; return r ? false : null; // Perform a normal xhr page replacement or abort the request } return false; }); this.boundUnsavedChanges = (event) => { this.hasUnsavedChanges = event.detail; window.removeEventListener("beforeunload", this.boundBeforeUnload, { passive: false }); if (this.hasUnsavedChanges) window.addEventListener("beforeunload", this.boundBeforeUnload, { passive: false }); }; this.boundBeforeUnload = (event) => { if (this.hasUnsavedChanges) { event.preventDefault(); event.returnValue = 'You have unsaved changes which will not be saved.'; return event.returnValue; } }; } prepareLoadedContent(event) { if (event.target && event.target.classList) event.target.classList.remove("disabled"); setTimeout(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); document.dispatchEvent(new Event('DOMContentLoaded')); }, 50); } checkReload(target, section) { const d = (target && target.dataset && target.dataset.noReload) ? target.dataset.noReload.split(",") : []; return !d.includes(section); } connectedCallback() { this.nav.init(); document.addEventListener("unsavedchanges", this.boundUnsavedChanges, { passive: true }); if (localStorage.getItem('skiphome') != "true") return; const hasRedirected = sessionStorage.getItem("redirected"); if (!hasRedirected) { sessionStorage.setItem("redirected", "true"); if (window.location.pathname === "/index.html") { this.nav.go("maintenance.html"); return; } } } disconnectedCallback() { document.removeEventListener("unsavedchanges", this.boundUnsavedChanges, { passive: true }); window.removeEventListener("beforeunload", this.boundBeforeUnload, { passive: false }); } } customElements.define('nav-ajax-page-load', NavAjaxPageLoad); /** * @license * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. * This code may only be used under the BSD style license found at * http://polymer.github.io/LICENSE.txt * The complete set of authors may be found at * http://polymer.github.io/AUTHORS.txt * The complete set of contributors may be found at * http://polymer.github.io/CONTRIBUTORS.txt * Code distributed by Google as part of the polymer project is also * subject to an additional IP rights grant found at * http://polymer.github.io/PATENTS.txt */ const directives = new WeakMap(); const isDirective = (o) => { return typeof o === 'function' && directives.has(o); }; /** * @license * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. * This code may only be used under the BSD style license found at * http://polymer.github.io/LICENSE.txt * The complete set of authors may be found at * http://polymer.github.io/AUTHORS.txt * The complete set of contributors may be found at * http://polymer.github.io/CONTRIBUTORS.txt * Code distributed by Google as part of the polymer project is also * subject to an additional IP rights grant found at * http://polymer.github.io/PATENTS.txt */ /** * True if the custom elements polyfill is in use. */ const isCEPolyfill = window.customElements !== undefined && window.customElements.polyfillWrapFlushCallback !== undefined; /** * Removes nodes, starting from `startNode` (inclusive) to `endNode` * (exclusive), from `container`. */ const removeNodes = (container, startNode, endNode = null) => { let node = startNode; while (node !== endNode) { const n = node.nextSibling; container.removeChild(node); node = n; } }; /** * @license * Copyright (c) 2018 The Polymer Project Authors. All rights reserved. * This code may only be used under the BSD style license found at * http://polymer.github.io/LICENSE.txt * The complete set of authors may be found at * http://polymer.github.io/AUTHORS.txt * The complete set of contributors may be found at * http://polymer.github.io/CONTRIBUTORS.txt * Code distributed by Google as part of the polymer project is also * subject to an additional IP rights grant found at * http://polymer.github.io/PATENTS.txt */ /** * A sentinel value that signals that a value was handled by a directive and * should not be written to the DOM. */ const noChange = {}; /** * A sentinel value that signals a NodePart to fully clear its content. */ const nothing = {}; /** * @license * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. * This code may only be used under the BSD style license found at * http://polymer.github.io/LICENSE.txt * The complete set of authors may be found at * http://polymer.github.io/AUTHORS.txt * The complete set of contributors may be found at * http://polymer.github.io/CONTRIBUTORS.txt * Code distributed by Google as part of the polymer project is also * subject to an additional IP rights grant found at * http://polymer.github.io/PATENTS.txt */ /** * An expression marker with embedded unique key to avoid collision with * possible text in templates. */ const marker = `{{lit-${String(Math.random()).slice(2)}}}`; /** * An expression marker used text-positions, multi-binding attributes, and * attributes with markup-like text values. */ const nodeMarker = `<!--${marker}-->`; const markerRegex = new RegExp(`${marker}|${nodeMarker}`); /** * Suffix appended to all bound attribute names. */ const boundAttributeSuffix = '$lit$'; /** * An updateable Template that tracks the location of dynamic parts. */ class Template { constructor(result, element) { this.parts = []; this.element = element; let index = -1; let partIndex = 0; const nodesToRemove = []; const _prepareTemplate = (template) => { const content = template.content; // Edge needs all 4 parameters present; IE11 needs 3rd parameter to be // null const walker = document.createTreeWalker(content, 133 /* NodeFilter.SHOW_{ELEMENT|COMMENT|TEXT} */, null, false); // Keeps track of the last index associated with a part. We try to delete // unnecessary nodes, but we never want to associate two different parts // to the same index. They must have a constant node between. let lastPartIndex = 0; while (walker.nextNode()) { index++; const node = walker.currentNode; if (node.nodeType === 1 /* Node.ELEMENT_NODE */) { if (node.hasAttributes()) { const attributes = node.attributes; // Per // https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap, // attributes are not guaranteed to be returned in document order. // In particular, Edge/IE can return them out of order, so we cannot // assume a correspondance between part index and attribute index. let count = 0; for (let i = 0; i < attributes.length; i++) { if (attributes[i].value.indexOf(marker) >= 0) { count++; } } while (count-- > 0) { // Get the template literal section leading up to the first // expression in this attribute const stringForPart = result.strings[partIndex]; // Find the attribute name const name = lastAttributeNameRegex.exec(stringForPart)[2]; // Find the corresponding attribute // All bound attributes have had a suffix added in // TemplateResult#getHTML to opt out of special attribute // handling. To look up the attribute value we also need to add // the suffix. const attributeLookupName = name.toLowerCase() + boundAttributeSuffix; const attributeValue = node.getAttribute(attributeLookupName); const strings = attributeValue.split(markerRegex); this.parts.push({ type: 'attribute', index, name, strings }); node.removeAttribute(attributeLookupName); partIndex += strings.length - 1; } } if (node.tagName === 'TEMPLATE') { _prepareTemplate(node); } } else if (node.nodeType === 3 /* Node.TEXT_NODE */) { const data = node.data; if (data.indexOf(marker) >= 0) { const parent = node.parentNode; const strings = data.split(markerRegex); const lastIndex = strings.length - 1; // Generate a new text node for each literal section // These nodes are also used as the markers for node parts for (let i = 0; i < lastIndex; i++) { parent.insertBefore((strings[i] === '') ? createMarker() : document.createTextNode(strings[i]), node); this.parts.push({ type: 'node', index: ++index }); } // If there's no text, we must insert a comment to mark our place. // Else, we can trust it will stick around after cloning. if (strings[lastIndex] === '') { parent.insertBefore(createMarker(), node); nodesToRemove.push(node); } else { node.data = strings[lastIndex]; } // We have a part for each match found partIndex += lastIndex; } } else if (node.nodeType === 8 /* Node.COMMENT_NODE */) { if (node.data === marker) { const parent = node.parentNode; // Add a new marker node to be the startNode of the Part if any of // the following are true: // * We don't have a previousSibling // * The previousSibling is already the start of a previous part if (node.previousSibling === null || index === lastPartIndex) { index++; parent.insertBefore(createMarker(), node); } lastPartIndex = index; this.parts.push({ type: 'node', index }); // If we don't have a nextSibling, keep this node so we have an end. // Else, we can remove it to save future costs. if (node.nextSibling === null) { node.data = ''; } else { nodesToRemove.push(node); index--; } partIndex++; } else { let i = -1; while ((i = node.data.indexOf(marker, i + 1)) !== -1) { // Comment node has a binding marker inside, make an inactive part // The binding won't work, but subsequent bindings will // TODO (justinfagnani): consider whether it's even worth it to // make bindings in comments work this.parts.push({ type: 'node', index: -1 }); } } } } }; _prepareTemplate(element); // Remove text binding nodes after the walk to not disturb the TreeWalker for (const n of nodesToRemove) { n.parentNode.removeChild(n); } } } const isTemplatePartActive = (part) => part.index !== -1; // Allows `document.createComment('')` to be renamed for a // small manual size-savings. const createMarker = () => document.createComment(''); /** * This regex extracts the attribute name preceding an attribute-position * expression. It does this by matching the syntax allowed for attributes * against the string literal directly preceding the expression, assuming that * the expression is in an attribute-value position. * * See attributes in the HTML spec: * https://www.w3.org/TR/html5/syntax.html#attributes-0 * * "\0-\x1F\x7F-\x9F" are Unicode control characters * * " \x09\x0a\x0c\x0d" are HTML space characters: * https://www.w3.org/TR/html5/infrastructure.html#space-character * * So an attribute is: * * The name: any character except a control character, space character, ('), * ("), ">", "=", or "/" * * Followed by zero or more space characters * * Followed by "=" * * Followed by zero or more space characters * * Followed by: * * Any character except space, ('), ("), "<", ">", "=", (`), or * * (") then any non-("), or * * (') then any non-(') */ const lastAttributeNameRegex = /([ \x09\x0a\x0c\x0d])([^\0-\x1F\x7F-\x9F \x09\x0a\x0c\x0d"'>=/]+)([ \x09\x0a\x0c\x0d]*=[ \x09\x0a\x0c\x0d]*(?:[^ \x09\x0a\x0c\x0d"'`<>=]*|"[^"]*|'[^']*))$/; /** * @license * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. * This code may only be used under the BSD style license found at * http://polymer.github.io/LICENSE.txt * The complete set of authors may be found at * http://polymer.github.io/AUTHORS.txt * The complete set of contributors may be found at * http://polymer.github.io/CONTRIBUTORS.txt * Code distributed by Google as part of the polymer project is also * subject to an additional IP rights grant found at * http://polymer.github.io/PATENTS.txt */ /** * An instance of a `Template` that can be attached to the DOM and updated * with new values. */ class TemplateInstance { constructor(template, processor, options) { this._parts = []; this.template = template; this.processor = processor; this.options = options; } update(values) { let i = 0; for (const part of this._parts) { if (part !== undefined) { part.setValue(values[i]); } i++; } for (const part of this._parts) { if (part !== undefined) { part.commit(); } } } _clone() { // When using the Custom Elements polyfill, clone the node, rather than // importing it, to keep the fragment in the template's document. This // leaves the fragment inert so custom elements won't upgrade and // potentially modify their contents by creating a polyfilled ShadowRoot // while we traverse the tree. const fragment = isCEPolyfill ? this.template.element.content.cloneNode(true) : document.importNode(this.template.element.content, true); const parts = this.template.parts; let partIndex = 0; let nodeIndex = 0; const _prepareInstance = (fragment) => { // Edge needs all 4 parameters present; IE11 needs 3rd parameter to be // null const walker = document.createTreeWalker(fragment, 133 /* NodeFilter.SHOW_{ELEMENT|COMMENT|TEXT} */, null, false); let node = walker.nextNode(); // Loop through all the nodes and parts of a template while (partIndex < parts.length && node !== null) { const part = parts[partIndex]; // Consecutive Parts may have the same node index, in the case of // multiple bound attributes on an element. So each iteration we either // increment the nodeIndex, if we aren't on a node with a part, or the // partIndex if we are. By not incrementing the nodeIndex when we find a // part, we allow for the next part to be associated with the current // node if neccessasry. if (!isTemplatePartActive(part)) { this._parts.push(undefined); partIndex++; } else if (nodeIndex === part.index) { if (part.type === 'node') { const part = this.processor.handleTextExpression(this.options); part.insertAfterNode(node.previousSibling); this._parts.push(part); } else { this._parts.push(...this.processor.handleAttributeExpressions(node, part.name, part.strings, this.options)); } partIndex++; } else { nodeIndex++; if (node.nodeName === 'TEMPLATE') { _prepareInstance(node.content); } node = walker.nextNode(); } } }; _prepareInstance(fragment); if (isCEPolyfill) { document.adoptNode(fragment); customElements.upgrade(fragment); } return fragment; } } /** * @license * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. * This code may only be used under the BSD style license found at * http://polymer.github.io/LICENSE.txt * The complete set of authors may be found at * http://polymer.github.io/AUTHORS.txt * The complete set of contributors may be found at * http://polymer.github.io/CONTRIBUTORS.txt * Code distributed by Google as part of the polymer project is also * subject to an additional IP rights grant found at * http://polymer.github.io/PATENTS.txt */ /** * The return type of `html`, which holds a Template and the values from * interpolated expressions. */ class TemplateResult { constructor(strings, values, type, processor) { this.strings = strings; this.values = values; this.type = type; this.processor = processor; } /** * Returns a string of HTML used to create a `<template>` element. */ getHTML() { const endIndex = this.strings.length - 1; let html = ''; for (let i = 0; i < endIndex; i++) { const s = this.strings[i]; // This exec() call does two things: // 1) Appends a suffix to the bound attribute name to opt out of special // attribute value parsing that IE11 and Edge do, like for style and // many SVG attributes. The Template class also appends the same suffix // when looking up attributes to create Parts. // 2) Adds an unquoted-attribute-safe marker for the first expression in // an attribute. Subsequent attribute expressions will use node markers, // and this is safe since attributes with multiple expressions are // guaranteed to be quoted. const match = lastAttributeNameRegex.exec(s); if (match) { // We're starting a new bound attribute. // Add the safe attribute suffix, and use unquoted-attribute-safe // marker. html += s.substr(0, match.index) + match[1] + match[2] + boundAttributeSuffix + match[3] + marker; } else { // We're either in a bound node, or trailing bound attribute. // Either way, nodeMarker is safe to use. html += s + nodeMarker; } } return html + this.strings[endIndex]; } getTemplateElement() { const template = document.createElement('template'); template.innerHTML = this.getHTML(); return template; } } /** * @license * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. * This code may only be used under the BSD style license found at * http://polymer.github.io/LICENSE.txt * The complete set of authors may be found at * http://polymer.github.io/AUTHORS.txt * The complete set of contributors may be found at * http://polymer.github.io/CONTRIBUTORS.txt * Code distributed by Google as part of the polymer project is also * subject to an additional IP rights grant found at * http://polymer.github.io/PATENTS.txt */ const isPrimitive = (value) => { return (value === null || !(typeof value === 'object' || typeof value === 'function')); }; /** * Sets attribute values for AttributeParts, so that the value is only set once * even if there are multiple parts for an attribute. */ class AttributeCommitter { constructor(element, name, strings) { this.dirty = true; this.element = element; this.name = name; this.strings = strings; this.parts = []; for (let i = 0; i < strings.length - 1; i++) { this.parts[i] = this._createPart(); } } /** * Creates a single part. Override this to create a differnt type of part. */ _createPart() { return new AttributePart(this); } _getValue() { const strings = this.strings; const l = strings.length - 1; let text = ''; for (let i = 0; i < l; i++) { text += strings[i]; const part = this.parts[i]; if (part !== undefined) { const v = part.value; if (v != null && (Array.isArray(v) || // tslint:disable-next-line:no-any typeof v !== 'string' && v[Symbol.iterator])) { for (const t of v) { text += typeof t === 'string' ? t : String(t); } } else { text += typeof v === 'string' ? v : String(v); } } } text += strings[l]; return text; } commit() { if (this.dirty) { this.dirty = false; this.element.setAttribute(this.name, this._getValue()); } } } class AttributePart { constructor(comitter) { this.value = undefined; this.committer = comitter; } setValue(value) { if (value !== noChange && (!isPrimitive(value) || value !== this.value)) { this.value = value; // If the value is a not a directive, dirty the committer so that it'll // call setAttribute. If the value is a directive, it'll dirty the // committer if it calls setValue(). if (!isDirective(value)) { this.committer.dirty = true; } } } commit() { while (isDirective(this.value)) { const directive = this.value; this.value = noChange; directive(this); } if (this.value === noChange) { return; } this.committer.commit(); } } class NodePart { constructor(options) { this.value = undefined; this._pendingValue = undefined; this.options = options; } /** * Inserts this part into a container. * * This part must be empty, as its contents are not automatically moved. */ appendInto(container) { this.startNode = container.appendChild(createMarker()); this.endNode = container.appendChild(createMarker()); } /** * Inserts this part between `ref` and `ref`'s next sibling. Both `ref` and * its next sibling must be static, unchanging nodes such as those that appear * in a literal section of a template. * * This part must be empty, as its contents are not automatically moved. */ insertAfterNode(ref) { this.startNode = ref; this.endNode = ref.nextSibling; } /** * Appends this part into a parent part. * * This part must be empty, as its contents are not automatically moved. */ appendIntoPart(part) { part._insert(this.startNode = createMarker()); part._insert(this.endNode = createMarker()); } /** * Appends this part after `ref` * * This part must be empty, as its contents are not automatically moved. */ insertAfterPart(ref) { ref._insert(this.startNode = createMarker()); this.endNode = ref.endNode; ref.endNode = this.startNode; } setValue(value) { this._pendingValue = value; } commit() { while (isDirective(this._pendingValue)) { const directive = this._pendingValue; this._pendingValue = noChange; directive(this); } const value = this._pendingValue; if (value === noChange) { return; } if (isPrimitive(value)) { if (value !== this.value) { this._commitText(value); } } else if (value instanceof TemplateResult) { this._commitTemplateResult(value); } else if (value instanceof Node) { this._commitNode(value); } else if (Array.isArray(value) || // tslint:disable-next-line:no-any value[Symbol.iterator]) { this._commitIterable(value); } else if (value === nothing) { this.value = nothing; this.clear(); } else { // Fallback, will render the string representation this._commitText(value); } } _insert(node) { this.endNode.parentNode.insertBefore(node, this.endNode); } _commitNode(value) { if (this.value === value) { return; } this.clear(); this._insert(value); this.value = value; } _commitText(value) { const node = this.startNode.nextSibling; value = value == null ? '' : value; if (node === this.endNode.previousSibling && node.nodeType === 3 /* Node.TEXT_NODE */) { // If we only have a single text node between the markers, we can just // set its value, rather than replacing it. // TODO(justinfagnani): Can we just check if this.value is primitive? node.data = value; } else { this._commitNode(document.createTextNode(typeof value === 'string' ? value : String(value))); } this.value = value; } _commitTemplateResult(value) { const template = this.options.templateFactory(value); if (this.value instanceof TemplateInstance && this.value.template === template) { this.value.update(value.values); } else { // Make sure we propagate the template processor from the TemplateResult // so that we use its syntax extension, etc. The template factory comes // from the render function options so that it can control template // caching and preprocessing. const instance = new TemplateInstance(template, value.processor, this.options); const fragment = instance._clone(); instance.update(value.values); this._commitNode(fragment); this.value = instance; } } _commitIterable(value) { // For an Iterable, we create a new InstancePart per item, then set its // value to the item. This is a little bit of overhead for every item in // an Iterable, but it lets us recurse easily and efficiently update Arrays // of TemplateResults that will be commonly returned from expressions like: // array.map((i) => html`${i}`), by reusing existing TemplateInstances. // If _value is an array, then the previous render was of an // iterable and _value will contain the NodeParts from the previous // render. If _value is not an array, clear this part and make a new // array for NodeParts. if (!Array.isArray(this.value)) { this.value = []; this.clear(); } // Lets us keep track of how many items we stamped so we can clear leftover // items from a previous render const itemParts = this.value; let partIndex = 0; let itemPart; for (const item of value) { // Try to reuse an existing part itemPart = itemParts[partIndex]; // If no existing part, create a new one if (itemPart === undefined) { itemPart = new NodePart(this.options); itemParts.push(itemPart); if (partIndex === 0) { itemPart.appendIntoPart(this); } else { itemPart.insertAfterPart(itemParts[partIndex - 1]); } } itemPart.setValue(item); itemPart.commit(); partIndex++; } if (partIndex < itemParts.length) { // Truncate the parts array so _value reflects the current state itemParts.length = partIndex; this.clear(itemPart && itemPart.endNode); } } clear(startNode = this.startNode) { removeNodes(this.startNode.parentNode, startNode.nextSibling, this.endNode); } } /** * Implements a boolean attribute, roughly as defined in the HTML * specification. * * If the value is truthy, then the attribute is present with a value of * ''. If the value is falsey, the attribute is removed. */ class BooleanAttributePart { constructor(element, name, strings) { this.value = undefined; this._pendingValue = undefined; if (strings.length !== 2 || strings[0] !== '' || strings[1] !== '') { throw new Error('Boolean attributes can only contain a single