/* eslint-env browser */ /** * Web components. * * @module component */ import * as dom from './dom.js' import * as diff from './diff.js' import * as object from './object.js' import * as json from './json.js' import * as string from './string.js' import * as array from './array.js' import * as number from './number.js' import * as func from './function.js' /** * @type {CustomElementRegistry} */ export const registry = customElements /** * @param {string} name * @param {any} constr * @param {ElementDefinitionOptions} [opts] */ export const define = (name, constr, opts) => registry.define(name, constr, opts) /** * @param {string} name * @return {Promise} */ export const whenDefined = name => registry.whenDefined(name) const upgradedEventName = 'upgraded' const connectedEventName = 'connected' const disconnectedEventName = 'disconnected' /** * @template S */ export class Lib0Component extends HTMLElement { /** * @param {S} [state] */ constructor (state) { super() /** * @type {S|null} */ this.state = /** @type {any} */ (state) /** * @type {any} */ this._internal = {} } /** * @param {S} _state * @param {boolean} [_forceStateUpdate] Force that the state is rerendered even if state didn't change */ setState (_state, _forceStateUpdate = true) {} /** * @param {any} _stateUpdate */ updateState (_stateUpdate) { } } /** * @param {any} val * @param {"json"|"string"|"number"} type * @return {string} */ const encodeAttrVal = (val, type) => { if (type === 'json') { val = json.stringify(val) } return val + '' } /** * @param {any} val * @param {"json"|"string"|"number"|"bool"} type * @return {any} */ const parseAttrVal = (val, type) => { switch (type) { case 'json': return json.parse(val) case 'number': return Number.parseFloat(val) case 'string': return val case 'bool': return val != null default: return null } } /** * @typedef {Object} CONF * @property {string?} [CONF.template] Template for the shadow dom. * @property {string} [CONF.style] shadow dom style. Is only used when * `CONF.template` is defined * @property {S} [CONF.state] Initial component state. * @property {function(S,S|null,Lib0Component):void} [CONF.onStateChange] Called when * the state changes. * @property {Object} [CONF.childStates] maps from * CSS-selector to transformer function. The first element that matches the * CSS-selector receives state updates via the transformer function. * @property {Object} [CONF.attrs] * attrs-keys and state-keys should be camelCase, but the DOM uses kebap-case. I.e. * `attrs = { myAttr: 4 }` is represeted as `` in the DOM * @property {Object):boolean|void>} [CONF.listeners] Maps from dom-event-name * to event listener. * @property {function(S, S, Lib0Component):Object} [CONF.slots] Fill slots * automatically when state changes. Maps from slot-name to slot-html. * @template S */ /** * @template T * @param {string} name * @param {CONF} cnf * @return {typeof Lib0Component} */ export const createComponent = (name, { template, style = '', state: defaultState, onStateChange = () => {}, childStates = { }, attrs = {}, listeners = {}, slots = () => ({}) }) => { /** * Maps from camelCase attribute name to kebap-case attribute name. * @type {Object} */ const normalizedAttrs = {} for (const key in attrs) { normalizedAttrs[string.fromCamelCase(key, '-')] = key } const templateElement = template ? /** @type {HTMLTemplateElement} */ (dom.parseElement(` `)) : null class _Lib0Component extends HTMLElement { /** * @param {T} [state] */ constructor (state) { super() /** * @type {Array<{d:Lib0Component, s:function(any, any):Object}>} */ this._childStates = [] /** * @type {Object} */ this._slots = {} this._init = false /** * @type {any} */ this._internal = {} /** * @type {any} */ this.state = state || null this.connected = false // init shadow dom if (templateElement) { const shadow = /** @type {ShadowRoot} */ (this.attachShadow({ mode: 'open' })) shadow.appendChild(templateElement.content.cloneNode(true)) // fill child states for (const key in childStates) { this._childStates.push({ d: /** @type {Lib0Component} */ (dom.querySelector(/** @type {any} */ (shadow), key)), s: childStates[key] }) } } dom.emitCustomEvent(this, upgradedEventName, { bubbles: true }) } connectedCallback () { this.connected = true if (!this._init) { this._init = true const shadow = this.shadowRoot if (shadow) { dom.addEventListener(shadow, upgradedEventName, event => { this.setState(this.state, true) event.stopPropagation() }) } /** * @type {Object} */ const startState = this.state || object.assign({}, defaultState) if (attrs) { for (const key in attrs) { const normalizedKey = string.fromCamelCase(key, '-') const val = parseAttrVal(this.getAttribute(normalizedKey), attrs[key]) if (val) { startState[key] = val } } } // add event listeners for (const key in listeners) { dom.addEventListener(shadow || this, key, event => { if (listeners[key](/** @type {CustomEvent} */ (event), this) !== false) { event.stopPropagation() event.preventDefault() return false } }) } // first setState call this.state = null this.setState(startState) } dom.emitCustomEvent(/** @type {any} */ (this.shadowRoot || this), connectedEventName, { bubbles: true }) } disconnectedCallback () { this.connected = false dom.emitCustomEvent(/** @type {any} */ (this.shadowRoot || this), disconnectedEventName, { bubbles: true }) this.setState(null) } static get observedAttributes () { return object.keys(normalizedAttrs) } /** * @param {string} name * @param {string} oldVal * @param {string} newVal * * @private */ attributeChangedCallback (name, oldVal, newVal) { const curState = /** @type {any} */ (this.state) const camelAttrName = normalizedAttrs[name] const type = attrs[camelAttrName] const parsedVal = parseAttrVal(newVal, type) if (curState && (type !== 'json' || json.stringify(curState[camelAttrName]) !== newVal) && curState[camelAttrName] !== parsedVal && !number.isNaN(parsedVal)) { this.updateState({ [camelAttrName]: parsedVal }) } } /** * @param {any} stateUpdate */ updateState (stateUpdate) { this.setState(object.assign({}, this.state, stateUpdate)) } /** * @param {any} state */ setState (state, forceStateUpdates = false) { const prevState = this.state this.state = state if (this._init && (!func.equalityFlat(state, prevState) || forceStateUpdates)) { // fill slots if (state) { const slotElems = slots(state, prevState, this) for (const key in slotElems) { const slotContent = slotElems[key] if (this._slots[key] !== slotContent) { this._slots[key] = slotContent const currentSlots = /** @type {Array} */ (key !== 'default' ? array.from(dom.querySelectorAll(this, `[slot="${key}"]`)) : array.from(this.childNodes).filter(/** @param {any} child */ child => !dom.checkNodeType(child, dom.ELEMENT_NODE) || !child.hasAttribute('slot'))) currentSlots.slice(1).map(dom.remove) const nextSlot = dom.parseFragment(slotContent) if (key !== 'default') { array.from(nextSlot.children).forEach(c => c.setAttribute('slot', key)) } if (currentSlots.length > 0) { dom.replaceWith(currentSlots[0], nextSlot) } else { dom.appendChild(this, nextSlot) } } } } onStateChange(state, prevState, this) if (state != null) { this._childStates.forEach(cnf => { const d = cnf.d if (d.updateState) { d.updateState(cnf.s(state, this)) } }) } for (const key in attrs) { const normalizedKey = string.fromCamelCase(key, '-') if (state == null) { this.removeAttribute(normalizedKey) } else { const stateVal = state[key] const attrsType = attrs[key] if (!prevState || prevState[key] !== stateVal) { if (attrsType === 'bool') { if (stateVal) { this.setAttribute(normalizedKey, '') } else { this.removeAttribute(normalizedKey) } } else if (stateVal == null && (attrsType === 'string' || attrsType === 'number')) { this.removeAttribute(normalizedKey) } else { this.setAttribute(normalizedKey, encodeAttrVal(stateVal, attrsType)) } } } } } } } define(name, _Lib0Component) // @ts-ignore return _Lib0Component } /** * @param {function} definer function that defines a component when executed */ export const createComponentDefiner = definer => { /** * @type {any} */ let defined = null return () => { if (!defined) { defined = definer() } return defined } } export const defineListComponent = createComponentDefiner(() => { const ListItem = createComponent('lib0-list-item', { template: '', slots: state => ({ content: `
${state}
` }) }) return createComponent('lib0-list', { state: { list: /** @type {Array} */ ([]), Item: ListItem }, onStateChange: (state, prevState, component) => { if (state == null) { return } const { list = /** @type {Array} */ ([]), Item = ListItem } = state // @todo deep compare here by providing another parameter to simpleDiffArray let { index, remove, insert } = diff.simpleDiffArray(prevState ? prevState.list : [], list, func.equalityFlat) if (remove === 0 && insert.length === 0) { return } let child = /** @type {Lib0Component} */ (component.firstChild) while (index-- > 0) { child = /** @type {Lib0Component} */ (child.nextElementSibling) } let insertStart = 0 while (insertStart < insert.length && remove-- > 0) { // update existing state child.setState(insert[insertStart++]) child = /** @type {Lib0Component} */ (child.nextElementSibling) } while (remove-- > 0) { // remove remaining const prevChild = child child = /** @type {Lib0Component} */ (child.nextElementSibling) component.removeChild(prevChild) } // insert remaining component.insertBefore(dom.fragment(insert.slice(insertStart).map(/** @param {any} insState */ insState => { const el = new Item() el.setState(insState) return el })), child) } }) }) export const defineLazyLoadingComponent = createComponentDefiner(() => createComponent('lib0-lazy', { state: /** @type {{component:null|String,import:null|function():Promise,state:null|object}} */ ({ component: null, import: null, state: null }), attrs: { component: 'string' }, onStateChange: ({ component, state, import: getImport }, prevState, componentEl) => { if (component !== null) { if (getImport) { getImport() } if (!prevState || component !== prevState.component) { const el = /** @type {any} */ (dom.createElement(component)) componentEl.innerHTML = '' componentEl.insertBefore(el, null) } const el = /** @type {any} */ (componentEl.firstElementChild) // @todo generalize setting state and check if setState is defined if (el.setState) { el.setState(state) } } } }))