aberdeen 0.0.10 → 0.0.11

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,74 +1,79 @@
1
- /*
2
- * QueueRunner
3
- *
4
- * `queue()`d runners are executed on the next timer tick, by order of their
5
- * `queueOrder` values.
6
- */
7
- let queued = new Set();
1
+ let queueArray = [];
2
+ let queueSet = new Set();
3
+ let queueOrdered = true;
4
+ let runQueueDepth = 0;
8
5
  function queue(runner) {
9
- if (!queued.size) {
6
+ if (queueSet.has(runner))
7
+ return;
8
+ if (runQueueDepth > 42) {
9
+ throw new Error("Too many recursive updates from observes");
10
+ }
11
+ if (!queueArray.length) {
10
12
  setTimeout(runQueue, 0);
11
13
  }
12
- queued.add(runner);
14
+ else if (runner.queueOrder < queueArray[queueArray.length - 1].queueOrder) {
15
+ queueOrdered = false;
16
+ }
17
+ queueArray.push(runner);
18
+ queueSet.add(runner);
13
19
  }
14
20
  function runQueue() {
15
- // Order queued observers by depth, lowest first
16
- let ordered;
17
- if (Array.from) {
18
- ordered = Array.from(queued);
19
- }
20
- else { // IE 11
21
- ordered = [];
22
- queued.forEach(item => ordered.push(item));
23
- }
24
- ordered.sort((a, b) => a.queueOrder - b.queueOrder);
25
- for (let runner of ordered) {
26
- queued.delete(runner);
27
- let size = queued.size;
28
- runner.queueRun();
29
- if (queued.size !== size) {
30
- // The queue was modified. We'll need to sort it again.
31
- return runQueue();
32
- }
33
- }
21
+ for (let index = 0; index < queueArray.length;) {
22
+ if (!queueOrdered) {
23
+ queueArray.splice(0, index);
24
+ index = 0;
25
+ // Order queued observers by depth, lowest first
26
+ queueArray.sort((a, b) => a.queueOrder - b.queueOrder);
27
+ queueOrdered = true;
28
+ }
29
+ let batchEndIndex = queueArray.length;
30
+ for (; index < batchEndIndex && queueOrdered; index++) {
31
+ let runner = queueArray[index];
32
+ queueSet.delete(runner);
33
+ runner.queueRun();
34
+ }
35
+ runQueueDepth++;
36
+ }
37
+ queueArray.length = 0;
38
+ runQueueDepth = 0;
34
39
  }
35
40
  /**
36
41
  * Given an integer number, a string or an array of these, this function returns a string that can be used
37
42
  * to compare items in a natural sorting order. So `[3, 'ab']` should be smaller than `[3, 'ac']`.
38
43
  * The resulting string is guaranteed to never be empty.
39
44
  */
40
- export function sortKeyToString(key) {
45
+ function sortKeyToString(key) {
41
46
  if (key instanceof Array) {
42
47
  return key.map(partToStr).join('');
43
48
  }
44
49
  else {
45
50
  return partToStr(key);
46
51
  }
47
- function partToStr(part) {
48
- if (typeof part === 'string') {
49
- return part + '\x01';
50
- }
51
- else {
52
- let result = numToString(Math.abs(Math.round(part)), part < 0);
53
- // Prefix the number of digits, counting down from 128 for negative and up for positive
54
- return String.fromCharCode(128 + (part > 0 ? result.length : -result.length)) + result;
55
- }
56
- }
57
- function numToString(num, neg) {
58
- let result = '';
59
- while (num > 0) {
60
- /*
61
- * We're reserving a few character codes:
62
- * 0 - for compatibility
63
- * 1 - separator between array items
64
- * 65535 - for compatibility
65
- */
66
- result += String.fromCharCode(neg ? 65535 - (num % 65533) : 2 + (num % 65533));
67
- num = Math.floor(num / 65533);
68
- }
69
- return result;
52
+ }
53
+ function partToStr(part) {
54
+ if (typeof part === 'string') {
55
+ return part + '\x01';
56
+ }
57
+ else {
58
+ let result = numToString(Math.abs(Math.round(part)), part < 0);
59
+ // Prefix the number of digits, counting down from 128 for negative and up for positive
60
+ return String.fromCharCode(128 + (part > 0 ? result.length : -result.length)) + result;
70
61
  }
71
62
  }
63
+ function numToString(num, neg) {
64
+ let result = '';
65
+ while (num > 0) {
66
+ /*
67
+ * We're reserving a few character codes:
68
+ * 0 - for compatibility
69
+ * 1 - separator between array items
70
+ * 65535 - for compatibility
71
+ */
72
+ result += String.fromCharCode(neg ? 65535 - (num % 65533) : 2 + (num % 65533));
73
+ num = Math.floor(num / 65533);
74
+ }
75
+ return result;
76
+ }
72
77
  /*
73
78
  * Scope
74
79
  *
@@ -109,28 +114,32 @@ class Scope {
109
114
  return this.lastChild.findLastNode() || this.lastChild.findPrecedingNode();
110
115
  }
111
116
  addNode(node) {
117
+ if (!this.parentElement)
118
+ throw new ScopeError(true);
112
119
  let prevNode = this.findLastNode() || this.findPrecedingNode();
113
120
  this.parentElement.insertBefore(node, prevNode ? prevNode.nextSibling : this.parentElement.firstChild);
114
121
  this.lastChild = node;
115
122
  }
116
123
  remove() {
117
- let lastNode = this.findLastNode();
118
- if (lastNode) {
119
- // at least one DOM node to be removed
120
- let precedingNode = this.findPrecedingNode();
121
- // Keep removing DOM nodes starting at our last node, until we encounter the preceding node
122
- // (which can be undefined)
123
- while (lastNode !== precedingNode) {
124
- /* istanbul ignore next */
125
- if (!lastNode) {
126
- return internalError(1);
124
+ if (this.parentElement) {
125
+ let lastNode = this.findLastNode();
126
+ if (lastNode) {
127
+ // at least one DOM node to be removed
128
+ let precedingNode = this.findPrecedingNode();
129
+ // Keep removing DOM nodes starting at our last node, until we encounter the preceding node
130
+ // (which can be undefined)
131
+ while (lastNode !== precedingNode) {
132
+ /* istanbul ignore next */
133
+ if (!lastNode) {
134
+ return internalError(1);
135
+ }
136
+ let nextLastNode = lastNode.previousSibling || undefined;
137
+ this.parentElement.removeChild(lastNode);
138
+ lastNode = nextLastNode;
127
139
  }
128
- let nextLastNode = lastNode.previousSibling || undefined;
129
- this.parentElement.removeChild(lastNode);
130
- lastNode = nextLastNode;
131
140
  }
141
+ this.lastChild = undefined;
132
142
  }
133
- this.lastChild = undefined;
134
143
  // run cleaners
135
144
  this._clean();
136
145
  }
@@ -139,6 +148,7 @@ class Scope {
139
148
  for (let cleaner of this.cleaners) {
140
149
  cleaner._clean(this);
141
150
  }
151
+ this.cleaners.length = 0;
142
152
  }
143
153
  onChange(index, newData, oldData) {
144
154
  queue(this);
@@ -173,6 +183,30 @@ class SimpleScope extends Scope {
173
183
  currentScope = savedScope;
174
184
  }
175
185
  }
186
+ class IsEmptyObserver {
187
+ constructor(scope, collection, triggerCount) {
188
+ this.scope = scope;
189
+ this.collection = collection;
190
+ this.triggerCount = triggerCount;
191
+ this.count = collection.getCount();
192
+ collection.addObserver(ANY_INDEX, this);
193
+ scope.cleaners.push(this);
194
+ }
195
+ onChange(index, newData, oldData) {
196
+ if (newData === undefined) {
197
+ // oldData is guaranteed not to be undefined
198
+ if (this.triggerCount || !--this.count)
199
+ queue(this.scope);
200
+ }
201
+ else if (oldData === undefined) {
202
+ if (this.triggerCount || !this.count++)
203
+ queue(this.scope);
204
+ }
205
+ }
206
+ _clean() {
207
+ this.collection.removeObserver(ANY_INDEX, this);
208
+ }
209
+ }
176
210
  class OnEachScope extends Scope {
177
211
  constructor(parentElement, precedingSibling, queueOrder, collection, renderer, makeSortKey) {
178
212
  super(parentElement, precedingSibling, queueOrder);
@@ -224,8 +258,8 @@ class OnEachScope extends Scope {
224
258
  _clean() {
225
259
  super._clean();
226
260
  this.collection.observers.delete(this);
227
- for (let item of this.byPosition) {
228
- item._clean();
261
+ for (const [index, scope] of this.byIndex) {
262
+ scope._clean();
229
263
  }
230
264
  // Help garbage collection:
231
265
  this.byPosition.length = 0;
@@ -257,12 +291,13 @@ class OnEachScope extends Scope {
257
291
  scope.remove();
258
292
  }
259
293
  findPosition(sortStr) {
294
+ // In case of duplicate `sortStr`s, this will return the first match.
260
295
  let items = this.byPosition;
261
- let min = 0, max = this.byPosition.length;
296
+ let min = 0, max = items.length;
262
297
  // Fast-path for elements that are already ordered (as is the case when working with arrays ordered by index)
263
298
  if (!max || sortStr > items[max - 1].sortStr)
264
299
  return max;
265
- // Binary search for the insert position
300
+ // Binary search for the insert position
266
301
  while (min < max) {
267
302
  let mid = (min + max) >> 1;
268
303
  if (items[mid].sortStr < sortStr) {
@@ -288,6 +323,8 @@ class OnEachScope extends Scope {
288
323
  }
289
324
  }
290
325
  removeFromPosition(child) {
326
+ if (child.sortStr === '')
327
+ return;
291
328
  let pos = this.findPosition(child.sortStr);
292
329
  while (true) {
293
330
  if (this.byPosition[pos] === child) {
@@ -335,7 +372,7 @@ class OnEachItemScope extends Scope {
335
372
  let itemStore = new Store(this.parent.collection, this.itemIndex);
336
373
  let sortKey;
337
374
  try {
338
- sortKey = this.parent.makeSortKey(itemStore); // TODO: catch
375
+ sortKey = this.parent.makeSortKey(itemStore);
339
376
  }
340
377
  catch (e) {
341
378
  handleError(e);
@@ -437,7 +474,7 @@ class ObsArray extends ObsCollection {
437
474
  return this.data[index];
438
475
  }
439
476
  rawSet(index, newData) {
440
- if (index !== (0 | index) || index < 0 || index > 99999) {
477
+ if (index !== (0 | index) || index < 0 || index > 999999) {
441
478
  throw new Error(`Invalid array index ${JSON.stringify(index)}`);
442
479
  }
443
480
  this.data[index] = newData;
@@ -467,7 +504,9 @@ class ObsArray extends ObsCollection {
467
504
  }
468
505
  iterateIndexes(scope) {
469
506
  for (let i = 0; i < this.data.length; i++) {
470
- scope.addChild(i);
507
+ if (this.data[i] !== undefined) {
508
+ scope.addChild(i);
509
+ }
471
510
  }
472
511
  }
473
512
  normalizeIndex(index) {
@@ -480,7 +519,10 @@ class ObsArray extends ObsCollection {
480
519
  if (index.length && num == index)
481
520
  return index;
482
521
  }
483
- throw new Error(`Invalid index ${JSON.stringify(index)} for array`);
522
+ throw new Error(`Invalid array index ${JSON.stringify(index)}`);
523
+ }
524
+ getCount() {
525
+ return this.data.length;
484
526
  }
485
527
  }
486
528
  class ObsMap extends ObsCollection {
@@ -538,6 +580,9 @@ class ObsMap extends ObsCollection {
538
580
  normalizeIndex(index) {
539
581
  return index;
540
582
  }
583
+ getCount() {
584
+ return this.data.size;
585
+ }
541
586
  }
542
587
  class ObsObject extends ObsMap {
543
588
  getType() {
@@ -577,7 +622,13 @@ class ObsObject extends ObsMap {
577
622
  return index;
578
623
  if (type === 'number')
579
624
  return '' + index;
580
- throw new Error(`Invalid index ${JSON.stringify(index)} for object`);
625
+ throw new Error(`Invalid object index ${JSON.stringify(index)}`);
626
+ }
627
+ getCount() {
628
+ let cnt = 0;
629
+ for (let key of this.data)
630
+ cnt++;
631
+ return cnt;
581
632
  }
582
633
  }
583
634
  /*
@@ -607,34 +658,85 @@ export class Store {
607
658
  this.idx = index;
608
659
  }
609
660
  }
661
+ /**
662
+ *
663
+ * @returns The index for this Store within its parent collection. This will be a `number`
664
+ * when the parent collection is an array, a `string` when it's an object, or any data type
665
+ * when it's a `Map`.
666
+ *
667
+ * @example
668
+ * ```
669
+ * let store = new Store({x: 123})
670
+ * let subStore = store.ref('x')
671
+ * assert(subStore.get() === 123)
672
+ * assert(subStore.index() === 'x') // <----
673
+ * ```
674
+ */
610
675
  index() {
611
676
  return this.idx;
612
677
  }
613
- _read() {
614
- return this.collection.rawGet(this.idx);
615
- }
678
+ /** @internal */
616
679
  _clean(scope) {
617
680
  this.collection.removeObserver(this.idx, scope);
618
681
  }
619
682
  /**
620
- * Resolves `path` using `ref` and then retrieves the value that is there, subscribing
621
- * to all read Store values. If `path` does not exist, `undefined` is returned.
683
+ * @returns Resolves `path` and then retrieves the value that is there, subscribing
684
+ * to all read `Store` values. If `path` does not exist, `undefined` is returned.
685
+ * @param path - Any path terms to resolve before retrieving the value.
686
+ * @example
687
+ * ```
688
+ * let store = new Store({a: {b: {c: {d: 42}}}})
689
+ * assert(store.get('a', 'b') === {c: {d: 42}})
690
+ * ```
622
691
  */
623
692
  get(...path) {
624
693
  return this.query({ path });
625
694
  }
626
- /** Like `get()`, but throw an exception if the resulting value is not of the named type.
627
- * Using these instead of `query()` directly is especially useful when using TypeScript.
695
+ /**
696
+ * @returns The same as [[`get`]], but doesn't subscribe to changes.
697
+ */
698
+ peek(...path) {
699
+ return this.query({ path, peek: true });
700
+ }
701
+ /**
702
+ * @returns Like [[`get`]], but throws a `TypeError` if the resulting value is not of type `number`.
703
+ * Using this instead of just [[`get`]] is especially useful from within TypeScript.
628
704
  */
629
705
  getNumber(...path) { return this.query({ path, type: 'number' }); }
706
+ /**
707
+ * @returns Like [[`get`]], but throws a `TypeError` if the resulting value is not of type `string`.
708
+ * Using this instead of just [[`get`]] is especially useful from within TypeScript.
709
+ */
630
710
  getString(...path) { return this.query({ path, type: 'string' }); }
711
+ /**
712
+ * @returns Like [[`get`]], but throws a `TypeError` if the resulting value is not of type `boolean`.
713
+ * Using this instead of just [[`get`]] is especially useful from within TypeScript.
714
+ */
631
715
  getBoolean(...path) { return this.query({ path, type: 'boolean' }); }
716
+ /**
717
+ * @returns Like [[`get`]], but throws a `TypeError` if the resulting value is not of type `function`.
718
+ * Using this instead of just [[`get`]] is especially useful from within TypeScript.
719
+ */
632
720
  getFunction(...path) { return this.query({ path, type: 'function' }); }
721
+ /**
722
+ * @returns Like [[`get`]], but throws a `TypeError` if the resulting value is not of type `array`.
723
+ * Using this instead of just [[`get`]] is especially useful from within TypeScript.
724
+ */
633
725
  getArray(...path) { return this.query({ path, type: 'array' }); }
726
+ /**
727
+ * @returns Like [[`get`]], but throws a `TypeError` if the resulting value is not of type `object`.
728
+ * Using this instead of just [[`get`]] is especially useful from within TypeScript.
729
+ */
634
730
  getObject(...path) { return this.query({ path, type: 'object' }); }
731
+ /**
732
+ * @returns Like [[`get`]], but throws a `TypeError` if the resulting value is not of type `map`.
733
+ * Using this instead of just [[`get`]] is especially useful from within TypeScript.
734
+ */
635
735
  getMap(...path) { return this.query({ path, type: 'map' }); }
636
- /** The first parameter is the default value (returned when the Store contains `undefined`).
637
- * This default value is also used to determine the expected time, and to throw otherwise.
736
+ /**
737
+ * @returns Like [[`get`]], but the first parameter is the default value (returned when the Store
738
+ * contains `undefined`). This default value is also used to determine the expected type,
739
+ * and to throw otherwise.
638
740
  */
639
741
  getOr(defaultValue, ...path) {
640
742
  let type = typeof defaultValue;
@@ -646,31 +748,77 @@ export class Store {
646
748
  }
647
749
  return this.query({ type, defaultValue, path });
648
750
  }
751
+ /** Retrieve a value. This is a more flexible form of the [[`get`]] and [[`peek`]] methods.
752
+ * @returns The resulting value, or `undefined` if the `path` does not exist.
753
+ */
649
754
  query(opts) {
755
+ if (opts.peek && currentScope) {
756
+ let savedScope = currentScope;
757
+ currentScope = undefined;
758
+ let result = this.query(opts);
759
+ currentScope = savedScope;
760
+ return result;
761
+ }
650
762
  let store = opts.path && opts.path.length ? this.ref(...opts.path) : this;
651
- let value = store ? store._observe() : undefined;
763
+ let value = store._observe();
652
764
  if (opts.type && (value !== undefined || opts.defaultValue === undefined)) {
653
- let type = (value instanceof ObsCollection) ? value.getType() : typeof value;
765
+ let type = (value instanceof ObsCollection) ? value.getType() : (value === null ? "null" : typeof value);
654
766
  if (type !== opts.type)
655
- throw new Error(`Expecting ${opts.type} but got ${type}`);
767
+ throw new TypeError(`Expecting ${opts.type} but got ${type}`);
656
768
  }
657
769
  if (value instanceof ObsCollection) {
658
- return value.getRecursive(opts.depth == null ? -1 : opts.depth);
770
+ return value.getRecursive(opts.depth == null ? -1 : opts.depth - 1);
659
771
  }
660
772
  return value === undefined ? opts.defaultValue : value;
661
773
  }
774
+ isEmpty(...path) {
775
+ let store = this.ref(...path);
776
+ let value = store._observe();
777
+ if (value instanceof ObsCollection) {
778
+ if (currentScope) {
779
+ let observer = new IsEmptyObserver(currentScope, value, false);
780
+ return !observer.count;
781
+ }
782
+ else {
783
+ return !value.getCount();
784
+ }
785
+ }
786
+ else if (value === undefined) {
787
+ return true;
788
+ }
789
+ else {
790
+ throw new Error(`isEmpty() expects a collection or undefined, but got ${JSON.stringify(value)}`);
791
+ }
792
+ }
793
+ count(...path) {
794
+ let store = this.ref(...path);
795
+ let value = store._observe();
796
+ if (value instanceof ObsCollection) {
797
+ if (currentScope) {
798
+ let observer = new IsEmptyObserver(currentScope, value, true);
799
+ return observer.count;
800
+ }
801
+ else {
802
+ return value.getCount();
803
+ }
804
+ }
805
+ else if (value === undefined) {
806
+ return 0;
807
+ }
808
+ else {
809
+ throw new Error(`count() expects a collection or undefined, but got ${JSON.stringify(value)}`);
810
+ }
811
+ }
662
812
  /**
663
813
  * Returns "undefined", "null", "boolean", "number", "string", "function", "array", "map" or "object"
664
814
  */
665
815
  getType(...path) {
666
816
  let store = this.ref(...path);
667
- if (!store)
668
- return "undefined";
669
817
  let value = store._observe();
670
- return (value instanceof ObsCollection) ? value.getType() : typeof value;
818
+ return (value instanceof ObsCollection) ? value.getType() : (value === null ? "null" : typeof value);
671
819
  }
672
820
  /**
673
- * Sets the Store value to the last given argument. And earlier argument are a Store-path that is first
821
+ * Sets the Store value to the last given argument. Any earlier argument are a Store-path that is first
674
822
  * resolved/created using `makeRef`.
675
823
  */
676
824
  set(...pathAndValue) {
@@ -679,57 +827,94 @@ export class Store {
679
827
  store.collection.setIndex(store.idx, newValue, true);
680
828
  }
681
829
  /**
682
- * Does the same as set, but in case of a top-level collection, it doesn't
683
- * delete keys that don't exist in `value`.
830
+ * Sets the `Store` to the given `mergeValue`, but without deleting any pre-existing
831
+ * items when a collection overwrites a similarly typed collection. This results in
832
+ * a deep merge.
684
833
  */
685
- merge(...pathAndValue) {
686
- let newValue = pathAndValue.pop();
687
- let store = this.makeRef(...pathAndValue);
688
- store.collection.setIndex(store.idx, newValue, false);
834
+ merge(mergeValue) {
835
+ this.collection.setIndex(this.idx, mergeValue, false);
689
836
  }
690
837
  /**
691
- * Sets the value for the store to `undefined`, which causes it to be ommitted from the map (or array, if it's at the end)
838
+ * 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)
692
839
  */
693
840
  delete(...path) {
694
841
  let store = this.makeRef(...path);
695
842
  store.collection.setIndex(store.idx, undefined, true);
696
843
  }
697
844
  /**
698
- * Return an store deeper within the tree by resolving each of the
699
- * arguments as Map indexes, while subscribing to each level.
700
- * If any level does not exist, a detached Store object is returned,
701
- * that will be automatically attached if it is written to.
845
+ * Pushes a value to the end of the Array that is at the specified path in the store.
846
+ * If that Store path is `undefined`, and Array is created first.
847
+ * The last argument is the value to be added, any earlier arguments indicate the path.
848
+ */
849
+ push(newValue) {
850
+ let obsArray = this.collection.rawGet(this.idx);
851
+ if (obsArray === undefined) {
852
+ obsArray = new ObsArray();
853
+ this.collection.setIndex(this.idx, obsArray, true);
854
+ }
855
+ else if (!(obsArray instanceof ObsArray)) {
856
+ throw new Error(`push() is only allowed for an array or undefined (which would become an array)`);
857
+ }
858
+ let newData = valueToData(newValue);
859
+ let pos = obsArray.data.length;
860
+ obsArray.data.push(newData);
861
+ obsArray.emitChange(pos, newData, undefined);
862
+ return pos;
863
+ }
864
+ /**
865
+ * [[`peek`]] the current value, pass it through `func`, and [[`set`]] the resulting
866
+ * value.
867
+ * @param func The function transforming the value.
702
868
  */
703
- ref(...indexes) {
869
+ modify(func) {
870
+ this.set(func(this.query({ peek: true })));
871
+ }
872
+ /**
873
+ * Return a `Store` deeper within the tree by resolving the given `path`,
874
+ * subscribing to every level.
875
+ * In case `undefined` is encountered while resolving the path, a newly
876
+ * created `Store` containing `undefined` is returned. In that case, the
877
+ * `Store`'s [[`isDetached`]] method will return `true`.
878
+ * In case something other than a collection is encountered, an error is thrown.
879
+ */
880
+ ref(...path) {
704
881
  let store = this;
705
- for (let i = 0; i < indexes.length; i++) {
882
+ for (let i = 0; i < path.length; i++) {
706
883
  let value = store._observe();
707
884
  if (value instanceof ObsCollection) {
708
- store = new Store(value, value.normalizeIndex(indexes[i]));
885
+ store = new Store(value, value.normalizeIndex(path[i]));
709
886
  }
710
887
  else {
711
888
  if (value !== undefined)
712
- throw new Error(`Value ${JSON.stringify(value)} is not a collection (nor undefined) in step ${i} of $(${JSON.stringify(indexes)})`);
713
- return;
889
+ throw new Error(`Value ${JSON.stringify(value)} is not a collection (nor undefined) in step ${i} of $(${JSON.stringify(path)})`);
890
+ return new DetachedStore();
714
891
  }
715
892
  }
716
893
  return store;
717
894
  }
718
- makeRef(...indexes) {
895
+ /**
896
+ * Similar to `ref()`, but instead of returning `undefined`, new objects are created when
897
+ * a path does not exist yet. An error is still thrown when the path tries to index an invalid
898
+ * type.
899
+ * Unlike `ref`, `makeRef` does *not* subscribe to the path levels, as it is intended to be
900
+ * a write-only operation.
901
+ */
902
+ makeRef(...path) {
719
903
  let store = this;
720
- for (let i = 0; i < indexes.length; i++) {
904
+ for (let i = 0; i < path.length; i++) {
721
905
  let value = store.collection.rawGet(store.idx);
722
906
  if (!(value instanceof ObsCollection)) {
723
907
  if (value !== undefined)
724
- throw new Error(`Value ${JSON.stringify(value)} is not a collection (nor undefined) in step ${i} of $(${JSON.stringify(indexes)})`);
908
+ throw new Error(`Value ${JSON.stringify(value)} is not a collection (nor undefined) in step ${i} of $(${JSON.stringify(path)})`);
725
909
  value = new ObsObject();
726
910
  store.collection.rawSet(store.idx, value);
727
911
  store.collection.emitChange(store.idx, value, undefined);
728
912
  }
729
- store = new Store(value, value.normalizeIndex(indexes[i]));
913
+ store = new Store(value, value.normalizeIndex(path[i]));
730
914
  }
731
915
  return store;
732
916
  }
917
+ /** @Internal */
733
918
  _observe() {
734
919
  if (currentScope) {
735
920
  if (this.collection.addObserver(this.idx, currentScope)) {
@@ -738,29 +923,6 @@ export class Store {
738
923
  }
739
924
  return this.collection.rawGet(this.idx);
740
925
  }
741
- /**
742
- * Adds `newValue` as a value to a Map, indexed by the old `size()` of the Map. An
743
- * error is thrown if that index already exists.
744
- * In case the Store does not refers to `undefined`, the Array is created first.
745
- * @param newValue
746
- */
747
- push(...pathAndValue) {
748
- let newValue = pathAndValue.pop();
749
- let store = this.makeRef(...pathAndValue);
750
- let obsArray = store.collection.rawGet(store.idx);
751
- if (obsArray === undefined) {
752
- obsArray = new ObsArray();
753
- store.collection.setIndex(store.idx, obsArray, true);
754
- }
755
- else if (!(obsArray instanceof ObsArray)) {
756
- throw new Error(`push() is only allowed for an array or undefined (which would become an array)`);
757
- }
758
- let newData = valueToData(newValue);
759
- let pos = obsArray.data.length;
760
- obsArray.data.push(newData);
761
- obsArray.emitChange(pos, newData, undefined);
762
- return pos;
763
- }
764
926
  onEach(...pathAndFuncs) {
765
927
  let makeSortKey = defaultMakeSortKey;
766
928
  let renderer = pathAndFuncs.pop();
@@ -772,10 +934,8 @@ export class Store {
772
934
  if (typeof renderer !== 'function')
773
935
  throw new Error(`onEach() expects a render function as its last argument but got ${JSON.stringify(renderer)}`);
774
936
  if (!currentScope)
775
- throw new Error("onEach() is only allowed from a render scope");
937
+ throw new ScopeError(false);
776
938
  let store = this.ref(...pathAndFuncs);
777
- if (!store)
778
- return;
779
939
  let val = store._observe();
780
940
  if (val instanceof ObsCollection) {
781
941
  // Subscribe to changes using the specialized OnEachScope
@@ -789,33 +949,123 @@ export class Store {
789
949
  throw new Error(`onEach() attempted on a value that is neither a collection nor undefined`);
790
950
  }
791
951
  }
952
+ /**
953
+ * Applies a filter/map function on each item within the `Store`'s collection,
954
+ * and reactively manages the returned `Map` `Store` to hold any results.
955
+ *
956
+ * @param func - Function that transform the given store into an output value or
957
+ * `undefined` in case this value should be skipped:
958
+ *
959
+ * @returns - A map `Store` with the values returned by `func` and the corresponding
960
+ * keys from the original map, array or object `Store`.
961
+ *
962
+ * When items disappear from the `Store` or are changed in a way that `func` depends
963
+ * upon, the resulting items are removed from the output `Store` as well. When multiple
964
+ * input items produce the same output keys, this may lead to unexpected results.
965
+ */
966
+ map(func) {
967
+ let out = new Store(new Map());
968
+ this.onEach((item) => {
969
+ let value = func(item);
970
+ if (value !== undefined) {
971
+ let key = item.index();
972
+ out.set(key, value);
973
+ clean(() => {
974
+ out.delete(key);
975
+ });
976
+ }
977
+ });
978
+ return out;
979
+ }
980
+ /*
981
+ * Applies a filter/map function on each item within the `Store`'s collection,
982
+ * each of which can deliver any number of key/value pairs, and reactively manages the
983
+ * returned map `Store` to hold any results.
984
+ *
985
+ * @param func - Function that transform the given store into output values
986
+ * that can take one of the following forms:
987
+ * - an `Object` or a `Map`: Each key/value pair will be added to the output `Store`.
988
+ * - anything else: No key/value pairs are added to the output `Store`.
989
+ *
990
+ * @returns - A map `Store` with the key/value pairs returned by all `func` invocations.
991
+ *
992
+ * When items disappear from the `Store` or are changed in a way that `func` depends
993
+ * upon, the resulting items are removed from the output `Store` as well. When multiple
994
+ * input items produce the same output keys, this may lead to unexpected results.
995
+ */
996
+ multiMap(func) {
997
+ let out = new Store(new Map());
998
+ this.onEach((item) => {
999
+ let result = func(item);
1000
+ let keys;
1001
+ if (result.constructor === Object) {
1002
+ for (let key in result) {
1003
+ out.set(key, result[key]);
1004
+ }
1005
+ keys = Object.keys(result);
1006
+ }
1007
+ else if (result instanceof Map) {
1008
+ result.forEach((value, key) => {
1009
+ out.set(key, value);
1010
+ });
1011
+ keys = Array.from(result.keys());
1012
+ }
1013
+ else {
1014
+ return;
1015
+ }
1016
+ if (keys.length) {
1017
+ clean(() => {
1018
+ for (let key of keys) {
1019
+ out.delete(key);
1020
+ }
1021
+ });
1022
+ }
1023
+ });
1024
+ return out;
1025
+ }
1026
+ /**
1027
+ * @returns Returns `true` when the `Store` was created by [[`ref`]]ing a path that
1028
+ * does not exist.
1029
+ */
1030
+ isDetached() { return false; }
1031
+ }
1032
+ class DetachedStore extends Store {
1033
+ isDetached() { return true; }
792
1034
  }
793
1035
  /**
794
1036
  * Create a new DOM element.
795
- * @param tagClass - The tag of the element to be created and optionally dot-seperated class names. For example: `h1` or `p.intro.has_avatar`.
1037
+ * @param tag - The tag of the element to be created and optionally dot-separated class names. For example: `h1` or `p.intro.has_avatar`.
796
1038
  * @param rest - The other arguments are flexible and interpreted based on their types:
797
1039
  * - `string`: Used as textContent for the element.
798
1040
  * - `object`: Used as attributes/properties for the element. See `applyProp` on how the distinction is made.
799
1041
  * - `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.
1042
+ * - `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`. After that, the `Store` will be updated when the input changes.
800
1043
  * @example
801
1044
  * node('aside.editorial', 'Yada yada yada....', () => {
802
- * node('a', {href: '/bio'}, () => {
803
- * node('img.author', {src: '/me.jpg', alt: 'The author'})
804
- * })
1045
+ * node('a', {href: '/bio'}, () => {
1046
+ * node('img.author', {src: '/me.jpg', alt: 'The author'})
1047
+ * })
805
1048
  * })
806
1049
  */
807
- export function node(tagClass, ...rest) {
1050
+ export function node(tag = "", ...rest) {
808
1051
  if (!currentScope)
809
- throw new Error(`node() outside of a render scope`);
1052
+ throw new ScopeError(true);
810
1053
  let el;
811
- if (tagClass.indexOf('.') >= 0) {
812
- let classes = tagClass.split('.');
813
- let tag = classes.shift();
814
- el = document.createElement(tag);
815
- el.className = classes.join(' ');
1054
+ if (tag instanceof Element) {
1055
+ el = tag;
816
1056
  }
817
1057
  else {
818
- el = document.createElement(tagClass);
1058
+ let pos = tag.indexOf('.');
1059
+ let classes;
1060
+ if (pos >= 0) {
1061
+ classes = tag.substr(pos + 1);
1062
+ tag = tag.substr(0, pos);
1063
+ }
1064
+ el = document.createElement(tag || 'div');
1065
+ if (classes) {
1066
+ // @ts-ignore (replaceAll is polyfilled)
1067
+ el.className = classes.replaceAll('.', ' ');
1068
+ }
819
1069
  }
820
1070
  currentScope.addNode(el);
821
1071
  for (let item of rest) {
@@ -835,24 +1085,62 @@ export function node(tagClass, ...rest) {
835
1085
  applyProp(el, k, item[k]);
836
1086
  }
837
1087
  }
1088
+ else if (item instanceof Store) {
1089
+ bindInput(el, item);
1090
+ }
838
1091
  else if (item != null) {
839
1092
  throw new Error(`Unexpected argument ${JSON.stringify(item)}`);
840
1093
  }
841
1094
  }
842
1095
  }
1096
+ function bindInput(el, store) {
1097
+ let updater;
1098
+ let type = el.getAttribute('type');
1099
+ let value = store.query({ peek: true });
1100
+ if (type === 'checkbox') {
1101
+ if (value === undefined)
1102
+ store.set(el.checked);
1103
+ else
1104
+ el.checked = value;
1105
+ updater = () => store.set(el.checked);
1106
+ }
1107
+ else if (type === 'radio') {
1108
+ if (value === undefined) {
1109
+ if (el.checked)
1110
+ store.set(el.value);
1111
+ }
1112
+ else
1113
+ el.checked = value === el.value;
1114
+ updater = () => {
1115
+ if (el.checked)
1116
+ store.set(el.value);
1117
+ };
1118
+ }
1119
+ else {
1120
+ if (value === undefined)
1121
+ store.set(el.value);
1122
+ else
1123
+ el.value = value;
1124
+ updater = () => store.set(el.value);
1125
+ }
1126
+ el.addEventListener('input', updater);
1127
+ clean(() => {
1128
+ el.removeEventListener('input', updater);
1129
+ });
1130
+ }
843
1131
  /**
844
1132
  * Add a text node at the current Scope position.
845
1133
  */
846
1134
  export function text(text) {
847
1135
  if (!currentScope)
848
- throw new Error(`text() outside of a render scope`);
849
- if (!text)
1136
+ throw new ScopeError(true);
1137
+ if (text == null)
850
1138
  return;
851
1139
  currentScope.addNode(document.createTextNode(text));
852
1140
  }
853
1141
  export function prop(prop, value = undefined) {
854
- if (!currentScope)
855
- throw new Error(`prop() outside of a render scope`);
1142
+ if (!currentScope || !currentScope.parentElement)
1143
+ throw new ScopeError(true);
856
1144
  if (typeof prop === 'object') {
857
1145
  for (let k in prop) {
858
1146
  applyProp(currentScope.parentElement, k, prop[k]);
@@ -863,54 +1151,153 @@ export function prop(prop, value = undefined) {
863
1151
  }
864
1152
  }
865
1153
  /**
866
- * Register a `clean` function that is executed when the current `Scope` disappears or redraws.
1154
+ * Return the browser Element that `node()`s would be rendered to at this point.
1155
+ * NOTE: Manually changing the DOM is not recommended in most cases. There is
1156
+ * usually a better, declarative way. Although there are no hard guarantees on
1157
+ * how your changes interact with Aberdeen, in most cases results will not be
1158
+ * terribly surprising. Be careful within the parent element of onEach() though.
1159
+ */
1160
+ export function getParentElement() {
1161
+ if (!currentScope || !currentScope.parentElement)
1162
+ throw new ScopeError(true);
1163
+ return currentScope.parentElement;
1164
+ }
1165
+ /**
1166
+ * Register a function that is to be executed right before the current reactive scope
1167
+ * disappears or redraws.
1168
+ * @param clean - The function to be executed.
867
1169
  */
868
1170
  export function clean(clean) {
869
1171
  if (!currentScope)
870
- throw new Error(`clean() outside of a render scope`);
1172
+ throw new ScopeError(false);
871
1173
  currentScope.cleaners.push({ _clean: clean });
872
1174
  }
873
1175
  /**
874
- * Create a new Scope and execute the `renderer` within that Scope. When
875
- * `Store`s that the `renderer` reads are updated, only this Scope will
876
- * need to be refreshed, leaving the parent Scope untouched.
1176
+ * Create a new reactive scope and execute the `func` within that scope. When
1177
+ * `Store`s that the `func` reads are updated, only this scope will need to be refreshed,
1178
+ * leaving the parent scope untouched.
1179
+ *
1180
+ * In case this function is called outside of a an existing scope, it will create a new
1181
+ * top-level scope (a [[`Mount`]]) without a `parentElement`, meaning that aberdeen operations
1182
+ * that create/modify DOM elements are not permitted.
1183
+ * @param func - The function to be (repeatedly) executed within the newly created scope.
1184
+ * @returns The newly created `Mount` object in case this is a top-level reactive scope.
1185
+ * @example
1186
+ * ```
1187
+ * let store = new Store('John Doe')
1188
+ * mount(document.body, () => {
1189
+ * node('div.card', () => {
1190
+ * node('input', {placeholder: 'Name'}, store)
1191
+ * observe(() => {
1192
+ * prop('class', {correct: store.get().length > 5})
1193
+ * })
1194
+ * })
1195
+ * })
1196
+ * ```
877
1197
  */
878
- export function scope(renderer) {
879
- if (!currentScope)
880
- throw new Error(`scope() outside of a render scope`);
881
- let scope = new SimpleScope(currentScope.parentElement, currentScope.lastChild || currentScope.precedingSibling, currentScope.queueOrder + 1, renderer);
882
- currentScope.lastChild = scope;
883
- scope.update();
884
- // Add it to our list of cleaners. Even if `scope` currently has
885
- // no cleaners, it may get them in a future refresh.
886
- currentScope.cleaners.push(scope);
1198
+ /**
1199
+ * Reactively run a function, meaning the function will rerun when any `Store` that was read
1200
+ * during its execution is updated.
1201
+ * Calls to `observe` can be nested, such that changes to `Store`s read by the inner function do
1202
+ * no cause the outer function to rerun.
1203
+ *
1204
+ * @param func - The function to be (repeatedly) executed.
1205
+ * @example
1206
+ * ```
1207
+ * let number = new Store(0)
1208
+ * let doubled = new Store()
1209
+ * setInterval(() => number.set(0|Math.random()*100)), 1000)
1210
+ *
1211
+ * observe(() => {
1212
+ * doubled.set(number.get() * 2)
1213
+ * })
1214
+ *
1215
+ * observe(() => {
1216
+ * console.log(doubled.get())
1217
+ * })
1218
+ */
1219
+ export function observe(func) {
1220
+ mount(undefined, func);
887
1221
  }
888
1222
  /**
889
- * Main entry point for using aberdeen. The elements created by the given `render` function are appended to `parentElement` (and updated when read `Store`s change).
890
- * @param parentElement - The DOM element to append to.
891
- * @param renderer - The function that does the rendering.
1223
+ * Like [[`observe`]], but allow the function to create DOM elements using [[`node`]].
1224
+
1225
+ * @param func - The function to be (repeatedly) executed, possibly adding DOM elements to `parentElement`.
1226
+ * @param parentElement - A DOM element that will be used as the parent element for calls to `node`.
1227
+ *
892
1228
  * @example
1229
+ * ```
1230
+ * let store = new Store(0)
1231
+ * setInterval(() => store.modify(v => v+1), 1000)
1232
+ *
893
1233
  * mount(document.body, () => {
894
- * node('h1', 'Hello world!', () => {
895
- * node('img.logo', {src: '/logo.png'})
896
- * })
1234
+ * node('h2', `${store.get()} seconds have passed`)
897
1235
  * })
898
- */
899
- class Mount {
900
- constructor(scope) {
901
- this.scope = scope;
1236
+ * ```
1237
+ *
1238
+ * An example nesting [[`observe`]] within `mount`:
1239
+ * ```
1240
+ * let selected = new Store(0)
1241
+ * let colors = new Store(new Map())
1242
+ *
1243
+ * mount(document.body, () => {
1244
+ * // This function will never rerun (as it does not read any `Store`s)
1245
+ * node('button', '<<', {click: () => selected.modify(n => n-1)})
1246
+ * node('button', '>>', {click: () => selected.modify(n => n+1)})
1247
+ *
1248
+ * observe(() => {
1249
+ * // This will rerun whenever `selected` changes, recreating the <h2> and <input>.
1250
+ * node('h2', '#'+selected.get())
1251
+ * node('input', {type: 'color', value: '#ffffff'}, colors.ref(selected.get()))
1252
+ * })
1253
+ *
1254
+ * observe(() => {
1255
+ * // This function will rerun when `selected` or the selected color changes.
1256
+ * // It will change the <body> background-color.
1257
+ * prop({style: {backgroundColor: colors.get(selected.get()) || 'white'}})
1258
+ * })
1259
+ * })
1260
+ * ```
1261
+ */
1262
+ export function mount(parentElement, func) {
1263
+ let scope;
1264
+ if (parentElement || !currentScope) {
1265
+ scope = new SimpleScope(parentElement, undefined, 0, func);
902
1266
  }
903
- unmount() {
904
- this.scope.remove();
1267
+ else {
1268
+ scope = new SimpleScope(currentScope.parentElement, currentScope.lastChild || currentScope.precedingSibling, currentScope.queueOrder + 1, func);
1269
+ currentScope.lastChild = scope;
905
1270
  }
906
- }
907
- export function mount(parentElement, renderer) {
908
- if (currentScope)
909
- throw new Error('mount() from within a render scope');
910
- let scope = new SimpleScope(parentElement, undefined, 0, renderer);
1271
+ // Do the initial run
911
1272
  scope.update();
912
- return new Mount(scope);
1273
+ // Add it to our list of cleaners. Even if `scope` currently has
1274
+ // no cleaners, it may get them in a future refresh.
1275
+ if (currentScope) {
1276
+ currentScope.cleaners.push(scope);
1277
+ }
913
1278
  }
1279
+ /** Runs the given function, while not subscribing the current scope when reading [[`Store`]] values.
1280
+ *
1281
+ * @param func Function to be executed immediately.
1282
+ * @returns Whatever `func()` returns.
1283
+ * @example
1284
+ * ```
1285
+ * import {Store, peek, text} from aberdeen
1286
+ *
1287
+ * let store = new Store(['a', 'b', 'c'])
1288
+ *
1289
+ * mount(document.body, () => {
1290
+ * // Prevent rerender when store changes
1291
+ * peek(() => {
1292
+ * text(`Store has ${store.count()} elements, and the first is ${store.get(0)}`)
1293
+ * })
1294
+ * })
1295
+ * ```
1296
+ *
1297
+ * In the above example `store.get(0)` could be replaced with `store.peek(0)` to achieve the
1298
+ * same result without `peek()` wrapping everything. There is no non-subscribing equivalent
1299
+ * for `count()` however.
1300
+ */
914
1301
  export function peek(func) {
915
1302
  let savedScope = currentScope;
916
1303
  currentScope = undefined;
@@ -925,16 +1312,24 @@ export function peek(func) {
925
1312
  * Helper functions
926
1313
  */
927
1314
  function applyProp(el, prop, value) {
928
- if (prop === 'value' || prop === 'className' || prop === 'selectedIndex' || value === true || value === false) {
1315
+ if ((prop === 'class' || prop === 'className') && typeof value === 'object') {
1316
+ // Allow setting classes using an object where the keys are the names and
1317
+ // the values are booleans stating whether to set or remove.
1318
+ for (let name in value) {
1319
+ if (value[name])
1320
+ el.classList.add(name);
1321
+ else
1322
+ el.classList.remove(name);
1323
+ }
1324
+ }
1325
+ else if (prop === 'value' || prop === 'className' || prop === 'selectedIndex' || value === true || value === false) {
929
1326
  // All boolean values and a few specific keys should be set as a property
930
1327
  el[prop] = value;
931
1328
  }
932
1329
  else if (typeof value === 'function') {
933
1330
  // Set an event listener; remove it again on clean.
934
1331
  el.addEventListener(prop, value);
935
- if (currentScope) {
936
- clean(() => el.removeEventListener(prop, value));
937
- }
1332
+ clean(() => el.removeEventListener(prop, value));
938
1333
  }
939
1334
  else if (prop === 'style' && typeof value === 'object') {
940
1335
  // `style` can receive an object
@@ -995,11 +1390,26 @@ function valueToData(value) {
995
1390
  function defaultMakeSortKey(store) {
996
1391
  return store.index();
997
1392
  }
1393
+ /* istanbul ignore next */
998
1394
  function internalError(code) {
999
- console.error(new Error("internal error " + code));
1395
+ let error = new Error("internal error " + code);
1396
+ setTimeout(() => { throw error; }, 0);
1000
1397
  }
1001
1398
  function handleError(e) {
1002
- console.error(e);
1003
1399
  // Throw the error async, so the rest of the rendering can continue
1004
1400
  setTimeout(() => { throw e; }, 0);
1005
1401
  }
1402
+ class ScopeError extends Error {
1403
+ constructor(mount) {
1404
+ super(`Operation not permitted outside of ${mount ? "a mount" : "an observe"}() scope`);
1405
+ }
1406
+ }
1407
+ let arrayFromSet = Array.from || /* istanbul ignore next */ ((set) => {
1408
+ let array = [];
1409
+ set.forEach(item => array.push(item));
1410
+ return array;
1411
+ });
1412
+ // @ts-ignore
1413
+ // istanbul ignore next
1414
+ if (!String.prototype.replaceAll)
1415
+ String.prototype.replaceAll = function (from, to) { return this.split(from).join(to); };