simplyflow 0.3.3 → 0.5.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
  }
@@ -190,10 +210,10 @@ class SimplyBind
190
210
  applyTemplate(context)
191
211
  {
192
212
  const path = context.path
213
+ const parent = context.parent
193
214
  const templates = context.templates
194
215
  const list = context.list
195
216
  const index = context.index
196
- const parent = context.parent
197
217
  const value = list ? list[index] : context.value
198
218
 
199
219
  let template = this.findTemplate(templates, value)
@@ -210,11 +230,16 @@ class SimplyBind
210
230
  throw new Error('template must contain a single root node', { cause: template })
211
231
  }
212
232
  const attribute = this.options.attribute
233
+
213
234
  const attributes = [attribute+'-field',attribute+'-list',attribute+'-map']
214
235
  const bindings = clone.querySelectorAll(`[${attribute}-field],[${attribute}-list],[${attribute}-map]`)
215
236
  for (let binding of bindings) {
237
+ if (binding.tagName=='TEMPLATE') {
238
+ continue
239
+ }
216
240
  const attr = attributes.find(attr => binding.hasAttribute(attr))
217
- const bind = binding.getAttribute(attr)
241
+ let bind = binding.getAttribute(attr)
242
+ bind = this.applyLinks(template.links, bind)
218
243
  if (bind.substring(0, ':root.'.length)==':root.') {
219
244
  binding.setAttribute(attr, bind.substring(':root.'.length))
220
245
  } else if (bind==':value' && index!=null) {
@@ -222,27 +247,42 @@ class SimplyBind
222
247
  } else if (index!=null) {
223
248
  binding.setAttribute(attr, path+'.'+index+'.'+bind)
224
249
  } else {
225
- binding.setAttribute(attr, parent+'.'+bind)
250
+ binding.setAttribute(attr, parent+bind)
226
251
  }
227
252
  }
228
253
  if (typeof index !== 'undefined') {
229
254
  clone.children[0].setAttribute(attribute+'-key',index)
230
255
  }
231
256
  // 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
- )
257
+ clone.children[0][Symbol.bindTemplate] = template
258
+
242
259
  // return clone, not the firstChild, so that all whitespace is cloned as well
243
260
  return clone
244
261
  }
245
262
 
263
+ parseLinks(links)
264
+ {
265
+ let result = {}
266
+ links = links.split(';').map(link => link.trim())
267
+ for (let link of links) {
268
+ link = link.split('=')
269
+ result[link[0].trim()] = link[1].trim()
270
+ }
271
+ return result
272
+ }
273
+
274
+ applyLinks(links, value)
275
+ {
276
+ for (let link in links) {
277
+ if (value.startsWith(link+'.')) {
278
+ return links[link] + value.substr(link.length)
279
+ } else if (value==link) {
280
+ return links[link]
281
+ }
282
+ }
283
+ return value
284
+ }
285
+
246
286
  /**
247
287
  * Returns the path referenced in either the field, list or map attribute
248
288
  * @param HTMLElement el
@@ -304,6 +344,10 @@ class SimplyBind
304
344
  }
305
345
  }
306
346
  let template = Array.from(templates).find(templateMatches)
347
+ let links = null
348
+ if (template?.hasAttribute(this.options.attribute+'-link')) {
349
+ links = this.parseLinks(template.getAttribute(this.options.attribute+'-link'))
350
+ }
307
351
  let rel = template?.getAttribute('rel')
308
352
  if (rel) {
309
353
  let replacement = document.querySelector('template#'+rel)
@@ -312,6 +356,9 @@ class SimplyBind
312
356
  }
313
357
  template = replacement
314
358
  }
359
+ if (template) {
360
+ template.links = links
361
+ }
315
362
  return template
316
363
  }
317
364
 
@@ -358,23 +405,6 @@ function untrack(el, path) {
358
405
  }
359
406
  }
360
407
 
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
408
 
379
409
  /**
380
410
  * Returns the value by walking the given path as a json pointer, starting at root
@@ -386,488 +416,14 @@ export function matchValue(a,b)
386
416
  */
387
417
  export function getValueByPath(root, path)
388
418
  {
389
- let parts = path.split('.');
390
- let curr = root;
391
- let part, prevPart;
392
- while (parts.length && curr) {
419
+ let parts = path.split('.')
420
+ let curr = root
421
+ let part
422
+ part = parts.shift()
423
+ while (part && curr) {
424
+ part = decodeURIComponent(part)
425
+ curr = curr[part]
393
426
  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
427
  }
406
428
  return curr
407
429
  }
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
- }