import morphdom from '@substrate-system/morphdom' export class TonicTemplate { rawText:string unsafe:boolean templateStrings?:string[]|TemplateStringsArray|null isTonicTemplate:true constructor ( rawText, templateStrings?:string[]|TemplateStringsArray|null, unsafe?:boolean ) { this.isTonicTemplate = true this.unsafe = !!unsafe this.rawText = rawText this.templateStrings = templateStrings } valueOf () { return this.rawText } toString () { return this.rawText } } /** * Class Tonic * * @template {T extends object = Record} T Type of the props */ export abstract class Tonic< T extends { [key:string]:any}=Record > extends window.HTMLElement { private static _tags = '' private static _refIds:string[] = [] private static _data = {} private static _states = {} private static _children = {} private static _reg = {} private static _stylesheetRegistry:(()=>string)[] = [] private static _index = 0 // @ts-expect-error VERSION is injected during build static get version () { return VERSION ?? null } static get SPREAD () { return /\.\.\.\s?(__\w+__\w+__)/g } static get ESC () { return /["&'<>`/]/g } static get AsyncFunctionGenerator ():AsyncGeneratorFunctionConstructor { return (async function * () { }.constructor) as AsyncGeneratorFunctionConstructor } // eslint-disable-next-line static get AsyncFunction ():Function { return (async function () {}.constructor) } static get MAP () { /* eslint-disable object-property-newline, object-property-newline, object-curly-newline */ return { '"': '"', '&': '&', '\'': ''', '<': '<', '>': '>', '`': '`', '/': '/' } } static ssr static nonce static _hydrating:boolean = false static _ssrState:Record|null = null private _state:any declare stylesheet?:()=>string declare styles?:()=>string props:T preventRenderOnReconnect:boolean private _id?:string pendingReRender?:Promise|null declare updated?:((props:Record)=>any) declare willRender?:(()=>any) root?:ShadowRoot|this declare willConnect?:()=>any private _source?:string declare connected?:()=>void declare disconnected?:()=>void private elements:Element[] & { __children__? } private nodes:ChildNode[] & { __children__? } private _props = Tonic.getPropertyNames(this) constructor () { super() const state = Tonic._states[super.id] delete Tonic._states[super.id] this._state = state || {} this.preventRenderOnReconnect = false this.props = {} as T this.elements = [...this.children] this.elements.__children__ = true this.nodes = [...this.childNodes] this.nodes.__children__ = true this._events() } abstract render ():TonicTemplate|Promise defaults ():Record { return {} } get isTonicComponent ():true { return true } /** * Get a namespaced event name, given a non-namespaced string. * * @example * MyElement.event('example') // => my-element:example * * @param {string} type The name of the event * @returns {string} The namespaced event name */ static event (type:string):string { return `${this.TAG}:${type}` } /** * Get the tag name of this component. */ static get TAG ():string { return Tonic.getTagName(this.name) } private static _createId () { return `tonic${Tonic._index++}` } private static _normalizeAttrs (o, x = {}) { [...o].forEach(o => (x[o.name] = o.value)) return x } private _checkId () { const _id = super.id if (!_id) { const html = this.outerHTML.replace(this.innerHTML, '...') throw new Error(`Component: ${html} has no id`) } return _id } /** * Get the component state property. */ get state () { return (this._checkId(), this._state) } set state (newState) { this._state = (this._checkId(), newState) } private _events () { const hp = Object.getOwnPropertyNames(window.HTMLElement.prototype) // this is where we map methods like `handle_click` to event handlers. // look at the HTMLElement prototype, and if it is has a method like // `onclick`, then add an event listener for 'click' for (const p of this._props) { if (!p.includes('handle_')) continue const evName = p.split('_')[1] if (hp.indexOf('on' + evName) === -1) continue this.addEventListener(evName, this) } } private _prop (o) { const id = this._id! const p = `__${id}__${Tonic._createId()}__` Tonic._data[id] = Tonic._data[id] || {} Tonic._data[id][p] = o return p } private _placehold (r) { const id = this._id! const ref = `placehold:${id}:${Tonic._createId()}__` Tonic._children[id] = Tonic._children[id] || {} Tonic._children[id][ref] = r return ref } static match (el:HTMLElement, s:string) { if (!el.matches) el = el.parentElement! return el.matches(s) ? el : el.closest(s) } static getTagName (camelName:string) { return camelName.match(/[A-Z][a-z0-9]*/g)!.join('-').toLowerCase() } /** * Add all methods to this._props */ static getPropertyNames (proto) { const props:string[] = [] while (proto && proto !== Tonic.prototype) { props.push(...Object.getOwnPropertyNames(proto)) proto = Object.getPrototypeOf(proto) } return props } /** * Add a component. Calls `window.customElements.define` with the * component's name. * * @param {Tonic} c Component to add * @param {string} [htmlName] Name of the element, default to the class name * @returns {Tonic} */ static add (c, htmlName?:string) { const hasValidName = htmlName || (c.name && c.name.length > 1) if (!hasValidName) { throw Error('Mangling. https://bit.ly/2TkJ6zP') } if (!htmlName) htmlName = Tonic.getTagName(c.name) if (!Tonic.ssr && window.customElements.get(htmlName)) { throw new Error(`Cannot Tonic.add(${c.name}, '${htmlName}') twice`) } if (!c.prototype || !c.prototype.isTonicComponent) { const tmp = { [c.name]: class extends Tonic { render () { return new TonicTemplate('', null) } } }[c.name] tmp.prototype.render = c c = tmp } c.prototype._props = Tonic.getPropertyNames(c.prototype) Tonic._reg[htmlName] = c Tonic._tags = Object.keys(Tonic._reg).join() window.customElements.define(htmlName, c as unknown as CustomElementConstructor) if (typeof c.stylesheet === 'function') { Tonic.registerStyles(c.stylesheet) } return c } static registerStyles (stylesheetFn:()=>string) { if (Tonic._stylesheetRegistry.includes(stylesheetFn)) return Tonic._stylesheetRegistry.push(stylesheetFn) const styleNode = document.createElement('style') if (Tonic.nonce) styleNode.setAttribute('nonce', Tonic.nonce) styleNode.appendChild(document.createTextNode(stylesheetFn())) if (document.head) document.head.appendChild(styleNode) } static escape (s:string):string { return s.replace(Tonic.ESC, c => Tonic.MAP[c]) } static unsafeRawString ( s:string, templateStrings:string[] ):InstanceType { return new TonicTemplate(s, templateStrings, true) } /** * Emit a regular, non-namespaced event. * * @param {string} eventName Event name as a string. * @param {any} detail Any data to go with the event. */ dispatch (eventName:string, detail:any = null):void { const opts = { bubbles: true, detail } this.dispatchEvent(new window.CustomEvent(eventName, opts)) } /** * Emit a namespaced event, using a convention for event names. * * @example * myComponent.emit('test') // => `my-compnent:test` * * @param {string} type The event type, comes after `:` in event name. * @param {string|object|any[]} detail detail for Event constructor * @param {{ bubbles?:boolean, cancelable?:boolean }} opts `Cancelable` and * `bubbles` * @returns {boolean} */ emit (type:string, detail:string|object|any[] = {}, opts:Partial<{ bubbles:boolean; cancelable:boolean }> = {}):boolean { const namespace = Tonic.getTagName(this.constructor.name) const event = new CustomEvent(`${namespace}:${type}`, { bubbles: (opts.bubbles === undefined) ? true : opts.bubbles, cancelable: (opts.cancelable === undefined) ? true : opts.cancelable, detail }) return this.dispatchEvent(event) } html ( strings:string[]|TemplateStringsArray, ...values ):InstanceType { const refs = o => { if (o && o.__children__) return this._placehold(o) if (o && o.isTonicTemplate) return o.rawText switch (Object.prototype.toString.call(o)) { case '[object HTMLCollection]': case '[object NodeList]': return this._placehold([...o]) case '[object Array]': { if (o.every(x => x.isTonicTemplate && !x.unsafe)) { return new TonicTemplate(o.join('\n'), null, false) } return this._prop(o) } case '[object Object]': case '[object Function]': case '[object AsyncFunction]': case '[object Set]': case '[object Map]': case '[object WeakMap]': case '[object File]': return this._prop(o) case '[object NamedNodeMap]': return this._prop(Tonic._normalizeAttrs(o)) case '[object Number]': return `${o}__float` case '[object String]': return Tonic.escape(o) case '[object Boolean]': return `${o}__boolean` case '[object Null]': return `${o}__null` case '[object HTMLElement]': return this._placehold([o]) } if ( typeof o === 'object' && o && o.nodeType === 1 && typeof o.cloneNode === 'function' ) { return this._placehold([o]) } return o } const out:string[] = [] for (let i = 0; i < strings.length - 1; i++) { out.push(strings[i], refs(values[i])) } out.push(strings[strings.length - 1]) const htmlStr = out.join('').replace(Tonic.SPREAD, (_, p) => { const o = Tonic._data[p.split('__')[1]][p] return Object.entries(o).map(([key, value]) => { const k = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() if (value === true) return k else if (value) return `${k}="${Tonic.escape(String(value))}"` else return '' }).filter(Boolean).join(' ') }) // Process type markers in template content .replace(/(\d+(?:\.\d+)?)__float/g, '$1') .replace(/(true|false)__boolean/g, '$1') .replace(/null__null/g, 'null') return new TonicTemplate(htmlStr, strings, false) } scheduleReRender (oldProps:any):Promise { if (this.pendingReRender) return this.pendingReRender this.pendingReRender = new Promise(resolve => setTimeout(() => { if (!this.isInDocument(this.shadowRoot || this)) return const p = this._set(this.shadowRoot || this, this.render) this.pendingReRender = null if (p && p.then) { return p.then(() => { this.updated && this.updated(oldProps) resolve(this) }) } this.updated && this.updated(oldProps) resolve(this) }, 0)) return this.pendingReRender } /** * Update the view */ reRender (o:T|((props:T)=>T) = this.props):Promise { const oldProps = { ...this.props } this.props = typeof o === 'function' ? (o as (props:T)=>T)(oldProps) : o return this.scheduleReRender(oldProps) } /** * If there is a method with the same name as the event type, * then call the method. * @see {@link https://gomakethings.com/the-handleevent-method-is-the-absolute-best-way-to-handle-events-in-web-components/#what-is-the-handleevent-method What is the handleEvent() method?} */ handleEvent (ev:Event):void { this['handle_' + ev.type] && this['handle_' + ev.type](ev) } private _drainIterator (target, iterator) { return iterator.next().then((result) => { this._set(target, null, result.value) if (result.done) return return this._drainIterator(target, iterator) }) } /** * _set * @param {Element|InstanceType|ShadowRoot} target * @param {()=>any} render * @param {string} content * @returns {Promise|void} * @private */ private _set (target, render, content = ''):Promise|void { this.willRender && this.willRender() for (const node of target.querySelectorAll(Tonic._tags)) { if (!node.isTonicComponent) continue const id = node.getAttribute('id') if (!id || !Tonic._refIds.includes(id)) continue Tonic._states[id] = node.state } if (render instanceof Tonic.AsyncFunction) { return ((render as (...args:any)=>any) .call(this, this.html, this.props) .then(content => this._apply(target, content)) ) } else if (render instanceof Tonic.AsyncFunctionGenerator) { return this._drainIterator( target, (render as AsyncGeneratorFunction).call(this)) } else if (render === null) { this._apply(target, content) } else if (render instanceof Function) { this._apply(target, render.call(this, this.html, this.props) || '') } } private _apply (target, content) { if (content && content.isTonicTemplate) { content = content.rawText } else if (typeof content === 'string') { content = Tonic.escape(content) } if (typeof content === 'string') { if (this.stylesheet) { content = `${content}` } // Check if we should use morphdom for DOM state // preservation (cursor position, selection, focus) const hasFormElements = target.querySelector && ( target.querySelector('input') || target.querySelector('textarea') || target.querySelector('select') ) const shouldUseMorphdom = ( hasFormElements && document.activeElement && ( target.contains(document.activeElement) || target === document.activeElement ) ) if (shouldUseMorphdom) { // Use morphdom to preserve DOM state const tempContainer = document.createElement('div') tempContainer.innerHTML = content morphdom(target, tempContainer, { childrenOnly: true, onBeforeElUpdated: (fromEl, toEl) => { if ( fromEl.isEqualNode && fromEl.isEqualNode(toEl) ) { return false } // For inputs, preserve value // and selection if ( fromEl.tagName === 'INPUT' && toEl.tagName === 'INPUT' ) { const fromInput = fromEl as HTMLInputElement const toInput = toEl as HTMLInputElement if (fromInput.value !== '') { toInput.value = fromInput.value } if ( document.activeElement === fromInput ) { toInput.setAttribute( 'data-preserve-focus', 'true') toInput.setAttribute( 'data-selection-start', String( fromInput .selectionStart || 0 )) toInput.setAttribute( 'data-selection-end', String( fromInput .selectionEnd || 0 )) } } // For textareas, preserve value // and selection if ( fromEl.tagName === 'TEXTAREA' && toEl.tagName === 'TEXTAREA' ) { const fromTa = fromEl as HTMLTextAreaElement const toTa = toEl as HTMLTextAreaElement if (fromTa.value !== '') { toTa.value = fromTa.value } if ( document.activeElement === fromTa ) { toTa.setAttribute( 'data-preserve-focus', 'true') toTa.setAttribute( 'data-selection-start', String( fromTa .selectionStart || 0 )) toTa.setAttribute( 'data-selection-end', String( fromTa .selectionEnd || 0 )) } } return true }, onElUpdated: (el) => { if ( !el.hasAttribute( 'data-preserve-focus' ) ) return const startPos = parseInt( el.getAttribute( 'data-selection-start' ) || '0', 10 ) const endPos = parseInt( el.getAttribute( 'data-selection-end' ) || '0', 10 ) el.removeAttribute( 'data-preserve-focus') el.removeAttribute( 'data-selection-start') el.removeAttribute( 'data-selection-end') el.focus() if ('setSelectionRange' in el) { (el as HTMLInputElement | HTMLTextAreaElement ).setSelectionRange( startPos, endPos ) } } }) } else { // Save user-modified form values by // position so they survive innerHTML // replacement. Only save when the user // changed the value (value !== default). type FormEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement const selector = 'input, textarea, select' const saved: { v:string; c:boolean; dirty:boolean }[] = [] if (hasFormElements) { const els = target.querySelectorAll( selector ) as NodeListOf for (const el of els) { const inp = el as HTMLInputElement const isCheck = ( inp.type === 'checkbox' || inp.type === 'radio' ) saved.push({ v: el.value, c: inp.checked, dirty: isCheck ? inp.checked !== inp.defaultChecked : el.value !== (el as HTMLInputElement) .defaultValue }) } } target.innerHTML = content if (saved.length) { const els = target.querySelectorAll( selector ) as NodeListOf for ( let i = 0; i < Math.min( saved.length, els.length ); i++ ) { if (!saved[i].dirty) continue const el = els[i] const type = (el as HTMLInputElement).type if ( type === 'checkbox' || type === 'radio' ) { (el as HTMLInputElement) .checked = saved[i].c } else { el.value = saved[i].v } } } } if (this.styles) { const styles = this.styles() for (const node of target.querySelectorAll('[styles]')) { for (const s of node.getAttribute('styles').split(/\s+/)) { Object.assign(node.style, styles[s.trim()]) } } } const children = Tonic._children[this._id!] || {} const walk = (node, fn) => { if (node.nodeType === 3) { const id = node.textContent.trim() if (children[id]) fn(node, children[id], id) } const childNodes = node.childNodes if (!childNodes) return for (let i = 0; i < childNodes.length; i++) { walk(childNodes[i], fn) } } walk(target, (node, children, id) => { for (const child of children) { node.parentNode.insertBefore(child, node) } delete Tonic._children[this._id!][id] node.parentNode.removeChild(node) }) } else { target.innerHTML = '' target.appendChild(content.cloneNode(true)) } } connectedCallback () { this.root = this.shadowRoot || this // here for back compat if (super.id && !Tonic._refIds.includes(super.id)) { Tonic._refIds.push(super.id) } const cc = s => s.replace(/-(.)/g, (_, m) => m.toUpperCase()) for (const { name: _name, value } of this.attributes) { const name = cc(_name) const p = (this.props as { [key:string]:any })[name] = value if (/__\w+__\w+__/.test(p)) { const { 1: root } = p.split('__'); (this.props as { [key:string]:any })[name] = Tonic._data[root][p] } else if (/\d+__float/.test(p)) { (this.props as { [key:string]:any })[name] = parseFloat(p) } else if (p === 'null__null') { (this.props as { [key:string]:any })[name] = null } else if (/\w+__boolean/.test(p)) { (this.props as { [key:string]:any })[name] = p.includes('true') } else if (/placehold:\w+:\w+__/.test(p)) { const { 1: root } = p.split(':'); (this.props as { [key:string]:any })[name] = Tonic._children[root][p][0] } } this.props = Object.assign( this.defaults(), this.props ) this._id = this._id || Tonic._createId() this.willConnect && this.willConnect() if (!this.isInDocument(this.root)) return if (Tonic._hydrating) { if (super.id && Tonic._ssrState?.[super.id]) { this.props = Object.assign( this.props, Tonic._ssrState[super.id] ) } this._source = this.innerHTML this.connected && this.connected() return } if (!this.preventRenderOnReconnect) { if (!this._source) { this._source = this.innerHTML } else { this.innerHTML = this._source } const p = this._set(this.root, this.render) if (p && p.then) { return p.then(() => this.connected && this.connected()) } } this.connected && this.connected() } isInDocument (target:HTMLElement|ShadowRoot):boolean { const root = target.getRootNode() return root === document || root.toString() === '[object ShadowRoot]' } disconnectedCallback ():void { this.disconnected && this.disconnected() delete Tonic._data[this._id!] delete Tonic._children[this._id!] } } export default Tonic