fundom.js
Version:
JS library for creating reactive ui elements in declarative way
728 lines (723 loc) • 24.6 kB
JavaScript
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 };