UNPKG

two-way-data-binding

Version:
445 lines (390 loc) 13.5 kB
import { getValueByPath, isHTMLElement, isHTMLString, isObject, setValueByPath } from "./utils.js"; /** * @param {object} config * @param {HTMLElement} [config.$context] * @param {string} [config.attributeBind] * @param {string} [config.attributeModel] * @param {string[]} [config.attributesCustomValue] * @param {string} [config.domRefPrefix] * @param {object} [config.dataModel] * @param {string[]} [config.events] * @param {string} [config.pathDelimiter] * @param {function(prop: any,value: any)} [config.setCustomValueCallback] * @return {any} */ export default (config = {}) => { const { $context = document, attributeBind = `data-bind`, attributeModel = `data-model`, attributesCustomValue = [`data-value`], domRefPrefix = `$`, events = [`change`, `keyup`], pathDelimiter = `.` } = config; const CUSTOM_EVENTS = { setCustomValue: `twowaydatabinding:setcustomvalue` }; // On every event defined, generate the equivalent name with 'twowaydatabinding' prefix // in order to dispatch that event after the logic is processed events.forEach((event) => { CUSTOM_EVENTS[event] = `twowaydatabinding:${event}`; }); const dataModel = { ...config.dataModel }; let _proxy; // Convert attributesCustomValue array to Set for O(1) lookups const customValueAttrsSet = new Set(attributesCustomValue); // Memoize path splitting to avoid repeated split operations const pathCache = new Map(); // Memoize `[name=...]` group lookups. DOM grouping by `name` is stable for // the bound context, so the static NodeList can be reused; element `.checked` // states are still read live from the cached elements. const groupCache = new Map(); /** * @param {string} name * @return {NodeList} */ function getGroupByName(name) { let group = groupCache.get(name); if (group === undefined) { group = $context.querySelectorAll(`[name=${name}]`); groupCache.set(name, group); } return group; } /** * @param {string} pathString * @return {string[]} */ function splitPath(pathString) { let path = pathCache.get(pathString); if (!path) { path = pathString.split(pathDelimiter); pathCache.set(pathString, path); } // Return a copy to prevent mutations affecting cached array return path.slice(); } /** * @param {HTMLElement} $target * @param {object} options * @param {string} [options.cancelable] * @param {string} [options.detail] * @param {string} options.name */ function dispatchCustomEvent($target, options) { const { bubbles = true, cancelable = true, detail, name } = options; $target.dispatchEvent(new CustomEvent(name, { bubbles, cancelable, detail })); } /** * Returns the matching custom-value attribute name for an element in a single * attribute scan, or `undefined` when none is present. * @param {HTMLFormElement} $element * @return {string|undefined} */ function getCustomValueAttr($element) { const attrNames = $element.getAttributeNames(); const len = attrNames.length; for (let i = 0; i < len; i += 1) { if (customValueAttrsSet.has(attrNames[i])) { return attrNames[i]; } } return undefined; } /** * @param {HTMLFormElement} $element * @return {boolean} */ function hasCustomValue($element) { return getCustomValueAttr($element) !== undefined; } /** * @param {HTMLFormElement} $element * @return {boolean} */ function isCheckboxOrRadio($element) { return $element.type === `checkbox` || $element.type === `radio`; } /** * @param {HTMLFormElement} $element * @return {boolean} */ function isPartOfGroup($element) { if ($element.name) { return getGroupByName($element.name).length > 1; } return false; } /** * @param {HTMLFormElement} $element * @return {boolean} */ function isCheckboxGroup($element) { return $element.type === `checkbox` && isPartOfGroup($element); } /** * @param {*} $element * @return {string} */ function propertyToGet($element) { let propName = ``; const customAttr = getCustomValueAttr($element); if (customAttr !== undefined) { propName = customAttr; } else if ($element.tagName === `INPUT`) { if (isCheckboxOrRadio($element) && !isPartOfGroup($element)) { propName = `checked`; } else { propName = `value`; } } else if ($element.tagName === `SELECT`) { propName = `selectedOptions`; } else { propName = isHTMLString($element.innerHTML) ? `innerHTML` : `textContent`; } return propName; } /** * Set at the same level of every 'data-bind="property.path"' the DOM Element * where the value in the model needs to be printed. * * dataModel defined on module initialisation: * { foo: { bar: 'bar' } } * * dataModel after appending DOM references: * { foo: { $bar: HTMLElement, bar: 'bar' } } */ function setDOMRefsInDataModel() { const $refs = $context.querySelectorAll(`[${attributeBind}]`); const len = $refs.length; for (let i = 0; i < len; i += 1) { const $ref = $refs[i]; const propPathString = $ref.getAttribute(attributeBind); const propPath = splitPath(propPathString); const lastPropIdx = propPath.length - 1; const lastprop = propPath[lastPropIdx]; // Set the DOM Reference with the same name of the data-bind attribute with domRefPrefix ($) const DOMRefPath = propPath.slice(); DOMRefPath[lastPropIdx] = `${domRefPrefix}${lastprop}`; // Concat the possible value to have an array of elements const $currentRef = getValueByPath(DOMRefPath, dataModel) || ``; setValueByPath([$ref, ...$currentRef], DOMRefPath, dataModel); if (typeof getValueByPath(propPath, dataModel) === `undefined`) { // Set the value that Element has in the static HTML in case is not // defined in the model const prop = propertyToGet($ref); let value; if (isCheckboxGroup($ref)) { const refName = $ref.name; const checkedValues = []; // Search for all checked checkboxes in the group for (let j = 0; j < len; j += 1) { const $el = $refs[j]; if ($el.name === refName && $el.checked) { checkedValues.push($el.value); } } value = checkedValues.join(`,`); } else if (isPartOfGroup($ref)) { const refName = $ref.name; let checkedValue = ``; // Search for checked element in already processed refs for (let j = 0; j < len; j += 1) { const $el = $refs[j]; if ($el.name === refName && $el.checked) { checkedValue = $el.value; break; } } value = checkedValue; } else if (prop === `selectedOptions`) { // In case of a select, set the default option or empty string const selectedOpts = $ref[prop]; const optsLen = selectedOpts.length; const values = new Array(optsLen); for (let k = 0; k < optsLen; k += 1) { values[k] = selectedOpts[k].value; } value = values.join(``); } else { value = prop.indexOf(`data`) !== -1 ? $ref.getAttribute(prop) : $ref[prop]; } setValueByPath( typeof value === `string` ? value.trim() : value, propPath, dataModel ); } } } /** * @param {HTMLElement[]} $elements * @param {any} value */ function updateDOM($elements, value) { if (typeof $elements === `undefined` || value === null) return; $elements.forEach(($element) => { const customAttr = getCustomValueAttr($element); if (customAttr !== undefined) { $element.setAttribute(customAttr, value); return; } if ($element.tagName === `INPUT`) { let checked = value !== `undefined` && value === ``; if (isCheckboxOrRadio($element)) { if (isCheckboxGroup($element)) { const values = typeof value === `string` ? value.split(`,`) : []; checked = values.includes($element.value); } else if (isPartOfGroup($element)) { checked = $element.value === value; } else { checked = value; // Make sure we're setting a boolean if (typeof checked !== `boolean`) { // Convert string to boolean checked = value.toLowerCase() === `true`; } } $element.checked = checked; } else { // Regular text inputs $element.value = value; } } else if ($element.tagName === `SELECT`) { // Check value is actually relevant and prevent the logic when it's a nodelist if (typeof value === `string`) { const options = $element.options; const optLen = options.length; for (let i = 0; i < optLen; i += 1) { const $option = options[i]; if (value.indexOf($option.value) !== -1) { $option.selected = true; } } } } else { // Elements that are not inputs nor form elements $element[isHTMLString(value) ? `innerHTML` : `textContent`] = value; } }); } /** * Considering a (already processed) dataModel: * { foo: { $bar: HTMLElement, bar: 'bar' } } * * Recursive function is executed, when an HTML is found, the DOM is updated * with the text belonging to the same key without '$' * @param {object} data */ function iterateDataModelAndUpdateDOM(data) { Object.keys(data).forEach((key) => { if (Array.isArray(data[key]) && data[key]?.every(isHTMLElement)) { updateDOM(data[key], data[key.replace(domRefPrefix, ``)]); } else if (typeof data[key] === `object` && data[key] !== null) { iterateDataModelAndUpdateDOM(data[key]); } }); } /** * @param {string} eventName * @param {object} DOMEvent */ function eventListener(eventName, DOMEvent) { const { target } = DOMEvent; if (target.hasAttribute(attributeModel)) { if (hasCustomValue(target)) return; let value; const path = splitPath(target.getAttribute(attributeModel)); if (isCheckboxGroup(target)) { const $sameNameCheckboxes = getGroupByName(target.name); const checkedValues = []; $sameNameCheckboxes.forEach(($cb) => { if ($cb.checked) checkedValues.push($cb.value); }); value = checkedValues.join(`,`); } else if (isCheckboxOrRadio(target) && !isPartOfGroup(target)) { value = target.checked; } else if (target.tagName === `SELECT`) { const selectedOpts = target.selectedOptions; const len = selectedOpts.length; const values = new Array(len); for (let i = 0; i < len; i += 1) { values[i] = selectedOpts[i].value; } value = values.toString(); } else { value = target.value; } setValueByPath(value, path, _proxy); if (CUSTOM_EVENTS[eventName]) { dispatchCustomEvent(target, { name: CUSTOM_EVENTS[eventName] }); } } } /** * @param {CustomEvent} DOMEvent */ function onSetCustomValue(DOMEvent) { const { detail, target } = DOMEvent; if (target.hasAttribute(attributeModel) || target.hasAttribute(attributeBind)) { setValueByPath(detail.value, splitPath(detail.path), _proxy); dispatchCustomEvent(target, { name: CUSTOM_EVENTS.change }); } } function addEventListeners() { events.forEach((eventName) => { $context.addEventListener(eventName, (DOMEvent) => { eventListener(eventName, DOMEvent); }); }); // Be prepared to react when any consumer dispatches twowaydatabinding:setcustomvalue $context.addEventListener(CUSTOM_EVENTS.setCustomValue, onSetCustomValue); } function init() { // Cache proxies to avoid creating new ones on every property access const proxyCache = new WeakMap(); const proxyHandler = { get: (data, prop) => { if (typeof data[prop] === `object` && data[prop] !== null && !isHTMLElement(data[prop])) { let proxy = proxyCache.get(data[prop]); if (!proxy) { proxy = new Proxy(data[prop], proxyHandler); proxyCache.set(data[prop], proxy); } return proxy; } return data[prop]; }, set: (data, prop, value) => { // Only merge if both existing and new values are objects if (isObject(value) && isObject(data[prop])) { // Merge properties instead of creating new object when necessary Object.assign(data[prop], value); } else { data[prop] = value; } /** * At this point: * 'data' = { $bar: HTMLElement, bar: 'bar' } * 'prop' = 'bar' * * In 'data' the property named as 'prop' prefixed with '$' * contains the HTML reference where to print 'value' */ updateDOM(data[`${domRefPrefix}${prop}`], value); return true; } }; setDOMRefsInDataModel(); iterateDataModelAndUpdateDOM(dataModel); addEventListeners(); _proxy = new Proxy(dataModel, proxyHandler); } init(); return _proxy; };