UNPKG

simplyflow

Version:

Flow based programming in javascript, with signals and effects

827 lines (771 loc) 28 kB
import { throttledEffect, destroy } from './state.mjs' /** * Implements one way databinding, updating dom elements with matching attributes * to changes in signals (see state.mjs) * * @class */ class SimplyBind { /** * @param Object options - a set of options for this instance, options may include: * - root (signal) (required) - the root data object that contains al signals that can be bound * - container (HTMLElement) - the dom element to use as the root for all bindings * - attribute (string) - the prefix for the field, list and map attributes, e.g. 'data-bind' * - transformers (object name:function) - a map of transformer names and functions * - defaultTransformers (object with field, list and map properties) */ constructor(options) { /** * A map of HTMLElements and the data bindings on each, in the form of * the connectedSignal returned by the (throttled)Effect. * @type {Map} * @public */ this.bindings = new Map() const defaultOptions = { container: document.body, attribute: 'data-bind', transformers: {}, defaultTransformers: { field: [defaultFieldTransformer], list: [defaultListTransformer], map: [defaultMapTransformer] } } if (!options?.root) { throw new Error('bind needs at least options.root set') } this.options = Object.assign({}, defaultOptions, options) const attribute = this.options.attribute const bindAttributes = [attribute+'-field',attribute+'-list',attribute+'-map'] const bindSelector = `[${attribute}-field],[${attribute}-list],[${attribute}-map]` const transformAttribute = attribute+'-transform' const getBindingAttribute = (el) => { const foundAttribute = bindAttributes.find(attr => el.hasAttribute(attr)) if (!foundAttribute) { console.error('No matching attribute found',el,attr) } return foundAttribute } // sets up the effect that updates the element if its // data binding value changes const render = (el) => { this.bindings.set(el, throttledEffect(() => { if (!el.isConnected) { // el is no longer part of this document destroy(this.bindings.get(el)) // doing this here instead of in a mutationobserver // allows an element to be temporary removed and then inserted // without the binding having to be reset return } const context = { templates: el.querySelectorAll(':scope > template'), attribute: getBindingAttribute(el) } context.path = this.getBindingPath(el) context.value = getValueByPath(this.options.root, context.path) context.element = el runTransformers(context) }, 50)) } // finds and runs applicable transformers // creates a stack of transformers, calls the topmost // each transformer can opt to call the next or not // transformers should return the context object (possibly altered) const runTransformers = (context) => { let transformers switch(context.attribute) { case this.options.attribute+'-field': transformers = this.options.defaultTransformers.field || [] break case this.options.attribute+'-list': transformers = this.options.defaultTransformers.list || [] break case this.options.attribute+'-map': transformers = this.options.defaultTransformers.map || [] break } if (context.element.hasAttribute(transformAttribute)) { context.element.getAttribute(transformAttribute) .split(' ').filter(Boolean) .forEach(t => { if (this.options.transformers[t]) { transformers.push(this.options.transformers[t]) } else { console.warn('No transformer with name '+t+' configured', {cause:context.element}) } }) } else { console.log(context.element.outerHTML) } let next for (let transformer of transformers) { next = ((next, transformer) => { return (context) => { return transformer.call(this, context, next) } })(next, transformer) } next(context) } // given a set of elements with data bind attribute // this renders each of those elements const applyBindings = (bindings) => { for (let bindingEl of bindings) { if (!this.bindings.get(bindingEl)) { // bindingEl may have moved from somewhere else in this document render(bindingEl) } } } // this handles the mutation observer changes // if any element is added, and has a data bind attribute // it applies that data binding const updateBindings = (changes) => { const selector = `[${attribute}-field],[${attribute}-list],[${attribute}-map]` for (const change of changes) { if (change.type=="childList" && change.addedNodes) { for (let node of change.addedNodes) { if (node instanceof HTMLElement) { let bindings = Array.from(node.querySelectorAll(selector)) if (node.matches(selector)) { bindings.unshift(node) } if (bindings.length) { applyBindings(bindings) } } } } } } // this responds to elements getting added to the dom // and if any have data bind attributes, it applies those bindings this.observer = new MutationObserver((changes) => { updateBindings(changes) }) this.observer.observe(this.options.container, { subtree: true, childList: true }) // this finds elements with data binding attributes and applies those bindings // must come after setting up the observer, or included templates // won't trigger their own bindings const bindings = this.options.container.querySelectorAll( ':is(['+this.options.attribute+'-field]'+ ',['+this.options.attribute+'-list]'+ ',['+this.options.attribute+'-map]):not(template)' ) if (bindings.length) { applyBindings(bindings) } } /** * Finds the first matching template and creates a new DocumentFragment * with the correct data bind attributes in it (prepends the current path) * @param Context context * @return DocumentFragment */ applyTemplate(context) { const path = context.path const templates = context.templates const list = context.list const index = context.index const parent = context.parent const value = list ? list[index] : context.value let template = this.findTemplate(templates, value) if (!template) { let result = new DocumentFragment() result.innerHTML = '<!-- no matching template -->' return result } let clone = template.content.cloneNode(true) if (!clone.children?.length) { return clone } if (clone.children.length>1) { throw new Error('template must contain a single root node', { cause: template }) } const attribute = this.options.attribute const attributes = [attribute+'-field',attribute+'-list',attribute+'-map'] const bindings = clone.querySelectorAll(`[${attribute}-field],[${attribute}-list],[${attribute}-map]`) for (let binding of bindings) { const attr = attributes.find(attr => binding.hasAttribute(attr)) const bind = binding.getAttribute(attr) if (bind.substring(0, ':root.'.length)==':root.') { binding.setAttribute(attr, bind.substring(':root.'.length)) } else if (bind==':value' && index!=null) { binding.setAttribute(attr, path+'.'+index) } else if (index!=null) { binding.setAttribute(attr, path+'.'+index+'.'+bind) } else { binding.setAttribute(attr, parent+'.'+bind) } } if (typeof index !== 'undefined') { clone.children[0].setAttribute(attribute+'-key',index) } // keep track of the used template, so if that changes, the item can be updated Object.defineProperty( clone.children[0], '$bindTemplate', { value: template, enumerable: false, writable: true, configurable: true } ) // return clone, not the firstChild, so that all whitespace is cloned as well return clone } /** * Returns the path referenced in either the field, list or map attribute * @param HTMLElement el * @return string The path referenced, or void */ getBindingPath(el) { const attributes = [ this.options.attribute+'-field', this.options.attribute+'-list', this.options.attribute+'-map' ] for (let attr of attributes) { if (el.hasAttribute(attr)) { return el.getAttribute(attr) } } } /** * Finds the first template from an array of templates that * matches the given value. */ findTemplate(templates, value) { const templateMatches = t => { // find the value to match against (e.g. data-bind="foo") let path = this.getBindingPath(t) let currentItem if (path) { if (path.substr(0,6)==':root.') { currentItem = getValueByPath(this.options.root, path) } else { currentItem = getValueByPath(value, path) } } else { currentItem = value } // then check the value against pattern, if set (e.g. data-bind-match="bar") const strItem = ''+currentItem let matches = t.getAttribute(this.options.attribute+'-match') if (matches) { if (matches===':empty' && !currentItem) { return t } else if (matches===':notempty' && currentItem) { return t } if (strItem.match(matches)) { return t } } if (!matches && currentItem!==null && currentItem!==undefined) { //FIXME: this doesn't run templates in lists where list entry is null //which messes up the count // // no data-bind-match is set, so return this template return t } } let template = Array.from(templates).find(templateMatches) let rel = template?.getAttribute('rel') if (rel) { let replacement = document.querySelector('template#'+rel) if (!replacement) { throw new Error('Could not find template with id '+rel) } template = replacement } return template } destroy() { this.bindings.forEach(binding => { destroy(binding) }) this.bindings = new Map() this.observer.disconnect() } } /** * Returns a new instance of SimplyBind. This is the normal start * of a data bind flow */ export function bind(options) { return new SimplyBind(options) } /** * Returns true if a matches b, either by having the * same string value, or matching string :empty against a falsy value */ export function matchValue(a,b) { if (a==':empty' && !b) { return true } if (b==':empty' && !a) { return true } if (''+a == ''+b) { return true } return false } /** * Returns the value by walking the given path as a json pointer, starting at root * if you have a property with a '.' in its name urlencode the '.', e.g: %46 * * @param HTMLElement root * @param string path e.g. 'foo.bar' * @return mixed the value found by walking the path from the root object or undefined */ export function getValueByPath(root, path) { let parts = path.split('.'); let curr = root; let part, prevPart; while (parts.length && curr) { part = parts.shift() if (part==':key') { return prevPart } else if (part==':value') { return curr } else if (part==':root') { curr = root } else { part = decodeURIComponent(part) curr = curr[part]; prevPart = part } } return curr } /** * Default transformer for data binding * Will be used unless overriden in the SimplyBind options parameter */ export function defaultFieldTransformer(context) { const el = context.element const templates = context.templates const templatesCount = templates.length const path = context.path const value = context.value const attribute = this.options.attribute if (templates?.length) { transformLiteralByTemplates.call(this, context) } else { switch(el.tagName) { case 'INPUT': transformInput.call(this, context) break case 'BUTTON': transformButton.call(this, context) break case 'SELECT': transformSelect.call(this, context) break case 'A': transformAnchor.call(this, context) break case 'IMG': transformImage.call(this, contet) break case 'IFRAME': transformIframe.call(this, context) break case 'META': transformMeta.call(this, context) break case 'TEMPLATE': // never touch templates! break default: transformElement.call(this, context) break } } return context } export function defaultListTransformer(context) { const el = context.element const templates = context.templates const templatesCount = templates.length const path = context.path const value = context.value const attribute = this.options.attribute if (!Array.isArray(value)) { console.error('Value is not an array.', el, path, value) } else if (!templates?.length) { console.error('No templates found in', el) } else { transformArrayByTemplates.call(this, context) } return context } export function defaultMapTransformer(context) { const el = context.element const templates = context.templates const templatesCount = templates.length const path = context.path const value = context.value const attribute = this.options.attribute if (typeof value != 'object') { console.error('Value is not an object.', el, path, value) } else if (!templates?.length) { console.error('No templates found in', el) } else { transformObjectByTemplates.call(this, context) } return context } /** * Renders an array value by applying templates for each entry * Replaces or removes existing DOM children if needed * Reuses (doesn't touch) DOM children if template doesn't change * FIXME: this doesn't handle situations where there is no matching template * this messes up self healing. check transformObjectByTemplates for a better implementation */ export function transformArrayByTemplates(context) { const el = context.element const templates = context.templates const templatesCount = templates.length const path = context.path const value = context.value const attribute = this.options.attribute let items = el.querySelectorAll(':scope > ['+attribute+'-key]') // do single merge strategy for now, in future calculate optimal merge strategy from a number // now just do a delete if a key <= last key, insert if a key >= last key let lastKey = 0 let skipped = 0 context.list = value for (let item of items) { let currentKey = parseInt(item.getAttribute(attribute+'-key')) if (currentKey>lastKey) { // insert before context.index = lastKey el.insertBefore(this.applyTemplate(context), item) } else if (currentKey<lastKey) { // remove this item.remove() } else { // check that all data-bind params start with current json path or ':root', otherwise replaceChild let bindings = Array.from(item.querySelectorAll(`[${attribute}]`)) if (item.matches(`[${attribute}]`)) { bindings.unshift(item) } let needsReplacement = bindings.find(b => { let databind = b.getAttribute(attribute) return (databind.substr(0,5)!==':root' && databind.substr(0, path.length)!==path) }) if (!needsReplacement) { if (item.$bindTemplate) { let newTemplate = this.findTemplate(templates, value[lastKey]) if (newTemplate != item.$bindTemplate){ needsReplacement = true if (!newTemplate) { skipped++ } } } } if (needsReplacement) { context.index = lastKey el.replaceChild(this.applyTemplate(context), item) } } lastKey++ if (lastKey>=value.length) { break } } items = el.querySelectorAll(':scope > ['+attribute+'-key]') let length = items.length + skipped if (length > value.length) { while (length > value.length) { let child = el.querySelectorAll(':scope > :not(template)')?.[length-1] child?.remove() length-- } } else if (length < value.length ) { while (length < value.length) { context.index = length el.appendChild(this.applyTemplate(context)) length++ } } } /** * Renders an object value by applying templates for each entry (Object.entries) * Replaces,moves or removes existing DOM children if needed * Reuses (doesn't touch) DOM children if template doesn't change */ export function transformObjectByTemplates(context) { const el = context.element const templates = context.templates const templatesCount = templates.length const path = context.path const value = context.value const attribute = this.options.attribute context.list = value let items = Array.from(el.querySelectorAll(':scope > ['+attribute+'-key]')) for (let key in context.list) { context.index = key let item = items.shift() if (!item) { // more properties than rendered items let clone = this.applyTemplate(context) el.appendChild(clone) continue } if (item.getAttribute[attribute+'-key']!=key) { // next item doesn't match key items.unshift(item) // put item back for next cycle let outOfOrderItem = el.querySelector(':scope > ['+attribute+'-key="'+key+'"]') //FIXME: escape key if (!outOfOrderItem) { let clone = this.applyTemplate(context) el.insertBefore(clone, item) continue // new template doesn't need replacement, so continue } else { el.insertBefore(outOfOrderItem, item) item = outOfOrderItem // check needsreplacement next items = items.filter(i => i!=outOfOrderItem) } } let newTemplate = this.findTemplate(templates, value[key]) if (newTemplate != item.$bindTemplate){ let clone = this.applyTemplate(context) el.replaceChild(clone, item) } } // clean up remaining items while (items.length) { let item = items.shift() item.remove() } } function getParentPath(el, attribute) { const parentEl = el.parentElement?.closest(`[${attribute}-list],[${attribute}-map]`) if (!parentEl) { return ':root' } if (parentEl.hasAttribute(`${attribute}-list`)) { return parentEl.getAttribute(`${attribute}-list`) } return parentEl.getAttribute(`${attribute}-map`) } /** * transforms the contents of an html element by rendering * a matching template, once. * data-bind attributes inside the template use the same * parent path as this html element uses */ export function transformLiteralByTemplates(context) { const el = context.element const templates = context.templates const value = context.value const attribute = this.options.attribute const rendered = el.querySelector(':scope > :not(template)') const template = this.findTemplate(templates, value) context.parent = getParentPath(el, attribute) if (rendered) { if (template) { if (rendered?.$bindTemplate != template) { const clone = this.applyTemplate(context) el.replaceChild(clone, rendered) } } else { el.removeChild(rendered) } } else if (template) { const clone = this.applyTemplate(context) el.appendChild(clone) } } /** * transforms a single input type * for radio/checkbox inputs it only sets the checked attribute to true/false * if the value attribute matches the current value * for other inputs the value attribute is updated */ export function transformInput(context) { const el = context.element let value = context.value transformElement(context) if (typeof value == 'undefined') { value = '' } if (el.type=='checkbox' || el.type=='radio') { if (matchValue(el.value, value)) { el.checked = true } else { el.checked = false } } else if (!matchValue(el.value, value)) { el.value = ''+value } } /** * Sets the value of the button, doesn't touch the innerHTML */ export function transformButton(context) { const el = context.element const value = context.value transformElement(context) setProperties(el, value, 'value') } /** * Sets the selected attribute of select options */ export function transformSelect(context) { const el = context.element let value = context.value if (value === null) { value = '' } if (typeof value!='object') { if (el.multiple) { if (Array.isArray(value)) { for (let option of el.options) { if (value.indexOf(option.value)===false) { option.selected = false } else { option.selected = true } } } } else { let option = el.options.find(o => matchValue(o.value,value)) if (option) { option.selected = true option.setAttribute('selected', true) } } } else { // value is a non-null object if (value.options) { setSelectOptions(el, value.options) } if (value.selected) { transformSelect(Object.asssign({}, context, {value:value.selected})) } setProperties(el, value, 'name', 'id', 'selectedIndex', 'className') // allow innerHTML? if so call transformElement instead } } export function addOption(select, option) { if (!option) { return } if (typeof option !== 'object') { select.options.add(new Option(''+option)) } else if (option.text) { select.options.add(new Option(option.text, option.value, option.defaultSelected, option.selected)) } else if (typeof option.value != 'undefined') { select.options.add(new Option(''+option.value, option.value, option.defaultSelected, option.selected)) } } export function setSelectOptions(select,options) { //@TODO: only update in case of changes? select.innerHTML = '' if (Array.isArray(options)) { for (const option of options) { addOption(select, option) } } else if (options && typeof options == 'object') { for (const option in options) { addOption(select, { text: options[option], value: option }) } } } /** * Sets the innerHTML and href attribute of an anchor * TODO: support target, title, etc. attributes */ export function transformAnchor(context) { const el = context.element const value = context.value transformElement(context) setProperties(el, value, 'title', 'target', 'href', 'name', 'newwindow', 'nofollow') } export function transformImage(context) { const el = context.element const value = context.value transformElement(context) setProperties(el, value, 'title', 'alt', 'src') } export function transformIframe(context) { const el = context.element const value = context.value transformElement(context) setProperties(el, value, 'title', 'src') } export function transformMeta(context) { const el = context.element const value = context.value transformElement(context) setProperties(el, value, 'content') } /** * sets the innerHTML and title and id properties of any HTML element */ export function transformElement(context) { const el = context.element let value = context.value if (typeof value=='undefined' || value==null) { value = '' } if (typeof value == 'string') { el.innerHTML = ''+value return } setProperties(el, value, 'innerHTML', 'title', 'id', 'className') } /** * Sets a list of properties on a dom element, equal to * the string value of a data object * only updates the dom element if the property doesn't match */ export function setProperties(el, data, ...properties) { if (!data || typeof data!=='object') { return } for (const property of properties) { if (typeof data[property] !== 'undefined') { if (!matchValue(el[property], data[property])) { if (data[property] === null) { el[property] = '' } else { el[property] = ''+data[property] } } } } }