UNPKG

fundom.js

Version:

JS library for creating reactive ui elements in declarative way

728 lines (723 loc) 24.6 kB
const FN_TYPE = Symbol('fnType'); const FN_TYPE_FORMAT = Symbol('format'); const FN_TYPE_COMPUTE = Symbol('compute'); const FN_TYPE_STATE_GETTER = Symbol('stateGetter'); const FN_TYPE_CASE_HANDLER = Symbol('caseHandler'); const _applyMutations = (el, fns, context, ctrlFlowId, useRevert) => { if (fns.length === 0) return; for (let fn of fns) { fn(el, context, ctrlFlowId, useRevert); } }; const _camelToKebab = (prop) => { if (typeof prop !== 'string') { return ''; } return prop.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); }; const _createContextItem = (el, comment) => { return { snapshot: _makeSnapshot(el), comment, }; }; const _makeSnapshot = (el) => { return { childrenLength: el.children.length, innerHTML: el.innerHTML, innerText: el.innerText, textContent: el.textContent || '', style: el.style, attributes: el.attributes, classList: el.classList, }; }; const _randomId = (prefix = '') => { return Math.random().toString(36).replace('0.', prefix); }; /* NOTE: comment is used for appending element before it so it is located in the same order in DOM as it is inside elem function */ const _appendComment = (el, comment, parentComment) => { if (!_hasChild(el, comment)) { if (parentComment) { el.insertBefore(comment, parentComment.nextSibling); } else { el.appendChild(comment); } } }; const _isStateGetter = (value) => { return _isFunction(value) && value[FN_TYPE] === FN_TYPE_STATE_GETTER; }; const _isFormatUtil = (value) => { return _isFunction(value) && value[FN_TYPE] === FN_TYPE_FORMAT; }; const _isComputeUtil = (value) => { return _isFunction(value) && value[FN_TYPE] === FN_TYPE_COMPUTE; }; const _isCaseUtil = (value) => { return _isFunction(value) && value[FN_TYPE] === FN_TYPE_CASE_HANDLER; }; const _ctrlFlowReleaseEffect = (ctrlFlowContext) => { if (ctrlFlowContext) { ctrlFlowContext.snapshot = null; } }; const _handleUtilityIncomingValue = (value, handler, ctrlFlowContext) => { if (_isComputeUtil(value)) { value(handler); } else if (_isFormatUtil(value)) { value(handler); } else { if (_isStateGetter(value)) { const val = value((v) => handler(v), { releaseEffect: () => _ctrlFlowReleaseEffect(ctrlFlowContext), }); handler(val); } else { handler(value); } } }; const _handleControlFlow = (data, targetFnsGetter) => { const ctrlFlowId = _randomId('ctrlFlow_'); const comment = document.createComment(''); let prevApplied = []; let prevReverted = []; return (el, context, parentCtrlFlowId, useRevert) => { if (!_isHtmlElement(el)) { console.warn(new NotHTMLElementError('ifElse/ifOnly/match').message); return el; } const handler = (val) => { if (useRevert) { if (prevApplied !== prevReverted) { _applyMutations(el, prevApplied, context, ctrlFlowId, true); prevReverted = prevApplied; } } else { const targetFns = targetFnsGetter(val); if (prevApplied !== targetFns) { _applyMutations(el, prevApplied, context, ctrlFlowId, true); _applyMutations(el, targetFns, context, ctrlFlowId, false); prevApplied = targetFns; prevReverted = []; } } }; if (!(ctrlFlowId in context)) { context[ctrlFlowId] = _createContextItem(el, comment); _appendComment(el, comment, context[parentCtrlFlowId]?.comment); } _handleUtilityIncomingValue(data, handler, context[ctrlFlowId]); return el; }; }; const _hasChild = (parent, child) => { // NOTE: probably has better performance than parent.contains(child) return child.parentNode === parent; }; const _removeChildren = (parent, ...children) => { for (let child of children) { if (_hasChild(parent, child)) { parent.removeChild(child); } } }; const _isFunction = (value) => typeof value === 'function'; const _isHtmlElement = (el) => el && el instanceof HTMLElement; class NotHTMLElementError extends Error { constructor(origin) { super(); this.message = `value passed to ${origin} is not HTMLElement type`; } } class NoSnapshotError extends Error { constructor(id) { super(); this.message = `snapshot for id ${id} does not exist`; } } const elem = (name, ...utils) => { return (...extraUtils) => { const el = document.createElement(name); _applyMutations(el, [...utils, ...extraUtils], {}, '', false); return el; }; }; function children(...values) { const childrenElements = []; return (el, context, ctrlFlowId, useRevert) => { if (!_isHtmlElement(el)) { console.warn(new NotHTMLElementError('children').message); return el; } const populateChildren = (element) => { if (_isFunction(element)) { const el = element(); if (_isHtmlElement(el)) { childrenElements.push(el); } else { console.warn('[children] passed function does not return HTMLElement type', el); } } else { if (_isHtmlElement(element)) { childrenElements.push(element); } else { console.warn('[children] passed argument is not HTMLElement type'); } } }; // TODO: compare performance with createDocumentFragment const handler = (value) => { if (Array.isArray(value)) { if (childrenElements.length === 0) { for (let element of value) { populateChildren(element); } } } else { if (childrenElements.length > 0) { _removeChildren(el, ...childrenElements); childrenElements.length = 0; } populateChildren(value); } if (useRevert) { _removeChildren(el, ...childrenElements); } else { const comment = context[ctrlFlowId]?.comment; for (let childElem of childrenElements) { if (!_hasChild(el, childElem)) { if (comment && comment instanceof Comment) { el.insertBefore(childElem, comment); } else { el.appendChild(childElem); } } } } }; _handleUtilityIncomingValue(values.length === 1 ? values[0] : values, handler); return el; }; } const child = (name, ...utils) => { let childElem = null; return (el, context, ctrlFlowId, useRevert) => { if (!_isHtmlElement(el)) { console.warn(new NotHTMLElementError('child').message); return el; } if (!_isHtmlElement(childElem)) { childElem = elem(name, ...utils)(); } _applyMutations(el, [children(childElem)], context, ctrlFlowId, useRevert); return el; }; }; const list = (data, newElementFn) => { const comment = document.createComment(''); let prevChildren = []; let prevItems = []; return (el, context, ctrlFlowId, useRevert) => { if (!_isHtmlElement(el)) { console.warn(new NotHTMLElementError('list').message); return el; } _appendComment(el, comment, context[ctrlFlowId]?.comment); const handler = (items) => { if (!Array.isArray(items)) { console.warn('[list] first argument of list should be Array'); return; } if (!_isFunction(newElementFn)) { console.warn('[list] second argument of list should be ReturnType<typeof elem>'); return; } if (useRevert) { if (prevItems.length > 0) { _applyMutations(el, [children(...prevChildren)], context, ctrlFlowId, useRevert); prevChildren = []; prevItems = []; } } else { const curChildren = []; if (prevItems.length > 0) { for (let [i, item] of items.entries()) { if (Object.is(item, prevItems[i])) { curChildren.push(prevChildren[i]); } else { const childElem = newElementFn(item, i)(); curChildren.push(childElem); if (i < prevItems.length) { el.replaceChild(childElem, prevChildren[i]); } else { _applyMutations(el, [children(childElem)], context, ctrlFlowId, useRevert); } } } if (prevItems.length > items.length) { for (let i = prevItems.length - 1; i >= items.length; i--) { _removeChildren(el, prevChildren[i]); } } } else { for (let [i, item] of items.entries()) { curChildren.push(newElementFn(item, i)()); } _applyMutations(el, [children(...curChildren)], context, ctrlFlowId, useRevert); } prevChildren = curChildren; prevItems = items; } }; _handleUtilityIncomingValue(data, handler); return el; }; }; const ifElse = (condition) => (...fns1) => (...fns2) => { return _handleControlFlow(condition, (val) => { return Boolean(val) ? fns1 : fns2; }); }; const ifOnly = (condition) => (...fns1) => { return ifElse(condition)(...fns1)(); }; const match = (data) => (...cases) => { return _handleControlFlow(data, (val) => { for (let [index, caseItem] of cases.entries()) { if (_isCaseUtil(caseItem)) { const fns = caseItem(val, index === cases.length - 1); if (fns.length > 0) { return fns; } } } return []; }); }; const matchCase = (caseValue) => (...fns) => { caseHandler[FN_TYPE] = FN_TYPE_CASE_HANDLER; function caseHandler(value, isLast) { if (caseValue === undefined && isLast) { // default case return fns; } else if (_isFunction(caseValue)) { if (caseValue(value)) { return fns; } } else { if (caseValue === value) { return fns; } } return []; } return caseHandler; }; const html = (value) => { return (el, context, ctrlFlowId, useRevert) => { if (!_isHtmlElement(el)) { console.warn(new NotHTMLElementError('html').message); return el; } const handler = (val) => { if (useRevert) { const snapshot = context[ctrlFlowId]?.snapshot; if (snapshot) { el.innerHTML = snapshot.innerHTML; } else { console.warn(new NoSnapshotError(ctrlFlowId).message); } } else { el.innerHTML = String(val); } }; _handleUtilityIncomingValue(value, handler); return el; }; }; const txt = (value, options = { useTextContent: false }) => { return (el, context, ctrlFlowId, useRevert) => { if (!_isHtmlElement(el)) { console.warn(new NotHTMLElementError('txt').message); return el; } const targetMethod = options.useTextContent ? 'textContent' : 'innerText'; const handler = (val) => { if (useRevert) { const snapshot = context[ctrlFlowId]?.snapshot; if (snapshot) { el[targetMethod] = snapshot[targetMethod]; } else { console.warn(new NoSnapshotError(ctrlFlowId).message); } } else { el[targetMethod] = String(val); } }; _handleUtilityIncomingValue(value, handler); return el; }; }; const style = (props) => { return (el, context, ctrlFlowId, useRevert) => { if (!_isHtmlElement(el)) { console.warn(new NotHTMLElementError('style').message); return el; } for (let [_key, propValue] of Object.entries(props)) { const key = _camelToKebab(_key); const handler = (value) => { if (useRevert) { const snapshot = context[ctrlFlowId]?.snapshot; if (snapshot) { if (snapshot.style.getPropertyValue(key) !== '') { el.style.setProperty(key, snapshot.style.getPropertyValue(key)); } else { el.style.removeProperty(key); } } else { console.warn(new NoSnapshotError(ctrlFlowId).message); } } else { el.style.setProperty(key, String(value)); } }; _handleUtilityIncomingValue(propValue, handler); } return el; }; }; const classList = (...classNames) => { return (el, context, ctrlFlowId, useRevert) => { if (!_isHtmlElement(el)) { console.warn(new NotHTMLElementError('classList').message); return el; } for (let className of classNames) { let prevAddedValue; const handler = (value) => { const classString = String(value); if (useRevert) { const snapshot = context[ctrlFlowId]?.snapshot; if (snapshot) { el.classList.remove(classString); } else { console.warn(new NoSnapshotError(ctrlFlowId).message); } } else { if (prevAddedValue) { el.classList.remove(prevAddedValue); } el.classList.add(classString); prevAddedValue = classString; } }; _handleUtilityIncomingValue(className, handler); } return el; }; }; const attr = (props) => { return (el, context, ctrlFlowId, useRevert) => { if (!_isHtmlElement(el)) { console.warn(new NotHTMLElementError('attr').message); return el; } for (let [key, propValue] of Object.entries(props)) { const handler = (value) => { if (useRevert) { const snapshot = context[ctrlFlowId]?.snapshot; if (snapshot) { if (snapshot.attributes.getNamedItem(key)) { el.setAttribute(key, snapshot.attributes.getNamedItem(key)?.value || ''); } else { el.removeAttribute(key); } } else { console.warn(new NoSnapshotError(ctrlFlowId).message); } } else { el.setAttribute(key, String(value)); } }; _handleUtilityIncomingValue(propValue, handler); } return el; }; }; const on = (type, cb, options) => { return (el) => { if (!_isHtmlElement(el)) { console.warn(new NotHTMLElementError('on').message); return el; } el.addEventListener(type, cb, options); if (options && options.offTrigger) { options.offTrigger(() => { el.removeEventListener(type, cb); }); } return el; }; }; const fmt = (...values) => { formatter[FN_TYPE] = FN_TYPE_FORMAT; function formatter(handler) { const SPLIT_CHAR = '{}'; const result = []; if (values.length < 2) { console.warn('fmt util needs at least 2 arguments to make sense'); pushToResult(values[0] ?? ''); return handler(result.join('')); } if (typeof values[0] === 'string') { const splitByBraces = values[0].split(SPLIT_CHAR); if (splitByBraces.length === values.length) { pushToResult(splitByBraces.shift() ?? ''); for (let i = 1; i < values.length; i++) { const value = values[i]; pushToResult(value ?? ''); if (splitByBraces.length > 0) { pushToResult(splitByBraces.shift() || ''); } } } else { console.warn(`number of ${SPLIT_CHAR} in fmt util is not equal to number of dynamic arguments, falling back to concatenating all`); populateResultWithAll(); } } else { console.warn(`first argument of fmt is not a string type, falling back to concatenating all`); populateResultWithAll(); } function pushToResult(value) { let indexAfterPush = result.length; if (_isComputeUtil(value) || _isFormatUtil(value)) { value((val) => { if (result.length > indexAfterPush) { result[indexAfterPush] = val; handler(result.join('')); } else { result.push(val); } }); } else if (_isStateGetter(value)) { const val = value((v) => { result[indexAfterPush] = v; handler(result.join('')); }); result.push(val); } else { result.push(value); } } function populateResultWithAll() { for (let value of values) { pushToResult(value); } } handler(result.join('')); } return formatter; }; const cmp = (stateGetter, computer) => { compute[FN_TYPE] = FN_TYPE_COMPUTE; function compute(handler) { if (_isStateGetter(stateGetter)) { const val = stateGetter((v) => { handler(computer(v)); }); handler(computer(val)); } else { console.warn(`${stateGetter} is not of FunStateGetter type, passing it to cmp function`); handler(computer(stateGetter)); } } return compute; }; const funState = (initialValue) => { const subs = []; const pausedSubs = []; let value = initialValue; const subIndex = (sub) => { return subs.findIndex(([_sub]) => _sub === sub); }; /* * @description pauses provided subscriber or all subscribers if no arguments provided **/ const pause = (sub) => { if (sub) { if (subIndex(sub) === -1) { console.warn('[funState] no such subscriber to pause: ', sub); return; } if (pausedSubs.indexOf(sub) === -1) { pausedSubs.push(sub); } else { console.warn(`[funState] ignore pause of ${sub} as it is already paused`); } } else { for (const _sub of subs) { if (pausedSubs.indexOf(_sub[0]) === -1) { pausedSubs.push(_sub[0]); } } } }; /* * @description resumes provided subscriber or all subscribers if no arguments provided **/ const resume = (sub) => { if (sub) { if (subIndex(sub) === -1) { console.warn('[funState] no such subscriber to pause: ', sub); return; } const index = pausedSubs.indexOf(sub); if (index > -1) { pausedSubs.splice(index, 1); } else { console.warn(`[funState] ignore resume of ${sub} as it is not paused`); } } else { pausedSubs.length = 0; } }; /* * @description unsubscribes provided subscriber or all subscribers if no arguments provided and invokes release effects **/ const release = (sub) => { if (sub) { const index = subIndex(sub); if (index > -1) { const removed = subs.splice(index, 1); if (removed[0]) { const options = removed[0][1]; if (_isFunction(options.releaseEffect)) { options.releaseEffect(); } } } else { console.warn('[funState] no such subscriber to release: ', sub); } } else { subs.forEach((item) => { if (_isFunction(item[1].releaseEffect)) { item[1].releaseEffect(); } }); subs.length = 0; } }; const controller = (action, sub) => { switch (action) { case 'pause': pause(sub); break; case 'resume': resume(sub); break; case 'release': release(sub); break; default: console.warn(`[funState] unknown action ${action} provided`); break; } }; /* * @description returns current value, can be used to add subscriber with release effect **/ const getter = (sub, options) => { if (sub) { if (_isFunction(sub)) { const usePush = subIndex(sub) === -1; if (usePush) { subs.push([sub, options || {}]); } } else { console.warn('[funState] provided value is not a function: ', sub); } } return value; }; getter[FN_TYPE] = FN_TYPE_STATE_GETTER; /* * @description updates current value, can be used to control subscribers' reactivity if callback is provided instead of new value **/ const setter = (arg, options) => { if (_isFunction(arg)) { arg(controller); } else { const setNextValue = () => { value = arg; subs.forEach(([sub, { once }]) => { if (pausedSubs.indexOf(sub) === -1) { sub(value); if (once) { release(sub); } } }); }; if (options && options.force) { setNextValue(); } else { if (!Object.is(arg, value)) { setNextValue(); } } } }; return [getter, setter]; }; export { attr, child, children, classList, cmp, elem, fmt, funState, html, ifElse, ifOnly, list, match, matchCase, on, style, txt };