@fruit-ui/core 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,6 +51,7 @@ FRUIT's features include:
51
51
  - Keys to preserve state among re-ordered siblings
52
52
  - An on-mount listener and handler methods
53
53
  - Bindings to elements within components
54
+ - "Memo" options to make child components rerender conditionally
54
55
 
55
56
  with all special functional features (state, controlled rerendering, bindings) accessed through the `this` argument.
56
57
 
@@ -63,16 +64,15 @@ Smaller apps don't always warrant heavyweight frameworks, but interfacing with t
63
64
  ## Getting started
64
65
 
65
66
  There are three ways to use FRUIT in your projects:
66
- - Download and copy the [Terser-compressed JS file](https://github.com/asantagata/fruit-ui/blob/main/dist/index.js) file into your project. (This is a compressed version built with Terser; you can just as well use the [non-compressed version](https://github.com/asantagata/fruit-ui/blob/main/src/index.js).) Then you can use `import { create, replaceWith, appendChild, insertBefore } from "./modules/fruit.js"` or `<script type="module" src="./modules/fruit.js">` to access FRUIT in your JS apps.
67
- - Access via browser loading, i.e., `import { create, replaceWith, appendChild, insertBefore } from "https://cdn.jsdelivr.net/npm/@fruit-ui/core@latest/index.js"`.
68
- - With NPM installed, run `npm install @fruit-ui/core`. Then use `import { create, replaceWith, appendChild, insertBefore } from "@fruit-ui/core"`.
67
+ - Download and copy the [Terser-compressed JS file](https://github.com/asantagata/fruit-ui/blob/main/dist/index.js) file into your project. (This is a compressed version built with Terser; you can just as well use the [non-compressed version](https://github.com/asantagata/fruit-ui/blob/main/src/index.js) which uses JSDoc annotations.) Then you can use `import \* as fruit from "./modules/fruit.js"` or `<script type="module" src="./modules/fruit.js">` to access FRUIT in your JS apps.
68
+ - Access via browser loading, i.e., `import \* as fruit from "https://cdn.jsdelivr.net/npm/@fruit-ui/core@latest/src/index.js"`.
69
+ - With NPM installed, run `npm install @fruit-ui/core`. Then use `import \* as fruit from "@fruit-ui/core"`.
69
70
 
70
71
  ## Contributing
71
72
 
72
73
  Ongoing development on FRUIT focuses on:
73
74
  - Thorough, interactive, user-facing documentation (available [here](https://asantagata.github.io/fruit-ui/))
74
- - Benchmarks to compare against other JS frameworks
75
- - A "useMemo" equivalent
75
+ - Benchmarks against other JS frameworks
76
76
  - Basic built-in components
77
77
 
78
78
  This project's initial release is currently not yet finished, so contributions aren't currently being sought out.
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@fruit-ui/core",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "A vanilla JS toolkit for reactive UI",
5
- "main": "dist/index.js",
5
+ "main": "src/index.js",
6
+ "homepage": "https://asantagata.github.io/fruit-ui/",
6
7
  "scripts": {
7
8
  "test": "echo \"Error: no test specified\" && exit 1",
8
9
  "build": "terser .\\src\\index.js -o .\\dist\\index.js"
package/src/index.js ADDED
@@ -0,0 +1,771 @@
1
+ /**
2
+ * Non-string properties of Templates.
3
+ * @typedef {object} TemplateSpecialProperties
4
+ * @property {string | string[] | Object.<string, any>} [class] - The element's class.
5
+ * @property {CSSStyleDeclaration} [style] - The element's style.
6
+ * @property {Object.<string, Function>} [on] - The element's listeners (and onmount function.)
7
+ * Within these, `this` refers to the nearest component's This (if applicable)
8
+ * and `this.element` refers to the element.
9
+ * @property {Elementable | Elementable[]} [children] - The element's children.
10
+ * @property {Element} [cloneFrom] - The element to deep-clone from for this element.
11
+ * This overrides all other properties.
12
+ * @property {Object.<string, string>} [dataset] - The element's dataset.
13
+ * @property {string} [key] - The element's key, used to distinguish re-ordered
14
+ * siblings and preserve state.
15
+ */
16
+
17
+ /**
18
+ * A JavaScript object describing a non-reactive HTML element.
19
+ * All element properties not explicitly listed (e.g., href, src, title, etc.)
20
+ * can also be written here.
21
+ * @typedef {Object.<string, string> & TemplateSpecialProperties} Template
22
+ */
23
+
24
+ /**
25
+ * A function which produces a Template using a This.
26
+ * @typedef {(this: This) => Template} TemplateProducer
27
+ */
28
+
29
+ /**
30
+ * A function which produces a Template with an exposed This.
31
+ * @typedef {() => Template} BoundTemplateProducer
32
+ * @property {This} this - The bounded This.
33
+ * @property {ComponentID} componentId - The corresponding ComponentId.
34
+ */
35
+
36
+ /**
37
+ * Any representation of an Element.
38
+ * Primitives (number, boolean) are stringified automatically.
39
+ * @typedef {Component | Template | string | number | boolean} Elementable
40
+ */
41
+
42
+ /**
43
+ * An auxiliary This argument for component TemplateProducers and listeners.
44
+ * Note listeners also have the property `.target` describing that element.
45
+ * @typedef {object} This
46
+ * @property {HTMLElement} [element] - The component, post-mount.
47
+ * @property {() => void} [rerender] - The rerender function, post-mount.
48
+ * @property {BoundTemplateProducer} [producer] - The producer function, post-mount.
49
+ * @property {object} state - The component's state.
50
+ * @property {object} setState - Functions to set the component's state with automatic reactivity.
51
+ * @property {Object.<string, Binding>} bindings - The component's bindings.
52
+ * @property {object} [memo] - The component's memo, if given.
53
+ */
54
+
55
+ /**
56
+ * A child, grandchild etc. to a Component.
57
+ * @typedef {object} Binding
58
+ * @property {HTMLElement} element - The bounded element.
59
+ * @property {() => void} rerender - The bounded element's rerender function.
60
+ */
61
+
62
+ /**
63
+ * A component, which can be rerendered reactively.
64
+ * @typedef {object} Component
65
+ * @property {TemplateProducer} render - The main render function.
66
+ * @property {() => Object.<string, any>} [state] - The initializer for local state.
67
+ * @property {string} [key] - The element's key, used to distinguish re-ordered siblings and preserve state.
68
+ * @property {string} [binding] - The element's binding.
69
+ * @property {object} [memo] - The element's memoized props, used to control rerendering.
70
+ */
71
+
72
+ /**
73
+ * A function which takes in props and produces a Component.
74
+ * @typedef {(...args: any) => Component} ComponentProducer
75
+ */
76
+
77
+ /**
78
+ * A component's ID.
79
+ * @typedef {string} ComponentID
80
+ */
81
+
82
+ /** @type {number} */
83
+ let globalComponentCount = 0;
84
+
85
+ /** @type {Object.<ComponentID, This>} */
86
+ const thisRecord = {};
87
+
88
+ /** @type {Set<ComponentID>} */
89
+ const rerenderQueue = new Set();
90
+
91
+ /**
92
+ * Rerender the elements in the rerenderQueue.
93
+ */
94
+ function rerenderEnqueuedComponents() {
95
+ Array.from(rerenderQueue).forEach(cId => thisRecord[cId]?.rerender());
96
+ rerenderQueue.clear();
97
+ }
98
+
99
+ /**
100
+ * Enqueue a component for rerender.
101
+ * @param {ComponentID} componentId - the componentId.
102
+ */
103
+ function enqueueToRerender(componentId) {
104
+ const enqueueRerenderTask = rerenderQueue.size === 0;
105
+ rerenderQueue.add(componentId);
106
+ if (enqueueRerenderTask)
107
+ queueMicrotask(rerenderEnqueuedComponents);
108
+ }
109
+
110
+ /**
111
+ * Create a new This.
112
+ * @returns {This} the new This.
113
+ */
114
+ function createThis(component) {
115
+ return {
116
+ state: {},
117
+ setState: {},
118
+ bindings: {},
119
+ memo: component.memo
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Initialize an empty This.
125
+ * @param {This} this - the This.
126
+ * @param {HTMLElement} element - the HTMLElement to use as this.element.
127
+ * @param {BoundTemplateProducer} producer - the producer function for rerendering.
128
+ */
129
+ function initializeThis(element, producer) {
130
+ this.element = element;
131
+ this.producer = producer;
132
+ this.rerender = rerender.bind(this);
133
+ this.setState = new Proxy({}, {
134
+ get: (o, p, r) => {
135
+ return (x) => {
136
+ this.state[p] = x;
137
+ enqueueToRerender(producer.componentId);
138
+ }
139
+ }
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Creates an HTMLElement from a Template.
145
+ * @param {This | undefined} this - the nearest Component's This, if applicable.
146
+ * @param {Template} template - the Template.
147
+ * @param {Function[]} onMounts - the aggregated onMounts.
148
+ * @param {BoundTemplateProducer} [producer] - the nearest Component's producer.
149
+ * @returns {HTMLElement} - An HTMLElement.
150
+ */
151
+ function createElementFromTemplate(template, onMounts, producer = null) {
152
+ if (template.cloneFrom) {
153
+ const element = template.cloneFrom.cloneNode(true);
154
+ if (this && !this.element) {
155
+ initializeThis.call(this, element, producer);
156
+ }
157
+ return element;
158
+ }
159
+ const {tag, class: c, style, on, componentId, children, cloneFrom, dataset, key, binding, innerHTML, ...rest} = template;
160
+ const element = document.createElement(template.tag || 'div');
161
+ if (template.class) {
162
+ switch (typeof template.class) {
163
+ case 'string':
164
+ element.className = template.class;
165
+ break;
166
+ case 'object':
167
+ if (Array.isArray(template)) {
168
+ element.className = template.class.join(" ");
169
+ } else {
170
+ element.className = Object.keys(template.class)
171
+ .filter(c => template.class[c]).join(" ");
172
+ }
173
+ }
174
+ }
175
+ if (template.style) {
176
+ for (const k in template.style) {
177
+ element.style[k] = template.style[k];
178
+ }
179
+ }
180
+ if (template.on) {
181
+ const {mount: onMount, ...listeners} = template.on;
182
+ for (const type in listeners) {
183
+ if (!listeners[type]) continue;
184
+ element.addEventListener(type, (event) => listeners[type].call({...(this ?? {}), target: element}, event));
185
+ }
186
+ if (onMount) {
187
+ onMounts.push(() => onMount.call({...(this ?? {}), target: element}));
188
+ }
189
+ }
190
+ for (const attribute in rest) {
191
+ if (!template[attribute]) continue;
192
+ element.setAttribute(attribute, template[attribute]);
193
+ }
194
+ if (template.dataset) {
195
+ for (const k in template.dataset) {
196
+ element.dataset[k] = template.dataset[k];
197
+ }
198
+ }
199
+ if (template.componentId) {
200
+ element.dataset.componentId = template.componentId;
201
+ }
202
+ if (template.key) {
203
+ element.dataset.key = template.key;
204
+ }
205
+ if (template.binding) {
206
+ element.dataset.binding = template.binding;
207
+ }
208
+ if (this && !this.element) {
209
+ initializeThis.call(this, element, producer);
210
+ }
211
+ if (template.innerHTML) {
212
+ element.innerHTML = template.innerHTML;
213
+ } else if ('children' in template) {
214
+ if (Array.isArray(template.children)) {
215
+ element.replaceChildren(
216
+ ...template.children.map(ct => createElementFromElementable.call(this, ct, onMounts))
217
+ );
218
+ // handle bindings
219
+ for (let i = 0; i < template.children.length; i++) {
220
+ const childTm = template.children[i];
221
+ if (childTm.binding && this) {
222
+ setBinding.call(this, childTm.binding, element.childNodes[i]);
223
+ }
224
+ }
225
+ } else {
226
+ element.replaceChildren(createElementFromElementable.call(this, template.children, onMounts));
227
+ // handle binding
228
+ if (template.children.binding && this) {
229
+ setBinding.call(this, template.children.binding, element.childNodes[0]);
230
+ }
231
+ }
232
+ }
233
+ return element;
234
+ }
235
+
236
+ /**
237
+ * Binds a TemplateProducer to a new This.
238
+ * @param {TemplateProducer} producer - the TemplateProducer.
239
+ * @param {Component} component - the Component to which the TemplateProducer belongs.
240
+ * @returns {BoundTemplateProducer} - the BoundTemplateProducer.
241
+ */
242
+ function bindTemplateProducer(producer, component) {
243
+ const newThis = createThis(component);
244
+ const boundProducer = producer.bind(newThis);
245
+ boundProducer.this = newThis;
246
+ boundProducer.componentId = `component-${globalComponentCount++}`;
247
+ return boundProducer;
248
+ }
249
+
250
+ /**
251
+ * Determines whether an Elementable is a Component.
252
+ * @param {Elementable} elementable - the Elementable.
253
+ * @returns {boolean} - whether it is a Component.
254
+ */
255
+ function elementableIsComponent(elementable) {
256
+ return !!elementable.render;
257
+ }
258
+
259
+ /**
260
+ * Creates an HTMLElement or string from any Elementable.
261
+ * @param {This} this - the nearest Component's This.
262
+ * @param {Elementable} elementable - the Elementable.
263
+ * @param {Function[]} onMounts - the aggregated onMounts.
264
+ * @returns {HTMLElement | string} - the HTMLElement or string.
265
+ */
266
+ function createElementFromElementable(elementable, onMounts) {
267
+ if (typeof elementable === 'object') {
268
+ if (elementableIsComponent(elementable)) {
269
+ return createElementFromComponent(elementable, onMounts);
270
+ } else {
271
+ return createElementFromTemplate.call(this, elementable, onMounts);
272
+ }
273
+ } else {
274
+ return elementable.toString();
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Creates an HTMLElement from a Component.
280
+ * @param {Component} component - the Component.
281
+ * @param {Function[]} onMounts - the aggregated onMounts.
282
+ * @returns {HTMLElement} - An HTMLElement.
283
+ */
284
+ function createElementFromComponent(component, onMounts) {
285
+ const boundProducer = bindTemplateProducer(component.render, component);
286
+ thisRecord[boundProducer.componentId] = boundProducer.this;
287
+ boundProducer.this.state = component.state ? component.state() : {};
288
+ const template = boundProducer();
289
+ return createElementFromTemplate.call(boundProducer.this, giveTemplateComponentMetadata(template, boundProducer.componentId, component.key, component.binding), onMounts, boundProducer);
290
+ }
291
+
292
+ /**
293
+ * Returns a template imbued with component data.
294
+ * @param {Template} template - the Template.
295
+ * @param {ComponentID} componentId - the ComponentId.
296
+ * @param {Key} [key] - the key, if applicable.
297
+ * @param {string} [bindingName] - the name of the binding, if applicable.
298
+ * @returns {Template} - the imbued Template.
299
+ */
300
+ function giveTemplateComponentMetadata(template, componentId, key, bindingName) {
301
+ return {
302
+ ...template,
303
+ componentId,
304
+ key: key ?? template.key,
305
+ binding: bindingName ?? template.binding
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Wrap a function in onMounts handling.
311
+ * @param {(onMounts: Function[]) => void} func - the function.
312
+ */
313
+ function doOnMountHandling(func) {
314
+ const onMounts = [];
315
+ func(onMounts);
316
+ onMounts.forEach(om => om());
317
+ }
318
+
319
+ /**
320
+ * Rerenders a component using its This.
321
+ * @param {This} this - the This.
322
+ */
323
+ function rerender() {
324
+ doOnMountHandling((onMounts) => {
325
+ const template = this.producer();
326
+ rerenderElementFromTemplate.call(this, this.element, giveTemplateComponentMetadata(template, this.producer.componentId), onMounts);
327
+ });
328
+ }
329
+
330
+ /**
331
+ * Rerenders one of a component's bindings using its This.
332
+ * @param {This} this - the This.
333
+ * @param {string} bindingName - the binding name of the bounded element.
334
+ */
335
+ function rerenderByBinding(bindingName) {
336
+ const template = this.producer();
337
+ const subTemplate = findSubtemplate(template, bindingName);
338
+ const subElement = findSubelement(this.element, bindingName);
339
+ if (subTemplate && subElement) {
340
+ doOnMountHandling((onMounts) => {
341
+ if (elementableIsComponent(subTemplate)) {
342
+ if ('componentId' in subElement.dataset)
343
+ rerenderChildComponent(subElement, subTemplate, onMounts);
344
+ else subElement.replaceWith(createElementFromComponent(subTemplate, onMounts));
345
+ } else {
346
+ if ('componentId' in subElement.dataset) delete thisRecord[subElement.dataset.componentId];
347
+ rerenderElementFromTemplate.call(this, subElement, subTemplate, onMounts);
348
+ }
349
+ });
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Finds a child with a given binding name. Stops at components.
355
+ * @param {HTMLElement} element - the HTMLElement.
356
+ * @param {string} bindingName - the binding name to search for.
357
+ * @param {boolean} atRoot - whether this is the root. True by default.
358
+ */
359
+ function findSubelement(element, bindingName, atRoot = true) {
360
+ if (element.dataset.binding === bindingName && !atRoot) {
361
+ return element;
362
+ } else if (!atRoot && 'componentId' in element.dataset) {
363
+ return undefined;
364
+ }
365
+ return Array.from(element.children).find(c => !!findSubelement(c, bindingName, false));
366
+ }
367
+
368
+ /**
369
+ * Finds a subtree template by a binding name. Stops at components.
370
+ * Excludes the given Template (the root) from the search.
371
+ * @param {Template} template - the Template.
372
+ * @param {string} bindingName - the binding name.
373
+ * @param {boolean} [atRoot] - whether this is the root. True by default.
374
+ * @returns {Template | Component | undefined} the result.
375
+ */
376
+ function findSubtemplate(template, bindingName, atRoot = true) {
377
+ if (template.binding === bindingName && !atRoot) {
378
+ return template;
379
+ }
380
+ if (template.children) {
381
+ if (Array.isArray(template.children)) {
382
+ return template.children.find(c => findSubtemplate(c, bindingName, false));
383
+ } else {
384
+ return findSubtemplate(template.children, bindingName, false);
385
+ }
386
+ }
387
+ return undefined;
388
+ }
389
+
390
+ /**
391
+ * Rerenders an HTMLElement from a Template.
392
+ * @param {This} this - the nearest Component's This.
393
+ * @param {HTMLElement} element - the Element.
394
+ * @param {Template} template - the Template.
395
+ * @param {Function[]} onMounts - the aggregated onMounts.
396
+ */
397
+ function rerenderElementFromTemplate(element, template, onMounts) {
398
+ if (typeof template !== 'object') {
399
+ return element.replaceWith(template.toString());
400
+ }
401
+ if (template.cloneFrom && !element.isEqualNode(template.cloneFrom)) {
402
+ return element.replaceWith(template.cloneFrom.cloneNode(true));
403
+ }
404
+ if ((template.tag?.toUpperCase() || 'DIV') !== element.tagName) {
405
+ // tag cannot be changed
406
+ return element.replaceWith(createElementFromElementable.call(this, template, onMounts));
407
+ }
408
+ if (template.class) {
409
+ if (typeof template.class === 'string') {
410
+ element.className = template.class;
411
+ } else if (Array.isArray(template)) {
412
+ element.className = template.class.join(" ");
413
+ } else {
414
+ for (const key in template.class) {
415
+ if (template.class[key]) {
416
+ element.classList.add(key);
417
+ } else {
418
+ element.classList.remove(key);
419
+ }
420
+ }
421
+ }
422
+ }
423
+ if (template.style) {
424
+ for (let key in template.style) {
425
+ element.style[key] = template.style[key];
426
+ }
427
+ }
428
+ if (template.dataset) {
429
+ for (let key in template.dataset) {
430
+ element.dataset[key] = template.dataset[key];
431
+ }
432
+ }
433
+ const {tag, cloneFrom, class: _, style, on, key, dataset, componentId, children, innerHTML, binding, ...rest} = template;
434
+ for (const attribute in rest) {
435
+ element.setAttribute(attribute, template[attribute]);
436
+ }
437
+ if (template.innerHTML) {
438
+ element.innerHTML = template.innerHTML;
439
+ } else if (template.children === undefined || template.children.length === 0) {
440
+ element.innerHTML = "";
441
+ } else {
442
+ rerenderChildren.call(this, element, template, onMounts);
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Determines whether two values are deeply equal.
448
+ * @param {*} a
449
+ * @param {*} b
450
+ * @returns {boolean} whether they are equal.
451
+ */
452
+ function deepEqual(a, b) {
453
+ if (typeof a !== typeof b) return false;
454
+ if (typeof a === 'function') return true;
455
+ if (typeof a === 'object' && a !== null) {
456
+ if (typeof a[Symbol.iterator] === "function") { // is iterable
457
+ if (a.length === b.length && a.size === b.size) {
458
+ const itA = a[Symbol.iterator](), itB = b[Symbol.iterator]();
459
+ while (true) {
460
+ const nextA = itA.next(), nextB = itB.next();
461
+ if (nextA.done === nextB.done) {
462
+ if (!deepEqual(nextA.value, nextB.value)) return false;
463
+ if (nextA.done) return true;
464
+ } else return false;
465
+ }
466
+ } else return false;
467
+ } else {
468
+ const keysA = Object.keys(a), keysB = Object.keys(b);
469
+ if (keysA.length !== keysB.length) return false;
470
+ return keysB.every(keyB => keyB in a && deepEqual(keysA[keyB], keysB[keyB]));
471
+ }
472
+ } else return a === b;
473
+ }
474
+
475
+ /**
476
+ * Returns true iff a component has a memo (functional or otherwise) and it is satisfied.
477
+ * @param {This} this - the This.
478
+ * @param {Component} component - the Component.
479
+ * @returns {boolean} whether the memo exists and is satisfied.
480
+ */
481
+ function evaluateMemo(component) {
482
+ if (component.memo && this.memo) {
483
+ // for functional memos: rerender if returns false
484
+ if (typeof component.memo === 'function') return !!component.memo.call(this);
485
+ return deepEqual(component.memo, this.memo);
486
+ } else return false;
487
+ }
488
+
489
+ /**
490
+ * Rerenders a component HTMLElement from a Component, preserving state but updating the producer.
491
+ * @param {HTMLElement} element - the element.
492
+ * @param {Component} component - the Component.
493
+ * @param {Function[]} onMounts - the aggregated onMounts.
494
+ */
495
+ function rerenderChildComponent(element, component, onMounts) {
496
+ const cmpThis = thisRecord[element.dataset.componentId];
497
+ if (evaluateMemo.call(cmpThis, component)) {
498
+ return;
499
+ }
500
+ cmpThis.producer = component.render.bind(cmpThis);
501
+ rerenderElementFromTemplate.call(cmpThis, element, cmpThis.producer(), onMounts);
502
+ }
503
+
504
+ /**
505
+ * Re-creates a keyed child component with a known This (i.e., when changing order).
506
+ * @param {Component} component - the Component's new template.
507
+ * @param {ComponentID} componentId - the Component's old ComponentId.
508
+ * @param {Function[]} onMounts - the aggregated onMounts.
509
+ * @returns {HTMLElement} the re-created HTMLElement.
510
+ */
511
+ function recreateKeyedChildComponent(component, componentId, onMounts) {
512
+ const cmpThis = thisRecord[componentId];
513
+ if (evaluateMemo.call(cmpThis, component)) {
514
+ return cmpThis.element;
515
+ }
516
+ cmpThis.producer = component.render.bind(cmpThis);
517
+ const template = cmpThis.producer();
518
+ const element = createElementFromTemplate.call(cmpThis, giveTemplateComponentMetadata(template, componentId, component.key, component.binding), onMounts, cmpThis.producer);
519
+ cmpThis.element = element;
520
+ return element;
521
+ }
522
+
523
+ /**
524
+ * Rerender an element's children into a Template.
525
+ * @param {This} this - the nearest Component's This.
526
+ * @param {HTMLElement} element - the Element.
527
+ * @param {Template} template - the Template.
528
+ * @param {Function[]} onMounts - the aggregated onMounts.
529
+ */
530
+ function rerenderChildren(element, template, onMounts) {
531
+ const elChildrenArray = Array.from(element.children);
532
+ const tmChildNodeArray = Array.isArray(template.children) ? template.children : [template.children];
533
+ const tmChildrenArray = tmChildNodeArray.filter(c => typeof c === 'object');
534
+
535
+ // element nodes
536
+ if (elChildrenArray.length > 0 && tmChildrenArray.length > 0 && elChildrenArray.every(elChild => 'key' in elChild.dataset) && tmChildrenArray.every(tmChild => 'key' in tmChild)) {
537
+
538
+ // be smart with keys in here
539
+ const keyIndexInEl = {};
540
+ for (let i = 0; i < elChildrenArray.length; i++) {
541
+ keyIndexInEl[elChildrenArray[i].dataset.key] = i;
542
+ }
543
+
544
+ const keyIndexInTm = {};
545
+ for (let i = 0; i < tmChildrenArray.length; i++) {
546
+ keyIndexInTm[tmChildrenArray[i].key] = i;
547
+ }
548
+
549
+ const tmKeyIndexInEl = tmChildrenArray.map(k => keyIndexInEl[k.key] ?? -1);
550
+
551
+ const lis = LIS(tmKeyIndexInEl.filter(i => i > -1));
552
+
553
+ for (let i = 0; i < lis.length; i++) {
554
+ const elIndex = lis[i];
555
+ const childEl = elChildrenArray[elIndex];
556
+ const childTm = tmChildrenArray[keyIndexInTm[childEl.dataset.key]];
557
+
558
+ if (elementableIsComponent(childTm)) {
559
+ if ('componentId' in childEl.dataset) rerenderChildComponent(childEl, childTm, onMounts);
560
+ else childEl.replaceWith(createElementFromComponent(childTm, onMounts));
561
+ } else {
562
+ if ('componentId' in childEl.dataset) delete thisRecord[childEl.dataset.componentId];
563
+ rerenderElementFromTemplate.call(this, childEl, childTm, onMounts);
564
+ }
565
+ }
566
+
567
+ const setLis = new Set(lis);
568
+ const movedElKeyToComponentId = {};
569
+
570
+ for (let i = elChildrenArray.length - 1; i >= 0; i--) {
571
+ if (!setLis.has(i)) {
572
+ const childEl = elChildrenArray[i];
573
+ if ('componentId' in childEl.dataset) {
574
+ if (childEl.dataset.key in keyIndexInTm) {
575
+ movedElKeyToComponentId[childEl.dataset.key] = childEl.dataset.componentId;
576
+ } else {
577
+ delete thisRecord[childEl.dataset.componentId];
578
+ }
579
+ }
580
+ if ('binding' in childEl.dataset) delete this.bindings[childEl.dataset.binding];
581
+ childEl.remove();
582
+ }
583
+ }
584
+
585
+ for (let i = 0; i < tmChildrenArray.length; i++) {
586
+ const childTm = tmChildrenArray[i], childEl = element.children[i];
587
+ if (!childEl) {
588
+ const componentId = movedElKeyToComponentId[childTm.key];
589
+ if (componentId) {
590
+ element.appendChild(recreateKeyedChildComponent(childTm, componentId, onMounts));
591
+ } else {
592
+ element.appendChild(createElementFromElementable.call(this, childTm, onMounts));
593
+ }
594
+ } else {
595
+ if (childEl.dataset.key !== childTm.key) {
596
+ const componentId = movedElKeyToComponentId[childTm.key];
597
+ if (componentId) {
598
+ element.insertBefore(recreateKeyedChildComponent(childTm, componentId, onMounts), childEl);
599
+ } else {
600
+ element.insertBefore(createElementFromElementable.call(this, childTm, onMounts), childEl);
601
+ }
602
+ }
603
+ }
604
+ }
605
+ } else if (tmChildrenArray.length > 0 && elChildrenArray.length > 0) {
606
+ // handle first Min(M,N) elements
607
+ for (let i = 0; i < Math.min(elChildrenArray.length, tmChildrenArray.length); i++) {
608
+ let childEl = elChildrenArray[i], childTm = tmChildrenArray[i];
609
+ if ('binding' in childEl.dataset && childEl.dataset.binding !== childTm.binding)
610
+ delete this.bindings[childEl.dataset.binding];
611
+ if (elementableIsComponent(childTm)) {
612
+ if ('componentId' in childEl.dataset) rerenderChildComponent(childEl, childTm, onMounts);
613
+ else childEl.replaceWith(createElementFromComponent(childTm, onMounts));
614
+ } else {
615
+ if ('componentId' in childEl.dataset) delete thisRecord[childEl.dataset.componentId];
616
+ rerenderElementFromTemplate.call(this, childEl, childTm, onMounts);
617
+ }
618
+ }
619
+
620
+ // prune at or append to end
621
+ if (elChildrenArray.length < tmChildrenArray.length) {
622
+ for (let i = elChildrenArray.length; i < tmChildrenArray.length; i++) {
623
+ let childTm = tmChildrenArray[i];
624
+ const newChild = createElementFromElementable.call(this, childTm, onMounts);
625
+ element.appendChild(newChild);
626
+ }
627
+ } else if (elChildrenArray.length > tmChildrenArray.length) {
628
+ for (let i = elChildrenArray.length - 1; i >= tmChildrenArray.length; i--) {
629
+ let childEl = elChildrenArray[i];
630
+ if ('componentId' in childEl.dataset) delete thisRecord[childEl.dataset.componentId];
631
+ if ('binding' in childEl.dataset) delete this.bindings[childEl.dataset.binding];
632
+ childEl.remove();
633
+ }
634
+ }
635
+ }
636
+
637
+ // handle bindings
638
+ for (let i = 0; i < tmChildrenArray.length; i++) {
639
+ const childTm = tmChildrenArray[i];
640
+ if (childTm.binding) {
641
+ setBinding.call(this, childTm.binding, element.children[i]);
642
+ }
643
+ }
644
+
645
+ // handle text nodes
646
+ for (let i = 0; i < tmChildNodeArray.length; i++) {
647
+ if (i >= element.childNodes.length) {
648
+ element.appendChild(document.createTextNode(tmChildNodeArray[i]));
649
+ } else {
650
+ if (element.childNodes[i].nodeType === Node.TEXT_NODE) {
651
+ if (typeof tmChildNodeArray[i] !== 'object') {
652
+ element.childNodes[i].textContent = tmChildNodeArray[i];
653
+ } else {
654
+ while (element.childNodes[i].nodeType !== Node.ELEMENT_NODE) {
655
+ element.childNodes[i].remove();
656
+ }
657
+ }
658
+ } else {
659
+ if (typeof tmChildNodeArray[i] !== 'object') {
660
+ element.insertBefore(document.createTextNode(tmChildNodeArray[i]), element.childNodes[i]);
661
+ }
662
+ }
663
+ }
664
+ }
665
+ while (element.childNodes.length > tmChildNodeArray.length) {
666
+ element.lastChild.remove();
667
+ }
668
+ }
669
+
670
+ function LIS(arr) {
671
+ let N = arr.length;
672
+ let P = new Array(N).fill(0);
673
+ let M = new Array(N + 1).fill(0);
674
+ M[0] = -1;
675
+ let L = 0;
676
+ for (let i = 0; i < N; i++) {
677
+ let low = 1, high = L + 1;
678
+ while (low < high) {
679
+ let mid = low + ((high - low) >> 1);
680
+ if (arr[M[mid]] >= arr[i])
681
+ high = mid;
682
+ else
683
+ low = mid + 1;
684
+ }
685
+ let newL = low;
686
+ P[i] = M[newL - 1];
687
+ M[newL] = i;
688
+ if (newL > L) {
689
+ L = newL;
690
+ }
691
+ }
692
+ let S = new Array(L);
693
+ let k = M[L];
694
+ for (let j = L-1; j >= 0; j--) {
695
+ S[j] = arr[k];
696
+ k = P[k];
697
+ }
698
+ return S;
699
+ }
700
+
701
+ /**
702
+ * Sets a binding in a This.
703
+ * @param {This} this - the This.
704
+ * @param {string} bindingName - the name of the binding.
705
+ * @param {HTMLElement} element - the element being bound.
706
+ */
707
+ function setBinding(bindingName, element) {
708
+ this.bindings[bindingName] = {
709
+ element,
710
+ rerender: () => rerenderByBinding.call(this, bindingName)
711
+ };
712
+ }
713
+
714
+ /**
715
+ * Creates an HTMLElement given an Elementable.
716
+ * @param {Elementable} elementable - the Elementable.
717
+ * @param {boolean} [includeOnMounts] - an option to include the aggregated onMounts functions. False by default.
718
+ * @returns {HTMLElement | Node | {element: HTMLElement | Node, onMounts: Function[]}} the resulting
719
+ * HTMLElement or text-node, or an object containing the HTMLElement or text-node
720
+ * and the aggregated onMounts functions.
721
+ */
722
+ function create(elementable, includeOnMounts = false) {
723
+ const onMounts = [];
724
+ let element = createElementFromElementable(elementable, onMounts);
725
+ if (typeof element === 'string') element = document.createTextNode(element);
726
+ return includeOnMounts ? {element, onMounts} : element;
727
+ }
728
+
729
+ /**
730
+ * Replaces an HTMLElement with an Elementable. Handles onMounts.
731
+ * @param {HTMLElement} element - the HTMLElement.
732
+ * @param {Elementable} elementable - the Elementable.
733
+ */
734
+ function replaceWith(element, elementable) {
735
+ const {element: newElement, onMounts} = create(elementable, true);
736
+ element.replaceWith(newElement);
737
+ handleOnMounts(onMounts);
738
+ }
739
+
740
+ /**
741
+ * Appends an Elementable to an HTMLElement. Handles onMounts.
742
+ * @param {HTMLElement} element - the HTMLElement.
743
+ * @param {Elementable} elementable - the Elementable.
744
+ */
745
+ function appendChild(element, elementable) {
746
+ const {element: newElement, onMounts} = create(elementable, true);
747
+ element.appendChild(newElement);
748
+ handleOnMounts(onMounts);
749
+ }
750
+
751
+ /**
752
+ * Inserts an Elementable before an HTMLElement. Handles onMounts.
753
+ * @param {HTMLElement} parentElement - the parent HTMLElement.
754
+ * @param {HTMLElement | null} nextSiblingElement - the next sibling HTMLElement, or null to append.
755
+ * @param {Elementable} elementable - the Elementable.
756
+ */
757
+ function insertBefore(parentElement, nextSiblingElement, elementable) {
758
+ const {element: newElement, onMounts} = create(elementable, true);
759
+ parentElement.insertBefore(newElement, nextSiblingElement);
760
+ handleOnMounts(onMounts);
761
+ }
762
+
763
+ /**
764
+ * Handles onMounts functions returned by `create` with `includeOnMounts = true`.
765
+ * @param {Function[]} onMounts
766
+ */
767
+ function handleOnMounts(onMounts) {
768
+ onMounts.forEach(om => om());
769
+ }
770
+
771
+ export { create, replaceWith, appendChild, insertBefore, handleOnMounts };
package/dist/index.js DELETED
@@ -1 +0,0 @@
1
- let globalComponentCount=0;const thisRecord={};const rerenderQueue=new Set;function rerenderEnqueuedComponents(){Array.from(rerenderQueue).forEach(cId=>thisRecord[cId]?.rerender());rerenderQueue.clear()}function enqueueToRerender(componentId){const enqueueRerenderTask=rerenderQueue.size===0;rerenderQueue.add(componentId);if(enqueueRerenderTask)queueMicrotask(rerenderEnqueuedComponents)}function createThis(){return{state:{},setState:{},bindings:{}}}function initializeThis(element,producer){this.element=element;this.producer=producer;this.rerender=rerender.bind(this);this.setState=new Proxy({},{get:(o,p,r)=>x=>{this.state[p]=x;enqueueToRerender(producer.componentId)}})}function createElementFromTemplate(template,onMounts,producer=null){if(template.cloneFrom){const element=template.cloneFrom.cloneNode(true);if(this&&!this.element){initializeThis.call(this,element,producer)}return element}const{tag:tag,class:c,style:style,on:on,componentId:componentId,children:children,cloneFrom:cloneFrom,dataset:dataset,key:key,binding:binding,innerHTML:innerHTML,...rest}=template;const element=document.createElement(template.tag||"div");if(template.class){switch(typeof template.class){case"string":element.className=template.class;break;case"object":if(Array.isArray(template)){element.className=template.class.join(" ")}else{element.className=Object.keys(template.class).filter(c=>template.class[c]).join(" ")}}}if(template.style){for(const k in template.style){element.style[k]=template.style[k]}}if(template.on){const{mount:onMount,...listeners}=template.on;for(const type in listeners){if(!listeners[type])continue;element.addEventListener(type,event=>listeners[type].call({...this??{},target:element},event))}if(onMount){onMounts.push(()=>onMount.call({...this??{},target:element}))}}for(const attribute in rest){if(!template[attribute])continue;element.setAttribute(attribute,template[attribute])}if(template.dataset){for(const k in template.dataset){element.dataset[k]=template.dataset[k]}}if(template.componentId){element.dataset.componentId=template.componentId}if(template.key){element.dataset.key=template.key}if(template.binding){element.dataset.binding=template.binding}if(this&&!this.element){initializeThis.call(this,element,producer)}if(template.innerHTML){element.innerHTML=template.innerHTML}else if("children"in template){if(Array.isArray(template.children)){element.replaceChildren(...template.children.map(ct=>createElementFromElementable.call(this,ct,onMounts)));for(let i=0;i<template.children.length;i++){const childTm=template.children[i];if(childTm.binding&&this){setBinding.call(this,childTm.binding,element.childNodes[i])}}}else{element.replaceChildren(createElementFromElementable.call(this,template.children,onMounts));if(template.children.binding&&this){setBinding.call(this,template.children.binding,element.childNodes[0])}}}return element}function bindTemplateProducer(producer){const newThis=createThis();const boundProducer=producer.bind(newThis);boundProducer.this=newThis;boundProducer.componentId=`component-${globalComponentCount++}`;return boundProducer}function elementableIsComponent(elementable){return!!elementable.render}function createElementFromElementable(elementable,onMounts){if(typeof elementable==="object"){if(elementableIsComponent(elementable)){return createElementFromComponent(elementable,onMounts)}else{return createElementFromTemplate.call(this,elementable,onMounts)}}else{return elementable.toString()}}function createElementFromComponent(component,onMounts){const boundProducer=bindTemplateProducer(component.render);thisRecord[boundProducer.componentId]=boundProducer.this;boundProducer.this.state=component.state?component.state():{};const template=boundProducer();return createElementFromTemplate.call(boundProducer.this,giveTemplateComponentMetadata(template,boundProducer.componentId,component.key,component.binding),onMounts,boundProducer)}function giveTemplateComponentMetadata(template,componentId,key,bindingName){return{...template,componentId:componentId,key:key??template.key,binding:bindingName??template.binding}}function doOnMountHandling(func){const onMounts=[];func(onMounts);onMounts.forEach(om=>om())}function rerender(){doOnMountHandling(onMounts=>{const template=this.producer();rerenderElementFromTemplate.call(this,this.element,giveTemplateComponentMetadata(template,this.producer.componentId),onMounts)})}function rerenderByBinding(bindingName){const template=this.producer();const subTemplate=findSubtemplate(template,bindingName);const subElement=findSubelement(this.element,bindingName);if(subTemplate&&subElement){doOnMountHandling(onMounts=>{if(elementableIsComponent(subTemplate)){if("componentId"in subElement.dataset)rerenderChildComponent(subElement,subTemplate,onMounts);else subElement.replaceWith(createElementFromComponent(subTemplate,onMounts))}else{if("componentId"in subElement.dataset)delete thisRecord[subElement.dataset.componentId];rerenderElementFromTemplate.call(this,subElement,subTemplate,onMounts)}})}}function findSubelement(element,bindingName,atRoot=true){if(element.dataset.binding===bindingName&&!atRoot){return element}else if(!atRoot&&"componentId"in element.dataset){return undefined}return Array.from(element.children).find(c=>!!findSubelement(c,bindingName,false))}function findSubtemplate(template,bindingName,atRoot=true){if(template.binding===bindingName&&!atRoot){return template}if(template.children){if(Array.isArray(template.children)){return template.children.find(c=>findSubtemplate(c,bindingName,false))}else{return findSubtemplate(template.children,bindingName,false)}}return undefined}function rerenderElementFromTemplate(element,template,onMounts){if(typeof template!=="object"){return element.replaceWith(template.toString())}if(template.cloneFrom&&!element.isEqualNode(template.cloneFrom)){return element.replaceWith(template.cloneFrom.cloneNode(true))}if((template.tag?.toUpperCase()||"DIV")!==element.tagName){return element.replaceWith(createElementFromElementable.call(this,template,onMounts))}if(template.class){if(typeof template.class==="string"){element.className=template.class}else if(Array.isArray(template)){element.className=template.class.join(" ")}else{for(const key in template.class){if(template.class[key]){element.classList.add(key)}else{element.classList.remove(key)}}}}if(template.style){for(let key in template.style){element.style[key]=template.style[key]}}if(template.dataset){for(let key in template.dataset){element.dataset[key]=template.dataset[key]}}const{tag:tag,cloneFrom:cloneFrom,class:_,style:style,on:on,key:key,dataset:dataset,componentId:componentId,children:children,innerHTML:innerHTML,binding:binding,...rest}=template;for(const attribute in rest){element.setAttribute(attribute,template[attribute])}if(template.innerHTML){element.innerHTML=template.innerHTML}else if(template.children===undefined||template.children.length===0){element.innerHTML=""}else{rerenderChildren.call(this,element,template,onMounts)}}function rerenderChildComponent(element,component,onMounts){const cmpThis=thisRecord[element.dataset.componentId];cmpThis.producer=component.render.bind(cmpThis);rerenderElementFromTemplate.call(cmpThis,element,cmpThis.producer(),onMounts)}function recreateKeyedChildComponent(component,componentId,onMounts){const cmpThis=thisRecord[componentId];cmpThis.producer=component.render.bind(cmpThis);const template=cmpThis.producer();const element=createElementFromTemplate.call(cmpThis,giveTemplateComponentMetadata(template,componentId,component.key,component.binding),onMounts,cmpThis.producer);cmpThis.element=element;return element}function rerenderChildren(element,template,onMounts){const elChildrenArray=Array.from(element.children);const tmChildNodeArray=Array.isArray(template.children)?template.children:[template.children];const tmChildrenArray=tmChildNodeArray.filter(c=>typeof c==="object");if(elChildrenArray.length>0&&tmChildrenArray.length>0&&elChildrenArray.every(elChild=>"key"in elChild.dataset)&&tmChildrenArray.every(tmChild=>"key"in tmChild)){const keyIndexInEl={};for(let i=0;i<elChildrenArray.length;i++){keyIndexInEl[elChildrenArray[i].dataset.key]=i}const keyIndexInTm={};for(let i=0;i<tmChildrenArray.length;i++){keyIndexInTm[tmChildrenArray[i].key]=i}const tmKeyIndexInEl=tmChildrenArray.map(k=>keyIndexInEl[k.key]??-1);const lis=LIS(tmKeyIndexInEl.filter(i=>i>-1));for(let i=0;i<lis.length;i++){const elIndex=lis[i];const childEl=elChildrenArray[elIndex];const childTm=tmChildrenArray[keyIndexInTm[childEl.dataset.key]];if(elementableIsComponent(childTm)){if("componentId"in childEl.dataset)rerenderChildComponent(childEl,childTm,onMounts);else childEl.replaceWith(createElementFromComponent(childTm,onMounts))}else{if("componentId"in childEl.dataset)delete thisRecord[childEl.dataset.componentId];rerenderElementFromTemplate.call(this,childEl,childTm,onMounts)}}const setLis=new Set(lis);const movedElKeyToComponentId={};for(let i=elChildrenArray.length-1;i>=0;i--){if(!setLis.has(i)){const childEl=elChildrenArray[i];if("componentId"in childEl.dataset){if(childEl.dataset.key in keyIndexInTm){movedElKeyToComponentId[childEl.dataset.key]=childEl.dataset.componentId}else{delete thisRecord[childEl.dataset.componentId]}}if("binding"in childEl.dataset)delete this.bindings[childEl.dataset.binding];childEl.remove()}}for(let i=0;i<tmChildrenArray.length;i++){const childTm=tmChildrenArray[i],childEl=element.children[i];if(!childEl){const componentId=movedElKeyToComponentId[childTm.key];if(componentId){element.appendChild(recreateKeyedChildComponent(childTm,componentId,onMounts))}else{element.appendChild(createElementFromElementable.call(this,childTm,onMounts))}}else{if(childEl.dataset.key!==childTm.key){const componentId=movedElKeyToComponentId[childTm.key];if(componentId){element.insertBefore(recreateKeyedChildComponent(childTm,componentId,onMounts),childEl)}else{element.insertBefore(createElementFromElementable.call(this,childTm,onMounts),childEl)}}}}}else if(tmChildrenArray.length>0&&elChildrenArray.length>0){for(let i=0;i<Math.min(elChildrenArray.length,tmChildrenArray.length);i++){let childEl=elChildrenArray[i],childTm=tmChildrenArray[i];if("binding"in childEl.dataset&&childEl.dataset.binding!==childTm.binding)delete this.bindings[childEl.dataset.binding];if(elementableIsComponent(childTm)){if("componentId"in childEl.dataset)rerenderChildComponent(childEl,childTm,onMounts);else childEl.replaceWith(createElementFromComponent(childTm,onMounts))}else{if("componentId"in childEl.dataset)delete thisRecord[childEl.dataset.componentId];rerenderElementFromTemplate.call(this,childEl,childTm,onMounts)}}if(elChildrenArray.length<tmChildrenArray.length){for(let i=elChildrenArray.length;i<tmChildrenArray.length;i++){let childTm=tmChildrenArray[i];const newChild=createElementFromElementable.call(this,childTm,onMounts);element.appendChild(newChild)}}else if(elChildrenArray.length>tmChildrenArray.length){for(let i=elChildrenArray.length-1;i>=tmChildrenArray.length;i--){let childEl=elChildrenArray[i];if("componentId"in childEl.dataset)delete thisRecord[childEl.dataset.componentId];if("binding"in childEl.dataset)delete this.bindings[childEl.dataset.binding];childEl.remove()}}}for(let i=0;i<tmChildrenArray.length;i++){const childTm=tmChildrenArray[i];if(childTm.binding){setBinding.call(this,childTm.binding,element.children[i])}}for(let i=0;i<tmChildNodeArray.length;i++){if(i>=element.childNodes.length){element.appendChild(document.createTextNode(tmChildNodeArray[i]))}else{if(element.childNodes[i].nodeType===Node.TEXT_NODE){if(typeof tmChildNodeArray[i]!=="object"){element.childNodes[i].textContent=tmChildNodeArray[i]}else{while(element.childNodes[i].nodeType!==Node.ELEMENT_NODE){element.childNodes[i].remove()}}}else{if(typeof tmChildNodeArray[i]!=="object"){element.insertBefore(document.createTextNode(tmChildNodeArray[i]),element.childNodes[i])}}}}while(element.childNodes.length>tmChildNodeArray.length){element.lastChild.remove()}}function LIS(arr){let N=arr.length;let P=new Array(N).fill(0);let M=new Array(N+1).fill(0);M[0]=-1;let L=0;for(let i=0;i<N;i++){let low=1,high=L+1;while(low<high){let mid=low+(high-low>>1);if(arr[M[mid]]>=arr[i])high=mid;else low=mid+1}let newL=low;P[i]=M[newL-1];M[newL]=i;if(newL>L){L=newL}}let S=new Array(L);let k=M[L];for(let j=L-1;j>=0;j--){S[j]=arr[k];k=P[k]}return S}function setBinding(bindingName,element){this.bindings[bindingName]={element:element,rerender:()=>rerenderByBinding.call(this,bindingName)}}function create(elementable,includeOnMounts=false){const onMounts=[];let element=createElementFromElementable(elementable,onMounts);if(typeof element==="string")element=document.createTextNode(element);return includeOnMounts?{element:element,onMounts:onMounts}:element}function replaceWith(element,elementable){const{element:newElement,onMounts:onMounts}=create(elementable,true);element.replaceWith(newElement);onMounts.forEach(om=>om())}function appendChild(element,elementable){const{element:newElement,onMounts:onMounts}=create(elementable,true);element.appendChild(newElement);onMounts.forEach(om=>om())}function insertBefore(parentElement,nextSiblingElement,elementable){const{element:newElement,onMounts:onMounts}=create(elementable,true);parentElement.insertBefore(newElement,nextSiblingElement);onMounts.forEach(om=>om())}export{create,replaceWith,appendChild,insertBefore};