simplyflow 0.2.3 → 0.3.1

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/src/bind.mjs CHANGED
@@ -1,12 +1,36 @@
1
1
  import { throttledEffect, destroy } from './state.mjs'
2
2
 
3
- class SimplyBind {
4
- constructor(options) {
3
+ /**
4
+ * Implements one way databinding, updating dom elements with matching attributes
5
+ * to changes in signals (see state.mjs)
6
+ *
7
+ * @class
8
+ */
9
+ class SimplyBind
10
+ {
11
+
12
+ /**
13
+ * @param Object options - a set of options for this instance, options may include:
14
+ * - root (signal) (required) - the root data object that contains al signals that can be bound
15
+ * - container (HTMLElement) - the dom element to use as the root for all bindings
16
+ * - attribute (string) - the prefix for the field, list and map attributes, e.g. 'data-bind'
17
+ * - transformers (object name:function) - a map of transformer names and functions
18
+ * - defaultTransformers (object with field, list and map properties)
19
+ */
20
+ constructor(options)
21
+ {
22
+ /**
23
+ * A map of HTMLElements and the data bindings on each, in the form of
24
+ * the connectedSignal returned by the (throttled)Effect.
25
+ * @type {Map}
26
+ * @public
27
+ */
5
28
  this.bindings = new Map()
29
+
6
30
  const defaultOptions = {
7
31
  container: document.body,
8
32
  attribute: 'data-bind',
9
- transformers: [],
33
+ transformers: {},
10
34
  defaultTransformers: {
11
35
  field: [defaultFieldTransformer],
12
36
  list: [defaultListTransformer],
@@ -17,10 +41,10 @@ class SimplyBind {
17
41
  throw new Error('bind needs at least options.root set')
18
42
  }
19
43
  this.options = Object.assign({}, defaultOptions, options)
20
-
21
44
  const attribute = this.options.attribute
22
45
  const bindAttributes = [attribute+'-field',attribute+'-list',attribute+'-map']
23
46
  const bindSelector = `[${attribute}-field],[${attribute}-list],[${attribute}-map]`
47
+ const transformAttribute = attribute+'-transform'
24
48
 
25
49
  const getBindingAttribute = (el) => {
26
50
  const foundAttribute = bindAttributes.find(attr => el.hasAttribute(attr))
@@ -32,9 +56,17 @@ class SimplyBind {
32
56
 
33
57
  // sets up the effect that updates the element if its
34
58
  // data binding value changes
35
-
36
59
  const render = (el) => {
37
60
  this.bindings.set(el, throttledEffect(() => {
61
+ if (!el.isConnected) {
62
+ // el is no longer part of this document
63
+ untrack(el, this.getBindingPath(el))
64
+ destroy(this.bindings.get(el))
65
+ // doing this here instead of in a mutationobserver
66
+ // allows an element to be temporary removed and then inserted
67
+ // without the binding having to be reset
68
+ return
69
+ }
38
70
  const context = {
39
71
  templates: el.querySelectorAll(':scope > template'),
40
72
  attribute: getBindingAttribute(el)
@@ -42,8 +74,9 @@ class SimplyBind {
42
74
  context.path = this.getBindingPath(el)
43
75
  context.value = getValueByPath(this.options.root, context.path)
44
76
  context.element = el
77
+ track(el, context)
45
78
  runTransformers(context)
46
- }, 100))
79
+ }, 50))
47
80
  }
48
81
 
49
82
  // finds and runs applicable transformers
@@ -63,14 +96,16 @@ class SimplyBind {
63
96
  transformers = this.options.defaultTransformers.map || []
64
97
  break
65
98
  }
66
- if (context.element.dataset.transform) {
67
- context.element.dataset.transform.split(' ').filter(Boolean).forEach(t => {
68
- if (this.options.transformers[t]) {
69
- transformers.push(this.options.transformers[t])
70
- } else {
71
- console.warn('No transformer with name '+t+' configured', {cause:context.element})
72
- }
73
- })
99
+ if (context.element.hasAttribute(transformAttribute)) {
100
+ context.element.getAttribute(transformAttribute)
101
+ .split(' ').filter(Boolean)
102
+ .forEach(t => {
103
+ if (this.options.transformers[t]) {
104
+ transformers.push(this.options.transformers[t])
105
+ } else {
106
+ console.warn('No transformer with name '+t+' configured', {cause:context.element})
107
+ }
108
+ })
74
109
  }
75
110
  let next
76
111
  for (let transformer of transformers) {
@@ -87,7 +122,9 @@ class SimplyBind {
87
122
  // this renders each of those elements
88
123
  const applyBindings = (bindings) => {
89
124
  for (let bindingEl of bindings) {
90
- render(bindingEl)
125
+ if (!this.bindings.get(bindingEl)) { // bindingEl may have moved from somewhere else in this document
126
+ render(bindingEl)
127
+ }
91
128
  }
92
129
  }
93
130
 
@@ -141,8 +178,11 @@ class SimplyBind {
141
178
  /**
142
179
  * Finds the first matching template and creates a new DocumentFragment
143
180
  * with the correct data bind attributes in it (prepends the current path)
181
+ * @param Context context
182
+ * @return DocumentFragment
144
183
  */
145
- applyTemplate(context) {
184
+ applyTemplate(context)
185
+ {
146
186
  const path = context.path
147
187
  const templates = context.templates
148
188
  const list = context.list
@@ -182,13 +222,28 @@ class SimplyBind {
182
222
  if (typeof index !== 'undefined') {
183
223
  clone.children[0].setAttribute(attribute+'-key',index)
184
224
  }
185
- // keep track of the used template, so if that changes, the
186
- // item can be updated
187
- clone.children[0].$bindTemplate = template
225
+ // keep track of the used template, so if that changes, the item can be updated
226
+ Object.defineProperty(
227
+ clone.children[0],
228
+ '$bindTemplate',
229
+ {
230
+ value: template,
231
+ enumerable: false,
232
+ writable: true,
233
+ configurable: true
234
+ }
235
+ )
236
+ // return clone, not the firstChild, so that all whitespace is cloned as well
188
237
  return clone
189
238
  }
190
239
 
191
- getBindingPath(el) {
240
+ /**
241
+ * Returns the path referenced in either the field, list or map attribute
242
+ * @param HTMLElement el
243
+ * @return string The path referenced, or void
244
+ */
245
+ getBindingPath(el)
246
+ {
192
247
  const attributes = [
193
248
  this.options.attribute+'-field',
194
249
  this.options.attribute+'-list',
@@ -205,7 +260,8 @@ class SimplyBind {
205
260
  * Finds the first template from an array of templates that
206
261
  * matches the given value.
207
262
  */
208
- findTemplate(templates, value) {
263
+ findTemplate(templates, value)
264
+ {
209
265
  const templateMatches = t => {
210
266
  // find the value to match against (e.g. data-bind="foo")
211
267
  let path = this.getBindingPath(t)
@@ -253,7 +309,8 @@ class SimplyBind {
253
309
  return template
254
310
  }
255
311
 
256
- destroy() {
312
+ destroy()
313
+ {
257
314
  this.bindings.forEach(binding => {
258
315
  destroy(binding)
259
316
  })
@@ -272,11 +329,33 @@ export function bind(options)
272
329
  return new SimplyBind(options)
273
330
  }
274
331
 
332
+ const tracking = new Map()
333
+
334
+ export function trace(path)
335
+ {
336
+ return tracking.get(path)
337
+ }
338
+
339
+ function track(el, context) {
340
+ if (!tracking.has(context.path)) {
341
+ tracking.set(context.path, [context])
342
+ } else {
343
+ tracking.get(context.path).push(context)
344
+ }
345
+ }
346
+
347
+ function untrack(el, path) {
348
+ let list = tracking.get(path)
349
+ list = list.filter(context => context.element == el)
350
+ tracking.set(path, list)
351
+ }
352
+
275
353
  /**
276
354
  * Returns true if a matches b, either by having the
277
355
  * same string value, or matching string :empty against a falsy value
278
356
  */
279
- export function matchValue(a,b) {
357
+ export function matchValue(a,b)
358
+ {
280
359
  if (a==':empty' && !b) {
281
360
  return true
282
361
  }
@@ -290,10 +369,12 @@ export function matchValue(a,b) {
290
369
  }
291
370
 
292
371
  /**
293
- * Returns the value by walking the given path
294
- * as a json pointer, starting at root
295
- * if you have a property with a '.' in its name
296
- * urlencode the '.', e.g: %46
372
+ * Returns the value by walking the given path as a json pointer, starting at root
373
+ * if you have a property with a '.' in its name urlencode the '.', e.g: %46
374
+ *
375
+ * @param HTMLElement root
376
+ * @param string path e.g. 'foo.bar'
377
+ * @return mixed the value found by walking the path from the root object or undefined
297
378
  */
298
379
  export function getValueByPath(root, path)
299
380
  {
@@ -321,7 +402,8 @@ export function getValueByPath(root, path)
321
402
  * Default transformer for data binding
322
403
  * Will be used unless overriden in the SimplyBind options parameter
323
404
  */
324
- export function defaultFieldTransformer(context) {
405
+ export function defaultFieldTransformer(context)
406
+ {
325
407
  const el = context.element
326
408
  const templates = context.templates
327
409
  const templatesCount = templates.length
@@ -345,6 +427,15 @@ export function defaultFieldTransformer(context) {
345
427
  case 'A':
346
428
  transformAnchor.call(this, context)
347
429
  break
430
+ case 'IMG':
431
+ transformImage.call(this, contet)
432
+ break
433
+ case 'IFRAME':
434
+ transformIframe.call(this, context)
435
+ break
436
+ case 'META':
437
+ transformMeta.call(this, context)
438
+ break
348
439
  case 'TEMPLATE': // never touch templates!
349
440
  break
350
441
  default:
@@ -355,7 +446,8 @@ export function defaultFieldTransformer(context) {
355
446
  return context
356
447
  }
357
448
 
358
- export function defaultListTransformer(context) {
449
+ export function defaultListTransformer(context)
450
+ {
359
451
  const el = context.element
360
452
  const templates = context.templates
361
453
  const templatesCount = templates.length
@@ -373,7 +465,8 @@ export function defaultListTransformer(context) {
373
465
  return context
374
466
  }
375
467
 
376
- export function defaultMapTransformer(context) {
468
+ export function defaultMapTransformer(context)
469
+ {
377
470
  const el = context.element
378
471
  const templates = context.templates
379
472
  const templatesCount = templates.length
@@ -399,7 +492,8 @@ export function defaultMapTransformer(context) {
399
492
  * FIXME: this doesn't handle situations where there is no matching template
400
493
  * this messes up self healing. check transformObjectByTemplates for a better implementation
401
494
  */
402
- export function transformArrayByTemplates(context) {
495
+ export function transformArrayByTemplates(context)
496
+ {
403
497
  const el = context.element
404
498
  const templates = context.templates
405
499
  const templatesCount = templates.length
@@ -476,7 +570,8 @@ export function transformArrayByTemplates(context) {
476
570
  * Replaces,moves or removes existing DOM children if needed
477
571
  * Reuses (doesn't touch) DOM children if template doesn't change
478
572
  */
479
- export function transformObjectByTemplates(context) {
573
+ export function transformObjectByTemplates(context)
574
+ {
480
575
  const el = context.element
481
576
  const templates = context.templates
482
577
  const templatesCount = templates.length
@@ -521,7 +616,8 @@ export function transformObjectByTemplates(context) {
521
616
  }
522
617
  }
523
618
 
524
- function getParentPath(el, attribute) {
619
+ function getParentPath(el, attribute)
620
+ {
525
621
  const parentEl = el.parentElement?.closest(`[${attribute}-list],[${attribute}-map]`)
526
622
  if (!parentEl) {
527
623
  return ':root'
@@ -538,7 +634,8 @@ function getParentPath(el, attribute) {
538
634
  * data-bind attributes inside the template use the same
539
635
  * parent path as this html element uses
540
636
  */
541
- export function transformLiteralByTemplates(context) {
637
+ export function transformLiteralByTemplates(context)
638
+ {
542
639
  const el = context.element
543
640
  const templates = context.templates
544
641
  const value = context.value
@@ -568,12 +665,16 @@ export function transformLiteralByTemplates(context) {
568
665
  * for radio/checkbox inputs it only sets the checked attribute to true/false
569
666
  * if the value attribute matches the current value
570
667
  * for other inputs the value attribute is updated
571
- * FIXME: handle radio/checkboxes in separate transformer
572
668
  */
573
- export function transformInput(context) {
574
- const el = context.element
575
- const value = context.value
669
+ export function transformInput(context)
670
+ {
671
+ const el = context.element
672
+ let value = context.value
576
673
 
674
+ transformElement(context)
675
+ if (typeof value == 'undefined') {
676
+ value = ''
677
+ }
577
678
  if (el.type=='checkbox' || el.type=='radio') {
578
679
  if (matchValue(el.value, value)) {
579
680
  el.checked = true
@@ -588,36 +689,80 @@ export function transformInput(context) {
588
689
  /**
589
690
  * Sets the value of the button, doesn't touch the innerHTML
590
691
  */
591
- export function transformButton(context) {
692
+ export function transformButton(context)
693
+ {
592
694
  const el = context.element
593
695
  const value = context.value
594
696
 
595
- if (!matchValue(el.value,value)) {
596
- el.value = ''+value
597
- }
697
+ transformElement(context)
698
+ setProperties(el, value, 'value')
598
699
  }
599
700
 
600
701
  /**
601
702
  * Sets the selected attribute of select options
602
703
  */
603
- export function transformSelect(context) {
604
- const el = context.element
605
- const value = context.value
704
+ export function transformSelect(context)
705
+ {
706
+ const el = context.element
707
+ let value = context.value
606
708
 
607
- if (el.multiple) {
608
- if (Array.isArray(value)) {
609
- for (let option of el.options) {
610
- if (value.indexOf(option.value)===false) {
611
- option.selected = false
612
- } else {
613
- option.selected = true
709
+ if (value === null) {
710
+ value = ''
711
+ }
712
+ if (typeof value!='object') {
713
+ if (el.multiple) {
714
+ if (Array.isArray(value)) { //FIXME: cannot be true, since typeof != 'object'
715
+ for (let option of el.options) {
716
+ if (value.indexOf(option.value)===false) {
717
+ option.selected = false
718
+ } else {
719
+ option.selected = true
720
+ }
614
721
  }
615
722
  }
723
+ } else {
724
+ let option = el.options.find(o => matchValue(o.value,value))
725
+ if (option) {
726
+ option.selected = true
727
+ option.setAttribute('selected', true)
728
+ }
616
729
  }
617
- } else {
618
- let option = el.options.find(o => matchValue(o.value,value))
619
- if (option) {
620
- option.selected = true
730
+ } else { // value is a non-null object
731
+ if (value.options) {
732
+ setSelectOptions(el, value.options)
733
+ }
734
+ if (value.selected) {
735
+ transformSelect(Object.asssign({}, context, {value:value.selected}))
736
+ }
737
+ setProperties(el, value, 'name', 'id', 'selectedIndex', 'className') // allow innerHTML? if so call transformElement instead
738
+ }
739
+ }
740
+
741
+ export function addOption(select, option)
742
+ {
743
+ if (!option) {
744
+ return
745
+ }
746
+ if (typeof option !== 'object') {
747
+ select.options.add(new Option(''+option))
748
+ } else if (option.text) {
749
+ select.options.add(new Option(option.text, option.value, option.defaultSelected, option.selected))
750
+ } else if (typeof option.value != 'undefined') {
751
+ select.options.add(new Option(''+option.value, option.value, option.defaultSelected, option.selected))
752
+ }
753
+ }
754
+
755
+ export function setSelectOptions(select,options)
756
+ {
757
+ //@TODO: only update in case of changes?
758
+ select.innerHTML = ''
759
+ if (Array.isArray(options)) {
760
+ for (const option of options) {
761
+ addOption(select, option)
762
+ }
763
+ } else if (options && typeof options == 'object') {
764
+ for (const option in options) {
765
+ addOption(select, { text: options[option], value: option })
621
766
  }
622
767
  }
623
768
  }
@@ -626,30 +771,80 @@ export function transformSelect(context) {
626
771
  * Sets the innerHTML and href attribute of an anchor
627
772
  * TODO: support target, title, etc. attributes
628
773
  */
629
- export function transformAnchor(context) {
774
+ export function transformAnchor(context)
775
+ {
630
776
  const el = context.element
631
777
  const value = context.value
632
778
 
633
- if (value?.innerHTML && !matchValue(el.innerHTML, value.innerHTML)) {
634
- el.innerHTML = ''+value.innerHTML
635
- }
636
- if (value?.href && !matchValue(el.href,value.href)) {
637
- el.href = ''+value.href
638
- }
779
+ transformElement(context)
780
+ setProperties(el, value, 'title', 'target', 'href', 'name', 'newwindow', 'nofollow')
639
781
  }
640
782
 
641
- /**
642
- * sets the innerHTML of any HTML element
643
- */
644
- export function transformElement(context) {
783
+ export function transformImage(context)
784
+ {
785
+ const el = context.element
786
+ const value = context.value
787
+
788
+ transformElement(context)
789
+ setProperties(el, value, 'title', 'alt', 'src')
790
+ }
791
+
792
+ export function transformIframe(context)
793
+ {
645
794
  const el = context.element
646
795
  const value = context.value
647
796
 
648
- if (!matchValue(el.innerHTML, value)) {
649
- if (typeof value=='undefined' || value==null) {
650
- el.innerHTML = ''
797
+ transformElement(context)
798
+ setProperties(el, value, 'title', 'src')
799
+ }
800
+
801
+ export function transformMeta(context)
802
+ {
803
+ const el = context.element
804
+ const value = context.value
805
+
806
+ transformElement(context)
807
+ setProperties(el, value, 'content')
808
+ }
809
+ /**
810
+ * sets the innerHTML and title and id properties of any HTML element
811
+ */
812
+ export function transformElement(context)
813
+ {
814
+ const el = context.element
815
+ let value = context.value
816
+
817
+ if (typeof value=='undefined' || value==null) {
818
+ value = ''
819
+ }
820
+ let strValue = ''+value
821
+ if (typeof value!='object' || strValue.substring(0,8)!='[object ') {
822
+ el.innerHTML = strValue
823
+ return
824
+ }
825
+ setProperties(el, value, 'innerHTML', 'title', 'id', 'className')
826
+ }
827
+
828
+ /**
829
+ * Sets a list of properties on a dom element, equal to
830
+ * the string value of a data object
831
+ * only updates the dom element if the property doesn't match
832
+ */
833
+ export function setProperties(el, data, ...properties) {
834
+ if (!data || typeof data!=='object') {
835
+ return
836
+ }
837
+ for (const property of properties) {
838
+ if (typeof data[property] === 'undefined') {
839
+ continue
840
+ }
841
+ if (matchValue(el[property], data[property])) {
842
+ continue
843
+ }
844
+ if (data[property] === null) {
845
+ el[property] = ''
651
846
  } else {
652
- el.innerHTML = ''+value
847
+ el[property] = ''+data[property]
653
848
  }
654
849
  }
655
- }
850
+ }
package/src/model.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import {signal, effect, batch} from './state.mjs'
1
+ import {signal, effect, throttledEffect, batch} from './state.mjs'
2
2
 
3
3
  /**
4
4
  * This class implements a pluggable data model, where you can
@@ -84,13 +84,13 @@ export function sort(options={}) {
84
84
  }, options);
85
85
  // then return the effect, which is called when
86
86
  // either the data or the sort options change
87
- return effect(() => {
87
+ return throttledEffect(() => {
88
88
  const sort = this.state.options.sort
89
89
  if (sort?.sortBy && sort?.direction) {
90
90
  return data.current.toSorted(sort?.sortFn)
91
91
  }
92
92
  return data.current
93
- })
93
+ }, 50)
94
94
  }
95
95
  }
96
96
 
@@ -112,7 +112,7 @@ export function paging(options={}) {
112
112
  pageSize: 20,
113
113
  max: 1
114
114
  }, options)
115
- return effect(() => {
115
+ return throttledEffect(() => {
116
116
  return batch(() => {
117
117
  const paging = this.state.options.paging
118
118
  if (!paging.pageSize) {
@@ -125,7 +125,7 @@ export function paging(options={}) {
125
125
  const end = start + paging.pageSize
126
126
  return data.current.slice(start, end)
127
127
  })
128
- })
128
+ }, 50)
129
129
  }
130
130
  }
131
131
 
@@ -149,12 +149,12 @@ export function filter(options) {
149
149
  throw new Error('a filter with this name already exists on this model')
150
150
  }
151
151
  this.state.options[options.name] = options
152
- return effect(() => {
152
+ return throttledEffect(() => {
153
153
  if (this.state.options[options.name].enabled) {
154
154
  return data.current.filter(this.state.options[options.name].matches.bind(this))
155
155
  }
156
156
  return data.current
157
- })
157
+ }, 50)
158
158
  }
159
159
  }
160
160
 
@@ -176,7 +176,7 @@ export function columns(options={}) {
176
176
  }
177
177
  return function(data) {
178
178
  this.state.options.columns = options
179
- return effect(() => {
179
+ return throttledEffect(() => {
180
180
  return data.current.map(input => {
181
181
  let result = {}
182
182
  for (let key of Object.keys(this.state.options.columns)) {
@@ -186,7 +186,7 @@ export function columns(options={}) {
186
186
  }
187
187
  return result
188
188
  })
189
- })
189
+ }, 50)
190
190
  }
191
191
  }
192
192
 
@@ -229,13 +229,13 @@ export function scroll(options) {
229
229
  })
230
230
  }
231
231
 
232
- effect(() => {
232
+ throttledEffect(() => {
233
233
  scrollOptions.size = data.current.length * scrollOptions.rowHeight
234
234
  scrollbar.style.height = scrollOptions.size + 'px'
235
- })
235
+ }, 50)
236
236
  }
237
237
 
238
- return effect(() => {
238
+ return throttledEffect(() => {
239
239
  if (scrollOptions.container) {
240
240
  //TODO: add a resize listener so that if the size of the container
241
241
  // changes, the rowCount is calculated again
@@ -252,6 +252,6 @@ export function scroll(options) {
252
252
  start = end - scrollOptions.rowCount
253
253
  }
254
254
  return data.current.slice(start, end)
255
- })
255
+ }, 50)
256
256
  }
257
257
  }