diff --git a/examples/updated/utterance_Contact.osdpi b/examples/updated/utterance_Contact.osdpi index c69b3a5..6c50307 100644 Binary files a/examples/updated/utterance_Contact.osdpi and b/examples/updated/utterance_Contact.osdpi differ diff --git a/index.css b/index.css index 10fb7b1..f07542e 100644 --- a/index.css +++ b/index.css @@ -77,28 +77,51 @@ div#messages { justify-content: center; align-items: center; } -div.labeledInput { +input:focus ~ span.propValue { + position: absolute; + top: -1.5em; + right: 0; + padding: 0.2em; + background-color: var(--surface2); display: inline-block; + font-size: 75%; } -div.labeledInput:has(div.Code) { +span.propValue { + display: none; +} +label[hiddenLabel]:has(div.Code) { display: block; flex-basis: 100%; } -label[hiddenLabel] { +label.labeledInput { + white-space: nowrap; + line-height: 1.7em; + position: relative; +} +label.labeledInput[hiddenLabel] span.labelText { + border: 0; clip: rect(0 0 0 0); - clip-path: inset(50%); height: 1px; + margin: -1px; overflow: hidden; + padding: 0; position: absolute; - white-space: nowrap; width: 1px; } +label.labeledInput input[type="checkbox"] { + vertical-align: middle; +} select:required:invalid { color: gray; border-color: red; } -input[inputmode="numeric"] { - width: 3em; +input[type="text"] { + min-width: 10ch; +} +input[type="text"][inputmode="numeric"], +input[type="text"][inputmode="decimal"] { + width: 3ch; + min-width: 3ch; } input[inputmode="numeric"]:invalid { color: red; @@ -110,6 +133,10 @@ textarea[invalid] { border-color: red; background-color: var(--warning); } +input, +select { + min-height: 1.5rem; +} option[value=""][disabled] { display: none; @@ -169,6 +196,15 @@ div.Code textarea.line-numbers::-webkit-scrollbar { display: none; } */ + +.swatch { + width: 1em; + height: 1em; + display: inline-block; + border: 1px solid black; + vertical-align: middle; + margin-left: 0.2em; +} button.treebase { background-color: rgba(0, 0, 0, 0.05); border-radius: 0.5em; @@ -213,27 +249,15 @@ div.Code textarea.line-numbers::-webkit-scrollbar { .treebase label { display: inline-block; } - color-input { - margin-right: 1em; - align-items: center; - } - - color-input input { - flex: 1 1 0; - margin-right: 0.2em; - } - - color-input .swatch { - width: 1em; - height: 1em; - display: inline-block; - border: 1px solid black; - vertical-align: middle; - } - color-input input:invalid { - background-color: #fcc; - border-color: red; - } +table.GridFilter td:nth-child(4) input { + width: 100% !important; +} +table.GridFilter td:nth-child(4) { + width: 100% !important; +} +table.GridFilter td:nth-child(4) label.labeledInput { + width: 100% !important; +} .stack { display: flex; width: 100%; @@ -351,10 +375,10 @@ body:not(.designing) video[dbsrc]:not([src]) { } .display button { - display: flex; width: 100%; height: 100%; background-color: inherit; + text-align: left; } .display button:disabled { @@ -379,6 +403,24 @@ body:not(.designing) video[dbsrc]:not([src]) { min-width: 45%; max-width: 45%; } + +.settings .Radio table.RadioOptions { + width: 100%; +} +.settings .Radio table.RadioOptions tr { + width: 100%; +} +.settings .Radio table.RadioOptions td:nth-child(2) { + width: 30%; +} + +.settings .Radio table.RadioOptions label.labeledInput { + width: 100% !important; +} + +.settings .Radio table.RadioOptions input { + width: 100% !important; +} .gap { display: flex; width: 100%; @@ -499,6 +541,13 @@ body:not(.designing) video[dbsrc]:not([src]) { .tabcontrol.none .buttons { display: none; } + +.tabcontrol .tabpanel { + display: none; +} +.tabcontrol .tabpanel.ActivePanel { + display: flex; +} div.modaldialog { visibility: hidden; position: fixed; @@ -606,48 +655,310 @@ button.button { height: 100%; width: 100%; } - #monitor { - margin-top: 1em; - margin-left: 1em; - } +#monitor { + margin-top: 1em; + margin-left: 1em; + overflow: auto; +} - #monitor div { - display: flex; - height: 100%; - overflow-y: auto; - font-size: 75%; - margin-top: 0.2em; - } +#monitor div { + display: flex; + overflow-y: auto; + font-size: 10px; + margin-top: 0.2em; +} - #monitor table { - border-collapse: collapse; - border: 1px solid black; - height: max-content; - margin-right: 1em; - } +#monitor table { + border-collapse: collapse; + border: 1px solid black; + height: max-content; + margin-right: 1em; +} - #monitor table td, - #monitor table th { - border: 1px solid black; - padding: 0.5em; - } - .content form { - display: flex; - width: 100%; - gap: 0.5em; - } +#monitor table td, +#monitor table th { + border: 1px solid black; + padding: 0.5em; +} - .content form input[type="url"] { - flex: 1; - max-width: 60%; - } +#monitor table tr[updated] { + font-weight: bold; +} - .content div#messages { - color: red; - font-size: 2em; - padding-left: 1em; - padding-top: 1em; - } +#monitor table tr[undefined] { + color: var(--failure); +} + +#monitor table tr[accessed] { + background-color: var(--surface2); +} +body.designing { + display: grid; + grid-template-rows: 2.5em 50% auto; + grid-template-columns: 50% 50%; +} + +body.designing div#UI { + font-size: 0.7vw; + flex: 1 1 0; +} + +div#designer { + display: none; +} + +div#tabs { + width: 100%; + overflow-y: hidden; + overflow-x: hidden; + flex: 1 1 0; +} + +body.designing div#designer { + display: flex; + grid-row-start: 1; + grid-row-end: 4; + grid-column-start: 2; + overflow: hidden; + height: 100vh; + flex-direction: column; +} +body.designing #UI { + grid-row-start: 1; + grid-row-end: 3; + grid-column-start: 1; + position: relative; +} +body.designing #monitor { + grid-row-start: 3; + grid-column-start: 1; +} + +#designer ol, +#designer ul { + list-style-type: none; + margin-block-start: 0; + padding-inline-start: 1em; + border-left: 1px solid #d0d0d0; +} + +#designer .settings > details > summary { + cursor: pointer; + background-color: var(--surface2); +} + +#designer .settings > details { + margin-bottom: 0.5em; +} + +details summary > * { + display: inline; +} + +#designer .settings > details[open] { + border: 4px inset var(--surface4); + padding-left: 0.2em; + padding-bottom: 0.2em; +} + +#designer .settings > details summary h3 { + font-weight: 500; +} + +#designer details label { + margin-left: 1em; + margin-bottom: 0.5em; +} + +#designer .settings { + background-color: var(--surface2); + color: var(--text2); + border: 0px; + box-shadow: none; +} + +#designer .settings:has([aria-selected="true"]), +#designer .settings[aria-selected="true"] { + background-color: var(--surface1); + color: var(--text1); + border: 4px dashed var(--brand); +} +#designer .settings:has(.settings [aria-selected="true"]) { + background-color: var(--surface2); + color: var(--text2); + border: 0px; + box-shadow: none; +} + +#designer :focus { + outline: var(--text2) 4px dashed; +} + +#designer .indicator { + color: var(--brand); +} + +#designer .panels { + display: block; + overflow-y: auto; +} + +.selectedInDesigner { + border: 2px dashed red; +} + +#UI [id]:has(.selectedInDesigner) { + border: 2px dotted yellow; +} + +.designer { + display: flex; + width: 100%; + height: 100%; +} +.designer .buttons button:focus { + background-color: var(--surface2); +} +.designer .panels { + display: flex; +} +.designer .buttons { + display: flex; + list-style-type: none; + padding-inline-start: 0 !important; + flex-direction: row; +} +.designer .buttons li { + flex: 1 1 0; + display: flex; +} +.designer .buttons button { + flex: 1 1 0; + background-color: var(--surface2); + color: var(--text2); +} +.designer .buttons button[active] { + font-weight: bold; + background-color: var(--surface1); + color: var(--text1); +} + +.designer.top { + flex-direction: column; +} +.designer.top .panels { + order: 2; +} +.designer.top .buttons { + order: 1; +} +.designer.top .buttons button[active] { + border-bottom: 1px; + margin-top: 0px; +} +.designer.top .buttons button { + border-top-left-radius: 1em; + border-top-right-radius: 1em; + margin-top: 10px; +} + +.designer.bottom { + flex-direction: column; +} +.designer.bottom .panels { + order: 1; +} +.designer.bottom .buttons { + order: 2; +} +.designer.bottom .buttons button[active] { + border-top: 1px; + margin-bottom: 0px; +} +.designer.bottom .buttons button { + border-bottom-left-radius: 1em; + border-bottom-right-radius: 1em; + margin-bottom: 10px; +} + +.designer.right { + flex-direction: row; +} +.designer.right .panels { + order: 1; +} +.designer.right .buttons { + order: 2; + flex-direction: column; +} +.designer.right .buttons button[active] { + border-left: 1px; + margin-right: 0; +} +.designer.right .buttons button { + border-top-right-radius: 1em; + border-bottom-right-radius: 1em; + margin-right: 10px; +} + +.designer.left { + flex-direction: row; +} +.designer.left .panels { + order: 2; + flex: 1; +} +.designer.left .buttons { + order: 1; + flex-direction: column; + flex: 1; +} +.designer.left .buttons button[active] { + border-right: 1px; + margin-left: 0; +} +.designer.left .buttons button { + border-top-left-radius: 1em; + border-bottom-left-radius: 1em; + margin-left: 10px; +} + +.designer.none .buttons { + display: none; +} + +.DesignerPanel { + display: none; +} + +.DesignerPanel.ActivePanel { + display: flex; + width: 100%; + height: 100%; + flex-direction: column; +} +.content form { + display: flex; + width: 100%; + gap: 0.5em; +} + +.content form input[type="url"] { + flex: 1; + max-width: 60%; +} + +.content div#messages { + color: red; + font-size: 2em; + padding-left: 1em; + padding-top: 1em; +} + +.content label input[type="checkbox"] { + vertical-align: bottom; + margin-right: 0.5em; +} #PleaseWait { position: fixed; width: 100vw; @@ -716,7 +1027,7 @@ div.logging-indicator[logging] { width: 100%; } -.settings div.labeledInput:has(textarea) { +.settings label.labeledInput:has(textarea) { width: 100%; } div.empty { @@ -741,12 +1052,10 @@ body.designing #UI [highlight="component"] { body.designing #UI [highlight="parent"] { border: 2px dotted red; } -div.actions { - display: flex; - flex-direction: column; - flex: 1 1 0; -} +#designer .layout summary { + border-bottom: 1px solid #d0d0d0; +} div.actions div.scroll { overflow-y: auto; } @@ -779,11 +1088,11 @@ div.actions div.scroll { margin-top: 0.2em; } -.actions div.condition div.labeledInput { +.actions div.condition label.labeledInput { width: 100%; } -.actions td.update div.labeledInput { +.actions td.update label.labeledInput { width: 100%; } @@ -791,6 +1100,10 @@ div.actions div.scroll { overflow-wrap: anywhere; } +.actions td.update input { + min-width: 100%; +} + .actions thead tr { background: white; } @@ -810,20 +1123,11 @@ div.actions div.scroll { background-color: var(--secondary-fg); } -.actions .updates { - display: grid; - grid-template-columns: auto 1fr; - grid-gap: 0.25em 1em; -} - -.actions input { +.actions input[type="text"] { + min-width: 100%; width: 100%; box-sizing: border-box; } - -.actions label { - width: 100%; -} #HotKeyHints { } @@ -839,6 +1143,10 @@ details.Method > *:not(summary) { font-weight: normal; font-size: 80%; } + +div.MethodChooser > ul { + width: 100%; +} div.access-pattern { padding-left: 12px; padding-top: 12px; @@ -871,13 +1179,7 @@ details.PatternManager summary h3 label { font-weight: normal; font-size: 80%; } -div.Cue { - display: flex; - flex-wrap: wrap; -} - .Cue details { - flex-basis: 100%; width: 100%; } @@ -1047,127 +1349,6 @@ dialog#OpenDialog button { display: inline; background-color: var(--primary-bg); } -body.designing { - display: grid; - grid-template-rows: 2.5em 50% auto; - grid-template-columns: 50% 50%; -} - -body.designing div#UI { - font-size: 0.7vw; - flex: 1 1 0; -} - -div#designer { - display: none; -} - -div#tabs { - width: 100%; - overflow-y: auto; - overflow-x: hidden; - flex: 1 1 0; -} - -body.designing div#designer { - display: flex; - grid-row-start: 1; - grid-row-end: 4; - grid-column-start: 2; - overflow: hidden; - height: 100vh; - flex-direction: column; -} -body.designing #UI { - grid-row-start: 1; - grid-row-end: 3; - grid-column-start: 1; - position: relative; -} -body.designing #monitor { - grid-row-start: 3; - grid-column-start: 1; -} - -#designer ol, -#designer ul { - list-style-type: none; - margin-block-start: 0; - padding-inline-start: 1em; - border-left: 1px solid #d0d0d0; -} - -#designer .panels ol li:not(:last-of-type), -#designer .panels ul li:not(:last-of-type) { - border-bottom: 1px solid #d0d0d0; - margin-bottom: 5px; -} - -#designer details summary { - cursor: pointer; -} - -details summary > * { - display: inline; -} - -#designer details[open] { - border: 1px solid black; - margin-left: 1em; -} - -#designer details[open] summary { - margin-left: -1em; -} - -#designer details summary h3 { - font-weight: 500; -} - -#designer details label { - margin-left: 1em; - margin-bottom: 0.5em; -} - -#designer .settings { - background-color: var(--surface2); - color: var(--text2); - border: 0px; - box-shadow: none; -} - -#designer .settings:has([aria-selected="true"]), -#designer .settings[aria-selected="true"] { - background-color: var(--surface1); - color: var(--text1); - border: 4px dashed var(--brand); -} -#designer .settings:has(.settings [aria-selected="true"]) { - background-color: var(--surface2); - color: var(--text2); - border: 0px; - box-shadow: none; -} - -#designer :focus { - outline: var(--text2) 4px dashed; -} - -#designer .indicator { - color: var(--brand); -} - -#designer .panels { - display: block; -} - -.selectedInDesigner { - border: 2px dashed red; -} - -#UI [id]:has(.selectedInDesigner) { - border: 2px dotted yellow; -} /* * Color management. * diff --git a/index.js b/index.js index e7e34b2..0af4f6e 100644 --- a/index.js +++ b/index.js @@ -3148,199 +3148,286 @@ function requireStacktraceGps () { var stacktraceExports = stacktrace.exports; -class MapSet extends Map { - set(key, value) { - super.set(key, value); - return value; - } -} - -class WeakMapSet extends WeakMap { - set(key, value) { - super.set(key, value); - return value; - } -} +const { isArray: isArray$3 } = Array; +const { getPrototypeOf: getPrototypeOf$1, getOwnPropertyDescriptor } = Object; -/*! (c) Andrea Giammarchi - ISC */ -const empty = /^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i; -const elements = /<([a-z]+[a-z0-9:._-]*)([^>]*?)(\/?)>/g; -const attributes = /([^\s\\>"'=]+)\s*=\s*(['"]?)\x01/g; -const holes = /[\x01\x02]/g; +const empty = []; -// \x01 Node.ELEMENT_NODE -// \x02 Node.ATTRIBUTE_NODE +const newRange = () => document.createRange(); /** - * Given a template, find holes as both nodes and attributes and - * return a string with holes as either comment nodes or named attributes. - * @param {string[]} template a template literal tag array - * @param {string} prefix prefix to use per each comment/attribute - * @param {boolean} svg enforces self-closing tags - * @returns {string} X/HTML with prefixed comments or attributes + * Set the `key` `value` pair to the *Map* or *WeakMap* and returns the `value` + * @template T + * @param {Map | WeakMap} map + * @param {any} key + * @param {T} value + * @returns {T} */ -const instrument = (template, prefix, svg) => { - let i = 0; - return template - .join('\x01') - .trim() - .replace( - elements, - (_, name, attrs, selfClosing) => { - let ml = name + attrs.replace(attributes, '\x02=$2$1').trimEnd(); - if (selfClosing.length) - ml += (svg || empty.test(name)) ? ' /' : ('>' + name); - return '<' + ml + '>'; - } - ) - .replace( - holes, - hole => hole === '\x01' ? - ('') : - (prefix + i++) - ); +const set = (map, key, value) => { + map.set(key, value); + return value; }; -const ELEMENT_NODE = 1; -const nodeType = 111; - -const remove = ({firstChild, lastChild}) => { - const range = document.createRange(); - range.setStartAfter(firstChild); - range.setEndAfter(lastChild); - range.deleteContents(); - return firstChild; +const gPD = (ref, prop) => { + let desc; + do { desc = getOwnPropertyDescriptor(ref, prop); } + while(!desc && (ref = getPrototypeOf$1(ref))); + return desc; }; -const diffable = (node, operation) => node.nodeType === nodeType ? - ((1 / operation) < 0 ? - (operation ? remove(node) : node.lastChild) : - (operation ? node.valueOf() : node.firstChild)) : - node -; - -const persistent = fragment => { - const {firstChild, lastChild} = fragment; - if (firstChild === lastChild) - return lastChild || fragment; - const {childNodes} = fragment; - const nodes = [...childNodes]; - return { - ELEMENT_NODE, - nodeType, - firstChild, - lastChild, - valueOf() { - if (childNodes.length !== nodes.length) - fragment.append(...nodes); - return fragment; - } - }; -}; +/** @typedef {import("domconstants/constants").ATTRIBUTE_NODE} ATTRIBUTE_NODE */ +/** @typedef {import("domconstants/constants").TEXT_NODE} TEXT_NODE */ +/** @typedef {import("domconstants/constants").COMMENT_NODE} COMMENT_NODE */ +/** @typedef {ATTRIBUTE_NODE | TEXT_NODE | COMMENT_NODE} Type */ -const {isArray: isArray$4} = Array; +/** @typedef {import("./persistent-fragment.js").PersistentFragment} PersistentFragment */ +/** @typedef {import("./rabbit.js").Hole} Hole */ -const aria = node => values => { - for (const key in values) { - const name = key === 'role' ? key : `aria-${key}`; - const value = values[key]; - if (value == null) - node.removeAttribute(name); - else - node.setAttribute(name, value); +/** @typedef {unknown} Value */ +/** @typedef {Node | Element | PersistentFragment} Target */ +/** @typedef {null | undefined | string | number | boolean | Node | Element | PersistentFragment} DOMValue */ + +/** + * @typedef {Object} Entry + * @property {Type} type + * @property {number[]} path + * @property {function} update + * @property {string} name + */ + +/** + * @param {PersistentFragment} c content retrieved from the template + * @param {Entry[]} e entries per each hole in the template + * @param {number} l the length of content childNodes + * @returns + */ +const cel = (c, e, l) => ({ c, e, l }); + +/** + * @typedef {Object} HoleDetails + * @property {null | Node | PersistentFragment} n the current live node, if any and not the `t` one + */ + +/** @type {() => HoleDetails} */ +const comment = () => ({ n: null }); + +/** + * @typedef {Object} Detail + * @property {any} v the current value of the interpolation / hole + * @property {function} u the callback to update the value + * @property {Node} t the target comment node or element + * @property {string} n the name of the attribute, if any + */ + +/** + * @param {any} v the current value of the interpolation / hole + * @param {function} u the callback to update the value + * @param {Node} t the target comment node or element + * @param {string} n the name of the attribute, if any + * @returns {Detail} + */ +const detail = (v, u, t, n) => ({ v, u, t, n }); + +/** + * @param {Type} t the operation type + * @param {number[]} p the path to retrieve the node + * @param {function} u the update function + * @param {string} n the attribute name, if any + * @returns {Entry} + */ +const entry = (t, p, u, n = '') => ({ t, p, u, n }); + +/** + * @typedef {Object} Cache + * @property {Cache[]} s the stack of caches per each interpolation / hole + * @property {null | TemplateStringsArray} t the cached template + * @property {null | Node | PersistentFragment} n the node returned when parsing the template + * @property {Detail[]} d the list of updates to perform + */ + +/** + * @param {Cache[]} s the cache stack + * @returns {Cache} + */ +const cache$1 = s => ({ s, t: null, n: null, d: empty}); + +/** + * @typedef {Object} Parsed + * @property {Node | PersistentFragment} n the returned node after parsing the template + * @property {Detail[]} d the list of details to update the node + */ + +/** + * @param {Node | PersistentFragment} n the returned node after parsing the template + * @param {Detail[]} d the list of details to update the node + * @returns {Parsed} + */ +const parsed = (n, d) => ({ n, d }); + +const ATTRIBUTE_NODE = 2; +const TEXT_NODE = 3; +const COMMENT_NODE = 8; +const DOCUMENT_FRAGMENT_NODE = 11; + +/*! (c) Andrea Giammarchi - ISC */ +const {setPrototypeOf} = Object; + +/** + * @param {Function} Class any base class to extend without passing through it via super() call. + * @returns {Function} an extensible class for the passed one. + * @example + * // creating this very same module utility + * import custom from 'custom-function/factory'; + * const CustomFunction = custom(Function); + * class MyFunction extends CustomFunction {} + * const mf = new MyFunction(() => {}); + */ +const custom = Class => { + function Custom(target) { + return setPrototypeOf(target, new.target.prototype); } + Custom.prototype = Class.prototype; + return Custom; }; -const getValue = value => value == null ? value : value.valueOf(); - -const attribute = (node, name) => { - let oldValue, orphan = true; - const attributeNode = document.createAttributeNS(null, name); - return newValue => { - const value = getValue(newValue); - if (oldValue !== value) { - if ((oldValue = value) == null) { - if (!orphan) { - node.removeAttributeNode(attributeNode); - orphan = true; - } - } - else { - attributeNode.value = value; - if (orphan) { - node.setAttributeNodeNS(attributeNode); - orphan = false; - } - } - } - }; +let range$1; +/** + * @param {Node | Element} firstChild + * @param {Node | Element} lastChild + * @param {boolean} preserve + * @returns + */ +const drop = (firstChild, lastChild, preserve) => { + if (!range$1) range$1 = newRange(); + if (preserve) + range$1.setStartAfter(firstChild); + else + range$1.setStartBefore(firstChild); + range$1.setEndAfter(lastChild); + range$1.deleteContents(); + return firstChild; }; -const boolean = (node, key, oldValue) => newValue => { - const value = !!getValue(newValue); - if (oldValue !== value) { - // when IE won't be around anymore ... - // node.toggleAttribute(key, oldValue = !!value); - if ((oldValue = value)) - node.setAttribute(key, ''); - else - node.removeAttribute(key); +/** + * @param {PersistentFragment} fragment + * @returns {Node | Element} + */ +const remove = ({firstChild, lastChild}, preserve) => drop(firstChild, lastChild, preserve); + +let checkType = false; + +/** + * @param {Node} node + * @param {1 | 0 | -0 | -1} operation + * @returns {Node} + */ +const diffFragment = (node, operation) => ( + checkType && node.nodeType === DOCUMENT_FRAGMENT_NODE ? + ((1 / operation) < 0 ? + (operation ? remove(node, true) : node.lastChild) : + (operation ? node.valueOf() : node.firstChild)) : + node +); + +/** @extends {DocumentFragment} */ +class PersistentFragment extends custom(DocumentFragment) { + #nodes; + #length; + constructor(fragment) { + const _nodes = [...fragment.childNodes]; + super(fragment); + this.#nodes = _nodes; + this.#length = _nodes.length; + checkType = true; + } + get firstChild() { return this.#nodes[0]; } + get lastChild() { return this.#nodes.at(-1); } + get parentNode() { return this.#nodes[0].parentNode; } + remove() { + remove(this, false); } -}; + replaceWith(node) { + remove(this, true).replaceWith(node); + } + valueOf() { + if (this.childNodes.length !== this.#length) + this.replaceChildren(...this.#nodes); + return this; + } +} -const data = ({dataset}) => values => { - for (const key in values) { - const value = values[key]; - if (value == null) - delete dataset[key]; - else - dataset[key] = value; +/** + * @param {DocumentFragment} content + * @param {number[]} path + * @returns {Element} + */ +const find = (content, path) => path.reduceRight(childNodesIndex, content); +const childNodesIndex = (node, i) => node.childNodes[i]; + +/** @param {(template: TemplateStringsArray, values: any[]) => import("./parser.js").Resolved} parse */ +const create = parse => ( + /** @param {(template: TemplateStringsArray, values: any[]) => import("./literals.js").Parsed} parse */ + (template, values) => { + const { c: content, e: entries, l: length } = parse(template, values); + const root = content.cloneNode(true); + // reverse loop to avoid missing paths while populating + // TODO: is it even worth to pre-populate nodes? see rabbit.js too + let current, prev, i = entries.length, details = i ? entries.slice(0) : empty; + while (i--) { + const { t: type, p: path, u: update, n: name } = entries[i]; + const node = path === prev ? current : (current = find(root, (prev = path))); + const callback = type === COMMENT_NODE ? update() : update; + details[i] = detail(callback(node, values[i], name, empty), callback, node, name); + } + return parsed( + length === 1 ? root.firstChild : new PersistentFragment(root), + details + ); } -}; +); -const event = (node, name) => { - let oldValue, lower, type = name.slice(2); - if (!(name in node) && (lower = name.toLowerCase()) in node) - type = lower.slice(2); - return newValue => { - const info = isArray$4(newValue) ? newValue : [newValue, false]; - if (oldValue !== info[0]) { - if (oldValue) - node.removeEventListener(type, oldValue, info[1]); - if (oldValue = info[0]) - node.addEventListener(type, oldValue, info[1]); - } - }; -}; +const TEXT_ELEMENTS = /^(?:plaintext|script|style|textarea|title|xmp)$/i; +const VOID_ELEMENTS = /^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i; -const ref = node => { - let oldValue; - return value => { - if (oldValue !== value) { - oldValue = value; - if (typeof value === 'function') - value(node); - else - value.current = node; - } - }; -}; +/*! (c) Andrea Giammarchi - ISC */ -const setter = (node, key) => key === 'dataset' ? - data(node) : - value => { - node[key] = value; - }; +const elements = /<([a-zA-Z0-9]+[a-zA-Z0-9:._-]*)([^>]*?)(\/?)>/g; +const attributes = /([^\s\\>"'=]+)\s*=\s*(['"]?)\x01/g; +const holes = /[\x01\x02]/g; -const text = node => { - let oldValue; - return newValue => { - const value = getValue(newValue); - if (oldValue != value) { - oldValue = value; - node.textContent = value == null ? '' : value; - } - }; +// \x01 Node.ELEMENT_NODE +// \x02 Node.ATTRIBUTE_NODE + +/** + * Given a template, find holes as both nodes and attributes and + * return a string with holes as either comment nodes or named attributes. + * @param {string[]} template a template literal tag array + * @param {string} prefix prefix to use per each comment/attribute + * @param {boolean} xml enforces self-closing tags + * @returns {string} X/HTML with prefixed comments or attributes + */ +const parser$1 = (template, prefix, xml) => { + let i = 0; + return template + .join('\x01') + .trim() + .replace( + elements, + (_, name, attrs, selfClosing) => `<${ + name + }${ + attrs.replace(attributes, '\x02=$2$1').trimEnd() + }${ + selfClosing ? ( + (xml || VOID_ELEMENTS.test(name)) ? ' /' : `>${name}` + ) : '' + }>` + ) + .replace( + holes, + hole => hole === '\x01' ? `` : (prefix + i++) + ) + ; }; /** @@ -3501,461 +3588,462 @@ const udomdiff = (parentNode, a, b, get, before) => { return b; }; -const {isArray: isArray$3, prototype} = Array; -const {indexOf} = prototype; - -const { - createDocumentFragment, - createElement, - createElementNS, - createTextNode, - createTreeWalker, - importNode -} = new Proxy({}, { - get: (_, method) => document[method].bind(document) -}); - -const createHTML = html => { - const template = createElement('template'); - template.innerHTML = html; - return template.content; -}; - -let xml; -const createSVG = svg => { - if (!xml) xml = createElementNS('http://www.w3.org/2000/svg', 'svg'); - xml.innerHTML = svg; - const content = createDocumentFragment(); - content.append(...xml.childNodes); - return content; -}; - -const createContent = (text, svg) => svg ? - createSVG(text) : createHTML(text); - -// from a generic path, retrieves the exact targeted node -const reducePath = ({childNodes}, i) => childNodes[i]; - -// this helper avoid code bloat around handleAnything() callback -const diff = (comment, oldNodes, newNodes) => udomdiff( - comment.parentNode, - // TODO: there is a possible edge case where a node has been - // removed manually, or it was a keyed one, attached - // to a shared reference between renders. - // In this case udomdiff might fail at removing such node - // as its parent won't be the expected one. - // The best way to avoid this issue is to filter oldNodes - // in search of those not live, or not in the current parent - // anymore, but this would require both a change to uwire, - // exposing a parentNode from the firstChild, as example, - // but also a filter per each diff that should exclude nodes - // that are not in there, penalizing performance quite a lot. - // As this has been also a potential issue with domdiff, - // and both lighterhtml and hyperHTML might fail with this - // very specific edge case, I might as well document this possible - // "diffing shenanigan" and call it a day. - oldNodes, - newNodes, - diffable, - comment -); - -// if an interpolation represents a comment, the whole -// diffing will be related to such comment. -// This helper is in charge of understanding how the new -// content for such interpolation/hole should be updated -const handleAnything = comment => { - let oldValue, text, nodes = []; - const anyContent = newValue => { - switch (typeof newValue) { - // primitives are handled as text content - case 'string': - case 'number': - case 'boolean': - if (oldValue !== newValue) { - oldValue = newValue; - if (!text) - text = createTextNode(''); - text.data = newValue; - nodes = diff(comment, nodes, [text]); - } - break; - // null, and undefined are used to cleanup previous content - case 'object': - case 'undefined': - if (newValue == null) { - if (oldValue != newValue) { - oldValue = newValue; - nodes = diff(comment, nodes, []); - } - break; - } - // arrays and nodes have a special treatment - if (isArray$3(newValue)) { - oldValue = newValue; - // arrays can be used to cleanup, if empty - if (newValue.length === 0) - nodes = diff(comment, nodes, []); - // or diffed, if these contains nodes or "wires" - else if (typeof newValue[0] === 'object') - nodes = diff(comment, nodes, newValue); - // in all other cases the content is stringified as is - else - anyContent(String(newValue)); - break; - } - // if the new value is a DOM node, or a wire, and it's - // different from the one already live, then it's diffed. - // if the node is a fragment, it's appended once via its childNodes - // There is no `else` here, meaning if the content - // is not expected one, nothing happens, as easy as that. - if (oldValue !== newValue) { - if ('ELEMENT_NODE' in newValue) { - oldValue = newValue; - nodes = diff( - comment, - nodes, - newValue.nodeType === 11 ? - [...newValue.childNodes] : - [newValue] - ); - } - else { - const value = newValue.valueOf(); - if (value !== newValue) - anyContent(value); - } - } - break; - case 'function': - anyContent(newValue(comment)); - break; - } - }; - return anyContent; -}; - -// attributes can be: -// * ref=${...} for hooks and other purposes -// * aria=${...} for aria attributes -// * ?boolean=${...} for boolean attributes -// * .dataset=${...} for dataset related attributes -// * .setter=${...} for Custom Elements setters or nodes with setters -// such as buttons, details, options, select, etc -// * @event=${...} to explicitly handle event listeners -// * onevent=${...} to automatically handle event listeners -// * generic=${...} to handle an attribute just like an attribute -const handleAttribute = (node, name/*, svg*/) => { - switch (name[0]) { - case '?': return boolean(node, name.slice(1), false); - case '.': return setter(node, name.slice(1)); - case '@': return event(node, 'on' + name.slice(1)); - case 'o': if (name[1] === 'n') return event(node, name); - } - - switch (name) { - case 'ref': return ref(node); - case 'aria': return aria(node); - } +const setAttribute = (element, name, value) => + element.setAttribute(name, value); - return attribute(node, name/*, svg*/); -}; +const removeAttribute = (element, name) => + element.removeAttribute(name); -// each mapped update carries the update type and its path -// the type is either node, attribute, or text, while -// the path is how to retrieve the related node to update. -// In the attribute case, the attribute name is also carried along. -function handlers(options) { - const {type, path} = options; - const node = path.reduceRight(reducePath, this); - return type === 'node' ? - handleAnything(node) : - (type === 'attr' ? - handleAttribute(node, options.name/*, options.svg*/) : - text(node)); -} - -// from a fragment container, create an array of indexes -// related to its child nodes, so that it's possible -// to retrieve later on exact node via reducePath -const createPath = node => { - const path = []; - let {parentNode} = node; - while (parentNode) { - path.push(indexOf.call(parentNode.childNodes, node)); - node = parentNode; - ({parentNode} = node); +/** + * @template T + * @param {Element} element + * @param {T} value + * @returns {T} + */ +const aria = (element, value) => { + for (const key in value) { + const $ = value[key]; + const name = key === 'role' ? key : `aria-${key}`; + if ($ == null) removeAttribute(element, name); + else setAttribute(element, name, $); } - return path; + return value; }; -// the prefix is used to identify either comments, attributes, or nodes -// that contain the related unique id. In the attribute cases -// isµX="attribute-name" will be used to map current X update to that -// attribute name, while comments will be like , to map -// the update to that specific comment node, hence its parent. -// style and textarea will have text content, and are handled -// directly through text-only updates. -const prefix = 'isµ'; +const arrayComment = () => array; -// Template Literals are unique per scope and static, meaning a template -// should be parsed once, and once only, as it will always represent the same -// content, within the exact same amount of updates each time. -// This cache relates each template to its unique content and updates. -const cache$1 = new WeakMapSet; - -// a RegExp that helps checking nodes that cannot contain comments -const textOnly = /^(?:textarea|script|style|title|plaintext|xmp)$/; - -const createCache = () => ({ - stack: [], // each template gets a stack for each interpolation "hole" - - entry: null, // each entry contains details, such as: - // * the template that is representing - // * the type of node it represents (html or svg) - // * the content fragment with all nodes - // * the list of updates per each node (template holes) - // * the "wired" node or fragment that will get updates - // if the template or type are different from the previous one - // the entry gets re-created each time - - wire: null // each rendered node represent some wired content and - // this reference to the latest one. If different, the node - // will be cleaned up and the new "wire" will be appended -}); +let listeners; -// the entry stored in the rendered node cache, and per each "hole" -const createEntry = (type, template) => { - const {content, updates} = mapUpdates(type, template); - return {type, template, content, updates, wire: null}; +/** + * @template T + * @param {Element} element + * @param {T} value + * @param {string} name + * @returns {T} + */ +const at = (element, value, name) => { + name = name.slice(1); + if (!listeners) listeners = new WeakMap; + const known = listeners.get(element) || set(listeners, element, {}); + let current = known[name]; + if (current && current[0]) element.removeEventListener(name, ...current); + current = isArray$3(value) ? value : [value, false]; + known[name] = current; + if (current[0]) element.addEventListener(name, ...current); + return value; }; -// a template is instrumented to be able to retrieve where updates are needed. -// Each unique template becomes a fragment, cloned once per each other -// operation based on the same template, i.e. data => html`
${data}
` -const mapTemplate = (type, template) => { - const svg = type === 'svg'; - const text = instrument(template, prefix, svg); - const content = createContent(text, svg); - // once instrumented and reproduced as fragment, it's crawled - // to find out where each update is in the fragment tree - const tw = createTreeWalker(content, 1 | 128); - const nodes = []; - const length = template.length - 1; - let i = 0; - // updates are searched via unique names, linearly increased across the tree - // - let search = `${prefix}${i}`; - while (i < length) { - const node = tw.nextNode(); - // if not all updates are bound but there's nothing else to crawl - // it means that there is something wrong with the template. - if (!node) - throw `bad template: ${text}`; - // if the current node is a comment, and it contains isµX - // it means the update should take care of any content - if (node.nodeType === 8) { - // The only comments to be considered are those - // which content is exactly the same as the searched one. - if (node.data === search) { - nodes.push({type: 'node', path: createPath(node)}); - search = `${prefix}${++i}`; - } +/** + * @template T + * @this {import("./literals.js").HoleDetails} + * @param {Node} node + * @param {T} value + * @returns {T} + */ +function hole(node, value) { + const n = this.n || (this.n = node); + switch (typeof value) { + case 'string': + case 'number': + case 'boolean': { + if (n !== node) n.replaceWith((this.n = node)); + this.n.data = value; + break; } - else { - // if the node is not a comment, loop through all its attributes - // named isµX and relate attribute updates to this node and the - // attribute name, retrieved through node.getAttribute("isµX") - // the isµX attribute will be removed as irrelevant for the layout - // let svg = -1; - while (node.hasAttribute(search)) { - nodes.push({ - type: 'attr', - path: createPath(node), - name: node.getAttribute(search) - }); - node.removeAttribute(search); - search = `${prefix}${++i}`; - } - // if the node was a style, textarea, or others, check its content - // and if it is then update tex-only this node - if ( - textOnly.test(node.localName) && - node.textContent.trim() === `` - ){ - node.textContent = ''; - nodes.push({type: 'text', path: createPath(node)}); - search = `${prefix}${++i}`; - } + case 'object': + case 'undefined': { + if (value == null) (this.n = node).data = ''; + else this.n = value.valueOf(); + n.replaceWith(this.n); + break; } } - // once all nodes to update, or their attributes, are known, the content - // will be cloned in the future to represent the template, and all updates - // related to such content retrieved right away without needing to re-crawl - // the exact same template, and its content, more than once. - return {content, nodes}; -}; - -// if a template is unknown, perform the previous mapping, otherwise grab -// its details such as the fragment with all nodes, and updates info. -const mapUpdates = (type, template) => { - const {content, nodes} = ( - cache$1.get(template) || - cache$1.set(template, mapTemplate(type, template)) - ); - // clone deeply the fragment - const fragment = importNode(content, true); - // and relate an update handler per each node that needs one - const updates = nodes.map(handlers, fragment); - // return the fragment and all updates to use within its nodes - return {content: fragment, updates}; -}; + return value; +} +const boundComment = () => hole.bind(comment()); -// as html and svg can be nested calls, but no parent node is known -// until rendered somewhere, the unroll operation is needed to -// discover what to do with each interpolation, which will result -// into an update operation. -const unroll = (info, {type, template, values}) => { - // interpolations can contain holes and arrays, so these need - // to be recursively discovered - const length = unrollValues(info, values); - let {entry} = info; - // if the cache entry is either null or different from the template - // and the type this unroll should resolve, create a new entry - // assigning a new content fragment and the list of updates. - if (!entry || (entry.template !== template || entry.type !== type)) - info.entry = (entry = createEntry(type, template)); - const {content, updates, wire} = entry; - // even if the fragment and its nodes is not live yet, - // it is already possible to update via interpolations values. - for (let i = 0; i < length; i++) - updates[i](values[i]); - // if the entry was new, or representing a different template or type, - // create a new persistent entity to use during diffing. - // This is simply a DOM node, when the template has a single container, - // as in ``, or a "wire" in `` and similar cases. - return wire || (entry.wire = persistent(content)); -}; +/** + * @template T + * @param {Element} element + * @param {T} value + * @returns {T} + */ +const className = (element, value) => maybeDirect( + element, value, value == null ? 'class' : 'className' +); -// the stack retains, per each interpolation value, the cache -// related to each interpolation value, or null, if the render -// was conditional and the value is not special (Array or Hole) -const unrollValues = ({stack}, values) => { - const {length} = values; - for (let i = 0; i < length; i++) { - const hole = values[i]; - // each Hole gets unrolled and re-assigned as value - // so that domdiff will deal with a node/wire, not with a hole - if (hole instanceof Hole) - values[i] = unroll( - stack[i] || (stack[i] = createCache()), - hole - ); - // arrays are recursively resolved so that each entry will contain - // also a DOM node or a wire, hence it can be diffed if/when needed - else if (isArray$3(hole)) - unrollValues(stack[i] || (stack[i] = createCache()), hole); - // if the value is nothing special, the stack doesn't need to retain data - // this is useful also to cleanup previously retained data, if the value - // was a Hole, or an Array, but not anymore, i.e.: - // const update = content => html`