framework.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. const U = (() => {
  2. let nextObservableId = 1;
  3. let reactiveContext = null;
  4. const createObservable = (initial, { equality = (x, y) => x === y, name } = {}) => {
  5. const _observableId = nextObservableId++;
  6. let _value = initial;
  7. let subscriberNum = 0;
  8. const subscribers = new Map();
  9. return {
  10. _observableId,
  11. name: name || `obs#${_observableId}`,
  12. get value() {
  13. reactiveContext?.add(this);
  14. return _value;
  15. },
  16. set value(newValue) {
  17. const previous = _value;
  18. _value = newValue;
  19. if (!equality?.(previous, _value)) {
  20. const info = { previous, initial, cause: this };
  21. subscribers.forEach((fn) => fn(_value, info));
  22. }
  23. },
  24. subscribe(fn) {
  25. const thisSubscriber = subscriberNum++;
  26. subscribers.set(thisSubscriber, fn);
  27. return () => subscribers.delete(thisSubscriber);
  28. },
  29. reset() {
  30. this.value = initial;
  31. },
  32. };
  33. };
  34. const trackDependencies = (action) => {
  35. const prevContext = reactiveContext;
  36. reactiveContext = new Set();
  37. const result = action();
  38. const dependencies = Array.from(reactiveContext.values());
  39. reactiveContext = prevContext;
  40. return [result, dependencies];
  41. };
  42. const getElement = (elementOrId) =>
  43. typeof elementOrId === "string" ? document.getElementById(elementOrId) : elementOrId;
  44. return {
  45. // U.obs(value);
  46. // U.obs(() => obs1.value + obs2.value);
  47. // U.obs([obs1, obs2, obs3]);
  48. obs(initializer, ...args) {
  49. if (Array.isArray(initializer) && initializer[0]?._observableId) {
  50. const getList = () => initializer.map(({ value }) => value);
  51. const result = createObservable(getList(), ...args);
  52. initializer.forEach((obs) =>
  53. obs.subscribe(() => {
  54. result.value = getList();
  55. })
  56. );
  57. return result;
  58. }
  59. if (typeof initializer === "function") {
  60. const [value, deps] = trackDependencies(initializer);
  61. const result = createObservable(value, ...args);
  62. deps.forEach((obs) =>
  63. obs.subscribe(() => {
  64. result.value = initializer();
  65. })
  66. );
  67. return result;
  68. }
  69. return createObservable(initializer, ...args);
  70. },
  71. // U.fsm(states);
  72. // U.fsm(s1, s2, s3);
  73. fsm(config, ...rest) {
  74. let initial, states;
  75. if (rest.length > 0) {
  76. // cycle, using [config, ...rest] as states
  77. initial = config;
  78. states = Object.fromEntries(
  79. [config, ...rest].map((state, i, ls) => [state, ls[(i + 1) % ls.length]])
  80. );
  81. } else {
  82. initial = config.initial;
  83. states = config.states;
  84. }
  85. const state = createObservable(initial);
  86. const transitions = Object.fromEntries(
  87. Object.entries(states).map(([current, next]) => [
  88. current,
  89. typeof next === "string"
  90. ? () => (state.value = next)
  91. : (transition) => (state.value = next[transition]),
  92. ])
  93. );
  94. return {
  95. get current() {
  96. return state.value;
  97. },
  98. get transition() {
  99. return transitions[state.value];
  100. },
  101. subscribe(...args) {
  102. return state.subscribe(...args);
  103. },
  104. reset() {
  105. state.reset();
  106. },
  107. };
  108. },
  109. // U.reactive(() => {});
  110. reactive(action) {
  111. const unsubs = trackDependencies(action)[1].map((obs) =>
  112. obs.subscribe(() => action())
  113. );
  114. return () => unsubs.forEach((fn) => fn());
  115. },
  116. // U.resource(async () => {});
  117. resource(sourceOrAction, actionOrInitial, ...args) {
  118. let resultArgs, action, source;
  119. if (typeof sourceOrAction === "function") {
  120. resultArgs = [actionOrInitial, ...args];
  121. action = sourceOrAction;
  122. source = null;
  123. } else {
  124. resultArgs = args;
  125. action = actionOrInitial;
  126. source = sourceOrAction;
  127. }
  128. const result = createObservable(...resultArgs);
  129. const loading = createObservable(true);
  130. const error = createObservable(false);
  131. const refresh = async () => {
  132. loading.value = true;
  133. error.value = false;
  134. try {
  135. result.value = await action(source?.value);
  136. } catch (err) {
  137. error.value = err;
  138. }
  139. loading.value = false;
  140. };
  141. refresh();
  142. source?.subscribe(() => refresh());
  143. return {
  144. result,
  145. loading,
  146. error,
  147. refresh,
  148. };
  149. },
  150. // U.element("idOrElem", (binds, element) => {});
  151. element(idOrRoot, action, keepBindPoints = false) {
  152. const root = getElement(idOrRoot);
  153. const binds = Object.fromEntries(
  154. Array.from(root.querySelectorAll("[bind-to]")).map((element) => {
  155. const name = element.getAttribute("bind-to");
  156. if (!keepBindPoints) {
  157. element.removeAttribute("bind-to");
  158. }
  159. return [name, element];
  160. })
  161. );
  162. return action(binds, root);
  163. },
  164. // U.template("idOrElem", (binds, api) => {});
  165. template(idOrTemplate, setup) {
  166. const fragment = getElement(idOrTemplate).content.cloneNode(true);
  167. const children = Array.from(fragment.children);
  168. const unsubs = [];
  169. const api = {
  170. fragment,
  171. remove() {
  172. children.forEach((elem) => elem.remove());
  173. },
  174. hide(state = true) {
  175. children.forEach((elem) => (elem.hidden = state));
  176. },
  177. reactive: (...args) => {
  178. unsubs.push(this.reactive(...args));
  179. },
  180. };
  181. const result = {
  182. fragment,
  183. parent: null,
  184. destroy: () => {
  185. unsubs.forEach((fn) => fn());
  186. api.remove();
  187. },
  188. };
  189. const target = this.element(fragment, (binds) => setup(binds, api));
  190. if (target) {
  191. const parent = getElement(target);
  192. parent.appendChild(fragment);
  193. result.parent = parent;
  194. }
  195. return result;
  196. },
  197. // U.list(idOrTemplate, (binds, api, arg) => {})([arg1, arg2, ...]);
  198. list(idOrTemplate, setup) {
  199. const old = [];
  200. const component = (arg) =>
  201. this.template(idOrTemplate, (binds, api) => setup(binds, api, arg));
  202. return (list) => {
  203. old.forEach((el) => el.destroy());
  204. old.splice(0);
  205. old.push(...list.map(component));
  206. };
  207. },
  208. // U.form("idOrElem", config);
  209. form(formOrId, { onSubmit, onChange = {}, onInput = {} } = {}) {
  210. const formElement = getElement(formOrId);
  211. formElement.setAttribute("action", "javascript:void(0);");
  212. formElement.addEventListener("submit", () =>
  213. onSubmit?.(
  214. Object.fromEntries(
  215. Array.from(formElement.elements)
  216. .filter((elem) => elem.name)
  217. .map((elem) => [elem.name, elem.value])
  218. ),
  219. formElement
  220. )
  221. );
  222. formElement.addEventListener("change", (event) => {
  223. onChange?.[event.target.name]?.(event.target.value, event.target, event);
  224. });
  225. formElement.addEventListener("input", (event) => {
  226. onInput?.[event.target.name]?.(event.target.value, event.target, event);
  227. });
  228. return formElement;
  229. },
  230. // U.field("idOrElem", config);
  231. field(fieldOrId, { allowInvalidInput = false, obs, ...config } = {}) {
  232. const fieldElement = getElement(fieldOrId);
  233. const valueKey = fieldElement.type === "checkbox" ? "checked" : "value";
  234. const fieldObs =
  235. obs ||
  236. createObservable(fieldElement[valueKey], {
  237. name: fieldElement.name,
  238. ...config,
  239. });
  240. fieldElement.addEventListener("input", () => {
  241. if (
  242. !allowInvalidInput &&
  243. fieldElement.willValidate &&
  244. !fieldElement.validity.valid
  245. ) {
  246. fieldElement[valueKey] = fieldObs.value;
  247. } else {
  248. fieldObs.value = fieldElement[valueKey];
  249. }
  250. });
  251. this.reactive(() => (fieldElement[valueKey] = fieldObs.value));
  252. return fieldObs;
  253. },
  254. };
  255. })();