UNPKG

nano-data-binding

Version:

Simple, barebone data binding for web components

251 lines (195 loc) 10.5 kB
import { DataBind, Changes } from '../interfaces/nano-data-binding' import * as utils from './utils' import { nanoBind } from './selectors' // Debug let Debug = require('debug'), debug = Debug ? Debug('ndb:Parser') : () => {} debug('Instantiate Parser') /** * ====== PARSER RULES / BEHAVIORS ====== * Each data bind rule has an expected behavior. * Most of these behaviors are inspired by the Angualr framework. * These data binds have been tailored for taking advantage of the web components API. * * <!> Behaviors * `n-call` - Pass data to the target component * `n-if` - Hide the target component * `n-for` - Render all elements from an array using the same template * `n-class` - Conditionally add CSS classes to target element * `n-call` - Execute a method in the context of the target element. * ALl the previous rules could be implemented using this one. */ /** * Parse templates provided by the FOR rule * Templates might contain custom attributes, text or multiple elements. * That's why caching only the tag name ofthe iterated element is not wnough. */ // let parser = new DOMParser() // DEPRECATED does not fulfill the expected role of generating dom elements /** * Binds the values defined in the descriptor object to the child element * These values can be mathced to getter setter properties and this is how the data is progragated trough all nesting levels * <!> Ideally the hierarchy of the components would be shallow and wide instead of tall and narrow. * Nonetheless this data binding will work as many levels are needed */ export function bindDataToElem(dataBind: DataBind): void { let { parent, child, source, code } = dataBind // DEPRECATED Previous attempt, will be removed eventually // Capture returned value from executed code // dataBind.modifier = 'this._evalOutput = ' // let inputs: { [key: string]: any } = utils.evalInContext.call(child, dataBind) // let inputId: string // for (inputId in inputs) { // ;(child as any)[inputId] = (<any>parent)[inputId] // } code = code.trim() ;(child as any)[code] = (<any>parent)[source] debug('Write data bind values to element', { dataBind }) // inputs } /** * Adds a comment placehodler for the if rule. * Initialised only once. The rest of the updates are controlled by the comment placeholder. * Instead of creating the event listener in the child element the if rule maintains it in the placeholder ocmmnet. * This simplifies a lot of the work needed to show/hide the element. However it breaks the pattern of the other rules. */ export function setupIfDataBindPlaceholder(dataBind: DataBind): void { let { child } = dataBind, //, origin, source placeholder: Comment // Setup placeholder comment placeholder = document.createComment('') dataBind.placeholder = placeholder debug('Setup IF rule placeholder', { dataBind }) // Hidden element clone ;(placeholder as any)._nano_originalElement = child.cloneNode() ;(placeholder as any)._nano_originalElement.innerHTML = child.innerHTML // Insert placeholder child.parentNode.insertBefore(placeholder, child) // TODO this will be done in pre processing // Remove the orginal element that hosted the n-if data bind attribute // <!> In case you need to show the element before the first event is dispatched // dispatch the same custom event with with the detail value set on true child.remove() // Release the original element from memory delete dataBind.child } /** Toggle the an element using a comment node as a placeholder */ export function toggleIfDataBindElement(dataBind: DataBind): void { let { child, placeholder, parent } = dataBind, isVisible: boolean, ifElement: HTMLElement debug('Toggle IF data bind element', { dataBind }) // Capture returned value from executed code dataBind.modifier = 'this._evalOutput = ' // Retrieve visibility value from evaluated code isVisible = utils.evalInContext.call(placeholder, dataBind) debug('IF element is visible', isVisible) // Fail safe for repeated values (same value multiple times in a row) if (isVisible === false && child === undefined) return if (isVisible === true && child !== undefined) return // Clone the placeholder clone. Prevents any cross communication between instances ifElement = (placeholder as any)._nano_originalElement.cloneNode() ifElement.innerHTML = (placeholder as any)._nano_originalElement.innerHTML if (isVisible === true) { debug('Insert child', {ifElement}) // Inset the hidden element in document placeholder.parentNode.insertBefore(ifElement, placeholder.nextSibling) // Reuse data bind object (It is kept alive by the placeholder comment) dataBind.child = ifElement // Bind again, other data binds might actuall need to run again nanoBind(parent, ifElement) } else if (isVisible === false) { debug('Remove child') // Remove the IF element dataBind.child.remove() // Release the old one from memory delete dataBind.child } } /** * Iterates all elements of an array * Update, Add, Remove operations are optimised to target only the changed elements * Renders text, html and web components * Binds data from the array to the web components * <!> The for loop is by design unabled to bind data to templates * In order to encourage a simpler cleaner architecture, items are expected to be defined as webcomponents * The performance cost is minimal to non-existent, and having two web components defined in the same file is permited * <!> Compares the old list with the new list, extracts the changes and then it syncs the dom with the new list * TODO Upgrade to allow multiple tags rendered by the same loop. In this case we need to scan for data binds and attach the data there. */ export function updateItemsInForList (dataBind: DataBind) { let { child } = dataBind, changes: Changes = { added: [], removed: [] } // Capture returned value from executed code dataBind.modifier = 'this._evalOutput = ' let newItems: any[] = utils.evalInContext.call(child, dataBind), elems: HTMLElement[] = Array.from(child.children), oldItems: any[] = elems.map((el: any) => el._nano_forItemData) if (newItems.constructor !== Array) { console.warn(`Cannot render list. Only arrays are accepted. ${utils.printDataBindInfo(dataBind)}`) return } changes.added = newItems.filter(itm => !oldItems.includes(itm)) changes.removed = oldItems.filter(itm => !newItems.includes(itm)) debug('Update items in for list', { newItems, oldItems, changes, dataBind }) // Validation elems.forEach(el => { if (!(el as any)._nano_forItemData) { console.warn('Metadata is missing. Ensure that no other library manipulates the items generated via n-for data bind.') } }) // Remove elements // <!> Removing before adding, clears up the children HTMLCollection of unwanted positions // Now all additions can be done with one single index rule let removedElems: Element[] = [] changes.removed.forEach( rem => { let i: number = oldItems.indexOf(rem) // Cache proper index and then remove removedElems.push(child.children[i]) }) debug('Removed old element', {removedElems}) removedElems.forEach( remEl => remEl.remove() ) // Add new elements // <!> TODO Can be optimised to add all modifications at once using one parse changes.added.forEach( add => { let i: number = newItems.indexOf(add) // Parked until replaced with something better // elem = parser.parseFromString(dataBind.template, "text/html").children[0] // DEPRECATED, does not fulfill the expected role of generating dom elements // <!> Currently this code assumes onla one element at a time is introduced // REVIEW Is this a memory leak? let tmpEl = document.createElement(`div`) tmpEl.innerHTML = dataBind.template let elem = tmpEl.children[0] // Cache data, Insert, Bind ;(elem as any)._nano_forItemData = add ;(elem as any).forItemData = add // TODO Add custom inputs child.insertBefore(elem, child.children[i]) // Fals when there are no chidlren // When inserting HTML into a page by using insertAdjacentHTML be careful not to use user input that hasn't been escaped. // <!> Currently this code assumes onla one element at a time is introduced // child.children[i].insertAdjacentHTML('beforebegin', dataBind.template) // let elem = child.children[i] // ;(elem as any)._nano_forItemData = add // ;(elem as any).forItemData = add // TODO Add custom inputs // debug('Added new element', {elem, add}) }) } export function addCssClassesToElem(dataBind: DataBind): void { let { child } = dataBind // Capture returned value from executed code dataBind.modifier = 'this._evalOutput = ' let classesObj: { [key: string]: boolean } = utils.evalInContext.call(child, dataBind) debug('Add css classes to element', { classesObj, dataBind }) let classes: string[] = Object.keys(classesObj) classes.forEach(cssClass => { if (typeof classesObj[cssClass] !== 'boolean') console.warn(`Cannot match class, value is not boolean. ${utils.printDataBindInfo(dataBind)}`) classesObj[cssClass] === true ? child.classList.add(cssClass) : child.classList.remove(cssClass) }) } /** Execute a method bound to the data source */ export function callChildContextMethod(dataBind: DataBind): void { let { child } = dataBind // Only execute code // dataBind.modifier = 'this.' // DEPRECATED - User should have control over the context used to invoke the bound method dataBind.modifier = '' utils.evalInContext.call( child, dataBind ) debug('Call child context method', { dataBind }) }