@openhab-ui/setup-and-maintenance
Version:
Configuration and maintenance interface for openHAB
1,466 lines (1,361 loc) • 281 kB
JavaScript
/**
* @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