const U = (() => { let nextObservableId = 1; let reactiveContext = null; const createObservable = (initial, { equality = (x, y) => x === y, name } = {}) => { const _observableId = nextObservableId++; let _value = initial; let subscriberNum = 0; const subscribers = new Map(); return { _observableId, name: name || `obs#${_observableId}`, get value() { reactiveContext?.add(this); return _value; }, set value(newValue) { const previous = _value; _value = newValue; if (!equality?.(previous, _value)) { const info = { previous, initial, cause: this }; subscribers.forEach((fn) => fn(_value, info)); } }, subscribe(fn) { const thisSubscriber = subscriberNum++; subscribers.set(thisSubscriber, fn); return () => subscribers.delete(thisSubscriber); }, reset() { this.value = initial; }, }; }; const trackDependencies = (action) => { const prevContext = reactiveContext; reactiveContext = new Set(); const result = action(); const dependencies = Array.from(reactiveContext.values()); reactiveContext = prevContext; return [result, dependencies]; }; const getElement = (elementOrId) => typeof elementOrId === "string" ? document.getElementById(elementOrId) : elementOrId; return { // U.obs(value); // U.obs(() => obs1.value + obs2.value); // U.obs([obs1, obs2, obs3]); obs(initializer, ...args) { if (Array.isArray(initializer) && initializer[0]?._observableId) { const getList = () => initializer.map(({ value }) => value); const result = createObservable(getList(), ...args); initializer.forEach((obs) => obs.subscribe(() => { result.value = getList(); }) ); return result; } if (typeof initializer === "function") { const [value, deps] = trackDependencies(initializer); const result = createObservable(value, ...args); deps.forEach((obs) => obs.subscribe(() => { result.value = initializer(); }) ); return result; } return createObservable(initializer, ...args); }, // U.fsm(states); // U.fsm(s1, s2, s3); fsm(config, ...rest) { let initial, states; if (rest.length > 0) { // cycle, using [config, ...rest] as states initial = config; states = Object.fromEntries( [config, ...rest].map((state, i, ls) => [state, ls[(i + 1) % ls.length]]) ); } else { initial = config.initial; states = config.states; } const state = createObservable(initial); const transitions = Object.fromEntries( Object.entries(states).map(([current, next]) => [ current, typeof next === "string" ? () => (state.value = next) : (transition) => (state.value = next[transition]), ]) ); return { get current() { return state.value; }, get transition() { return transitions[state.value]; }, subscribe(...args) { return state.subscribe(...args); }, reset() { state.reset(); }, }; }, // U.reactive(() => {}); reactive(action) { const unsubs = trackDependencies(action)[1].map((obs) => obs.subscribe(() => action()) ); return () => unsubs.forEach((fn) => fn()); }, // U.resource(async () => {}); resource(sourceOrAction, actionOrInitial, ...args) { let resultArgs, action, source; if (typeof sourceOrAction === "function") { resultArgs = [actionOrInitial, ...args]; action = sourceOrAction; source = null; } else { resultArgs = args; action = actionOrInitial; source = sourceOrAction; } const result = createObservable(...resultArgs); const loading = createObservable(true); const error = createObservable(false); const refresh = async () => { loading.value = true; error.value = false; try { result.value = await action(source?.value); } catch (err) { error.value = err; } loading.value = false; }; refresh(); source?.subscribe(() => refresh()); return { result, loading, error, refresh, }; }, // U.element("idOrElem", (binds, element) => {}); element(idOrRoot, action, keepBindPoints = false) { const root = getElement(idOrRoot); const binds = Object.fromEntries( Array.from(root.querySelectorAll("[bind-to]")).map((element) => { const name = element.getAttribute("bind-to"); if (!keepBindPoints) { element.removeAttribute("bind-to"); } return [name, element]; }) ); return action(binds, root); }, // U.template("idOrElem", (binds, api) => {}); template(idOrTemplate, setup) { const fragment = getElement(idOrTemplate).content.cloneNode(true); const children = Array.from(fragment.children); const unsubs = []; const api = { fragment, remove() { children.forEach((elem) => elem.remove()); }, hide(state = true) { children.forEach((elem) => (elem.hidden = state)); }, reactive: (...args) => { unsubs.push(this.reactive(...args)); }, }; const result = { fragment, parent: null, destroy: () => { unsubs.forEach((fn) => fn()); api.remove(); }, }; const target = this.element(fragment, (binds) => setup(binds, api)); if (target) { const parent = getElement(target); parent.appendChild(fragment); result.parent = parent; } return result; }, // U.list(idOrTemplate, (binds, api, arg) => {})([arg1, arg2, ...]); list(idOrTemplate, setup) { const old = []; const component = (arg) => this.template(idOrTemplate, (binds, api) => setup(binds, api, arg)); return (list) => { old.forEach((el) => el.destroy()); old.splice(0); old.push(...list.map(component)); }; }, // U.form("idOrElem", config); form(formOrId, { onSubmit, onChange = {}, onInput = {} } = {}) { const formElement = getElement(formOrId); formElement.setAttribute("action", "javascript:void(0);"); formElement.addEventListener("submit", () => onSubmit?.( Object.fromEntries( Array.from(formElement.elements) .filter((elem) => elem.name) .map((elem) => [elem.name, elem.value]) ), formElement ) ); formElement.addEventListener("change", (event) => { onChange?.[event.target.name]?.(event.target.value, event.target, event); }); formElement.addEventListener("input", (event) => { onInput?.[event.target.name]?.(event.target.value, event.target, event); }); return formElement; }, // U.field("idOrElem", config); field(fieldOrId, { allowInvalidInput = false, obs, ...config } = {}) { const fieldElement = getElement(fieldOrId); const valueKey = fieldElement.type === "checkbox" ? "checked" : "value"; const fieldObs = obs || createObservable(fieldElement[valueKey], { name: fieldElement.name, ...config, }); fieldElement.addEventListener("input", () => { if ( !allowInvalidInput && fieldElement.willValidate && !fieldElement.validity.valid ) { fieldElement[valueKey] = fieldObs.value; } else { fieldObs.value = fieldElement[valueKey]; } }); this.reactive(() => (fieldElement[valueKey] = fieldObs.value)); return fieldObs; }, }; })();