UNPKG

@srfnstack/fntags

Version:

<p align="center"> <img alt="fntags header" src="https://raw.githubusercontent.com/SRFNStack/fntags/master/docs/fntags_header.gif"> </p>

795 lines (740 loc) 28.7 kB
/** * @module fntags */ /** * A function to create dom elements with the given attributes and children. * * The first element of the children array can be an object containing element attributes. * The attribute names are the standard attribute names used in html, and should all be lower case as usual. * * Any attribute starting with 'on' that is a function is added as an event listener with the 'on' removed. * i.e. { onclick: fn } gets added to the element as element.addEventListener('click', fn) * * The style attribute can be an object and the properties of the object will be added as style properties to the element. * i.e. { style: { color: blue } } becomes element.style.color = blue * * The rest of the arguments will be considered children of this element and appended to it in the same order as passed. * * @template {HTMLElement|SVGElement} T * @param {string} tag html tag to use when created the element * @param {Node|Object} children optional attributes object and children for the element * @return {T} an html element * */ export function h (tag, ...children) { let firstChildIdx = 0 let element const nsIndex = hasNs(tag) if (nsIndex > -1) { const { ns, val } = splitNs(tag, nsIndex) element = document.createElementNS(ns, val) } else { element = document.createElement(tag) } if (isAttrs(children[firstChildIdx])) { const attrs = children[firstChildIdx] firstChildIdx += 1 let hasValue = false for (const a in attrs) { // set value last to ensure value constraints are set before trying to set the value to avoid modification // For example, when using a range and specifying a min and max // if the value is set first and is outside the default 1 to 100 range // the value will be adjusted to be within the range, even though the value attribute will be set correctly if (a === 'value') { hasValue = true continue } setAttribute(a, attrs[a], element) } if (hasValue) { setAttribute('value', attrs.value, element) } } for (let i = firstChildIdx; i < children.length; i++) { const child = children[i] if (Array.isArray(child)) { for (const c of child) { element.append(renderNode(c)) } } else { element.append(renderNode(child)) } } return element } function splitNs (val, i) { return { ns: val.slice(0, i), val: val.slice(i + 1) } } function hasNs (val) { return val.lastIndexOf(':') } /** * Create a compiled template function. The returned function takes a single object that contains the properties * defined in the template. * * This allows fast rendering by pre-creating a dom element with the entire template structure then cloning and populating * the clone with data from the provided context. This avoids the work of having to re-execute the tag functions * one by one and can speed up situations where a similar element is created many times. * * You cannot bind state to the initial template. If you attempt to, the state will be read, but the elements will * not be updated when the state changes because they will not be bound to the cloned element. * All state bindings must be passed in the context to the compiled template to work correctly. * * @param {(any)=>Node} templateFn A function that returns a html node. * @return {(any)=>Node} A function that takes a context object and returns a rendered node. * */ export function fntemplate (templateFn) { if (typeof templateFn !== 'function') { throw new Error('You must pass a function to fntemplate. The function must return an html node.') } const placeholders = {} let id = 1 const initContext = prop => { if (!prop || typeof prop !== 'string') { throw new Error('You must pass a non empty string prop name to the context function.') } const placeholder = (element, type, attrOrStyle) => { let selector = element.__fnselector if (!selector) { selector = `fntpl-${id++}` element.__fnselector = selector element.classList.add(selector) } if (!placeholders[selector]) placeholders[selector] = [] placeholders[selector].push({ prop, type, attrOrStyle }) } placeholder.isTemplatePlaceholder = true return placeholder } // The initial render is cloned to prevent invalid state bindings from changing it const compiled = templateFn(initContext).cloneNode(true) return ctx => { const clone = compiled.cloneNode(true) for (const selectorClass in placeholders) { let targetElement = clone.getElementsByClassName(selectorClass)[0] if (!targetElement) { if (clone.classList.contains(selectorClass)) { targetElement = clone } else { throw new Error(`Cannot find template element for selectorClass ${selectorClass}`) } } targetElement.classList.remove(selectorClass) for (const placeholder of placeholders[selectorClass]) { switch (placeholder.type) { case 'node': targetElement.replaceWith(renderNode(ctx[placeholder.prop])) break case 'attr': setAttribute(placeholder.attrOrStyle, ctx[placeholder.prop], targetElement) break case 'style': setStyle(placeholder.attrOrStyle, ctx[placeholder.prop], targetElement) break default: throw new Error(`Unexpected bindType ${placeholder.type}`) } } } return clone } } /** * @template T The type of data stored in the state container * @typedef FnStateObj A container for a state value that can be bound to. * @property {(element?: ()=>(Node|any))=>Node} bindAs Bind this state to the given element function. This causes the element to be replaced when state changes. * If called with no parameters, the state's value will be rendered as an element. * @property {(parent: (()=>(Node|any))|any|Node, element: (childState: FnState)=>(Node|any))=>Node} bindChildren Bind the values of this state to the given element. * Values are items/elements of an array. * If the current value is not an array, this will behave the same as bindAs. * @property {(prop: string)=>Node} bindProp Bind to a property of an object stored in this state instead of the state itself. * Shortcut for `mystate.bindAs((current)=> current[prop])` * @property {(attribute?: ()=>(string|any))=>any} bindAttr Bind attribute values to state changes * @property {(style?: ()=>string) => string} bindStyle Bind style values to state changes * @property {(element?: ()=>(Node|any))=>Node} bindSelect Bind selected state to an element * @property {(attribute?: ()=>(string|any))=>any} bindSelectAttr Bind selected state to an attribute * @property {(key: any)=>void} select Mark the element with the given key as selected * where the key is identified using the mapKey function passed on creation of the fnstate. * This causes the bound select functions to be executed. * @property {()=> any} selected Get the currently selected key * @property {(update: T)=>void} assign Perform an Object.assign() on the current state using the provided update, triggers * a state change and is a shortcut for `mystate(Object.assign(mystate(), update))` * @property {(path: string)=>any} getPath Get a value at the given property path, an error is thrown if the value is not an object * This returns a reference to the real current value. If you perform any modifications to the object, be sure to call setPath after you're done or the changes * will not be reflected correctly. * @property {(path: string, value: any, fillWithObjects: boolean)=>void} setPath Set a value at the given property path * @property {(subscriber: (newState: T, oldState: T)=>void) => void} subscribe Register a callback that will be executed whenever the state is changed * @property {(reinit: boolean)=>{}} reset Remove all of the observers and optionally reset the value to it's initial value * @property {boolean} isFnState A flag to indicate that this is a fnstate object */ /** * @template T The type of data stored in the state container * @typedef {FnStateObj<T> & ((newState?: T)=>T)} FnState A container for a state value that can be bound to. */ /** * Create a state object that can be bound to. * @template T * @param {T} initialValue The initial state * @param {(T)=>any} [mapKey] A map function to extract a key from an element in the array. Receives the array value to extract the key from. * A key can be any unique value. * @return {FnState<T>} A function that can be used to get and set the state. * When getting the state, you get the actual reference to the underlying value. * If you perform modifications to the value, be sure to call the state function with the updated value when you're done * or the changes won't be reflected correctly and binding updates won't be triggered even though the state appears to be correct. */ export function fnstate (initialValue, mapKey) { const ctx = { currentValue: initialValue, observers: [], bindContexts: [], selectObservers: {}, nextId: 0, mapKey, state (newState) { if (arguments.length === 0 || (arguments.length === 1 && arguments[0] === ctx.state)) { return ctx.currentValue } else { const oldState = ctx.currentValue ctx.currentValue = newState for (const observer of ctx.observers) { observer.fn(newState, oldState) } } return newState } } /** * Bind this state to the given element * * @param {((T)=>(Node|any))?} [element] The element to bind to. If not a function, an update function must be passed. If not passed, defaults to the state's value * @returns {()=>Node} */ ctx.state.bindAs = (element) => doBindAs(ctx, element ?? ctx.state) /** * Bind the values of this state to the given element. * Values are items/elements of an array. * If the current value is not an array, this will behave the same as bindAs. * * @param {(()=>(Node|any)) | Node | any} parent The parent to bind the children to. * @param {(childState: FnState)=>(Node|any)} element A function that receives each element wrapped as a fnstate and produces an element * @returns {Node} */ ctx.state.bindChildren = (parent, element) => doBindChildren(ctx, parent, element) /** * Bind to a property of an object stored in this state instead of the state itself. * * Shortcut for `mystate.bindAs((current)=> current[prop])` * * @param {string} prop The object property to bind as * @returns {()=>Node} */ ctx.state.bindProp = (prop) => doBindAs(ctx, (st) => st[prop]) /** * Bind attribute values to state changes * @param {(()=>(string|any))?} [attribute] A function that returns an attribute value. If not passed, defaults to the state's value * @returns {()=>(string|any)} A function that calls the passed function, with some extra metadata */ ctx.state.bindAttr = (attribute) => doBindAttr(ctx.state, attribute ?? ctx.state) /** * Bind style values to state changes * @param {(()=>string)?} [style] A function that returns a style's value. If not passed, defaults to the state's value * @returns {()=>Node} A function that calls the passed function, with some extra metadata */ ctx.state.bindStyle = (style) => doBindStyle(ctx.state, style ?? ctx.state) /** * Bind select and deselect to an element * @param {(()=>(Node|any))?} [element] The element to bind to. If not passed, defaults to the state's value * @returns {()=>Node} */ ctx.state.bindSelect = (element) => doBindSelect(ctx, element ?? ctx.state) /** * Bind select and deselect to an attribute * @param {(()=>(string|any))?} [attribute] A function that returns an attribute value. If not passed, defaults to the state's value * @returns {()=>(string|any)} A function that calls the passed function, with some extra metadata */ ctx.state.bindSelectAttr = (attribute) => doBindSelectAttr(ctx, attribute ?? ctx.state) /** * Mark the element with the given key as selected. This causes the bound select functions to be executed. */ ctx.state.select = (key) => doSelect(ctx, key) /** * Get the currently selected key * @returns {any} */ ctx.state.selected = () => ctx.selected ctx.state.isFnState = true /** * Perform an Object.assign() on the current state using the provided update * @param {T} [update] */ ctx.state.assign = (update) => ctx.state(Object.assign(ctx.currentValue, update)) /** * Get a value at the given property path, an error is thrown if the value is not an object * * This returns a reference to the real current value. If you perform any modifications to the object, be sure to call setPath after you're done or the changes * will not be reflected correctly. * @param {string} [path] a json path type path that points to a property */ ctx.state.getPath = (path) => { if (typeof path !== 'string') { throw new Error('Invalid path') } if (typeof ctx.currentValue !== 'object') { throw new Error('Value is not an object') } return path .split('.') .reduce( (curr, part) => { if (part in curr) { return curr[part] } else { return undefined } }, ctx.currentValue ) } /** * Set a value at the given property path * @param {string} path The JSON path of the value to set * @param {any} value The value to set the path to * @param {boolean} fillWithObjects Whether to non object values with new empty objects. */ ctx.state.setPath = (path, value, fillWithObjects = false) => { const s = path.split('.') const parent = s .slice(0, -1) .reduce( (current, part) => { if (fillWithObjects && typeof current[part] !== 'object') { current[part] = {} } return current[part] }, ctx.currentValue ) if (parent && typeof parent === 'object') { parent[s.slice(-1)] = value ctx.state(ctx.currentValue) } else { throw new Error(`No object at path ${path}`) } } /** * Register a callback that will be executed whenever the state is changed * @param {(newValue:T,oldValue:T)=>void} callback * @return {()=>void} a function to stop the subscription */ ctx.state.subscribe = (callback) => doSubscribe(ctx, ctx.observers, callback) /** * Remove all the observers and optionally reset the value to it's initial value * @param {boolean} reInit whether to reset the state to it's initial value */ ctx.state.reset = (reInit) => doReset(ctx, reInit, initialValue) return ctx.state } function doSubscribe (ctx, list, listener) { const id = ctx.nextId++ list.push({ id, fn: listener }) return () => { list.splice(list.findIndex(l => l.id === id), 1) list = null } } const subscribeSelect = (ctx, callback) => { const parentCtx = ctx.state.parentCtx const key = keyMapper(parentCtx.mapKey, ctx.currentValue) if (parentCtx.selectObservers[key] === undefined) { parentCtx.selectObservers[key] = [] } parentCtx.selectObservers[key].push(callback) } const doBindSelectAttr = function (ctx, attribute) { const boundAttr = createBoundAttr(attribute) boundAttr.init = (attrName, element) => subscribeSelect(ctx, () => setAttribute(attrName, attribute(), element)) return boundAttr } function createBoundAttr (attr) { if (typeof attr !== 'function') { throw new Error('You must pass a function to bindAttr') } // wrap the function to avoid modifying it const boundAttr = () => attr() boundAttr.isBoundAttribute = true return boundAttr } function doBindAttr (state, attribute) { const boundAttr = createBoundAttr(attribute) boundAttr.init = (attrName, element) => state.subscribe(() => setAttribute(attrName, attribute(), element)) return boundAttr } function doBindStyle (state, style) { if (typeof style !== 'function') { throw new Error('You must pass a function to bindStyle') } const boundStyle = () => style() boundStyle.isBoundStyle = true boundStyle.init = (styleName, element) => state.subscribe(() => { element.style[styleName] = style() }) return boundStyle } function doReset (ctx, reInit, initialValue) { ctx.observers = [] ctx.selectObservers = {} if (reInit) { ctx.currentValue = initialValue } } function doSelect (ctx, key) { const currentSelected = ctx.selected ctx.selected = key if (ctx.selectObservers[currentSelected] !== undefined) { for (const obs of ctx.selectObservers[currentSelected]) obs() } if (ctx.selectObservers[ctx.selected] !== undefined) { for (const obs of ctx.selectObservers[ctx.selected]) obs() } } function doBindChildren (ctx, parent, element) { parent = renderNode(parent) if (parent === undefined || parent.nodeType === undefined) { throw new Error('You must provide a parent element to bind the children to. aka Need Bukkit.') } if (typeof element !== 'function') { throw new Error('You must pass a function to produce child elements.') } if (typeof ctx.mapKey !== 'function') { console.warn('Using value index as key, may not work correctly when moving items...') ctx.mapKey = (o, i) => i } if (!Array.isArray(ctx.currentValue)) { throw new Error('You can only use bindChildren with a state that contains an array. try myState([mystate]) before calling this function.') } ctx.currentValue = ctx.currentValue.map(v => v.isFnState ? v : fnstate(v)) ctx.bindContexts.push({ element, parent }) ctx.state.subscribe((_, oldState) => { if (!Array.isArray(ctx.currentValue)) { console.warn('A state used with bindChildren was updated to a non array value. This will be converted to an array of 1 and the state will be updated.') new Promise((resolve) => { ctx.state([ctx.currentValue]) resolve() }).catch(e => { console.error('Failed to update element: ') console.dir(element) const err = new Error('Failed to update element') err.stack += '\nCaused by: ' + e.stack throw e }) } else { reconcile(ctx, oldState) } }) reconcile(ctx) return parent } const doBind = function (ctx, element, handleReplace) { if (typeof element !== 'function') { throw new Error('You must pass a function to bind with') } const elCtx = { current: renderNode(evaluateElement(element, ctx.currentValue)) } handleReplace(elCtx) return () => elCtx.current } const updateReplacer = (ctx, element, elCtx) => () => { let rendered = renderNode(evaluateElement(element, ctx.currentValue)) if (rendered !== undefined) { if (elCtx.current.key !== undefined) { rendered.current.key = elCtx.current.key } if (ctx.parentCtx) { for (const bindContext of ctx.parentCtx.bindContexts) { bindContext.boundElementByKey[elCtx.current.key] = rendered } } // Perform this action on the next event loop to give the parent a chance to render new Promise((resolve) => { elCtx.current.replaceWith(rendered) elCtx.current = rendered rendered = null resolve() }).catch(e => { console.error('Failed to replace element with new element') console.dir(elCtx, rendered) const err = new Error('Failed to replace element with new element') err.stack += '\nCaused by: ' + e.stack throw e }) } } const doBindSelect = (ctx, element) => doBind(ctx, element, (elCtx) => subscribeSelect(ctx, updateReplacer(ctx, element, elCtx))) const doBindAs = (ctx, element) => doBind(ctx, element, (elCtx) => ctx.state.subscribe(updateReplacer(ctx, element, elCtx))) /** * Reconcile the state of the current array value with the state of the bound elements */ function reconcile (ctx, oldState) { for (const bindContext of ctx.bindContexts) { if (bindContext.boundElementByKey === undefined) { bindContext.boundElementByKey = {} } arrangeElements(ctx, bindContext, oldState) } } function keyMapper (mapKey, value) { if (typeof value !== 'object') { return value } else if (typeof mapKey !== 'function') { return 0 } else { return mapKey(value) } } function arrangeElements (ctx, bindContext, oldState) { if (!ctx?.currentValue?.length) { bindContext.parent.textContent = '' bindContext.boundElementByKey = {} ctx.selectObservers = {} return } const keys = {} const keysArr = [] for (const i in ctx.currentValue) { let valueState = ctx.currentValue[i] if (valueState === null || valueState === undefined || !valueState.isFnState) { valueState = ctx.currentValue[i] = fnstate(valueState) } const key = keyMapper(ctx.mapKey, valueState()) if (keys[key]) { if (oldState) ctx.state(oldState) throw new Error('Duplicate keys in a bound array are not allowed, state reset to previous value.') } keys[key] = i keysArr[i] = key } let prev = null const parent = bindContext.parent for (let i = ctx.currentValue.length - 1; i >= 0; i--) { const key = keysArr[i] const valueState = ctx.currentValue[i] let current = bindContext.boundElementByKey[key] let isNew = false // ensure the parent state is always set and can be accessed by the child states to listen to the selection change and such if (valueState.parentCtx === undefined) { valueState.parentCtx = ctx } if (current === undefined) { isNew = true current = bindContext.boundElementByKey[key] = renderNode(evaluateElement(bindContext.element, valueState)) current.key = key } // place the element in the parent if (prev == null) { if (!parent.lastChild || parent.lastChild.key !== current.key) { parent.append(current) } } else { if (prev.previousSibling === null) { // insertAdjacentElement is faster, but some nodes don't have it (lookin' at you text) if (prev.insertAdjacentElement !== undefined && current.insertAdjacentElement !== undefined) { prev.insertAdjacentElement('beforebegin', current) } else { parent.insertBefore(current, prev) } } else if (prev.previousSibling.key !== current.key) { // the previous was deleted all together, so we will delete it and replace the element if (keys[prev.previousSibling.key] === undefined) { delete bindContext.boundElementByKey[prev.previousSibling.key] if (ctx.selectObservers[prev.previousSibling.key] !== undefined && current.insertAdjacentElement !== undefined) { delete ctx.selectObservers[prev.previousSibling.key] } prev.previousSibling.replaceWith(current) } else if (isNew) { // insertAdjacentElement is faster, but some nodes don't have it (lookin' at you text) if (prev.insertAdjacentElement !== undefined) { prev.insertAdjacentElement('beforebegin', current) } else { parent.insertBefore(current, prev) } } else { // if it's an existing key, replace the current object with the correct object prev.previousSibling.replaceWith(current) } } } prev = current } // catch any strays for (const key in bindContext.boundElementByKey) { if (keys[key] === undefined) { bindContext.boundElementByKey[key].remove() delete bindContext.boundElementByKey[key] if (ctx.selectObservers[key] !== undefined) { delete ctx.selectObservers[key] } } } } const evaluateElement = (element, value) => { if (element.isFnState) { return element() } else { return typeof element === 'function' ? element(value) : element } } /** * Convert non objects (objects are assumed to be nodes) to text nodes and allow promises to resolve to nodes * @param {any} node The node to render * @returns {Node} The rendered node */ export function renderNode (node) { if (node && node.isTemplatePlaceholder) { const element = h('div') node(element, 'node') return element } else if (node && typeof node === 'object') { if (typeof node.then === 'function') { let temp = h('div', { style: 'display:none', class: 'fntags-promise-marker' }) node.then(el => { temp.replaceWith(renderNode(el)) temp = null }).catch(e => console.error('Caught failed node promise.', e)) return temp } else { return node } } else if (typeof node === 'function') { return renderNode(node()) } else { return document.createTextNode(node + '') } } /** * All of these attributes must be set to an actual boolean to function correctly */ const booleanAttributes = { allowfullscreen: true, allowpaymentrequest: true, async: true, autofocus: true, autoplay: true, checked: true, controls: true, default: true, disabled: true, formnovalidate: true, hidden: true, ismap: true, itemscope: true, loop: true, multiple: true, muted: true, nomodule: true, novalidate: true, open: true, playsinline: true, readonly: true, required: true, reversed: true, selected: true, truespeed: true } const setAttribute = function (attrName, attr, element) { if (typeof attr === 'function') { if (attr.isBoundAttribute) { attr.init(attrName, element) attr = attr() } else if (attr.isTemplatePlaceholder) { attr(element, 'attr', attrName) return } else if (attrName.startsWith('on')) { element.addEventListener(attrName.substring(2), attr) return } else { attr = attr() } } if (attrName === 'style' && typeof attr === 'object') { for (const style in attr) { setStyle(style, attr[style], element) } } else if (element.__fnselector && element.className && attrName === 'class') { // special handling for class to ensure the selector classes from fntemplate don't get overwritten element.className += ` ${attr}` } else if (attrName === 'value') { element.setAttribute('value', attr) // html5 nodes like range don't update unless the value property on the object is set element.value = attr } else if (booleanAttributes[attrName]) { element[attrName] = !!attr } else { let ns = null const nsIndex = hasNs(attrName) if (nsIndex > -1) { const split = splitNs(attrName, nsIndex) ns = split.ns attrName = split.val } element.setAttributeNS(ns, attrName, attr) } } const setStyle = (style, styleValue, element) => { if (typeof styleValue === 'function') { if (styleValue.isBoundStyle) { styleValue.init(style, element) styleValue = styleValue() } else if (styleValue.isTemplatePlaceholder) { styleValue(element, 'style', style) return } else { styleValue = styleValue() } } element.style[style] = styleValue && styleValue.toString() } /** * Check if the given value is an object that can be used as attributes * @param {any} val The value to check * @returns {boolean} true if the value is an object that can be used as attributes */ export function isAttrs (val) { return val && typeof val === 'object' && val.nodeType === undefined && !Array.isArray(val) && typeof val.then !== 'function' } /** * helper to get the attr object * @param {any} children * @return {object} the attr object or an empty object */ export function getAttrs (children) { return Array.isArray(children) && isAttrs(children[0]) ? children[0] : {} } /** * A function to create an element with a pre-defined style. * For example, the flex* elements in fnelements. * * @template {HTMLElement|SVGElement} T * * @param {object|string} style The style to apply to the element * @param {string} tag The tag to use when creating the element * @param {object[]|Node[]} children The children to append to the element * @return {T} The styled element */ export function styled (style, tag, children) { const firstChild = children[0] if (isAttrs(firstChild)) { if (typeof firstChild.style === 'string') { firstChild.style = [stringifyStyle(style), stringifyStyle(firstChild.style)].join(';') } else { firstChild.style = Object.assign(style, firstChild.style) } } else { children.unshift({ style }) } return h(tag, ...children) } const stringifyStyle = style => typeof style === 'string' ? style : Object.keys(style).map(prop => `${prop}:${style[prop]}`).join(';')