Blog/wwwroot/common.module.js 7.9 K · 282 lines · raw · history

1 /**
2 * Create a text node with the contents
3 * @param {string} text The text content
4 * @returns The text node
5 */
6 export function t(text) {
7 return document.createTextNode(text);
8 }
9
10 /**
11 * @typedef {"stop"|"prevent"|"self"} EventModifier
12 */
13
14 /**
15 *
16 * @param {CallableFunction} fn
17 * @param {EventModifier|EventModifier[]} modifiers
18 * @returns {CallableFunction}
19 */
20 export function withModifiers(fn, modifiers) {
21 if (!Array.isArray(modifiers)) {
22 modifiers = [modifiers];
23 }
24 return function (/** @type {Event} */ event , /** @type {any} */ ...args) {
25 for (const modifier of modifiers) {
26 if (modifier === "stop") {
27 event.stopPropagation();
28 } else if (modifier === "prevent") {
29 event.preventDefault();
30 } else if (modifier === "self") {
31 if (event.target !== event.currentTarget) {
32 return;
33 }
34 }
35 }
36 return fn(event, ...args);
37 }
38 }
39
40 /**
41 * @typedef {{ style?: Record<string, string>|Record<string, string>[], class?: Record<string, unknown> } & Record<string, string>} HtmlAttrs
42 */
43
44 /**
45 * Assign attributes to the node
46 * @param {HTMLElement} node
47 * @param {HtmlAttrs} attrs
48 */
49 function setAttrs(node, attrs) {
50 for (const key in attrs) {
51 if (!Object.hasOwn(attrs, key)) continue;
52 if (key === "style" && typeof attrs[key] === "object") {
53 let styleAttrs = attrs[key];
54 if (!Array.isArray(styleAttrs)) {
55 styleAttrs = [styleAttrs];
56 }
57 for (const styles of styleAttrs) {
58 for (const styleProp in styles) {
59 if (!Object.hasOwn(styles, styleProp)) continue;
60
61 const val = styles[styleProp];
62
63 node.style.setProperty(styleProp, val);
64 }
65 }
66 } else if (key === "class" && typeof attrs[key] === "object" && !Array.isArray(attrs[key])) {
67 for (const className in attrs[key]) {
68 if (!Object.hasOwn(attrs[key], className)) continue;
69
70 const element = attrs[key][className];
71
72 if (element) {
73 node.classList.add(className);
74 }
75 }
76 } else if (typeof attrs[key] === "string") {
77 node.setAttribute(key, attrs[key]);
78 } else if (typeof attrs[key] === "function" && /^on[A-Z]/.test(key)) {
79 const eventSpec = key.substring(2);
80 // Loop through PascalCase substrings
81 const re = /[A-Z][a-z]+/g;
82 const results = [...eventSpec.matchAll(re)];
83 if (results.length === 0) {
84 throw new Error(`Invalid event listener: on${eventSpec}`);
85 }
86 let eventName = results[0][0].toLowerCase();
87 let options = /** @type {AddEventListenerOptions} */ ({});
88 for (const regexResult of results.slice(1)) {
89 let modifier = regexResult[0].toLowerCase();
90 if (modifier === "capture") {
91 options.capture = true;
92 } else if (modifier === "once") {
93 options.once = true;
94 } else if (modifier === "passive") {
95 options.passive = true;
96 } else {
97 throw new Error(`Unknown modifier: ${modifier}`);
98 }
99 }
100 node.addEventListener(eventName, attrs[key], options);
101 } else {
102 throw new TypeError(`Unsupported attribute type ${typeof attrs[key]}`);
103 }
104 }
105 }
106
107 /**
108 * Add the children to the node
109 * @param {HTMLElement} node
110 * @param {Array<HTMLElement|Text|string>} children
111 */
112 function setChildren(node, children) {
113 for (const c of children) {
114 if (typeof c === "string") {
115 node.appendChild(t(c));
116 } else {
117 node.appendChild(c);
118 }
119 }
120 }
121
122 /**
123 * Build a HTMLElement
124 * @overload
125 * @param {keyof HTMLElementTagNameMap} tag
126 * @returns {HTMLElement}
127 */
128 /**
129 * @overload
130 * @param {keyof HTMLElementTagNameMap} tag
131 * @param {string} text
132 * @returns {HTMLElement}
133 */
134 /**
135 * @overload
136 * @param {keyof HTMLElementTagNameMap} tag
137 * @param {Array<HTMLElement|Text|string>} children
138 * @returns {HTMLElement}
139 */
140 /**
141 * @overload
142 * @param {keyof HTMLElementTagNameMap} tag
143 * @param {HtmlAttrs} attrs
144 * @returns {HTMLElement}
145 */
146 /**
147 * @overload
148 * @param {keyof HTMLElementTagNameMap} tag
149 * @param {HtmlAttrs} attrs
150 * @param {string} text
151 * @returns {HTMLElement}
152 */
153 /**
154 * @overload
155 * @param {keyof HTMLElementTagNameMap} tag
156 * @param {HtmlAttrs} attrs
157 * @param {Array<HTMLElement|Text|string>} children
158 * @returns {HTMLElement}
159 */
160 /**
161 * @param {keyof HTMLElementTagNameMap} tag
162 * @param {...(HtmlAttrs|Array<HTMLElement|Text|string>|string)} args
163 * @returns {HTMLElement}
164 */
165 export function h(tag, ...args) {
166 let node = document.createElement(tag);
167
168 if (args.length === 0) {
169 return node;
170 } else if (args.length === 1) {
171 const attrs = args[0];
172 if (typeof attrs === "object" && Array.isArray(attrs)) {
173 // Children
174 setChildren(node, attrs);
175 return node;
176 } else if (typeof attrs === "object") {
177 // Attributes
178 setAttrs(node, attrs);
179 return node;
180 } else if (typeof attrs === "string") {
181 // Text node child
182 const textNode = document.createTextNode(attrs);
183 node.appendChild(textNode);
184 return node;
185 } else {
186 throw new TypeError(`Invalid second argument type: ${typeof attrs}`);
187 }
188 } else if (args.length === 2) {
189 // Second arg must be attributes, third arg must be children
190 const attrs = args[0];
191 const children = args[1];
192
193 if (typeof attrs !== "object" || Array.isArray(attrs)) {
194 throw new TypeError("Second argument must be an object");
195 }
196
197 setAttrs(node, attrs);
198
199 if (typeof children === "string") {
200 node.appendChild(t(children));
201 } else if (typeof children === "object" && Array.isArray(children)) {
202 setChildren(node, children);
203 } else {
204 throw new TypeError("Third argument must be an array or string");
205 }
206
207 return node;
208 } else {
209 throw new Error("Too many arguments");
210 }
211 }
212 function getById(id) {
213 let element = document.getElementById(id);
214 if (element) {
215 return element;
216 } else {
217 throw `Element with id '${id}' not found.`;
218 }
219 }
220
221 function writeError(error) {
222 writeLog(error, "error");
223 }
224
225 function writeInfo(msg) {
226 writeLog(msg, "info");
227 }
228
229 function writeDebug(msg) {
230 writeLog(msg, "debug");
231 }
232
233 /**
234 * Normalise anything writeLog is handed into children `h` accepts.
235 * @param {string|String|Node|Array<HTMLElement|Text|string>|Error|unknown} msg
236 * @returns {Array<HTMLElement|Text|string>}
237 */
238 function logChildren(msg) {
239 if (typeof msg === "string" || msg instanceof String) {
240 return [h("span", String(msg))];
241 }
242 if (msg instanceof Node) {
243 return [msg];
244 }
245 if (Array.isArray(msg)) {
246 return msg;
247 }
248 // Error, DOMException, or anything else: show its message.
249 const message = typeof msg?.message === "string" ? msg.message : String(msg);
250 return [h("span", message)];
251 }
252
253 function writeLog(msg, className) {
254 log.appendChild(h("div", {class: className}, logChildren(msg)));
255 }
256
257 function resetLog() {
258 log.replaceChildren();
259 }
260
261 const log = getById("log");
262
263 // https://stackoverflow.com/questions/75988682/debounce-in-javascript
264 // https://www.joshwcomeau.com/snippets/javascript/debounce/
265 function debounce(callback, wait) {
266 let timeoutId = null;
267 return (...args) => {
268 window.clearTimeout(timeoutId);
269 timeoutId = window.setTimeout(() => {
270 callback(...args);
271 }, wait);
272 };
273 }
274
275 export {
276 getById,
277 writeError,
278 writeInfo,
279 writeDebug,
280 resetLog,
281 debounce,
282 };