simplyflow 0.2.2 → 0.3.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/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,24 +41,31 @@ 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))
27
51
  if (!foundAttribute) {
28
- console.error('No matching attribute found',el)
52
+ console.error('No matching attribute found',el,attr)
29
53
  }
30
54
  return foundAttribute
31
55
  }
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
+ destroy(this.bindings.get(el))
64
+ // doing this here instead of in a mutationobserver
65
+ // allows an element to be temporary removed and then inserted
66
+ // without the binding having to be reset
67
+ return
68
+ }
38
69
  const context = {
39
70
  templates: el.querySelectorAll(':scope > template'),
40
71
  attribute: getBindingAttribute(el)
@@ -43,7 +74,7 @@ class SimplyBind {
43
74
  context.value = getValueByPath(this.options.root, context.path)
44
75
  context.element = el
45
76
  runTransformers(context)
46
- }, 100))
77
+ }, 50))
47
78
  }
48
79
 
49
80
  // finds and runs applicable transformers
@@ -63,14 +94,18 @@ class SimplyBind {
63
94
  transformers = this.options.defaultTransformers.map || []
64
95
  break
65
96
  }
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
- })
97
+ if (context.element.hasAttribute(transformAttribute)) {
98
+ context.element.getAttribute(transformAttribute)
99
+ .split(' ').filter(Boolean)
100
+ .forEach(t => {
101
+ if (this.options.transformers[t]) {
102
+ transformers.push(this.options.transformers[t])
103
+ } else {
104
+ console.warn('No transformer with name '+t+' configured', {cause:context.element})
105
+ }
106
+ })
107
+ } else {
108
+ console.log(context.element.outerHTML)
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
 
@@ -119,7 +156,7 @@ class SimplyBind {
119
156
  updateBindings(changes)
120
157
  })
121
158
 
122
- this.observer.observe(options.container, {
159
+ this.observer.observe(this.options.container, {
123
160
  subtree: true,
124
161
  childList: true
125
162
  })
@@ -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
  })
@@ -276,7 +333,8 @@ export function bind(options)
276
333
  * Returns true if a matches b, either by having the
277
334
  * same string value, or matching string :empty against a falsy value
278
335
  */
279
- export function matchValue(a,b) {
336
+ export function matchValue(a,b)
337
+ {
280
338
  if (a==':empty' && !b) {
281
339
  return true
282
340
  }
@@ -290,10 +348,12 @@ export function matchValue(a,b) {
290
348
  }
291
349
 
292
350
  /**
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
351
+ * Returns the value by walking the given path as a json pointer, starting at root
352
+ * if you have a property with a '.' in its name urlencode the '.', e.g: %46
353
+ *
354
+ * @param HTMLElement root
355
+ * @param string path e.g. 'foo.bar'
356
+ * @return mixed the value found by walking the path from the root object or undefined
297
357
  */
298
358
  export function getValueByPath(root, path)
299
359
  {
@@ -321,7 +381,8 @@ export function getValueByPath(root, path)
321
381
  * Default transformer for data binding
322
382
  * Will be used unless overriden in the SimplyBind options parameter
323
383
  */
324
- export function defaultFieldTransformer(context) {
384
+ export function defaultFieldTransformer(context)
385
+ {
325
386
  const el = context.element
326
387
  const templates = context.templates
327
388
  const templatesCount = templates.length
@@ -345,6 +406,15 @@ export function defaultFieldTransformer(context) {
345
406
  case 'A':
346
407
  transformAnchor.call(this, context)
347
408
  break
409
+ case 'IMG':
410
+ transformImage.call(this, contet)
411
+ break
412
+ case 'IFRAME':
413
+ transformIframe.call(this, context)
414
+ break
415
+ case 'META':
416
+ transformMeta.call(this, context)
417
+ break
348
418
  case 'TEMPLATE': // never touch templates!
349
419
  break
350
420
  default:
@@ -355,7 +425,8 @@ export function defaultFieldTransformer(context) {
355
425
  return context
356
426
  }
357
427
 
358
- export function defaultListTransformer(context) {
428
+ export function defaultListTransformer(context)
429
+ {
359
430
  const el = context.element
360
431
  const templates = context.templates
361
432
  const templatesCount = templates.length
@@ -364,7 +435,7 @@ export function defaultListTransformer(context) {
364
435
  const attribute = this.options.attribute
365
436
 
366
437
  if (!Array.isArray(value)) {
367
- console.error('Value is not an array.', el, value)
438
+ console.error('Value is not an array.', el, path, value)
368
439
  } else if (!templates?.length) {
369
440
  console.error('No templates found in', el)
370
441
  } else {
@@ -373,7 +444,8 @@ export function defaultListTransformer(context) {
373
444
  return context
374
445
  }
375
446
 
376
- export function defaultMapTransformer(context) {
447
+ export function defaultMapTransformer(context)
448
+ {
377
449
  const el = context.element
378
450
  const templates = context.templates
379
451
  const templatesCount = templates.length
@@ -382,7 +454,7 @@ export function defaultMapTransformer(context) {
382
454
  const attribute = this.options.attribute
383
455
 
384
456
  if (typeof value != 'object') {
385
- console.error('Value is not an object.', el, value)
457
+ console.error('Value is not an object.', el, path, value)
386
458
  } else if (!templates?.length) {
387
459
  console.error('No templates found in', el)
388
460
  } else {
@@ -399,7 +471,8 @@ export function defaultMapTransformer(context) {
399
471
  * FIXME: this doesn't handle situations where there is no matching template
400
472
  * this messes up self healing. check transformObjectByTemplates for a better implementation
401
473
  */
402
- export function transformArrayByTemplates(context) {
474
+ export function transformArrayByTemplates(context)
475
+ {
403
476
  const el = context.element
404
477
  const templates = context.templates
405
478
  const templatesCount = templates.length
@@ -476,7 +549,8 @@ export function transformArrayByTemplates(context) {
476
549
  * Replaces,moves or removes existing DOM children if needed
477
550
  * Reuses (doesn't touch) DOM children if template doesn't change
478
551
  */
479
- export function transformObjectByTemplates(context) {
552
+ export function transformObjectByTemplates(context)
553
+ {
480
554
  const el = context.element
481
555
  const templates = context.templates
482
556
  const templatesCount = templates.length
@@ -521,7 +595,8 @@ export function transformObjectByTemplates(context) {
521
595
  }
522
596
  }
523
597
 
524
- function getParentPath(el, attribute) {
598
+ function getParentPath(el, attribute)
599
+ {
525
600
  const parentEl = el.parentElement?.closest(`[${attribute}-list],[${attribute}-map]`)
526
601
  if (!parentEl) {
527
602
  return ':root'
@@ -538,7 +613,8 @@ function getParentPath(el, attribute) {
538
613
  * data-bind attributes inside the template use the same
539
614
  * parent path as this html element uses
540
615
  */
541
- export function transformLiteralByTemplates(context) {
616
+ export function transformLiteralByTemplates(context)
617
+ {
542
618
  const el = context.element
543
619
  const templates = context.templates
544
620
  const value = context.value
@@ -568,12 +644,16 @@ export function transformLiteralByTemplates(context) {
568
644
  * for radio/checkbox inputs it only sets the checked attribute to true/false
569
645
  * if the value attribute matches the current value
570
646
  * for other inputs the value attribute is updated
571
- * FIXME: handle radio/checkboxes in separate transformer
572
647
  */
573
- export function transformInput(context) {
574
- const el = context.element
575
- const value = context.value
648
+ export function transformInput(context)
649
+ {
650
+ const el = context.element
651
+ let value = context.value
576
652
 
653
+ transformElement(context)
654
+ if (typeof value == 'undefined') {
655
+ value = ''
656
+ }
577
657
  if (el.type=='checkbox' || el.type=='radio') {
578
658
  if (matchValue(el.value, value)) {
579
659
  el.checked = true
@@ -588,36 +668,80 @@ export function transformInput(context) {
588
668
  /**
589
669
  * Sets the value of the button, doesn't touch the innerHTML
590
670
  */
591
- export function transformButton(context) {
671
+ export function transformButton(context)
672
+ {
592
673
  const el = context.element
593
674
  const value = context.value
594
675
 
595
- if (!matchValue(el.value,value)) {
596
- el.value = ''+value
597
- }
676
+ transformElement(context)
677
+ setProperties(el, value, 'value')
598
678
  }
599
679
 
600
680
  /**
601
681
  * Sets the selected attribute of select options
602
682
  */
603
- export function transformSelect(context) {
604
- const el = context.element
605
- const value = context.value
683
+ export function transformSelect(context)
684
+ {
685
+ const el = context.element
686
+ let value = context.value
606
687
 
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
688
+ if (value === null) {
689
+ value = ''
690
+ }
691
+ if (typeof value!='object') {
692
+ if (el.multiple) {
693
+ if (Array.isArray(value)) {
694
+ for (let option of el.options) {
695
+ if (value.indexOf(option.value)===false) {
696
+ option.selected = false
697
+ } else {
698
+ option.selected = true
699
+ }
614
700
  }
615
701
  }
702
+ } else {
703
+ let option = el.options.find(o => matchValue(o.value,value))
704
+ if (option) {
705
+ option.selected = true
706
+ option.setAttribute('selected', true)
707
+ }
616
708
  }
617
- } else {
618
- let option = el.options.find(o => matchValue(o.value,value))
619
- if (option) {
620
- option.selected = true
709
+ } else { // value is a non-null object
710
+ if (value.options) {
711
+ setSelectOptions(el, value.options)
712
+ }
713
+ if (value.selected) {
714
+ transformSelect(Object.asssign({}, context, {value:value.selected}))
715
+ }
716
+ setProperties(el, value, 'name', 'id', 'selectedIndex', 'className') // allow innerHTML? if so call transformElement instead
717
+ }
718
+ }
719
+
720
+ export function addOption(select, option)
721
+ {
722
+ if (!option) {
723
+ return
724
+ }
725
+ if (typeof option !== 'object') {
726
+ select.options.add(new Option(''+option))
727
+ } else if (option.text) {
728
+ select.options.add(new Option(option.text, option.value, option.defaultSelected, option.selected))
729
+ } else if (typeof option.value != 'undefined') {
730
+ select.options.add(new Option(''+option.value, option.value, option.defaultSelected, option.selected))
731
+ }
732
+ }
733
+
734
+ export function setSelectOptions(select,options)
735
+ {
736
+ //@TODO: only update in case of changes?
737
+ select.innerHTML = ''
738
+ if (Array.isArray(options)) {
739
+ for (const option of options) {
740
+ addOption(select, option)
741
+ }
742
+ } else if (options && typeof options == 'object') {
743
+ for (const option in options) {
744
+ addOption(select, { text: options[option], value: option })
621
745
  }
622
746
  }
623
747
  }
@@ -626,30 +750,77 @@ export function transformSelect(context) {
626
750
  * Sets the innerHTML and href attribute of an anchor
627
751
  * TODO: support target, title, etc. attributes
628
752
  */
629
- export function transformAnchor(context) {
753
+ export function transformAnchor(context)
754
+ {
630
755
  const el = context.element
631
756
  const value = context.value
632
757
 
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
- }
758
+ transformElement(context)
759
+ setProperties(el, value, 'title', 'target', 'href', 'name', 'newwindow', 'nofollow')
639
760
  }
640
761
 
641
- /**
642
- * sets the innerHTML of any HTML element
643
- */
644
- export function transformElement(context) {
762
+ export function transformImage(context)
763
+ {
645
764
  const el = context.element
646
765
  const value = context.value
647
766
 
648
- if (!matchValue(el.innerHTML, value)) {
649
- if (typeof value=='undefined' || value==null) {
650
- el.innerHTML = ''
651
- } else {
652
- el.innerHTML = ''+value
767
+ transformElement(context)
768
+ setProperties(el, value, 'title', 'alt', 'src')
769
+ }
770
+
771
+ export function transformIframe(context)
772
+ {
773
+ const el = context.element
774
+ const value = context.value
775
+
776
+ transformElement(context)
777
+ setProperties(el, value, 'title', 'src')
778
+ }
779
+
780
+ export function transformMeta(context)
781
+ {
782
+ const el = context.element
783
+ const value = context.value
784
+
785
+ transformElement(context)
786
+ setProperties(el, value, 'content')
787
+ }
788
+ /**
789
+ * sets the innerHTML and title and id properties of any HTML element
790
+ */
791
+ export function transformElement(context)
792
+ {
793
+ const el = context.element
794
+ let value = context.value
795
+
796
+ if (typeof value=='undefined' || value==null) {
797
+ value = ''
798
+ }
799
+ if (typeof value == 'string') {
800
+ el.innerHTML = ''+value
801
+ return
802
+ }
803
+ setProperties(el, value, 'innerHTML', 'title', 'id', 'className')
804
+ }
805
+
806
+ /**
807
+ * Sets a list of properties on a dom element, equal to
808
+ * the string value of a data object
809
+ * only updates the dom element if the property doesn't match
810
+ */
811
+ export function setProperties(el, data, ...properties) {
812
+ if (!data || typeof data!=='object') {
813
+ return
814
+ }
815
+ for (const property of properties) {
816
+ if (typeof data[property] !== 'undefined') {
817
+ if (!matchValue(el[property], data[property])) {
818
+ if (data[property] === null) {
819
+ el[property] = ''
820
+ } else {
821
+ el[property] = ''+data[property]
822
+ }
823
+ }
653
824
  }
654
825
  }
655
- }
826
+ }
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
 
@@ -141,19 +141,20 @@ export function filter(options) {
141
141
  if (!options?.name || typeof options.name!=='string') {
142
142
  throw new Error('filter requires options.name to be a string')
143
143
  }
144
- if (this.state.options[options.name]) {
145
- throw new Error('a filter with this name already exists on this model')
146
- }
147
144
  if (!options.matches || typeof options.matches!=='function') {
148
145
  throw new Error('filter requires options.matches to be a function')
149
146
  }
150
147
  return function(data) {
148
+ if (this.state.options[options.name]) {
149
+ throw new Error('a filter with this name already exists on this model')
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
- return data.filter(this.state.options[options.name].matches)
154
+ return data.current.filter(this.state.options[options.name].matches.bind(this))
155
155
  }
156
- })
156
+ return data.current
157
+ }, 50)
157
158
  }
158
159
  }
159
160
 
@@ -175,7 +176,7 @@ export function columns(options={}) {
175
176
  }
176
177
  return function(data) {
177
178
  this.state.options.columns = options
178
- return effect(() => {
179
+ return throttledEffect(() => {
179
180
  return data.current.map(input => {
180
181
  let result = {}
181
182
  for (let key of Object.keys(this.state.options.columns)) {
@@ -185,7 +186,7 @@ export function columns(options={}) {
185
186
  }
186
187
  return result
187
188
  })
188
- })
189
+ }, 50)
189
190
  }
190
191
  }
191
192
 
@@ -228,13 +229,13 @@ export function scroll(options) {
228
229
  })
229
230
  }
230
231
 
231
- effect(() => {
232
+ throttledEffect(() => {
232
233
  scrollOptions.size = data.current.length * scrollOptions.rowHeight
233
234
  scrollbar.style.height = scrollOptions.size + 'px'
234
- })
235
+ }, 50)
235
236
  }
236
237
 
237
- return effect(() => {
238
+ return throttledEffect(() => {
238
239
  if (scrollOptions.container) {
239
240
  //TODO: add a resize listener so that if the size of the container
240
241
  // changes, the rowCount is calculated again
@@ -251,6 +252,6 @@ export function scroll(options) {
251
252
  start = end - scrollOptions.rowCount
252
253
  }
253
254
  return data.current.slice(start, end)
254
- })
255
+ }, 50)
255
256
  }
256
257
  }
package/src/state.mjs CHANGED
@@ -2,12 +2,18 @@ const iterate = Symbol('iterate')
2
2
  if (!Symbol.xRay) {
3
3
  Symbol.xRay = Symbol('xRay')
4
4
  }
5
+ if (!Symbol.Signal) {
6
+ Symbol.Signal = Symbol('Signal')
7
+ }
5
8
 
6
9
  const signalHandler = {
7
10
  get: (target, property, receiver) => {
8
11
  if (property===Symbol.xRay) {
9
12
  return target // don't notifyGet here, this is only called by set
10
13
  }
14
+ if (property===Symbol.Signal) {
15
+ return true
16
+ }
11
17
  const value = target?.[property] // Reflect.get fails on a Set.
12
18
  notifyGet(receiver, property)
13
19
  if (typeof value === 'function') {
@@ -116,7 +122,13 @@ const signals = new WeakMap()
116
122
  * to allow reactive functions to be triggered when signal values change.
117
123
  */
118
124
  export function signal(v) {
119
- if (!signals.has(v)) {
125
+ if (v[Symbol.Signal]) { // avoid wrapping a Signal inside a Signal
126
+ let target = v[Symbol.xRay]
127
+ if (!signals.has(target)) {
128
+ signals.set(target, v)
129
+ }
130
+ v = target
131
+ } else if (!signals.has(v)) {
120
132
  signals.set(v, new Proxy(v, signalHandler))
121
133
  }
122
134
  return signals.get(v)