simplyflow 0.3.3 → 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/src/bind.mjs CHANGED
@@ -1,4 +1,10 @@
1
1
  import { throttledEffect, destroy } from './state.mjs'
2
+ import { escape_html, fixed_content } from './bind.transformers.mjs'
3
+ import * as render from './bind.render.mjs'
4
+
5
+ if (!Symbol.bindTemplate) {
6
+ Symbol.bindTemplate = Symbol('bindTemplate')
7
+ }
2
8
 
3
9
  /**
4
10
  * Implements one way databinding, updating dom elements with matching attributes
@@ -15,7 +21,7 @@ class SimplyBind
15
21
  * - container (HTMLElement) - the dom element to use as the root for all bindings
16
22
  * - attribute (string) - the prefix for the field, list and map attributes, e.g. 'data-bind'
17
23
  * - transformers (object name:function) - a map of transformer names and functions
18
- * - defaultTransformers (object with field, list and map properties)
24
+ * - render (object with field, list and map properties)
19
25
  */
20
26
  constructor(options)
21
27
  {
@@ -27,18 +33,29 @@ class SimplyBind
27
33
  */
28
34
  this.bindings = new Map()
29
35
 
30
- const standardTransformers = {
36
+ const defaultTransformers = {
31
37
  escape_html,
32
38
  fixed_content
33
39
  }
34
40
  const defaultOptions = {
35
41
  container: document.body,
36
- attribute: 'data-bind',
37
- transformers: standardTransformers,
38
- defaultTransformers: {
39
- field: [defaultFieldTransformer],
40
- list: [defaultListTransformer],
41
- map: [defaultMapTransformer]
42
+ attribute: 'data-flow',
43
+ transformers: defaultTransformers,
44
+ render: {
45
+ field: [render.field],
46
+ list: [render.list],
47
+ map: [render.map]
48
+ },
49
+ renderers: {
50
+ 'INPUT':render.input,
51
+ 'BUTTON':render.button,
52
+ 'SELECT':render.select,
53
+ 'A':render.anchor,
54
+ 'IMG':render.image,
55
+ 'IFRAME':render.iframe,
56
+ 'META':render.meta,
57
+ 'TEMPLATE':null,
58
+ '*':render.element
42
59
  }
43
60
  }
44
61
  if (!options?.root) {
@@ -46,7 +63,7 @@ class SimplyBind
46
63
  }
47
64
  this.options = Object.assign({}, defaultOptions, options)
48
65
  if (options.transformers) {
49
- this.options.transformers = Object.assign({}, standardTransformers, options?.transformers)
66
+ this.options.transformers = Object.assign({}, defaultTransformers, options?.transformers)
50
67
  }
51
68
  const attribute = this.options.attribute
52
69
  const bindAttributes = [attribute+'-field',attribute+'-list',attribute+'-map']
@@ -55,14 +72,14 @@ class SimplyBind
55
72
  const getBindingAttribute = (el) => {
56
73
  const foundAttribute = bindAttributes.find(attr => el.hasAttribute(attr))
57
74
  if (!foundAttribute) {
58
- console.error('No matching attribute found',el,attr)
75
+ console.error('No matching attribute found',el,bindAttributes)
59
76
  }
60
77
  return foundAttribute
61
78
  }
62
79
 
63
80
  // sets up the effect that updates the element if its
64
81
  // data binding value changes
65
- const render = (el) => {
82
+ const renderElement = (el) => {
66
83
  this.bindings.set(el, throttledEffect(() => {
67
84
  if (!el.isConnected) {
68
85
  // el is no longer part of this document
@@ -73,7 +90,7 @@ class SimplyBind
73
90
  // without the binding having to be reset
74
91
  return
75
92
  }
76
- const context = {
93
+ let context = {
77
94
  templates: el.querySelectorAll(':scope > template'),
78
95
  attribute: getBindingAttribute(el)
79
96
  }
@@ -93,13 +110,16 @@ class SimplyBind
93
110
  let transformers
94
111
  switch(context.attribute) {
95
112
  case this.options.attribute+'-field':
96
- transformers = Array.from(this.options.defaultTransformers.field)
113
+ transformers = Array.from(this.options.render.field)
97
114
  break
98
115
  case this.options.attribute+'-list':
99
- transformers = Array.from(this.options.defaultTransformers.list)
116
+ transformers = Array.from(this.options.render.list)
100
117
  break
101
118
  case this.options.attribute+'-map':
102
- transformers = Array.from(this.options.defaultTransformers.map)
119
+ transformers = Array.from(this.options.render.map)
120
+ break
121
+ default:
122
+ throw new Error('no valid context attribute specified',context)
103
123
  break
104
124
  }
105
125
  if (context.element.hasAttribute(transformAttribute)) {
@@ -129,7 +149,7 @@ class SimplyBind
129
149
  const applyBindings = (bindings) => {
130
150
  for (let bindingEl of bindings) {
131
151
  if (!this.bindings.get(bindingEl)) { // bindingEl may have moved from somewhere else in this document
132
- render(bindingEl)
152
+ renderElement(bindingEl)
133
153
  }
134
154
  }
135
155
  }
@@ -193,7 +213,6 @@ class SimplyBind
193
213
  const templates = context.templates
194
214
  const list = context.list
195
215
  const index = context.index
196
- const parent = context.parent
197
216
  const value = list ? list[index] : context.value
198
217
 
199
218
  let template = this.findTemplate(templates, value)
@@ -222,23 +241,15 @@ class SimplyBind
222
241
  } else if (index!=null) {
223
242
  binding.setAttribute(attr, path+'.'+index+'.'+bind)
224
243
  } else {
225
- binding.setAttribute(attr, parent+'.'+bind)
244
+ binding.setAttribute(attr, path+'.'+bind)
226
245
  }
227
246
  }
228
247
  if (typeof index !== 'undefined') {
229
248
  clone.children[0].setAttribute(attribute+'-key',index)
230
249
  }
231
250
  // keep track of the used template, so if that changes, the item can be updated
232
- Object.defineProperty(
233
- clone.children[0],
234
- '$bindTemplate',
235
- {
236
- value: template,
237
- enumerable: false,
238
- writable: true,
239
- configurable: true
240
- }
241
- )
251
+ clone.children[0][Symbol.bindTemplate] = template
252
+
242
253
  // return clone, not the firstChild, so that all whitespace is cloned as well
243
254
  return clone
244
255
  }
@@ -358,23 +369,6 @@ function untrack(el, path) {
358
369
  }
359
370
  }
360
371
 
361
- /**
362
- * Returns true if a matches b, either by having the
363
- * same string value, or matching string :empty against a falsy value
364
- */
365
- export function matchValue(a,b)
366
- {
367
- if (a==':empty' && !b) {
368
- return true
369
- }
370
- if (b==':empty' && !a) {
371
- return true
372
- }
373
- if (''+a == ''+b) {
374
- return true
375
- }
376
- return false
377
- }
378
372
 
379
373
  /**
380
374
  * Returns the value by walking the given path as a json pointer, starting at root
@@ -386,488 +380,14 @@ export function matchValue(a,b)
386
380
  */
387
381
  export function getValueByPath(root, path)
388
382
  {
389
- let parts = path.split('.');
390
- let curr = root;
391
- let part, prevPart;
392
- while (parts.length && curr) {
383
+ let parts = path.split('.')
384
+ let curr = root
385
+ let part
386
+ part = parts.shift()
387
+ while (part && curr) {
388
+ part = decodeURIComponent(part)
389
+ curr = curr[part]
393
390
  part = parts.shift()
394
- if (part==':key') {
395
- return prevPart
396
- } else if (part==':value') {
397
- return curr
398
- } else if (part==':root') {
399
- curr = root
400
- } else {
401
- part = decodeURIComponent(part)
402
- curr = curr[part];
403
- prevPart = part
404
- }
405
391
  }
406
392
  return curr
407
393
  }
408
-
409
- /**
410
- * Default transformer for data binding
411
- * Will be used unless overriden in the SimplyBind options parameter
412
- */
413
- export function defaultFieldTransformer(context)
414
- {
415
- const el = context.element
416
- const templates = context.templates
417
-
418
- if (templates?.length) {
419
- transformLiteralByTemplates.call(this, context)
420
- } else {
421
- switch(el.tagName) {
422
- case 'INPUT':
423
- transformInput.call(this, context)
424
- break
425
- case 'BUTTON':
426
- transformButton.call(this, context)
427
- break
428
- case 'SELECT':
429
- transformSelect.call(this, context)
430
- break
431
- case 'A':
432
- transformAnchor.call(this, context)
433
- break
434
- case 'IMG':
435
- transformImage.call(this, context)
436
- break
437
- case 'IFRAME':
438
- transformIframe.call(this, context)
439
- break
440
- case 'META':
441
- transformMeta.call(this, context)
442
- break
443
- case 'TEMPLATE': // never touch templates!
444
- break
445
- default:
446
- transformElement.call(this, context)
447
- break
448
- }
449
- }
450
- return context
451
- }
452
-
453
- export function defaultListTransformer(context)
454
- {
455
- const el = context.element
456
- const templates = context.templates
457
- const path = context.path
458
- const value = context.value
459
-
460
- if (!Array.isArray(value)) {
461
- console.error('Value is not an array.', el, path, value)
462
- } else if (!templates?.length) {
463
- console.error('No templates found in', el)
464
- } else {
465
- transformArrayByTemplates.call(this, context)
466
- }
467
- return context
468
- }
469
-
470
- export function defaultMapTransformer(context)
471
- {
472
- const el = context.element
473
- const templates = context.templates
474
- const path = context.path
475
- const value = context.value
476
-
477
- if (typeof value != 'object') {
478
- console.error('Value is not an object.', el, path, value)
479
- } else if (!templates?.length) {
480
- console.error('No templates found in', el)
481
- } else {
482
- transformObjectByTemplates.call(this, context)
483
- }
484
- return context
485
- }
486
-
487
-
488
- /**
489
- * Renders an array value by applying templates for each entry
490
- * Replaces or removes existing DOM children if needed
491
- * Reuses (doesn't touch) DOM children if template doesn't change
492
- * FIXME: this doesn't handle situations where there is no matching template
493
- * this messes up self healing. check transformObjectByTemplates for a better implementation
494
- */
495
- export function transformArrayByTemplates(context)
496
- {
497
- const el = context.element
498
- const templates = context.templates
499
- const path = context.path
500
- const value = context.value
501
- const attribute = this.options.attribute
502
-
503
- let items = el.querySelectorAll(':scope > ['+attribute+'-key]')
504
- // do single merge strategy for now, in future calculate optimal merge strategy from a number
505
- // now just do a delete if a key <= last key, insert if a key >= last key
506
- let lastKey = 0
507
- let skipped = 0
508
- context.list = value
509
- for (let item of items) {
510
- let currentKey = parseInt(item.getAttribute(attribute+'-key'))
511
- if (currentKey>lastKey) {
512
- // insert before
513
- context.index = lastKey
514
- el.insertBefore(this.applyTemplate(context), item)
515
- } else if (currentKey<lastKey) {
516
- // remove this
517
- item.remove()
518
- } else {
519
- // check that all data-bind params start with current json path or ':root', otherwise replaceChild
520
- let bindings = Array.from(item.querySelectorAll(`[${attribute}]`))
521
- if (item.matches(`[${attribute}]`)) {
522
- bindings.unshift(item)
523
- }
524
- let needsReplacement = bindings.find(b => {
525
- let databind = b.getAttribute(attribute)
526
- return (databind.substr(0,5)!==':root'
527
- && databind.substr(0, path.length)!==path)
528
- })
529
- if (!needsReplacement) {
530
- if (item.$bindTemplate) {
531
- let newTemplate = this.findTemplate(templates, value[lastKey])
532
- if (newTemplate != item.$bindTemplate){
533
- needsReplacement = true
534
- if (!newTemplate) {
535
- skipped++
536
- }
537
- }
538
- }
539
- }
540
- if (needsReplacement) {
541
- context.index = lastKey
542
- el.replaceChild(this.applyTemplate(context), item)
543
- }
544
- }
545
- lastKey++
546
- if (lastKey>=value.length) {
547
- break
548
- }
549
- }
550
- items = el.querySelectorAll(':scope > ['+attribute+'-key]')
551
- let length = items.length + skipped
552
- if (length > value.length) {
553
- while (length > value.length) {
554
- let child = el.querySelectorAll(':scope > :not(template)')?.[length-1]
555
- child?.remove()
556
- length--
557
- }
558
- } else if (length < value.length ) {
559
- while (length < value.length) {
560
- context.index = length
561
- el.appendChild(this.applyTemplate(context))
562
- length++
563
- }
564
- }
565
- }
566
-
567
- /**
568
- * Renders an object value by applying templates for each entry (Object.entries)
569
- * Replaces,moves or removes existing DOM children if needed
570
- * Reuses (doesn't touch) DOM children if template doesn't change
571
- */
572
- export function transformObjectByTemplates(context)
573
- {
574
- const el = context.element
575
- const templates = context.templates
576
- const value = context.value
577
- const attribute = this.options.attribute
578
- context.list = value
579
-
580
- let items = Array.from(el.querySelectorAll(':scope > ['+attribute+'-key]'))
581
- for (let key in context.list) {
582
- context.index = key
583
- let item = items.shift()
584
- if (!item) { // more properties than rendered items
585
- let clone = this.applyTemplate(context)
586
- el.appendChild(clone)
587
- continue
588
- }
589
- if (item.getAttribute[attribute+'-key']!=key) {
590
- // next item doesn't match key
591
- items.unshift(item) // put item back for next cycle
592
- let outOfOrderItem = el.querySelector(':scope > ['+attribute+'-key="'+key+'"]') //FIXME: escape key
593
- if (!outOfOrderItem) {
594
- let clone = this.applyTemplate(context)
595
- el.insertBefore(clone, item)
596
- continue // new template doesn't need replacement, so continue
597
- } else {
598
- el.insertBefore(outOfOrderItem, item)
599
- item = outOfOrderItem // check needsreplacement next
600
- items = items.filter(i => i!=outOfOrderItem)
601
- }
602
- }
603
- let newTemplate = this.findTemplate(templates, value[key])
604
- if (newTemplate != item.$bindTemplate){
605
- let clone = this.applyTemplate(context)
606
- el.replaceChild(clone, item)
607
- }
608
- }
609
- // clean up remaining items
610
- while (items.length) {
611
- let item = items.shift()
612
- item.remove()
613
- }
614
- }
615
-
616
- function getParentPath(el, attribute)
617
- {
618
- const parentEl = el.parentElement?.closest(`[${attribute}-list],[${attribute}-map]`)
619
- if (!parentEl) {
620
- return ':root'
621
- }
622
- if (parentEl.hasAttribute(`${attribute}-list`)) {
623
- return parentEl.getAttribute(`${attribute}-list`)
624
- }
625
- return parentEl.getAttribute(`${attribute}-map`)
626
- }
627
-
628
- /**
629
- * transforms the contents of an html element by rendering
630
- * a matching template, once.
631
- * data-bind attributes inside the template use the same
632
- * parent path as this html element uses
633
- */
634
- export function transformLiteralByTemplates(context)
635
- {
636
- const el = context.element
637
- const templates = context.templates
638
- const value = context.value
639
- const attribute = this.options.attribute
640
-
641
- const rendered = el.querySelector(':scope > :not(template)')
642
- const template = this.findTemplate(templates, value)
643
-
644
- context.parent = getParentPath(el, attribute)
645
- if (rendered) {
646
- if (template) {
647
- if (rendered?.$bindTemplate != template) {
648
- const clone = this.applyTemplate(context)
649
- el.replaceChild(clone, rendered)
650
- }
651
- } else {
652
- el.removeChild(rendered)
653
- }
654
- } else if (template) {
655
- const clone = this.applyTemplate(context)
656
- el.appendChild(clone)
657
- }
658
- }
659
-
660
- /**
661
- * transforms a single input type
662
- * for radio/checkbox inputs it only sets the checked attribute to true/false
663
- * if the value attribute matches the current value
664
- * for other inputs the value attribute is updated
665
- */
666
- export function transformInput(context)
667
- {
668
- const el = context.element
669
- let value = context.value
670
-
671
- transformElement(context)
672
- if (typeof value == 'undefined') {
673
- value = ''
674
- }
675
- if (el.type=='checkbox' || el.type=='radio') {
676
- if (matchValue(el.value, value)) {
677
- el.checked = true
678
- } else {
679
- el.checked = false
680
- }
681
- } else if (!matchValue(el.value, value)) {
682
- el.value = ''+value
683
- }
684
- }
685
-
686
- /**
687
- * Sets the value of the button, doesn't touch the innerHTML
688
- */
689
- export function transformButton(context)
690
- {
691
- const el = context.element
692
- const value = context.value
693
-
694
- transformElement(context)
695
- setProperties(el, value, 'value')
696
- }
697
-
698
- /**
699
- * Sets the selected attribute of select options
700
- */
701
- export function transformSelect(context)
702
- {
703
- const el = context.element
704
- let value = context.value
705
-
706
- if (value === null) {
707
- value = ''
708
- }
709
- if (typeof value!='object') {
710
- if (el.multiple) {
711
- if (Array.isArray(value)) { //FIXME: cannot be true, since typeof != 'object'
712
- for (let option of el.options) {
713
- if (value.indexOf(option.value)===false) {
714
- option.selected = false
715
- } else {
716
- option.selected = true
717
- }
718
- }
719
- }
720
- } else {
721
- let option = el.options.find(o => matchValue(o.value,value))
722
- if (option) {
723
- option.selected = true
724
- option.setAttribute('selected', true)
725
- }
726
- }
727
- } else { // value is a non-null object
728
- if (value.options) {
729
- setSelectOptions(el, value.options)
730
- }
731
- if (value.selected) {
732
- transformSelect(Object.asssign({}, context, {value:value.selected}))
733
- }
734
- setProperties(el, value, 'name', 'id', 'selectedIndex', 'className') // allow innerHTML? if so call transformElement instead
735
- }
736
- }
737
-
738
- export function addOption(select, option)
739
- {
740
- if (!option) {
741
- return
742
- }
743
- if (typeof option !== 'object') {
744
- select.options.add(new Option(''+option))
745
- } else if (option.text) {
746
- select.options.add(new Option(option.text, option.value, option.defaultSelected, option.selected))
747
- } else if (typeof option.value != 'undefined') {
748
- select.options.add(new Option(''+option.value, option.value, option.defaultSelected, option.selected))
749
- }
750
- }
751
-
752
- export function setSelectOptions(select,options)
753
- {
754
- //@TODO: only update in case of changes?
755
- select.innerHTML = ''
756
- if (Array.isArray(options)) {
757
- for (const option of options) {
758
- addOption(select, option)
759
- }
760
- } else if (options && typeof options == 'object') {
761
- for (const option in options) {
762
- addOption(select, { text: options[option], value: option })
763
- }
764
- }
765
- }
766
-
767
- /**
768
- * Sets the innerHTML and href attribute of an anchor
769
- * TODO: support target, title, etc. attributes
770
- */
771
- export function transformAnchor(context)
772
- {
773
- const el = context.element
774
- const value = context.value
775
-
776
- transformElement(context)
777
- setProperties(el, value, 'title', 'target', 'href', 'name', 'newwindow', 'nofollow')
778
- }
779
-
780
- export function transformImage(context)
781
- {
782
- const el = context.element
783
- const value = context.value
784
-
785
- transformElement(context)
786
- setProperties(el, value, 'title', 'alt', 'src')
787
- }
788
-
789
- export function transformIframe(context)
790
- {
791
- const el = context.element
792
- const value = context.value
793
-
794
- transformElement(context)
795
- setProperties(el, value, 'title', 'src')
796
- }
797
-
798
- export function transformMeta(context)
799
- {
800
- const el = context.element
801
- const value = context.value
802
-
803
- transformElement(context)
804
- setProperties(el, value, 'content')
805
- }
806
- /**
807
- * sets the innerHTML and title and id properties of any HTML element
808
- */
809
- export function transformElement(context)
810
- {
811
- const el = context.element
812
- let value = context.value
813
-
814
- if (typeof value=='undefined' || value==null) {
815
- value = ''
816
- }
817
- let strValue = ''+value
818
- if (typeof value!='object' || strValue.substring(0,8)!='[object ') {
819
- el.innerHTML = strValue
820
- return
821
- }
822
- setProperties(el, value, 'innerHTML', 'title', 'id', 'className')
823
- }
824
-
825
- /**
826
- * Sets a list of properties on a dom element, equal to
827
- * the string value of a data object
828
- * only updates the dom element if the property doesn't match
829
- */
830
- export function setProperties(el, data, ...properties) {
831
- if (!data || typeof data!=='object') {
832
- return
833
- }
834
- for (const property of properties) {
835
- if (typeof data[property] === 'undefined') {
836
- continue
837
- }
838
- if (matchValue(el[property], data[property])) {
839
- continue
840
- }
841
- if (data[property] === null) {
842
- el[property] = ''
843
- } else {
844
- el[property] = ''+data[property]
845
- }
846
- }
847
- }
848
-
849
- export function escape_html(context, next) {
850
- let content = context.value.innerHTML
851
- if (typeof context.value == 'string') {
852
- content = context.value
853
- context.value = { innerHTML: content }
854
- }
855
- if (content) {
856
- content = content.replace(/&/g, '&amp;')
857
- .replace(/</g, '&lt;')
858
- .replace(/>/g, '&gt;')
859
- .replace(/"/g, '&quot;')
860
- .replace(/'/g, '&#39;');
861
- context.value.innerHTML = content
862
- }
863
- next(context)
864
- }
865
-
866
- export function fixed_content(context, next) {
867
- if (typeof context.value == 'string') {
868
- context.value = {}
869
- } else {
870
- delete context.value.innerHTML
871
- }
872
- next(context)
873
- }