aberdeen 0.2.4 → 0.4.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/dist/aberdeen.js CHANGED
@@ -1,8 +1,18 @@
1
- let queueArray = [];
2
- let queueSet = new Set();
3
- let queueOrdered = true;
4
- let runQueueDepth = 0;
5
- let queueIndex;
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ let queueArray = []; // When not empty, a runQueue is scheduled or currently running.
11
+ let queueIndex = 0; // This first element in queueArray that still needs to be processed.
12
+ let queueSet = new Set(); // Contains the subset of queueArray at index >= queueIndex.
13
+ let queueOrdered = true; // Set to `false` when `queue()` appends a runner to `queueArray` that should come before the previous last item in the array. Will trigger a sort.
14
+ let runQueueDepth = 0; // Incremented when a queue event causes another queue event to be added. Reset when queue is empty. Throw when >= 42 to break (infinite) recursion.
15
+ let showCreateTransitions = false; // Set to `true` only when creating top level elements in response to `Store` changes, triggering `create` transitions.
6
16
  function queue(runner) {
7
17
  if (queueSet.has(runner))
8
18
  return;
@@ -18,9 +28,18 @@ function queue(runner) {
18
28
  queueArray.push(runner);
19
29
  queueSet.add(runner);
20
30
  }
21
- function runQueue() {
22
- onCreateEnabled = true;
23
- for (queueIndex = 0; queueIndex < queueArray.length;) {
31
+ /**
32
+ * Normally, changes to `Store`s are reacted to asynchronously, in an (optimized)
33
+ * batch, after a timeout of 0s. Calling `runQueue()` will do so immediately
34
+ * and synchronously. Doing so may be helpful in cases where you need some DOM
35
+ * modification to be done synchronously.
36
+ *
37
+ * This function is re-entrant, meaning it is safe to call `runQueue` from a
38
+ * function that is called due to another (automatic) invocation of `runQueue`.
39
+ */
40
+ export function runQueue() {
41
+ showCreateTransitions = true;
42
+ for (; queueIndex < queueArray.length;) {
24
43
  // Sort queue if new unordered items have been added since last time.
25
44
  if (!queueOrdered) {
26
45
  queueArray.splice(0, queueIndex);
@@ -31,8 +50,8 @@ function runQueue() {
31
50
  }
32
51
  // Process the rest of what's currently in the queue.
33
52
  let batchEndIndex = queueArray.length;
34
- for (; queueIndex < batchEndIndex && queueOrdered; queueIndex++) {
35
- let runner = queueArray[queueIndex];
53
+ while (queueIndex < batchEndIndex && queueOrdered) {
54
+ let runner = queueArray[queueIndex++];
36
55
  queueSet.delete(runner);
37
56
  runner._queueRun();
38
57
  }
@@ -40,17 +59,17 @@ function runQueue() {
40
59
  // batch, we'll need to run this loop again.
41
60
  runQueueDepth++;
42
61
  }
62
+ queueIndex = 0;
43
63
  queueArray.length = 0;
44
- queueIndex = undefined;
45
64
  runQueueDepth = 0;
46
- onCreateEnabled = false;
65
+ showCreateTransitions = false;
47
66
  }
67
+ let domWaiters = [];
68
+ let domInReadPhase = false;
48
69
  /**
49
- * Schedule a DOM read operation to be executed in Aberdeen's internal task queue.
50
- *
51
- * This function is used to batch DOM read operations together, avoiding unnecessary
52
- * layout recalculations and improving browser performance. A DOM read operation should
53
- * only *read* from the DOM, such as measuring element dimensions or retrieving computed styles.
70
+ * A promise-like object that you can `await`. It will resolve *after* the current batch
71
+ * of DOM-write operations has completed. This is the best time to retrieve DOM properties
72
+ * that dependent on a layout being completed, such as `offsetHeight`.
54
73
  *
55
74
  * By batching DOM reads separately from DOM writes, this prevents the browser from
56
75
  * interleaving layout reads and writes, which can force additional layout recalculations.
@@ -60,18 +79,25 @@ function runQueue() {
60
79
  * Unlike `setTimeout` or `requestAnimationFrame`, this mechanism ensures that DOM read
61
80
  * operations happen before any DOM writes in the same queue cycle, minimizing layout thrashing.
62
81
  *
63
- * @param func The function to be executed as a DOM read operation.
82
+ * See `transitions.js` for some examples.
64
83
  */
65
- export function scheduleDomReader(func) {
66
- let order = (queueIndex != null && queueIndex < queueArray.length && queueArray[queueIndex]._queueOrder >= 1000) ? ((queueArray[queueIndex]._queueOrder + 1) & (~1)) : 1000;
67
- queue({ _queueOrder: order, _queueRun: func });
68
- }
84
+ export const DOM_READ_PHASE = {
85
+ then: function (fulfilled) {
86
+ if (domInReadPhase)
87
+ fulfilled();
88
+ else {
89
+ if (!domWaiters.length)
90
+ queue(DOM_PHASE_RUNNER);
91
+ domWaiters.push(fulfilled);
92
+ }
93
+ return this;
94
+ }
95
+ };
69
96
  /**
70
- * Schedule a DOM write operation to be executed in Aberdeen's internal task queue.
71
- *
72
- * This function is used to batch DOM write operations together, avoiding unnecessary
73
- * layout recalculations and improving browser performance. A DOM write operation should
74
- * only *write* to the DOM, such as modifying element properties or applying styles.
97
+ * A promise-like object that you can `await`. It will resolve *after* the current
98
+ * DOM_READ_PHASE has completed (if any) and after any DOM triggered by Aberdeen
99
+ * have completed. This is a good time to do little manual DOM tweaks that depend
100
+ * on a *read phase* first, like triggering transitions.
75
101
  *
76
102
  * By batching DOM writes separately from DOM reads, this prevents the browser from
77
103
  * interleaving layout reads and writes, which can force additional layout recalculations.
@@ -81,12 +107,36 @@ export function scheduleDomReader(func) {
81
107
  * Unlike `setTimeout` or `requestAnimationFrame`, this mechanism ensures that DOM write
82
108
  * operations happen after all DOM reads in the same queue cycle, minimizing layout thrashing.
83
109
  *
84
- * @param func The function to be executed as a DOM write operation.
110
+ * See `transitions.js` for some examples.
85
111
  */
86
- export function scheduleDomWriter(func) {
87
- let order = (queueIndex != null && queueIndex < queueArray.length && queueArray[queueIndex]._queueOrder >= 1000) ? (queueArray[queueIndex]._queueOrder | 1) : 1001;
88
- queue({ _queueOrder: order, _queueRun: func });
89
- }
112
+ export const DOM_WRITE_PHASE = {
113
+ then: function (fulfilled) {
114
+ if (!domInReadPhase)
115
+ fulfilled();
116
+ else {
117
+ if (!domWaiters.length)
118
+ queue(DOM_PHASE_RUNNER);
119
+ domWaiters.push(fulfilled);
120
+ }
121
+ return this;
122
+ }
123
+ };
124
+ const DOM_PHASE_RUNNER = {
125
+ _queueOrder: 99999,
126
+ _queueRun: function () {
127
+ let waiters = domWaiters;
128
+ domWaiters = [];
129
+ domInReadPhase = !domInReadPhase;
130
+ for (let waiter of waiters) {
131
+ try {
132
+ waiter();
133
+ }
134
+ catch (e) {
135
+ console.error(e);
136
+ }
137
+ }
138
+ }
139
+ };
90
140
  /**
91
141
  * Given an integer number, a string or an array of these, this function returns a string that can be used
92
142
  * to compare items in a natural sorting order. So `[3, 'ab']` should be smaller than `[3, 'ac']`.
@@ -136,16 +186,21 @@ function numToString(num, neg) {
136
186
  * and the `clean` functions for the scope and all sub-scopes are called.
137
187
  */
138
188
  class Scope {
139
- constructor(parentElement, precedingSibling, queueOrder) {
189
+ constructor(_parentElement,
190
+ // The node or scope right before this scope that has the same `parentElement`
191
+ _precedingSibling,
192
+ // How deep is this scope nested in other scopes; we use this to make sure events
193
+ // at lower depths are handled before events at higher depths.
194
+ _queueOrder) {
195
+ this._parentElement = _parentElement;
196
+ this._precedingSibling = _precedingSibling;
197
+ this._queueOrder = _queueOrder;
140
198
  // The list of clean functions to be called when this scope is cleaned. These can
141
199
  // be for child scopes, subscriptions as well as `clean(..)` hooks.
142
200
  this._cleaners = [];
143
201
  // Set to true after the scope has been cleaned, causing any spurious reruns to
144
202
  // be ignored.
145
203
  this._isDead = false;
146
- this._parentElement = parentElement;
147
- this._precedingSibling = precedingSibling;
148
- this._queueOrder = queueOrder;
149
204
  }
150
205
  // Get a reference to the last Node preceding this Scope, or undefined if there is none
151
206
  _findPrecedingNode(stopAt = undefined) {
@@ -170,8 +225,6 @@ class Scope {
170
225
  }
171
226
  }
172
227
  _addNode(node) {
173
- if (!this._parentElement)
174
- throw new ScopeError(true);
175
228
  let prevNode = this._findLastNode() || this._findPrecedingNode();
176
229
  this._parentElement.insertBefore(node, prevNode ? prevNode.nextSibling : this._parentElement.firstChild);
177
230
  this._lastChild = node;
@@ -230,8 +283,15 @@ class Scope {
230
283
  class SimpleScope extends Scope {
231
284
  constructor(parentElement, precedingSibling, queueOrder, renderer) {
232
285
  super(parentElement, precedingSibling, queueOrder);
233
- this._renderer = renderer;
286
+ if (renderer)
287
+ this._renderer = renderer;
234
288
  }
289
+ /* c8 ignore start */
290
+ _renderer() {
291
+ // Should be overriden by a subclass or the constructor
292
+ internalError(14);
293
+ }
294
+ /* c8 ignore stop */
235
295
  _queueRun() {
236
296
  /* c8 ignore next */
237
297
  if (currentScope)
@@ -250,10 +310,38 @@ class SimpleScope extends Scope {
250
310
  }
251
311
  catch (e) {
252
312
  // Throw the error async, so the rest of the rendering can continue
253
- handleError(e);
313
+ handleError(e, true);
254
314
  }
255
315
  currentScope = savedScope;
256
316
  }
317
+ _install() {
318
+ if (showCreateTransitions) {
319
+ showCreateTransitions = false;
320
+ this._update();
321
+ showCreateTransitions = true;
322
+ }
323
+ else {
324
+ this._update();
325
+ }
326
+ // Add it to our list of cleaners. Even if `childScope` currently has
327
+ // no cleaners, it may get them in a future refresh.
328
+ currentScope._cleaners.push(this);
329
+ }
330
+ }
331
+ /**
332
+ * This could have been done with a SimpleScope, but then we'd have to draw along an instance of
333
+ * that as well as a renderer function that closes over quite a few variables, which probably
334
+ * wouldn't be great for the performance of this common feature.
335
+ */
336
+ class SetArgScope extends SimpleScope {
337
+ constructor(parentElement, precedingSibling, queueOrder, _key, _value) {
338
+ super(parentElement, precedingSibling, queueOrder);
339
+ this._key = _key;
340
+ this._value = _value;
341
+ }
342
+ _renderer() {
343
+ applyArg(this._parentElement, this._key, this._value.get());
344
+ }
257
345
  }
258
346
  let immediateQueue = new Set();
259
347
  class ImmediateScope extends SimpleScope {
@@ -494,7 +582,7 @@ class OnEachItemScope extends Scope {
494
582
  sortKey = this._parent._makeSortKey(itemStore);
495
583
  }
496
584
  catch (e) {
497
- handleError(e);
585
+ handleError(e, false);
498
586
  }
499
587
  let oldSortStr = this._sortStr;
500
588
  let newSortStr = sortKey == null ? '' : sortKeyToString(sortKey);
@@ -510,7 +598,7 @@ class OnEachItemScope extends Scope {
510
598
  this._parent._renderer(itemStore);
511
599
  }
512
600
  catch (e) {
513
- handleError(e);
601
+ handleError(e, true);
514
602
  }
515
603
  }
516
604
  currentScope = savedScope;
@@ -518,7 +606,7 @@ class OnEachItemScope extends Scope {
518
606
  }
519
607
  /**
520
608
  * This global is set during the execution of a `Scope.render`. It is used by
521
- * functions like `node`, `text` and `clean`.
609
+ * functions like `$` and `clean`.
522
610
  */
523
611
  let currentScope;
524
612
  /**
@@ -760,33 +848,52 @@ class ObsObject extends ObsMap {
760
848
  return cnt;
761
849
  }
762
850
  }
763
- /**
764
- * A data store that automatically subscribes the current scope to updates
765
- * whenever data is read from it.
766
- *
767
- * Supported data types are: `string`, `number`, `boolean`, `undefined`, `null`,
768
- * `Array`, `object` and `Map`. The latter three will always have `Store` objects as
769
- * values, creating a tree of `Store`-objects.
770
- */
851
+ const DETACHED_KEY = {};
771
852
  export class Store {
853
+ /** @internal */
772
854
  constructor(value = undefined, index = undefined) {
855
+ /**
856
+ * Create and return a new `Store` that represents the subtree at `path` of
857
+ * the current `Store`.
858
+ *
859
+ * The `path` is only actually resolved when this new `Store` is first used,
860
+ * and how this is done depends on whether a read or a write operation is
861
+ * performed. Read operations will just use an `undefined` value when a
862
+ * subtree that we're diving into does not exist. Also, they'll subscribe
863
+ * to changes at each level of the tree indexed by `path`.
864
+ *
865
+ * Write operations will create any missing subtrees as objects. They don't
866
+ * subscribe to changes (as they are the ones causing the changes).
867
+ *
868
+ * Both read and write operations will throw an error if, while resolving
869
+ * `path`, they encounters a non-collection data type (such as a number)
870
+ */
871
+ const ref = function (...path) {
872
+ const result = new Store(ref._collection, ref._idx);
873
+ if (path.length || ref._virtual) {
874
+ result._virtual = ref._virtual ? ref._virtual.concat(path) : path;
875
+ }
876
+ return result;
877
+ };
878
+ Object.setPrototypeOf(ref, Store.prototype);
773
879
  if (index === undefined) {
774
- this._collection = new ObsArray();
775
- this._idx = 0;
880
+ ref._collection = new ObsArray();
881
+ ref._idx = 0;
776
882
  if (value !== undefined) {
777
- this._collection.rawSet(0, valueToData(value));
883
+ ref._collection.rawSet(0, valueToData(value));
778
884
  }
779
885
  }
780
886
  else {
781
887
  if (!(value instanceof ObsCollection)) {
782
888
  throw new Error("1st parameter should be an ObsCollection if the 2nd is also given");
783
889
  }
784
- this._collection = value;
785
- this._idx = index;
890
+ ref._collection = value;
891
+ ref._idx = index;
786
892
  }
893
+ // @ts-ignore
894
+ return ref;
787
895
  }
788
896
  /**
789
- *
790
897
  * @returns The index for this Store within its parent collection. This will be a `number`
791
898
  * when the parent collection is an array, a `string` when it's an object, or any data type
792
899
  * when it's a `Map`.
@@ -795,8 +902,8 @@ export class Store {
795
902
  * ```
796
903
  * let store = new Store({x: 123})
797
904
  * let subStore = store.ref('x')
798
- * assert(subStore.get() === 123)
799
- * assert(subStore.index() === 'x') // <----
905
+ * subStore.get() // 123
906
+ * subStore.index() // 'x'
800
907
  * ```
801
908
  */
802
909
  index() {
@@ -807,118 +914,121 @@ export class Store {
807
914
  this._collection._removeObserver(this._idx, scope);
808
915
  }
809
916
  /**
810
- * @returns Resolves `path` and then retrieves the value that is there, subscribing
811
- * to all read `Store` values. If `path` does not exist, `undefined` is returned.
812
- * @param path - Any path terms to resolve before retrieving the value.
813
- * @example
814
- * ```
815
- * let store = new Store({a: {b: {c: {d: 42}}}})
816
- * assert('a' in store.get())
817
- * assert(store.get('a', 'b') === {c: {d: 42}})
818
- * ```
917
+ * Retrieve the value for store, subscribing the observe scope to changes.
918
+ *
919
+ * @param depth Limit the depth of the retrieved data structure to this positive integer.
920
+ * When `depth` is `1`, only a single level of the value at `path` is unpacked. This
921
+ * makes no difference for primitive values (like strings), but for objects, maps and
922
+ * arrays, it means that each *value* in the resulting data structure will be a
923
+ * reference to the `Store` for that value.
924
+ *
925
+ * @returns The resulting value (or `undefined` if the `Store` does not exist).
926
+ */
927
+ get(depth = 0) {
928
+ let value = this._observe();
929
+ return value instanceof ObsCollection ? value._getRecursive(depth - 1) : value;
930
+ }
931
+ /**
932
+ * Exactly like {@link Store.get}, except that when executed from an observe scope,
933
+ * we will not subscribe to changes in the data retrieved data.
819
934
  */
820
- get(...path) {
821
- return this.query({ path });
935
+ peek(depth = 0) {
936
+ let savedScope = currentScope;
937
+ currentScope = undefined;
938
+ let result = this.get(depth);
939
+ currentScope = savedScope;
940
+ return result;
822
941
  }
823
942
  /**
824
- * Like {@link Store.get}, but doesn't subscribe to changes.
943
+ * Like {@link Store.get}, but with return type checking.
944
+ *
945
+ * @param expectType A string specifying what type the.get is expected to return. Options are:
946
+ * "undefined", "null", "boolean", "number", "string", "function", "array", "map"
947
+ * and "object". If the store holds a different type of value, a `TypeError`
948
+ * exception is thrown.
949
+ * @returns
825
950
  */
826
- peek(...path) {
827
- return this.query({ path, peek: true });
951
+ getTyped(expectType, depth = 0) {
952
+ let value = this._observe();
953
+ let type = (value instanceof ObsCollection) ? value._getType() : (value === null ? "null" : typeof value);
954
+ if (type !== expectType)
955
+ throw new TypeError(`Expecting ${expectType} but got ${type}`);
956
+ return value instanceof ObsCollection ? value._getRecursive(depth - 1) : value;
828
957
  }
829
958
  /**
830
959
  * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `number`.
831
960
  * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
832
961
  */
833
- getNumber(...path) { return this.query({ path, type: 'number' }); }
962
+ getNumber() { return this.getTyped('number'); }
834
963
  /**
835
964
  * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `string`.
836
965
  * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
837
966
  */
838
- getString(...path) { return this.query({ path, type: 'string' }); }
967
+ getString() { return this.getTyped('string'); }
839
968
  /**
840
969
  * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `boolean`.
841
970
  * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
842
971
  */
843
- getBoolean(...path) { return this.query({ path, type: 'boolean' }); }
972
+ getBoolean() { return this.getTyped('boolean'); }
844
973
  /**
845
974
  * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `function`.
846
975
  * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
847
976
  */
848
- getFunction(...path) { return this.query({ path, type: 'function' }); }
977
+ getFunction() { return this.getTyped('function'); }
849
978
  /**
850
979
  * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `array`.
851
980
  * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
852
981
  */
853
- getArray(...path) { return this.query({ path, type: 'array' }); }
982
+ getArray(depth = 0) { return this.getTyped('array', depth); }
854
983
  /**
855
984
  * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `object`.
856
985
  * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
857
986
  */
858
- getObject(...path) { return this.query({ path, type: 'object' }); }
987
+ getObject(depth = 0) { return this.getTyped('object', depth); }
859
988
  /**
860
989
  * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `map`.
861
990
  * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
862
991
  */
863
- getMap(...path) { return this.query({ path, type: 'map' }); }
992
+ getMap(depth = 0) { return this.getTyped('map', depth); }
864
993
  /**
865
- * Like {@link Store.get}, but the first parameter is the default value (returned when the Store
994
+ * Like {@link Store.get}, but with a default value (returned when the Store
866
995
  * contains `undefined`). This default value is also used to determine the expected type,
867
996
  * and to throw otherwise.
868
997
  *
869
998
  * @example
870
999
  * ```
871
- * let store = {x: 42}
872
- * assert(getOr(99, 'x') == 42)
873
- * assert(getOr(99, 'y') == 99)
874
- * getOr('hello', x') # throws TypeError (because 42 is not a string)
1000
+ * let store = new Store({x: 42})
1001
+ * store('x').getOr(99) // 42
1002
+ * store('y').getOr(99) // 99
1003
+ * store('x').getOr('hello') // throws TypeError (because 42 is not a string)
875
1004
  * ```
876
1005
  */
877
- getOr(defaultValue, ...path) {
878
- let type = typeof defaultValue;
879
- if (type === 'object') {
1006
+ getOr(defaultValue) {
1007
+ let value = this._observe();
1008
+ if (value === undefined)
1009
+ return defaultValue;
1010
+ let expectType = typeof defaultValue;
1011
+ if (expectType === 'object') {
880
1012
  if (defaultValue instanceof Map)
881
- type = 'map';
1013
+ expectType = 'map';
882
1014
  else if (defaultValue instanceof Array)
883
- type = 'array';
1015
+ expectType = 'array';
1016
+ else if (defaultValue === null)
1017
+ expectType = 'null';
884
1018
  }
885
- return this.query({ type, defaultValue, path });
886
- }
887
- /** Retrieve a value, subscribing to all read `Store` values. This is a more flexible
888
- * form of the {@link Store.get} and {@link Store.peek} methods.
889
- *
890
- * @returns The resulting value, or `undefined` if the `path` does not exist.
891
- */
892
- query(opts) {
893
- if (opts.peek && currentScope) {
894
- let savedScope = currentScope;
895
- currentScope = undefined;
896
- let result = this.query(opts);
897
- currentScope = savedScope;
898
- return result;
899
- }
900
- let store = opts.path && opts.path.length ? this.ref(...opts.path) : this;
901
- let value = store._observe();
902
- if (opts.type && (value !== undefined || opts.defaultValue === undefined)) {
903
- let type = (value instanceof ObsCollection) ? value._getType() : (value === null ? "null" : typeof value);
904
- if (type !== opts.type)
905
- throw new TypeError(`Expecting ${opts.type} but got ${type}`);
906
- }
907
- if (value instanceof ObsCollection) {
908
- return value._getRecursive(opts.depth == null ? -1 : opts.depth - 1);
909
- }
910
- return value === undefined ? opts.defaultValue : value;
1019
+ let type = (value instanceof ObsCollection) ? value._getType() : (value === null ? "null" : typeof value);
1020
+ if (type !== expectType)
1021
+ throw new TypeError(`Expecting ${expectType} but got ${type}`);
1022
+ return (value instanceof ObsCollection ? value._getRecursive(-1) : value);
911
1023
  }
912
1024
  /**
913
- * Checks if the specified collection is empty, and subscribes the current scope to changes of the emptiness of this collection.
1025
+ * Checks if the collection held in `Store` is empty, and subscribes the current scope to changes of the emptiness of this collection.
914
1026
  *
915
- * @param path Any path terms to resolve before retrieving the value.
916
- * @returns When the specified collection is not empty `true` is returned. If it is empty or if the value is undefined, `false` is returned.
1027
+ * @returns When the collection is not empty `true` is returned. If it is empty or if the value is undefined, `false` is returned.
917
1028
  * @throws When the value is not a collection and not undefined, an Error will be thrown.
918
1029
  */
919
- isEmpty(...path) {
920
- let store = this.ref(...path);
921
- let value = store._observe();
1030
+ isEmpty() {
1031
+ let value = this._observe();
922
1032
  if (value instanceof ObsCollection) {
923
1033
  if (currentScope) {
924
1034
  let observer = new IsEmptyObserver(currentScope, value, false);
@@ -936,15 +1046,13 @@ export class Store {
936
1046
  }
937
1047
  }
938
1048
  /**
939
- * Returns the number of items in the specified collection, and subscribes the current scope to changes in this count.
1049
+ * Returns the number of items in the collection held in Store, and subscribes the current scope to changes in this count.
940
1050
  *
941
- * @param path Any path terms to resolve before retrieving the value.
942
1051
  * @returns The number of items contained in the collection, or 0 if the value is undefined.
943
1052
  * @throws When the value is not a collection and not undefined, an Error will be thrown.
944
1053
  */
945
- count(...path) {
946
- let store = this.ref(...path);
947
- let value = store._observe();
1054
+ count() {
1055
+ let value = this._observe();
948
1056
  if (value instanceof ObsCollection) {
949
1057
  if (currentScope) {
950
1058
  let observer = new IsEmptyObserver(currentScope, value, true);
@@ -962,22 +1070,38 @@ export class Store {
962
1070
  }
963
1071
  }
964
1072
  /**
965
- * Returns a strings describing the type of the store value, subscribing to changes of this type.
1073
+ * Returns a strings describing the type of the `Store` value, subscribing to changes of this type.
966
1074
  * Note: this currently also subscribes to changes of primitive values, so changing a value from 3 to 4
967
1075
  * would cause the scope to be rerun. This is not great, and may change in the future. This caveat does
968
1076
  * not apply to changes made *inside* an object, `Array` or `Map`.
969
1077
  *
970
- * @param path Any path terms to resolve before retrieving the value.
971
1078
  * @returns Possible options: "undefined", "null", "boolean", "number", "string", "function", "array", "map" or "object".
972
1079
  */
973
- getType(...path) {
974
- let store = this.ref(...path);
975
- let value = store._observe();
1080
+ getType() {
1081
+ let value = this._observe();
976
1082
  return (value instanceof ObsCollection) ? value._getType() : (value === null ? "null" : typeof value);
977
1083
  }
978
1084
  /**
979
- * Sets the value to the last given argument. Any earlier argument are a Store-path that is first
980
- * resolved/created using {@link Store.makeRef}.
1085
+ * Returns a new `Store` that will always hold either the value of `whenTrue` or the value
1086
+ * of `whenFalse` depending on whether the original `Store` is truthy or not.
1087
+ *
1088
+ * @param whenTrue The value set to the return-`Store` while `this` is truthy. This can be
1089
+ * any type of value. If it's a `Store`, the return-`Store` will reference the same
1090
+ * data (so *no* deep copy will be made).
1091
+ * @param whenFalse Like `whenTrue`, but for falsy values (false, undefined, null, 0, "").
1092
+ * @returns A store holding the result value. The value will keep getting updated while
1093
+ * the observe context from which `if()` was called remains active.
1094
+ */
1095
+ if(whenTrue, whenFalse) {
1096
+ const result = new Store();
1097
+ observe(() => {
1098
+ const value = this.get() ? whenTrue : whenFalse;
1099
+ result.set(value);
1100
+ });
1101
+ return result;
1102
+ }
1103
+ /**
1104
+ * Sets the `Store` value to the given argument.
981
1105
  *
982
1106
  * When a `Store` is passed in as the value, its value will be copied (subscribing to changes). In
983
1107
  * case the value is an object, an `Array` or a `Map`, a *reference* to that data structure will
@@ -987,95 +1111,140 @@ export class Store {
987
1111
  *
988
1112
  * If you intent to make a copy instead of a reference, call {@link Store.get} on the origin `Store`.
989
1113
  *
1114
+ * @returns The `Store` itself, for chaining other methods.
990
1115
  *
991
1116
  * @example
992
1117
  * ```
993
1118
  * let store = new Store() // Value is `undefined`
994
1119
  *
995
- * store.set('x', 6) // Causes the store to become an object
996
- * assert(store.get() == {x: 6})
1120
+ * store.set(6)
1121
+ * store.get() // 6
997
1122
  *
998
- * store.set('a', 'b', 'c', 'd') // Create parent path as objects
999
- * assert(store.get() == {x: 6, a: {b: {c: 'd'}}})
1123
+ * store.set({}) // Change value to an empty object
1124
+ * store('a', 'b', 'c').set('d') // Create parent path as objects
1125
+ * store.get() // {x: 6, a: {b: {c: 'd'}}}
1000
1126
  *
1001
1127
  * store.set(42) // Overwrites all of the above
1002
- * assert(store.get() == 42)
1128
+ * store.get() // 42
1003
1129
  *
1004
- * store.set('x', 6) // Throw Error (42 is not a collection)
1130
+ * store('x').set(6) // Throw Error (42 is not a collection)
1005
1131
  * ```
1006
1132
  */
1007
- set(...pathAndValue) {
1008
- let newValue = pathAndValue.pop();
1009
- let store = this.makeRef(...pathAndValue);
1010
- store._collection._setIndex(store._idx, newValue, true);
1133
+ set(newValue) {
1134
+ this._materialize(true);
1135
+ this._collection._setIndex(this._idx, newValue, true);
1011
1136
  runImmediateQueue();
1137
+ return this;
1138
+ }
1139
+ /** @internal */
1140
+ _materialize(forWriting) {
1141
+ if (!this._virtual)
1142
+ return true;
1143
+ let collection = this._collection;
1144
+ let idx = this._idx;
1145
+ for (let i = 0; i < this._virtual.length; i++) {
1146
+ if (!forWriting && currentScope) {
1147
+ if (collection._addObserver(idx, currentScope)) {
1148
+ currentScope._cleaners.push(this);
1149
+ }
1150
+ }
1151
+ let value = collection.rawGet(idx);
1152
+ if (!(value instanceof ObsCollection)) {
1153
+ // Throw an error if trying to index a primitive type
1154
+ if (value !== undefined)
1155
+ throw new Error(`While resolving ${JSON.stringify(this._virtual)}, found ${JSON.stringify(value)} at index ${i} instead of a collection.`);
1156
+ // For reads, we'll just give up. We might reactively get another shot at this.
1157
+ if (!forWriting)
1158
+ return false;
1159
+ // For writes, create a new collection.
1160
+ value = new ObsObject();
1161
+ collection.rawSet(idx, value);
1162
+ collection.emitChange(idx, value, undefined);
1163
+ }
1164
+ collection = value;
1165
+ const prop = this._virtual[i];
1166
+ idx = collection._normalizeIndex(prop);
1167
+ }
1168
+ this._collection = collection;
1169
+ this._idx = idx;
1170
+ delete this._virtual;
1171
+ return true;
1012
1172
  }
1013
1173
  /**
1014
1174
  * Sets the `Store` to the given `mergeValue`, but without deleting any pre-existing
1015
1175
  * items when a collection overwrites a similarly typed collection. This results in
1016
1176
  * a deep merge.
1017
1177
  *
1178
+ * @returns The `Store` itself, for chaining other methods.
1179
+ *
1018
1180
  * @example
1019
1181
  * ```
1020
1182
  * let store = new Store({a: {x: 1}})
1021
1183
  * store.merge({a: {y: 2}, b: 3})
1022
- * assert(store.get() == {a: {x: 1, y: 2}, b: 3})
1184
+ * store.get() // {a: {x: 1, y: 2}, b: 3}
1023
1185
  * ```
1024
1186
  */
1025
- merge(...pathAndValue) {
1026
- let mergeValue = pathAndValue.pop();
1027
- let store = this.makeRef(...pathAndValue);
1028
- store._collection._setIndex(store._idx, mergeValue, false);
1187
+ merge(mergeValue) {
1188
+ this._materialize(true);
1189
+ this._collection._setIndex(this._idx, mergeValue, false);
1029
1190
  runImmediateQueue();
1191
+ return this;
1030
1192
  }
1031
1193
  /**
1032
1194
  * Sets the value for the store to `undefined`, which causes it to be omitted from the map (or array, if it's at the end)
1033
1195
  *
1196
+ * @returns The `Store` itself, for chaining other methods.
1197
+ *
1034
1198
  * @example
1035
1199
  * ```
1036
1200
  * let store = new Store({a: 1, b: 2})
1037
- * store.delete('a')
1038
- * assert(store.get() == {b: 2})
1201
+ * store('a').delete()
1202
+ * store.get() // {b: 2}
1039
1203
  *
1040
1204
  * store = new Store(['a','b','c'])
1041
- * store.delete(1)
1042
- * assert(store.get() == ['a', undefined, 'c'])
1043
- * store.delete(2)
1044
- * assert(store.get() == ['a'])
1205
+ * store(1).delete()
1206
+ * store.get() // ['a', undefined, 'c']
1207
+ * store(2).delete()
1208
+ * store.get() // ['a']
1209
+ * store.delete()
1210
+ * store.get() // undefined
1045
1211
  * ```
1046
1212
  */
1047
- delete(...path) {
1048
- let store = this.makeRef(...path);
1049
- store._collection._setIndex(store._idx, undefined, true);
1213
+ delete() {
1214
+ this._materialize(true);
1215
+ this._collection._setIndex(this._idx, undefined, true);
1050
1216
  runImmediateQueue();
1217
+ return this;
1051
1218
  }
1052
1219
  /**
1053
1220
  * Pushes a value to the end of the Array that is at the specified path in the store.
1054
1221
  * If that store path is `undefined`, an Array is created first.
1055
1222
  * The last argument is the value to be added, any earlier arguments indicate the path.
1056
1223
  *
1224
+ * @returns The index at which the item was appended.
1225
+ * @throws TypeError when the store contains a primitive data type.
1226
+ *
1057
1227
  * @example
1058
1228
  * ```
1059
1229
  * let store = new Store()
1060
1230
  * store.push(3) // Creates the array
1061
1231
  * store.push(6)
1062
- * assert(store.get() == [3,6])
1232
+ * store.get() // [3,6]
1063
1233
  *
1064
1234
  * store = new Store({myArray: [1,2]})
1065
- * store.push('myArray', 3)
1066
- * assert(store.get() == {myArray: [1,2,3]})
1235
+ * store('myArray').push(3)
1236
+ * store.get() // {myArray: [1,2,3]}
1067
1237
  * ```
1068
1238
  */
1069
- push(...pathAndValue) {
1070
- let newValue = pathAndValue.pop();
1071
- let store = this.makeRef(...pathAndValue);
1072
- let obsArray = store._collection.rawGet(store._idx);
1239
+ push(newValue) {
1240
+ this._materialize(true);
1241
+ let obsArray = this._collection.rawGet(this._idx);
1073
1242
  if (obsArray === undefined) {
1074
1243
  obsArray = new ObsArray();
1075
- store._collection._setIndex(store._idx, obsArray, true);
1244
+ this._collection._setIndex(this._idx, obsArray, true);
1076
1245
  }
1077
1246
  else if (!(obsArray instanceof ObsArray)) {
1078
- throw new Error(`push() is only allowed for an array or undefined (which would become an array)`);
1247
+ throw new TypeError(`push() is only allowed for an array or undefined (which would become an array)`);
1079
1248
  }
1080
1249
  let newData = valueToData(newValue);
1081
1250
  let pos = obsArray._data.length;
@@ -1088,71 +1257,16 @@ export class Store {
1088
1257
  * {@link Store.peek} the current value, pass it through `func`, and {@link Store.set} the resulting
1089
1258
  * value.
1090
1259
  * @param func The function transforming the value.
1260
+ * @returns The `Store` itself, for chaining other methods.
1091
1261
  */
1092
1262
  modify(func) {
1093
- this.set(func(this.query({ peek: true })));
1094
- }
1095
- /**
1096
- * Return a `Store` deeper within the tree by resolving the given `path`,
1097
- * subscribing to every level.
1098
- * In case `undefined` is encountered while resolving the path, a newly
1099
- * created `Store` containing `undefined` is returned. In that case, the
1100
- * `Store`'s {@link Store.isDetached} method will return `true`.
1101
- * In case something other than a collection is encountered, an error is thrown.
1102
- */
1103
- ref(...path) {
1104
- let store = this;
1105
- for (let i = 0; i < path.length; i++) {
1106
- let value = store._observe();
1107
- if (value instanceof ObsCollection) {
1108
- store = new Store(value, value._normalizeIndex(path[i]));
1109
- }
1110
- else {
1111
- if (value !== undefined)
1112
- throw new Error(`Value ${JSON.stringify(value)} is not a collection (nor undefined) in step ${i} of $(${JSON.stringify(path)})`);
1113
- return new DetachedStore();
1114
- }
1115
- }
1116
- return store;
1117
- }
1118
- /**
1119
- * Similar to `ref()`, but instead of returning `undefined`, new objects are created when
1120
- * a path does not exist yet. An error is still thrown when the path tries to index an invalid
1121
- * type.
1122
- * Unlike `ref`, `makeRef` does *not* subscribe to the path levels, as it is intended to be
1123
- * a write-only operation.
1124
- *
1125
- * @example
1126
- * ```
1127
- * let store = new Store() // Value is `undefined`
1128
- *
1129
- * let ref = store.makeRef('a', 'b', 'c')
1130
- * assert(store.get() == {a: {b: {}}}
1131
- *
1132
- * ref.set(42)
1133
- * assert(store.get() == {a: {b: {c: 42}}}
1134
- *
1135
- * ref.makeRef('d') // Throw Error (42 is not a collection)
1136
- * ```
1137
- */
1138
- makeRef(...path) {
1139
- let store = this;
1140
- for (let i = 0; i < path.length; i++) {
1141
- let value = store._collection.rawGet(store._idx);
1142
- if (!(value instanceof ObsCollection)) {
1143
- if (value !== undefined)
1144
- throw new Error(`Value ${JSON.stringify(value)} is not a collection (nor undefined) in step ${i} of $(${JSON.stringify(path)})`);
1145
- value = new ObsObject();
1146
- store._collection.rawSet(store._idx, value);
1147
- store._collection.emitChange(store._idx, value, undefined);
1148
- }
1149
- store = new Store(value, value._normalizeIndex(path[i]));
1150
- }
1151
- runImmediateQueue();
1152
- return store;
1263
+ this.set(func(this.peek()));
1264
+ return this;
1153
1265
  }
1154
1266
  /** @internal */
1155
1267
  _observe() {
1268
+ if (!this._materialize(false))
1269
+ return undefined;
1156
1270
  if (currentScope) {
1157
1271
  if (this._collection._addObserver(this._idx, currentScope)) {
1158
1272
  currentScope._cleaners.push(this);
@@ -1165,24 +1279,17 @@ export class Store {
1165
1279
  * When items are added to the collection at some later point, the code block will be ran for them as well.
1166
1280
  * When an item is removed, the {@link Store.clean} handlers left by its code block are executed.
1167
1281
  *
1168
- *
1169
- *
1170
- * @param pathAndFuncs
1282
+ * @param renderer The function to be called for each item. It receives the item's `Store` object as its only argument.
1283
+ * @param makeSortKey An optional function that, given an items `Store` object, returns a value to be sorted on.
1284
+ * This value can be a number, a string, or an array containing a combination of both. When undefined is returned,
1285
+ * the item is *not* rendered. If `makeSortKey` is not specified, the output will be sorted by `index()`.
1171
1286
  */
1172
- onEach(...pathAndFuncs) {
1173
- let makeSortKey = defaultMakeSortKey;
1174
- let renderer = pathAndFuncs.pop();
1175
- if (typeof pathAndFuncs[pathAndFuncs.length - 1] === 'function' && (typeof renderer === 'function' || renderer == null)) {
1176
- if (renderer != null)
1177
- makeSortKey = renderer;
1178
- renderer = pathAndFuncs.pop();
1179
- }
1180
- if (typeof renderer !== 'function')
1181
- throw new Error(`onEach() expects a render function as its last argument but got ${JSON.stringify(renderer)}`);
1182
- if (!currentScope)
1183
- throw new ScopeError(false);
1184
- let store = this.ref(...pathAndFuncs);
1185
- let val = store._observe();
1287
+ onEach(renderer, makeSortKey = defaultMakeSortKey) {
1288
+ if (!currentScope) { // Do this in a new top-level scope
1289
+ _mount(undefined, () => this.onEach(renderer, makeSortKey), SimpleScope);
1290
+ return;
1291
+ }
1292
+ let val = this._observe();
1186
1293
  if (val instanceof ObsCollection) {
1187
1294
  // Subscribe to changes using the specialized OnEachScope
1188
1295
  let onEachScope = new OnEachScope(currentScope._parentElement, currentScope._lastChild || currentScope._precedingSibling, currentScope._queueOrder + 1, val, renderer, makeSortKey);
@@ -1195,6 +1302,30 @@ export class Store {
1195
1302
  throw new Error(`onEach() attempted on a value that is neither a collection nor undefined`);
1196
1303
  }
1197
1304
  }
1305
+ /**
1306
+ * Derive a new `Store` from this `Store`, by reactively passing its value
1307
+ * through the specified function.
1308
+ * @param func Your function. It should accept a the input store's value, and return
1309
+ * a result to be reactively set to the output store.
1310
+ * @returns The output `Store`.
1311
+ * @example
1312
+ * ```javascript
1313
+ * const store = new Store(21)
1314
+ * const double = store.derive(v => v*2)
1315
+ * double.get() // 42
1316
+ *
1317
+ * store.set(100)
1318
+ * runQueue() // Or after a setTimeout 0, due to batching
1319
+ * double.get() // 200
1320
+ * ```
1321
+ */
1322
+ derive(func) {
1323
+ let out = new Store();
1324
+ observe(() => {
1325
+ out.set(func(this.get()));
1326
+ });
1327
+ return out;
1328
+ }
1198
1329
  /**
1199
1330
  * Applies a filter/map function on each item within the `Store`'s collection,
1200
1331
  * and reactively manages the returned `Map` `Store` to hold any results.
@@ -1202,24 +1333,29 @@ export class Store {
1202
1333
  * @param func - Function that transform the given store into an output value or
1203
1334
  * `undefined` in case this value should be skipped:
1204
1335
  *
1205
- * @returns - A map `Store` with the values returned by `func` and the corresponding
1206
- * keys from the original map, array or object `Store`.
1336
+ * @returns - A array/map/object `Store` with the values returned by `func` and the
1337
+ * corresponding keys from the original map, array or object `Store`.
1207
1338
  *
1208
1339
  * When items disappear from the `Store` or are changed in a way that `func` depends
1209
1340
  * upon, the resulting items are removed from the output `Store` as well. When multiple
1210
1341
  * input items produce the same output keys, this may lead to unexpected results.
1211
1342
  */
1212
1343
  map(func) {
1213
- let out = new Store(new Map());
1214
- this.onEach((item) => {
1215
- let value = func(item);
1216
- if (value !== undefined) {
1217
- let key = item.index();
1218
- out.set(key, value);
1219
- clean(() => {
1220
- out.delete(key);
1221
- });
1222
- }
1344
+ let out = new Store();
1345
+ observe(() => {
1346
+ let t = this.getType();
1347
+ out.set(t === 'array' ? [] : (t === 'object' ? {} : new Map()));
1348
+ this.onEach((item) => {
1349
+ let value = func(item);
1350
+ if (value !== undefined) {
1351
+ let key = item.index();
1352
+ const ref = out(key);
1353
+ ref.set(value);
1354
+ clean(() => {
1355
+ ref.delete();
1356
+ });
1357
+ }
1358
+ });
1223
1359
  });
1224
1360
  return out;
1225
1361
  }
@@ -1243,154 +1379,80 @@ export class Store {
1243
1379
  let out = new Store(new Map());
1244
1380
  this.onEach((item) => {
1245
1381
  let result = func(item);
1246
- let keys;
1382
+ let refs = [];
1247
1383
  if (result.constructor === Object) {
1248
1384
  for (let key in result) {
1249
- out.set(key, result[key]);
1385
+ const ref = out(key);
1386
+ ref.set(result[key]);
1387
+ refs.push(ref);
1250
1388
  }
1251
- keys = Object.keys(result);
1252
1389
  }
1253
1390
  else if (result instanceof Map) {
1254
1391
  result.forEach((value, key) => {
1255
- out.set(key, value);
1392
+ const ref = out(key);
1393
+ ref.set(value);
1394
+ refs.push(ref);
1256
1395
  });
1257
- keys = [...result.keys()];
1258
1396
  }
1259
1397
  else {
1260
1398
  return;
1261
1399
  }
1262
- if (keys.length) {
1400
+ if (refs.length) {
1263
1401
  clean(() => {
1264
- for (let key of keys) {
1265
- out.delete(key);
1402
+ for (let ref of refs) {
1403
+ ref.delete();
1266
1404
  }
1267
1405
  });
1268
1406
  }
1269
1407
  });
1270
1408
  return out;
1271
1409
  }
1272
- /**
1273
- * @returns Returns `true` when the `Store` was created by {@link Store.ref}ing a path that
1274
- * does not exist.
1275
- */
1276
- isDetached() { return false; }
1277
1410
  /**
1278
1411
  * Dump a live view of the `Store` tree as HTML text, `ul` and `li` nodes at
1279
1412
  * the current mount position. Meant for debugging purposes.
1413
+ * @returns The `Store` itself, for chaining other methods.
1280
1414
  */
1281
1415
  dump() {
1282
1416
  let type = this.getType();
1283
1417
  if (type === 'array' || type === 'object' || type === 'map') {
1284
- text('<' + type + '>');
1285
- node('ul', () => {
1418
+ $({ text: `<${type}>` });
1419
+ $('ul', () => {
1286
1420
  this.onEach((sub) => {
1287
- node('li', () => {
1288
- text(JSON.stringify(sub.index()) + ': ');
1421
+ $('li:' + JSON.stringify(sub.index()) + ": ", () => {
1289
1422
  sub.dump();
1290
1423
  });
1291
1424
  });
1292
1425
  });
1293
1426
  }
1294
1427
  else {
1295
- text(JSON.stringify(this.get()));
1428
+ $({ text: JSON.stringify(this.get()) });
1296
1429
  }
1430
+ return this;
1297
1431
  }
1298
1432
  }
1299
- class DetachedStore extends Store {
1300
- isDetached() { return true; }
1301
- }
1302
- let onCreateEnabled = false;
1303
1433
  let onDestroyMap = new WeakMap();
1304
1434
  function destroyWithClass(element, cls) {
1305
1435
  element.classList.add(cls);
1306
1436
  setTimeout(() => element.remove(), 2000);
1307
1437
  }
1308
- /**
1309
- * Create a new DOM element, and insert it into the DOM at the position held by the current scope.
1310
- * @param tag - The tag of the element to be created and optionally dot-separated class names. For example: `h1` or `p.intro.has_avatar`.
1311
- * @param rest - The other arguments are flexible and interpreted based on their types:
1312
- * - `string`: Used as textContent for the element.
1313
- * - `object`: Used as attributes, properties or event listeners for the element. See {@link Store.prop} on how the distinction is made and to read about a couple of special keys.
1314
- * - `function`: The render function used to draw the scope of the element. This function gets its own `Scope`, so that if any `Store` it reads changes, it will redraw by itself.
1315
- * - `Store`: Presuming `tag` is `"input"`, `"textarea"` or `"select"`, create a two-way binding between this `Store` value and the input element. The initial value of the input will be set to the initial value of the `Store`, or the other way around if the `Store` holds `undefined`. After that, the `Store` will be updated when the input changes and vice versa.
1316
- * @example
1317
- * node('aside.editorial', 'Yada yada yada....', () => {
1318
- * node('a', {href: '/bio'}, () => {
1319
- * node('img.author', {src: '/me.jpg', alt: 'The author'})
1320
- * })
1321
- * })
1322
- */
1323
- export function node(tag = "", ...rest) {
1324
- if (!currentScope)
1325
- throw new ScopeError(true);
1326
- let el;
1327
- if (tag instanceof Element) {
1328
- el = tag;
1438
+ function addLeafNode(deepEl, node) {
1439
+ if (deepEl === currentScope._parentElement) {
1440
+ currentScope._addNode(node);
1329
1441
  }
1330
1442
  else {
1331
- let pos = tag.indexOf('.');
1332
- let classes;
1333
- if (pos >= 0) {
1334
- classes = tag.substr(pos + 1);
1335
- tag = tag.substr(0, pos);
1336
- }
1337
- el = document.createElement(tag || 'div');
1338
- if (classes) {
1339
- // @ts-ignore (replaceAll is polyfilled)
1340
- el.className = classes.replaceAll('.', ' ');
1341
- }
1342
- }
1343
- currentScope._addNode(el);
1344
- for (let item of rest) {
1345
- let type = typeof item;
1346
- if (type === 'function') {
1347
- let scope = new SimpleScope(el, undefined, currentScope._queueOrder + 1, item);
1348
- if (onCreateEnabled) {
1349
- onCreateEnabled = false;
1350
- scope._update();
1351
- onCreateEnabled = true;
1352
- }
1353
- else {
1354
- scope._update();
1355
- }
1356
- // Add it to our list of cleaners. Even if `scope` currently has
1357
- // no cleaners, it may get them in a future refresh.
1358
- currentScope._cleaners.push(scope);
1359
- }
1360
- else if (type === 'string' || type === 'number') {
1361
- el.textContent = item;
1362
- }
1363
- else if (type === 'object' && item && item.constructor === Object) {
1364
- for (let k in item) {
1365
- applyProp(el, k, item[k]);
1366
- }
1367
- }
1368
- else if (item instanceof Store) {
1369
- bindInput(el, item);
1370
- }
1371
- else if (item != null) {
1372
- throw new Error(`Unexpected argument ${JSON.stringify(item)}`);
1373
- }
1443
+ deepEl.appendChild(node);
1374
1444
  }
1375
1445
  }
1376
- /**
1377
- * Convert an HTML string to one or more DOM elements, and add them to the current DOM scope.
1378
- * @param html - The HTML string. For example `"<section><h2>Test</h2><p>Info..</p></section>"`.
1379
- */
1380
- export function html(html) {
1381
- if (!currentScope || !currentScope._parentElement)
1382
- throw new ScopeError(true);
1383
- let tmpParent = document.createElement(currentScope._parentElement.tagName);
1384
- tmpParent.innerHTML = '' + html;
1385
- while (tmpParent.firstChild) {
1386
- currentScope._addNode(tmpParent.firstChild);
1387
- }
1388
- }
1389
- function bindInput(el, store) {
1446
+ function applyBinding(_el, _key, store) {
1447
+ if (store == null)
1448
+ return;
1449
+ if (!(store instanceof Store))
1450
+ throw new Error(`Unexpect bind-argument: ${JSON.parse(store)}`);
1451
+ const el = _el;
1390
1452
  let onStoreChange;
1391
1453
  let onInputChange;
1392
1454
  let type = el.getAttribute('type');
1393
- let value = store.query({ peek: true });
1455
+ let value = store.peek();
1394
1456
  if (type === 'checkbox') {
1395
1457
  if (value === undefined)
1396
1458
  store.set(el.checked);
@@ -1423,30 +1485,268 @@ function bindInput(el, store) {
1423
1485
  el.removeEventListener('input', onInputChange);
1424
1486
  });
1425
1487
  }
1488
+ const SPECIAL_PROPS = {
1489
+ create: function (el, value) {
1490
+ if (!showCreateTransitions)
1491
+ return;
1492
+ if (typeof value === 'function') {
1493
+ value(el);
1494
+ }
1495
+ else {
1496
+ el.classList.add(value);
1497
+ (function () {
1498
+ return __awaiter(this, void 0, void 0, function* () {
1499
+ yield DOM_READ_PHASE;
1500
+ el.offsetHeight;
1501
+ yield DOM_WRITE_PHASE;
1502
+ el.classList.remove(value);
1503
+ });
1504
+ })();
1505
+ }
1506
+ },
1507
+ destroy: function (deepEl, value) {
1508
+ onDestroyMap.set(deepEl, value);
1509
+ },
1510
+ html: function (deepEl, value) {
1511
+ if (!value)
1512
+ return;
1513
+ let tmpParent = document.createElement(deepEl.tagName);
1514
+ tmpParent.innerHTML = '' + value;
1515
+ while (tmpParent.firstChild)
1516
+ addLeafNode(deepEl, tmpParent.firstChild);
1517
+ },
1518
+ text: function (deepEl, value) {
1519
+ if (value != null)
1520
+ addLeafNode(deepEl, document.createTextNode(value));
1521
+ },
1522
+ element: function (deepEl, value) {
1523
+ if (value == null)
1524
+ return;
1525
+ if (!(value instanceof Node))
1526
+ throw new Error(`Unexpect element-argument: ${JSON.parse(value)}`);
1527
+ addLeafNode(deepEl, value);
1528
+ },
1529
+ };
1426
1530
  /**
1427
- * Add a text node at the current Scope position.
1531
+ * Modifies the *parent* DOM element in the current reactive scope, or adds
1532
+ * new DOM elements to it.
1533
+ *
1534
+ * @param args - Arguments that define how to modify/create elements.
1535
+ *
1536
+ * ### String arguments
1537
+ * Create new elements with optional classes and text content:
1538
+ * ```js
1539
+ * $('div.myClass') // <div class="myClass"></div>
1540
+ * $('span.c1.c2:Hello') // <span class="c1 c2">Hello</span>
1541
+ * $('p:Some text') // <p>Some text</p>
1542
+ * $('.my-thing') // <div class="my-thing"></div>
1543
+ * $('div', 'span', 'p.cls') // <div><span<p class="cls"></p></span></div>
1544
+ * $(':Just some text!') // Just some text! (No new element, just a text node)
1545
+ * ```
1546
+ *
1547
+ * ### Object arguments
1548
+ * Set properties, attributes, events and special features:
1549
+ * ```js
1550
+ * // Classes (dot prefix)
1551
+ * $('div', {'.active': true}) // Add class
1552
+ * $('div', {'.hidden': false}) // Remove (or don't add) class
1553
+ * $('div', {'.selected': myStore}) // Reactively add/remove class
1554
+ *
1555
+ * // Styles (dollar prefixed and camel-cased CSS properties)
1556
+ * $('div', {$color: 'red'}) // style.color = 'red'
1557
+ * $('div', {$marginTop: '10px'}) // style.marginTop = '10px'
1558
+ * $('div', {$color: myColorStore}) // Reactively change color
1559
+ *
1560
+ * // Events (function values)
1561
+ * $('button', {click: () => alert()}) // Add click handler
1562
+ *
1563
+ * // Properties (boolean values, `selectedIndex`, `value`)
1564
+ * $('input', {disabled: true}) // el.disabled = true
1565
+ * $('input', {value: 'test'}) // el.value = 'test'
1566
+ * $('select', {selectedIndex: 2}) // el.selectedIndex = 2
1567
+ *
1568
+ * // Transitions
1569
+ * $('div', {create: 'fade-in'}) // Add class on create
1570
+ * $('div', {create: el => {...}}) // Run function on create
1571
+ * $('div', {destroy: 'fade-out'}) // Add class before remove
1572
+ * $('div', {destroy: el => {...}}) // Run function before remove
1573
+ *
1574
+ * // Content
1575
+ * $('div', {html: '<b>Bold</b>'}) // Set innerHTML
1576
+ * $('div', {text: 'Plain text'}) // Add text node
1577
+ * const myElement = document.createElement('video')
1578
+ * $('div', {element: myElement}) // Add existing DOM element
1579
+ *
1580
+ * // Regular attributes (everything else)
1581
+ * $('div', {title: 'Info'}) // el.setAttribute('title', 'info')
1582
+ * ```
1583
+ *
1584
+ * When a `Store` is passed as a value, a seperate observe-scope will
1585
+ * be created for it, such that when the `Store` changes, only *that*
1586
+ * UI property will need to be updated.
1587
+ * So in the following example, when `colorStore` changes, only the
1588
+ * `color` CSS property will be updated.
1589
+ * ```js
1590
+ * $('div', {
1591
+ * '.active': activeStore, // Reactive class
1592
+ * $color: colorStore, // Reactive style
1593
+ * text: textStore // Reactive text
1594
+ * })
1595
+ * ```
1596
+ *
1597
+ * ### Two-way input binding
1598
+ * Set the initial value of an <input> <textarea> or <select> to that
1599
+ * of a `Store`, and then start reflecting user changes to the former
1600
+ * in the latter.
1601
+ * ```js
1602
+ * $('input', {bind: myStore}) // Binds input.value
1603
+ * ```
1604
+ * This is a special case, as changes to the `Store` will *not* be
1605
+ * reflected in the UI.
1606
+ *
1607
+ * ### Function arguments
1608
+ * Create child scopes that re-run on observed `Store` changes:
1609
+ * ```js
1610
+ * $('div', () => {
1611
+ * $(myStore.get() ? 'span' : 'p') // Reactive element type
1612
+ * })
1613
+ * ```
1614
+ * When *only* a function is given, `$` behaves exactly like {@link Store.observe},
1615
+ * except that it will only work when we're inside a `mount`.
1616
+ *
1617
+ * @throws {ScopeError} If called outside an observable scope.
1618
+ * @throws {Error} If invalid arguments are provided.
1428
1619
  */
1429
- export function text(text) {
1430
- if (!currentScope)
1431
- throw new ScopeError(true);
1432
- if (text == null)
1433
- return;
1434
- currentScope._addNode(document.createTextNode(text));
1435
- }
1436
- export function prop(name, value = undefined) {
1620
+ export function $(...args) {
1437
1621
  if (!currentScope || !currentScope._parentElement)
1438
1622
  throw new ScopeError(true);
1439
- if (typeof name === 'object') {
1440
- for (let k in name) {
1441
- applyProp(currentScope._parentElement, k, name[k]);
1623
+ let deepEl = currentScope._parentElement;
1624
+ for (let arg of args) {
1625
+ if (arg == null || arg === false)
1626
+ continue;
1627
+ if (typeof arg === 'string') {
1628
+ let text, classes;
1629
+ const textPos = arg.indexOf(':');
1630
+ if (textPos >= 0) {
1631
+ text = arg.substring(textPos + 1);
1632
+ if (textPos === 0) { // Just a string to add as text, no new node
1633
+ addLeafNode(deepEl, document.createTextNode(text));
1634
+ continue;
1635
+ }
1636
+ arg = arg.substring(0, textPos);
1637
+ }
1638
+ const classPos = arg.indexOf('.');
1639
+ if (classPos >= 0) {
1640
+ classes = arg.substring(classPos + 1).replaceAll('.', ' ');
1641
+ arg = arg.substring(0, classPos);
1642
+ }
1643
+ if (arg.indexOf(' ') >= 0)
1644
+ throw new Error(`Tag '${arg}' cannot contain space`);
1645
+ const el = document.createElement(arg || 'div');
1646
+ if (classes)
1647
+ el.className = classes;
1648
+ if (text)
1649
+ el.textContent = text;
1650
+ addLeafNode(deepEl, el);
1651
+ deepEl = el;
1652
+ }
1653
+ else if (typeof arg === 'object') {
1654
+ if (arg.constructor !== Object)
1655
+ throw new Error(`Unexpected argument: ${arg}`);
1656
+ for (const key in arg) {
1657
+ const val = arg[key];
1658
+ if (key === 'bind') { // Special case, as for this prop we *don't* want to resolve the Store to a value first.
1659
+ applyBinding(deepEl, key, val);
1660
+ }
1661
+ else if (val instanceof Store) {
1662
+ let childScope = new SetArgScope(deepEl, deepEl.lastChild, currentScope._queueOrder + 1, key, val);
1663
+ childScope._install();
1664
+ }
1665
+ else {
1666
+ applyArg(deepEl, key, val);
1667
+ }
1668
+ }
1669
+ }
1670
+ else if (typeof arg === 'function') {
1671
+ if (deepEl === currentScope._parentElement) { // do what observe does
1672
+ _mount(undefined, args[0], SimpleScope);
1673
+ }
1674
+ else { // new scope for a new node without any scope attached yet
1675
+ let childScope = new SimpleScope(deepEl, deepEl.lastChild, currentScope._queueOrder + 1, arg);
1676
+ childScope._install();
1677
+ }
1678
+ }
1679
+ else {
1680
+ throw new Error(`Unexpected argument: ${JSON.stringify(arg)}`);
1442
1681
  }
1443
1682
  }
1444
- else {
1445
- applyProp(currentScope._parentElement, name, value);
1683
+ }
1684
+ function applyArg(deepEl, key, value) {
1685
+ if (key[0] === '.') { // CSS class(es)
1686
+ const classes = key.substring(1).split('.');
1687
+ if (value)
1688
+ deepEl.classList.add(...classes);
1689
+ else
1690
+ deepEl.classList.remove(...classes);
1691
+ }
1692
+ else if (key[0] === '$') { // Style
1693
+ const name = key.substring(1);
1694
+ if (value == null || value === false)
1695
+ deepEl.style[name] = '';
1696
+ else
1697
+ deepEl.style[name] = '' + value;
1698
+ }
1699
+ else if (key in SPECIAL_PROPS) { // Special property
1700
+ SPECIAL_PROPS[key](deepEl, value);
1701
+ }
1702
+ else if (typeof value === 'function') { // Event listener
1703
+ deepEl.addEventListener(key, value);
1704
+ clean(() => deepEl.removeEventListener(key, value));
1705
+ }
1706
+ else if (value === true || value === false || key === 'value' || key === 'selectedIndex') { // DOM property
1707
+ deepEl[key] = value;
1708
+ }
1709
+ else { // HTML attribute
1710
+ deepEl.setAttribute(key, value);
1446
1711
  }
1447
1712
  }
1713
+ function defaultOnError(error) {
1714
+ console.error('Error while in Aberdeen render:', error);
1715
+ return true;
1716
+ }
1717
+ let onError = defaultOnError;
1718
+ /**
1719
+ * Set a custome error handling function, thast is called when an error occurs during rendering
1720
+ * while in a reactive scope. The default implementation logs the error to the console, and then
1721
+ * just returns `true`, which causes an 'Error' message to be displayed in the UI. When this function
1722
+ * returns `false`, the error is suppressed. This mechanism exists because rendering errors can occur
1723
+ * at any time, not just synchronous when making a call to Aberdeen, thus normal exception handling
1724
+ * is not always possible.
1725
+ *
1726
+ * @param handler The handler function, getting an `Error` as its argument, and returning `false`
1727
+ * if it does *not* want an error message to be added to the DOM.
1728
+ * When `handler is `undefined`, the default error handling will be reinstated.
1729
+ *
1730
+ * @example
1731
+ * ```javascript
1732
+ * //
1733
+ * setErrorHandler(error => {
1734
+ * // Tell our developers about the problem.
1735
+ * fancyErrorLogger(error)
1736
+ * // Add custom error message to the DOM.
1737
+ * try {
1738
+ * $('.error:Sorry, something went wrong!')
1739
+ * } catch() {} // In case there is no parent element.
1740
+ * // Don't add default error message to the DOM.
1741
+ * return false
1742
+ * })
1743
+ * ```
1744
+ */
1745
+ export function setErrorHandler(handler) {
1746
+ onError = handler || defaultOnError;
1747
+ }
1448
1748
  /**
1449
- * Return the browser Element that `node()`s would be rendered to at this point.
1749
+ * Return the browser Element that nodes would be rendered to at this point.
1450
1750
  * NOTE: Manually changing the DOM is not recommended in most cases. There is
1451
1751
  * usually a better, declarative way. Although there are no hard guarantees on
1452
1752
  * how your changes interact with Aberdeen, in most cases results will not be
@@ -1505,10 +1805,10 @@ export function immediateObserve(func) {
1505
1805
  return _mount(undefined, func, ImmediateScope);
1506
1806
  }
1507
1807
  /**
1508
- * Like {@link Store.observe}, but allow the function to create DOM elements using {@link Store.node}.
1808
+ * Reactively run the function, adding any DOM-elements created using {@link $} to the given parent element.
1509
1809
 
1510
1810
  * @param func - The function to be (repeatedly) executed, possibly adding DOM elements to `parentElement`.
1511
- * @param parentElement - A DOM element that will be used as the parent element for calls to `node`.
1811
+ * @param parentElement - A DOM element that will be used as the parent element for calls to `$`.
1512
1812
  * @returns The mount id (usable for `unmount`) if this is a top-level mount.
1513
1813
  *
1514
1814
  * @example
@@ -1517,7 +1817,7 @@ export function immediateObserve(func) {
1517
1817
  * setInterval(() => store.modify(v => v+1), 1000)
1518
1818
  *
1519
1819
  * mount(document.body, () => {
1520
- * node('h2', `${store.get()} seconds have passed`)
1820
+ * $(`h2:${store.get()} seconds have passed`)
1521
1821
  * })
1522
1822
  * ```
1523
1823
  *
@@ -1527,25 +1827,30 @@ export function immediateObserve(func) {
1527
1827
  * let colors = new Store(new Map())
1528
1828
  *
1529
1829
  * mount(document.body, () => {
1530
- * // This function will never rerun (as it does not read any `Store`s)
1531
- * node('button', '<<', {click: () => selected.modify(n => n-1)})
1532
- * node('button', '>>', {click: () => selected.modify(n => n+1)})
1830
+ * // This function will never rerun (as it does not read any `Store`s)
1831
+ * $('button:<<', {click: () => selected.modify(n => n-1)})
1832
+ * $('button:>>', {click: () => selected.modify(n => n+1)})
1533
1833
  *
1534
- * observe(() => {
1535
- * // This will rerun whenever `selected` changes, recreating the <h2> and <input>.
1536
- * node('h2', '#'+selected.get())
1537
- * node('input', {type: 'color', value: '#ffffff'}, colors.ref(selected.get()))
1538
- * })
1834
+ * observe(() => {
1835
+ * // This will rerun whenever `selected` changes, recreating the <h2> and <input>.
1836
+ * $('h2', {text: '#' + selected.get()})
1837
+ * $('input', {type: 'color', value: '#ffffff' bind: colors(selected.get())})
1838
+ * })
1539
1839
  *
1540
- * observe(() => {
1541
- * // This function will rerun when `selected` or the selected color changes.
1542
- * // It will change the <body> background-color.
1543
- * prop({style: {backgroundColor: colors.get(selected.get()) || 'white'}})
1544
- * })
1840
+ * observe(() => {
1841
+ * // This function will rerun when `selected` or the selected color changes.
1842
+ * // It will change the <body> background-color.
1843
+ * $({$backgroundColor: colors.get(selected.get()) || 'white'})
1844
+ * })
1545
1845
  * })
1546
1846
  * ```
1547
1847
  */
1548
1848
  export function mount(parentElement, func) {
1849
+ for (let scope of topScopes.values()) {
1850
+ if (parentElement === scope._parentElement) {
1851
+ throw new Error("Only a single mount per parent element");
1852
+ }
1853
+ }
1549
1854
  return _mount(parentElement, func, SimpleScope);
1550
1855
  }
1551
1856
  let maxTopScopeId = 0;
@@ -1586,6 +1891,7 @@ export function unmount(id) {
1586
1891
  let scope = topScopes.get(id);
1587
1892
  if (!scope)
1588
1893
  throw new Error("No such mount " + id);
1894
+ topScopes.delete(id);
1589
1895
  scope._remove();
1590
1896
  }
1591
1897
  }
@@ -1623,63 +1929,16 @@ export function peek(func) {
1623
1929
  /*
1624
1930
  * Helper functions
1625
1931
  */
1626
- function applyProp(el, prop, value) {
1627
- if (prop === 'create') {
1628
- if (onCreateEnabled) {
1629
- if (typeof value === 'function') {
1630
- value(el);
1631
- }
1632
- else {
1633
- el.classList.add(value);
1634
- setTimeout(function () { el.classList.remove(value); }, 0);
1635
- }
1636
- }
1637
- }
1638
- else if (prop === 'destroy') {
1639
- onDestroyMap.set(el, value);
1640
- }
1641
- else if (typeof value === 'function') {
1642
- // Set an event listener; remove it again on clean.
1643
- el.addEventListener(prop, value);
1644
- clean(() => el.removeEventListener(prop, value));
1645
- }
1646
- else if (prop === 'value' || prop === 'className' || prop === 'selectedIndex' || value === true || value === false) {
1647
- // All boolean values and a few specific keys should be set as a property
1648
- el[prop] = value;
1649
- }
1650
- else if (prop === 'text') {
1651
- // `text` is set as textContent
1652
- el.textContent = value;
1653
- }
1654
- else if ((prop === 'class' || prop === 'className') && typeof value === 'object') {
1655
- // Allow setting classes using an object where the keys are the names and
1656
- // the values are booleans stating whether to set or remove.
1657
- for (let name in value) {
1658
- if (value[name])
1659
- el.classList.add(name);
1660
- else
1661
- el.classList.remove(name);
1662
- }
1663
- }
1664
- else if (prop === 'style' && typeof value === 'object') {
1665
- // `style` can receive an object
1666
- Object.assign(el.style, value);
1667
- }
1668
- else {
1669
- // Everything else is an HTML attribute
1670
- el.setAttribute(prop, value);
1671
- }
1672
- }
1673
1932
  function valueToData(value) {
1674
- if (typeof value !== "object" || !value) {
1675
- // Simple data types
1676
- return value;
1677
- }
1678
- else if (value instanceof Store) {
1933
+ if (value instanceof Store) {
1679
1934
  // When a Store is passed pointing at a collection, a reference
1680
1935
  // is made to that collection.
1681
1936
  return value._observe();
1682
1937
  }
1938
+ else if (typeof value !== "object" || !value) {
1939
+ // Simple data types
1940
+ return value;
1941
+ }
1683
1942
  else if (value instanceof Map) {
1684
1943
  let result = new ObsMap();
1685
1944
  value.forEach((v, k) => {
@@ -1718,13 +1977,17 @@ function defaultMakeSortKey(store) {
1718
1977
  }
1719
1978
  /* c8 ignore start */
1720
1979
  function internalError(code) {
1721
- let error = new Error("Aberdeen internal error " + code);
1722
- setTimeout(() => { throw error; }, 0);
1980
+ throw new Error("Aberdeen internal error " + code);
1723
1981
  }
1724
1982
  /* c8 ignore end */
1725
- function handleError(e) {
1726
- // Throw the error async, so the rest of the rendering can continue
1727
- setTimeout(() => { throw e; }, 0);
1983
+ function handleError(e, showMessage) {
1984
+ try {
1985
+ if (onError(e) === false)
1986
+ showMessage = false;
1987
+ }
1988
+ catch (_a) { }
1989
+ if (showMessage && (currentScope === null || currentScope === void 0 ? void 0 : currentScope._parentElement))
1990
+ $('.aberdeen-error:Error');
1728
1991
  }
1729
1992
  class ScopeError extends Error {
1730
1993
  constructor(mount) {
@@ -1742,13 +2005,6 @@ export function withEmitHandler(handler, func) {
1742
2005
  ObsCollection.prototype.emitChange = oldEmitHandler;
1743
2006
  }
1744
2007
  }
1745
- /**
1746
- * Run a function, while *not* causing reactive effects for any changes it makes to `Store`s.
1747
- * @param func The function to be executed once immediately.
1748
- */
1749
- export function inhibitEffects(func) {
1750
- withEmitHandler(() => { }, func);
1751
- }
1752
2008
  // @ts-ignore
1753
2009
  // c8 ignore next
1754
2010
  if (!String.prototype.replaceAll)