simplyflow 0.3.2 → 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,36 +33,53 @@ class SimplyBind
27
33
  */
28
34
  this.bindings = new Map()
29
35
 
36
+ const defaultTransformers = {
37
+ escape_html,
38
+ fixed_content
39
+ }
30
40
  const defaultOptions = {
31
41
  container: document.body,
32
- attribute: 'data-bind',
33
- transformers: {},
34
- defaultTransformers: {
35
- field: [defaultFieldTransformer],
36
- list: [defaultListTransformer],
37
- 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
38
59
  }
39
60
  }
40
61
  if (!options?.root) {
41
62
  throw new Error('bind needs at least options.root set')
42
63
  }
43
64
  this.options = Object.assign({}, defaultOptions, options)
65
+ if (options.transformers) {
66
+ this.options.transformers = Object.assign({}, defaultTransformers, options?.transformers)
67
+ }
44
68
  const attribute = this.options.attribute
45
69
  const bindAttributes = [attribute+'-field',attribute+'-list',attribute+'-map']
46
- const bindSelector = `[${attribute}-field],[${attribute}-list],[${attribute}-map]`
47
70
  const transformAttribute = attribute+'-transform'
48
71
 
49
72
  const getBindingAttribute = (el) => {
50
73
  const foundAttribute = bindAttributes.find(attr => el.hasAttribute(attr))
51
74
  if (!foundAttribute) {
52
- console.error('No matching attribute found',el,attr)
75
+ console.error('No matching attribute found',el,bindAttributes)
53
76
  }
54
77
  return foundAttribute
55
78
  }
56
79
 
57
80
  // sets up the effect that updates the element if its
58
81
  // data binding value changes
59
- const render = (el) => {
82
+ const renderElement = (el) => {
60
83
  this.bindings.set(el, throttledEffect(() => {
61
84
  if (!el.isConnected) {
62
85
  // el is no longer part of this document
@@ -67,7 +90,7 @@ class SimplyBind
67
90
  // without the binding having to be reset
68
91
  return
69
92
  }
70
- const context = {
93
+ let context = {
71
94
  templates: el.querySelectorAll(':scope > template'),
72
95
  attribute: getBindingAttribute(el)
73
96
  }
@@ -87,13 +110,16 @@ class SimplyBind
87
110
  let transformers
88
111
  switch(context.attribute) {
89
112
  case this.options.attribute+'-field':
90
- transformers = Array.from(this.options.defaultTransformers.field)
113
+ transformers = Array.from(this.options.render.field)
91
114
  break
92
115
  case this.options.attribute+'-list':
93
- transformers = Array.from(this.options.defaultTransformers.list)
116
+ transformers = Array.from(this.options.render.list)
94
117
  break
95
118
  case this.options.attribute+'-map':
96
- 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)
97
123
  break
98
124
  }
99
125
  if (context.element.hasAttribute(transformAttribute)) {
@@ -123,7 +149,7 @@ class SimplyBind
123
149
  const applyBindings = (bindings) => {
124
150
  for (let bindingEl of bindings) {
125
151
  if (!this.bindings.get(bindingEl)) { // bindingEl may have moved from somewhere else in this document
126
- render(bindingEl)
152
+ renderElement(bindingEl)
127
153
  }
128
154
  }
129
155
  }
@@ -187,7 +213,6 @@ class SimplyBind
187
213
  const templates = context.templates
188
214
  const list = context.list
189
215
  const index = context.index
190
- const parent = context.parent
191
216
  const value = list ? list[index] : context.value
192
217
 
193
218
  let template = this.findTemplate(templates, value)
@@ -216,23 +241,15 @@ class SimplyBind
216
241
  } else if (index!=null) {
217
242
  binding.setAttribute(attr, path+'.'+index+'.'+bind)
218
243
  } else {
219
- binding.setAttribute(attr, parent+'.'+bind)
244
+ binding.setAttribute(attr, path+'.'+bind)
220
245
  }
221
246
  }
222
247
  if (typeof index !== 'undefined') {
223
248
  clone.children[0].setAttribute(attribute+'-key',index)
224
249
  }
225
250
  // 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
- )
251
+ clone.children[0][Symbol.bindTemplate] = template
252
+
236
253
  // return clone, not the firstChild, so that all whitespace is cloned as well
237
254
  return clone
238
255
  }
@@ -346,28 +363,13 @@ function track(el, context) {
346
363
 
347
364
  function untrack(el, path) {
348
365
  let list = tracking.get(path)
349
- list = list.filter(context => context.element == el)
350
- tracking.set(path, list)
351
- }
352
-
353
- /**
354
- * Returns true if a matches b, either by having the
355
- * same string value, or matching string :empty against a falsy value
356
- */
357
- export function matchValue(a,b)
358
- {
359
- if (a==':empty' && !b) {
360
- return true
361
- }
362
- if (b==':empty' && !a) {
363
- return true
364
- }
365
- if (''+a == ''+b) {
366
- return true
366
+ if (list) {
367
+ list = list.filter(context => context.element == el)
368
+ tracking.set(path, list)
367
369
  }
368
- return false
369
370
  }
370
371
 
372
+
371
373
  /**
372
374
  * Returns the value by walking the given path as a json pointer, starting at root
373
375
  * if you have a property with a '.' in its name urlencode the '.', e.g: %46
@@ -378,473 +380,14 @@ export function matchValue(a,b)
378
380
  */
379
381
  export function getValueByPath(root, path)
380
382
  {
381
- let parts = path.split('.');
382
- let curr = root;
383
- let part, prevPart;
384
- 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]
385
390
  part = parts.shift()
386
- if (part==':key') {
387
- return prevPart
388
- } else if (part==':value') {
389
- return curr
390
- } else if (part==':root') {
391
- curr = root
392
- } else {
393
- part = decodeURIComponent(part)
394
- curr = curr[part];
395
- prevPart = part
396
- }
397
391
  }
398
392
  return curr
399
393
  }
400
-
401
- /**
402
- * Default transformer for data binding
403
- * Will be used unless overriden in the SimplyBind options parameter
404
- */
405
- export function defaultFieldTransformer(context)
406
- {
407
- const el = context.element
408
- const templates = context.templates
409
- const templatesCount = templates.length
410
- const path = context.path
411
- const value = context.value
412
- const attribute = this.options.attribute
413
-
414
- if (templates?.length) {
415
- transformLiteralByTemplates.call(this, context)
416
- } else {
417
- switch(el.tagName) {
418
- case 'INPUT':
419
- transformInput.call(this, context)
420
- break
421
- case 'BUTTON':
422
- transformButton.call(this, context)
423
- break
424
- case 'SELECT':
425
- transformSelect.call(this, context)
426
- break
427
- case 'A':
428
- transformAnchor.call(this, context)
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
439
- case 'TEMPLATE': // never touch templates!
440
- break
441
- default:
442
- transformElement.call(this, context)
443
- break
444
- }
445
- }
446
- return context
447
- }
448
-
449
- export function defaultListTransformer(context)
450
- {
451
- const el = context.element
452
- const templates = context.templates
453
- const templatesCount = templates.length
454
- const path = context.path
455
- const value = context.value
456
- const attribute = this.options.attribute
457
-
458
- if (!Array.isArray(value)) {
459
- console.error('Value is not an array.', el, path, value)
460
- } else if (!templates?.length) {
461
- console.error('No templates found in', el)
462
- } else {
463
- transformArrayByTemplates.call(this, context)
464
- }
465
- return context
466
- }
467
-
468
- export function defaultMapTransformer(context)
469
- {
470
- const el = context.element
471
- const templates = context.templates
472
- const templatesCount = templates.length
473
- const path = context.path
474
- const value = context.value
475
- const attribute = this.options.attribute
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 templatesCount = templates.length
500
- const path = context.path
501
- const value = context.value
502
- const attribute = this.options.attribute
503
-
504
- let items = el.querySelectorAll(':scope > ['+attribute+'-key]')
505
- // do single merge strategy for now, in future calculate optimal merge strategy from a number
506
- // now just do a delete if a key <= last key, insert if a key >= last key
507
- let lastKey = 0
508
- let skipped = 0
509
- context.list = value
510
- for (let item of items) {
511
- let currentKey = parseInt(item.getAttribute(attribute+'-key'))
512
- if (currentKey>lastKey) {
513
- // insert before
514
- context.index = lastKey
515
- el.insertBefore(this.applyTemplate(context), item)
516
- } else if (currentKey<lastKey) {
517
- // remove this
518
- item.remove()
519
- } else {
520
- // check that all data-bind params start with current json path or ':root', otherwise replaceChild
521
- let bindings = Array.from(item.querySelectorAll(`[${attribute}]`))
522
- if (item.matches(`[${attribute}]`)) {
523
- bindings.unshift(item)
524
- }
525
- let needsReplacement = bindings.find(b => {
526
- let databind = b.getAttribute(attribute)
527
- return (databind.substr(0,5)!==':root'
528
- && databind.substr(0, path.length)!==path)
529
- })
530
- if (!needsReplacement) {
531
- if (item.$bindTemplate) {
532
- let newTemplate = this.findTemplate(templates, value[lastKey])
533
- if (newTemplate != item.$bindTemplate){
534
- needsReplacement = true
535
- if (!newTemplate) {
536
- skipped++
537
- }
538
- }
539
- }
540
- }
541
- if (needsReplacement) {
542
- context.index = lastKey
543
- el.replaceChild(this.applyTemplate(context), item)
544
- }
545
- }
546
- lastKey++
547
- if (lastKey>=value.length) {
548
- break
549
- }
550
- }
551
- items = el.querySelectorAll(':scope > ['+attribute+'-key]')
552
- let length = items.length + skipped
553
- if (length > value.length) {
554
- while (length > value.length) {
555
- let child = el.querySelectorAll(':scope > :not(template)')?.[length-1]
556
- child?.remove()
557
- length--
558
- }
559
- } else if (length < value.length ) {
560
- while (length < value.length) {
561
- context.index = length
562
- el.appendChild(this.applyTemplate(context))
563
- length++
564
- }
565
- }
566
- }
567
-
568
- /**
569
- * Renders an object value by applying templates for each entry (Object.entries)
570
- * Replaces,moves or removes existing DOM children if needed
571
- * Reuses (doesn't touch) DOM children if template doesn't change
572
- */
573
- export function transformObjectByTemplates(context)
574
- {
575
- const el = context.element
576
- const templates = context.templates
577
- const templatesCount = templates.length
578
- const path = context.path
579
- const value = context.value
580
- const attribute = this.options.attribute
581
- context.list = value
582
-
583
- let items = Array.from(el.querySelectorAll(':scope > ['+attribute+'-key]'))
584
- for (let key in context.list) {
585
- context.index = key
586
- let item = items.shift()
587
- if (!item) { // more properties than rendered items
588
- let clone = this.applyTemplate(context)
589
- el.appendChild(clone)
590
- continue
591
- }
592
- if (item.getAttribute[attribute+'-key']!=key) {
593
- // next item doesn't match key
594
- items.unshift(item) // put item back for next cycle
595
- let outOfOrderItem = el.querySelector(':scope > ['+attribute+'-key="'+key+'"]') //FIXME: escape key
596
- if (!outOfOrderItem) {
597
- let clone = this.applyTemplate(context)
598
- el.insertBefore(clone, item)
599
- continue // new template doesn't need replacement, so continue
600
- } else {
601
- el.insertBefore(outOfOrderItem, item)
602
- item = outOfOrderItem // check needsreplacement next
603
- items = items.filter(i => i!=outOfOrderItem)
604
- }
605
- }
606
- let newTemplate = this.findTemplate(templates, value[key])
607
- if (newTemplate != item.$bindTemplate){
608
- let clone = this.applyTemplate(context)
609
- el.replaceChild(clone, item)
610
- }
611
- }
612
- // clean up remaining items
613
- while (items.length) {
614
- let item = items.shift()
615
- item.remove()
616
- }
617
- }
618
-
619
- function getParentPath(el, attribute)
620
- {
621
- const parentEl = el.parentElement?.closest(`[${attribute}-list],[${attribute}-map]`)
622
- if (!parentEl) {
623
- return ':root'
624
- }
625
- if (parentEl.hasAttribute(`${attribute}-list`)) {
626
- return parentEl.getAttribute(`${attribute}-list`)
627
- }
628
- return parentEl.getAttribute(`${attribute}-map`)
629
- }
630
-
631
- /**
632
- * transforms the contents of an html element by rendering
633
- * a matching template, once.
634
- * data-bind attributes inside the template use the same
635
- * parent path as this html element uses
636
- */
637
- export function transformLiteralByTemplates(context)
638
- {
639
- const el = context.element
640
- const templates = context.templates
641
- const value = context.value
642
- const attribute = this.options.attribute
643
-
644
- const rendered = el.querySelector(':scope > :not(template)')
645
- const template = this.findTemplate(templates, value)
646
-
647
- context.parent = getParentPath(el, attribute)
648
- if (rendered) {
649
- if (template) {
650
- if (rendered?.$bindTemplate != template) {
651
- const clone = this.applyTemplate(context)
652
- el.replaceChild(clone, rendered)
653
- }
654
- } else {
655
- el.removeChild(rendered)
656
- }
657
- } else if (template) {
658
- const clone = this.applyTemplate(context)
659
- el.appendChild(clone)
660
- }
661
- }
662
-
663
- /**
664
- * transforms a single input type
665
- * for radio/checkbox inputs it only sets the checked attribute to true/false
666
- * if the value attribute matches the current value
667
- * for other inputs the value attribute is updated
668
- */
669
- export function transformInput(context)
670
- {
671
- const el = context.element
672
- let value = context.value
673
-
674
- transformElement(context)
675
- if (typeof value == 'undefined') {
676
- value = ''
677
- }
678
- if (el.type=='checkbox' || el.type=='radio') {
679
- if (matchValue(el.value, value)) {
680
- el.checked = true
681
- } else {
682
- el.checked = false
683
- }
684
- } else if (!matchValue(el.value, value)) {
685
- el.value = ''+value
686
- }
687
- }
688
-
689
- /**
690
- * Sets the value of the button, doesn't touch the innerHTML
691
- */
692
- export function transformButton(context)
693
- {
694
- const el = context.element
695
- const value = context.value
696
-
697
- transformElement(context)
698
- setProperties(el, value, 'value')
699
- }
700
-
701
- /**
702
- * Sets the selected attribute of select options
703
- */
704
- export function transformSelect(context)
705
- {
706
- const el = context.element
707
- let value = context.value
708
-
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
- }
721
- }
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
- }
729
- }
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 })
766
- }
767
- }
768
- }
769
-
770
- /**
771
- * Sets the innerHTML and href attribute of an anchor
772
- * TODO: support target, title, etc. attributes
773
- */
774
- export function transformAnchor(context)
775
- {
776
- const el = context.element
777
- const value = context.value
778
-
779
- transformElement(context)
780
- setProperties(el, value, 'title', 'target', 'href', 'name', 'newwindow', 'nofollow')
781
- }
782
-
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
- {
794
- const el = context.element
795
- const value = context.value
796
-
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] = ''
846
- } else {
847
- el[property] = ''+data[property]
848
- }
849
- }
850
- }