@muze-labs/simplyflow 0.9.0 → 0.10.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.
@@ -1,694 +0,0 @@
1
- /*
2
- * Default renderers for data binding
3
- * Will be used unless overriden in the SimplyBind options parameter
4
- */
5
- import { signal as domSignal, trackDomField, trackDomList } from './dom.mjs'
6
- import { throttledEffect, effect, untracked, batch } from './state.mjs'
7
- import { getValueByPath } from './bind.mjs'
8
- import { DEP } from './symbols.mjs'
9
-
10
- function writesFromDom(binding, context)
11
- {
12
- return binding.options.twoway || context.edit
13
- }
14
-
15
- /**
16
- * This function is used by default to render dom elements with the `data-flow-field` attribute.
17
- * It will switch to only switching in template content if the context has any templates.
18
- * Otherwise it will call the matching render function depending on the tagName of the
19
- * context.element
20
- */
21
- export function field(context)
22
- {
23
- if (context.templates?.length) {
24
- fieldByTemplates.call(this, context)
25
- // TODO: check if existence of one or more templates must mean that
26
- // only the template rendering is applied, instead of also rendering attributes
27
- } else if (Object.hasOwnProperty.call(this.options.renderers, context.element.tagName)) {
28
- const renderer = this.options.renderers[context.element.tagName]
29
- if (renderer) {
30
- renderer.call(this, context)
31
- }
32
- } else if (this.options.renderers['*']) {
33
- this.options.renderers['*'].call(this, context)
34
- }
35
- return context
36
- }
37
-
38
- /**
39
- * This function is used by default to render DOM elements with the `data-flow-list` attribute.
40
- * The context.value must be an array. And context.templates must not be empty.
41
- */
42
- export function list(context)
43
- {
44
- if (!Array.isArray(context.value)) {
45
- context.value = [context.value]
46
- }
47
- // make sure this effect is triggered if the length of the array changes
48
- const length = context.value.length
49
- if (!context.templates?.length) {
50
- console.error('No templates found in', context.element)
51
- } else {
52
- arrayByTemplates.call(this, context)
53
- }
54
- return context
55
- }
56
-
57
- /**
58
- * This function is used by default to render DOM elements with the `data-flow-map` attribute.
59
- * The context.value must be a non-null object. And context.templates must not be empty.
60
- */
61
- export function map(context)
62
- {
63
- if (typeof context.value != 'object' || !context.value) {
64
- console.error('Value is not an object.', context.element, context.path, context.value)
65
- } else if (!context.templates?.length) {
66
- console.error('No templates found in', context.element)
67
- } else {
68
- objectByTemplates.call(this, context)
69
- }
70
- return context
71
- }
72
-
73
- function isInt(s) {
74
- if (parseInt(s)==s) {
75
- return true
76
- }
77
- }
78
-
79
- /**
80
- * This function sets a given value on the given path, starting at the given root.
81
- * It will automatically create objects if a path part does not yet exist.
82
- * @param root the root object
83
- * @param path a JSON path
84
- * @param value the value to set
85
- */
86
- export function setValueByPath(root, path, value)
87
- {
88
- batch(() => {
89
- let parts = path.split('.')
90
- let curr = root
91
- let part
92
- part = parts.shift()
93
- let prev = null
94
- let prevPart = null
95
- let prevCurr = curr
96
- while (part && curr) {
97
- prevCurr = curr
98
- part = decodeURIComponent(part)
99
- if (part=='0' && !Array.isArray(curr)) {
100
- // ignore so that data-flow-list="nonarray" will work
101
- } else if (part==':key') {
102
- // FIXME: should change the key, not the value... not supported yet?
103
- throw new Error('setting key not yet supported')
104
- curr = prevPart
105
- } else if (part==':value') {
106
- // do nothing
107
- } else if (Array.isArray(curr) && !isInt(part) && typeof curr[part]=='undefined') {
108
- prev = curr[0]
109
- curr = curr[0][part] // so that data-flow-field="array.foo" works
110
- } else {
111
- prev = curr
112
- curr = curr[part]
113
- }
114
- prevPart = part
115
- part = parts.shift()
116
- if (part && !curr) {
117
- // path in html does not exist yet, so create it
118
- const intKey = parseInt(part)
119
- if (intKey>=0 && part===''+intKey) {
120
- prevCurr[prevPart] = []
121
- } else {
122
- prevCurr[prevPart] = {}
123
- }
124
- curr = prevCurr[prevPart]
125
- }
126
- }
127
- if (prev && prevPart && prev[prevPart]!==value) {
128
- if (Array.isArray(value)) {
129
- prev[prevPart] = value
130
- } else if (value && typeof value=='object') {
131
- curr = prev[prevPart]
132
- if (!curr) {
133
- // last part of path in html does not exist yet, create it
134
- prev[prevPart] = {}
135
- curr = prev[prevPart]
136
- }
137
- for (const prop in value) {
138
- if (curr[prop]!==value[prop]) {
139
- curr[prop] = value[prop]
140
- }
141
- }
142
- } else {
143
- prev[prevPart] = value
144
- }
145
- }
146
- })
147
- }
148
-
149
- /**
150
- * Renders an array value by applying templates for each entry
151
- * Replaces or removes existing DOM children if needed
152
- * Reuses (doesn't touch) DOM children if template doesn't change
153
- * FIXME: this doesn't handle situations where there is no matching template
154
- * this messes up self healing. check renderObjectByTemplates for a better implementation
155
- */
156
- export function arrayByTemplates(context)
157
- {
158
- const attribute = this.options.attribute
159
- const attributes = [attribute+'-field',attribute+'-edit',attribute+'-list',attribute+'-map',attribute+'-value-path']
160
- const attrQuery = '['+attributes.join('],[')+']'
161
- const keyAttribute = attribute+'-key'
162
- const items = Array.from(context.element.querySelectorAll(':scope > ['+keyAttribute+']'))
163
- const usedItems = new Set()
164
- let cursor = 0
165
-
166
- context.list = context.value
167
-
168
- for (let index = 0; index < context.value.length; index++) {
169
- context.index = index
170
- const value = context.list[index]
171
- let item = nextUnusedItem(items, usedItems, cursor)
172
-
173
- if (!item) {
174
- context.element.appendChild(this.applyTemplate(context))
175
- continue
176
- }
177
-
178
- const newTemplate = this.findTemplate(context.templates, value)
179
- const currentValueMatches = item[DEP.VALUE] === value
180
- let reusableItem = currentValueMatches
181
- ? item
182
- : findReusableItem(items, usedItems, value, newTemplate, cursor + 1)
183
-
184
- if (reusableItem) {
185
- if (newTemplate != reusableItem[DEP.TEMPLATE]) {
186
- context.element.replaceChild(this.applyTemplate(context), reusableItem)
187
- } else {
188
- context.element.insertBefore(reusableItem, item)
189
- updateItemKey(reusableItem, index, context.path, keyAttribute, attributes, attrQuery)
190
- reusableItem[DEP.VALUE] = value
191
- }
192
- usedItems.add(reusableItem)
193
- if (reusableItem === item) {
194
- cursor++
195
- }
196
- continue
197
- }
198
-
199
- context.element.insertBefore(this.applyTemplate(context), item)
200
- }
201
-
202
- for (let item of items) {
203
- if (!usedItems.has(item)) {
204
- item.remove()
205
- }
206
- }
207
-
208
- if (this.options.twoway) {
209
- trackDomList.call(this, context.element)
210
- }
211
- }
212
-
213
- function nextUnusedItem(items, usedItems, start)
214
- {
215
- while (start < items.length) {
216
- const item = items[start]
217
- if (!usedItems.has(item)) {
218
- return item
219
- }
220
- start++
221
- }
222
- }
223
-
224
- function findReusableItem(items, usedItems, value, template, start)
225
- {
226
- for (let i = start; i < items.length; i++) {
227
- const item = items[i]
228
- if (!usedItems.has(item) && item[DEP.VALUE] === value && item[DEP.TEMPLATE] === template) {
229
- return item
230
- }
231
- }
232
- }
233
-
234
-
235
- function updateItemKey(item, key, path, keyAttribute, attributes, attrQuery)
236
- {
237
- const oldKey = item.getAttribute(keyAttribute)
238
- const newKey = ''+key
239
-
240
- if (oldKey === newKey) {
241
- return
242
- }
243
-
244
- item.setAttribute(keyAttribute, newKey)
245
-
246
- const oldPrefix = path+'.'+oldKey
247
- const newPrefix = path+'.'+newKey
248
- const bindings = Array.from(item.querySelectorAll(attrQuery))
249
- if (item.matches(attrQuery)) {
250
- bindings.unshift(item)
251
- }
252
-
253
- for (let binding of bindings) {
254
- for (let attr of attributes) {
255
- const bindPath = binding.getAttribute(attr)
256
- if (!bindPath || bindPath.substr(0,5)===':root') {
257
- continue
258
- }
259
- if (bindPath === oldPrefix) {
260
- binding.setAttribute(attr, newPrefix)
261
- } else if (bindPath.startsWith(oldPrefix+'.')) {
262
- binding.setAttribute(attr, newPrefix+bindPath.substr(oldPrefix.length))
263
- }
264
- }
265
- }
266
- }
267
-
268
- /**
269
- * Renders an object value by applying templates for each entry (Object.entries)
270
- * Replaces,moves or removes existing DOM children if needed
271
- * Reuses (doesn't touch) DOM children if template doesn't change
272
- */
273
- export function objectByTemplates(context)
274
- {
275
- const attribute = this.options.attribute
276
- const attributes = [attribute+'-field',attribute+'-edit',attribute+'-list',attribute+'-map',attribute+'-value-path']
277
- const attrQuery = '['+attributes.join('],[')+']'
278
- const keyAttribute = attribute+'-key'
279
- const items = Array.from(context.element.querySelectorAll(':scope > ['+keyAttribute+']'))
280
- const usedItems = new Set()
281
- let cursor = 0
282
-
283
- context.list = context.value
284
-
285
- for (let key in context.list) {
286
- context.index = key
287
- const value = context.list[key]
288
- let item = nextUnusedItem(items, usedItems, cursor)
289
-
290
- if (!item) {
291
- context.element.appendChild(this.applyTemplate(context))
292
- continue
293
- }
294
-
295
- const newTemplate = this.findTemplate(context.templates, value)
296
- let reusableItem
297
-
298
- if (item.getAttribute(keyAttribute) === key) {
299
- reusableItem = item
300
- } else {
301
- reusableItem = findItemByKey(items, usedItems, key, keyAttribute)
302
- || findReusableItem(items, usedItems, value, newTemplate, cursor)
303
- }
304
-
305
- if (reusableItem) {
306
- if (newTemplate != reusableItem[DEP.TEMPLATE]) {
307
- context.element.replaceChild(this.applyTemplate(context), reusableItem)
308
- } else {
309
- context.element.insertBefore(reusableItem, item)
310
- updateItemKey(reusableItem, key, context.path, keyAttribute, attributes, attrQuery)
311
- reusableItem[DEP.VALUE] = value
312
- }
313
- usedItems.add(reusableItem)
314
- if (reusableItem === item) {
315
- cursor++
316
- }
317
- continue
318
- }
319
-
320
- context.element.insertBefore(this.applyTemplate(context), item)
321
- }
322
-
323
- for (let item of items) {
324
- if (!usedItems.has(item)) {
325
- item.remove()
326
- }
327
- }
328
- }
329
-
330
- function findItemByKey(items, usedItems, key, keyAttribute)
331
- {
332
- const stringKey = ''+key
333
- for (let item of items) {
334
- if (!usedItems.has(item) && item.getAttribute(keyAttribute) === stringKey) {
335
- return item
336
- }
337
- }
338
- }
339
-
340
- /**
341
- * renders the contents of an html element by rendering
342
- * a matching template, once.
343
- */
344
- export function fieldByTemplates(context)
345
- {
346
- const rendered = context.element.querySelector(':scope > :not(template)')
347
- const template = this.findTemplate(context.templates, context.value)
348
- context.parent = getParentPath(context.element)
349
- if (rendered) {
350
- if (template) {
351
- if (rendered?.[DEP.TEMPLATE] != template) {
352
- const clone = this.applyTemplate(context)
353
- context.element.replaceChild(clone, rendered)
354
- }
355
- } else {
356
- context.element.removeChild(rendered)
357
- }
358
- } else if (template) {
359
- const clone = this.applyTemplate(context)
360
- context.element.appendChild(clone)
361
- }
362
- }
363
-
364
- function getParentPath(el, attribute)
365
- {
366
- const parentEl = el.parentElement?.closest(`[${attribute}-list],[${attribute}-map]`)
367
- if (!parentEl) {
368
- return ''
369
- }
370
- if (parentEl.hasAttribute(`${attribute}-list`)) {
371
- return parentEl.getAttribute(`${attribute}-list`)+'.'
372
- }
373
- return parentEl.getAttribute(`${attribute}-map`)+'.'
374
- }
375
-
376
- /**
377
- * renders a single input type
378
- * for radio/checkbox inputs it only sets the checked attribute to true/false
379
- * if the value attribute matches the current value
380
- * for other inputs the value attribute is updated
381
- */
382
- export function input(context)
383
- {
384
- const el = context.element
385
- let value = context.value
386
-
387
- // Inputs display their bound primitive in `value`, not `innerHTML`.
388
- // Calling element() here would also enable two-way tracking on
389
- // innerHTML, which would overwrite text input data with an empty string.
390
- if (value && typeof value === 'object' && !Array.isArray(value)) {
391
- setProperties(el, value, 'title', 'id', 'className', 'value', 'checked')
392
- value = value.value
393
- }
394
- if (typeof value == 'undefined') {
395
- value = ''
396
- }
397
- if (el.type=='checkbox') {
398
- el.checked = checkboxIsChecked(el, value)
399
- } else if (el.type=='radio') {
400
- el.checked = matchValue(el.value, value)
401
- } else if (!matchValue(el.value, value)) {
402
- el.value = ''+value
403
- }
404
-
405
- if (writesFromDom(this, context)) {
406
- if (el.type=='checkbox') {
407
- trackDomField.call(this, context.element, ['checked'], true, 'checked', checkboxEditValue)
408
- } else if (el.type=='radio') {
409
- trackDomField.call(this, context.element, ['checked'], true, 'checked', radioEditValue)
410
- } else {
411
- trackDomField.call(this, context.element, ['value'], true, 'value')
412
- }
413
- }
414
- }
415
-
416
- function checkboxIsChecked(el, value)
417
- {
418
- if (Array.isArray(value)) {
419
- return value.some(selected => matchValue(el.value, selected))
420
- }
421
- if (typeof value === 'boolean') {
422
- return value
423
- }
424
- return matchValue(el.value, value)
425
- }
426
-
427
- function checkboxEditValue(el, currentValue)
428
- {
429
- // An array-bound checkbox toggles its value in that array. Otherwise a
430
- // checkbox edits a boolean; this keeps the app API simple and predictable.
431
- // Existing string values are left alone on the initial checked render so
432
- // lower-level two-way bindings do not immediately rewrite legacy data.
433
- if (Array.isArray(currentValue)) {
434
- const value = el.value
435
- const values = currentValue.filter(item => !matchValue(item, value))
436
- if (el.checked) {
437
- values.push(value)
438
- }
439
- return values
440
- }
441
- if (typeof currentValue === 'boolean') {
442
- return el.checked
443
- }
444
- if (el.checked && matchValue(el.value, currentValue)) {
445
- return currentValue
446
- }
447
- return el.checked
448
- }
449
-
450
- function radioEditValue(el, currentValue)
451
- {
452
- // Browsers fire the useful change event on the newly checked radio. If this
453
- // radio is not checked, leave the bound value unchanged.
454
- if (!el.checked) {
455
- return undefined
456
- }
457
- return el.value
458
- }
459
-
460
- /**
461
- * Sets the value of the button, doesn't touch the innerHTML
462
- */
463
- export function button(context)
464
- {
465
- element.call(this, context, 'value')
466
- }
467
-
468
- /**
469
- * Sets the selected attribute of select options
470
- */
471
- export function select(context)
472
- {
473
- const el = context.element
474
- let value = context.value
475
-
476
- if (value === null) {
477
- value = ''
478
- }
479
-
480
- if (Array.isArray(value)) {
481
- for (let option of el.options) {
482
- option.selected = value.some(selected => matchValue(option.value, selected))
483
- if (option.selected) {
484
- option.setAttribute('selected', true)
485
- } else {
486
- option.removeAttribute('selected')
487
- }
488
- }
489
- } else if (typeof value!='object') {
490
- let option = Array.from(el.options).find(o => matchValue(o.value,value))
491
- if (option) {
492
- option.selected = true
493
- option.setAttribute('selected', true)
494
- }
495
- } else { // value is a non-null object
496
- if (value.options) {
497
- setSelectOptions(el, value.options)
498
- }
499
- if (typeof value.selected !== 'undefined') {
500
- select.call(this, Object.assign({}, context, {value:value.selected}))
501
- }
502
- setProperties(el, value, 'name', 'id', 'selectedIndex', 'className') // allow innerHTML? if so call element instead
503
- }
504
-
505
- if (writesFromDom(this, context)) {
506
- if (el.multiple) {
507
- trackDomField.call(this, context.element, ['value'], true, 'value', selectMultipleEditValue)
508
- } else {
509
- trackDomField.call(this, context.element, ['value'], true, 'value')
510
- }
511
- }
512
- }
513
-
514
- function selectMultipleEditValue(el)
515
- {
516
- // Keep multiple-select editing as ordinary data: an array of selected values.
517
- // Reading `value` registers the DOM dependency; the change listener notifies
518
- // it whenever the selection changes.
519
- const value = el.value
520
- return Array.from(el.options)
521
- .filter(option => option.selected)
522
- .map(option => option.value)
523
- }
524
-
525
- /**
526
- * adds a single option to a select element. The option.text property is optional, if not set option.value is used.
527
- * @param select The select element
528
- * @param option An option descriptor, either a string, object with {text,value,defaultSelected,selected} properties or an Option object
529
- */
530
- export function addOption(select, option)
531
- {
532
- if (!option) {
533
- return
534
- }
535
- if (typeof option !== 'object') {
536
- select.options.add(new Option(''+option))
537
- } else if (option.text) {
538
- select.options.add(new Option(option.text, option.value, option.defaultSelected, option.selected))
539
- } else if (typeof option.value != 'undefined') {
540
- select.options.add(new Option(''+option.value, option.value, option.defaultSelected, option.selected))
541
- }
542
- }
543
-
544
- /**
545
- * This function clears all existing options of a select element, and adds the specified options.
546
- */
547
- export function setSelectOptions(select,options)
548
- {
549
- //@TODO: only update in case of changes?
550
- select.innerHTML = ''
551
- if (Array.isArray(options)) {
552
- for (const option of options) {
553
- addOption(select, option)
554
- }
555
- } else if (options && typeof options == 'object') {
556
- for (const option in options) {
557
- addOption(select, { text: options[option], value: option })
558
- }
559
- }
560
- }
561
-
562
- /**
563
- * Sets the innerHTML and href, id, title, target, name, newwindow, nofollow attributes of an anchor
564
- */
565
- export function anchor(context)
566
- {
567
- element.call(this, context, 'target', 'href', 'name', 'newwindow', 'nofollow')
568
- if (writesFromDom(this, context)) {
569
- batch(() => {
570
- updateProperties.call(this, context, ['target', 'href', 'name', 'newwindow', 'nofollow'])
571
- })
572
- }
573
- }
574
-
575
- /**
576
- * Sets the title, id, alt and src attributes of an image.
577
- */
578
- export function image(context)
579
- {
580
- setProperties(context.element, context.value, 'title', 'alt', 'src', 'id')
581
- if (writesFromDom(this, context)) {
582
- batch(() => {
583
- updateProperties.call(this, context, ['title', 'alt', 'src', 'id'])
584
- })
585
- }
586
- }
587
-
588
- /**
589
- * Sets the title, id and src attribute of an iframe
590
- */
591
- export function iframe(context)
592
- {
593
- setProperties(context.element, context.value, 'title', 'src', 'id')
594
- if (writesFromDom(this, context)) {
595
- batch(() => {
596
- updateProperties.call(this, context, ['title','src','id'])
597
- })
598
- }
599
- }
600
-
601
- /**
602
- * Sets the content and id attribute of a meta element
603
- */
604
- export function meta(context)
605
- {
606
- setProperties(context.element, context.value, 'content', 'id')
607
- if (writesFromDom(this, context)) {
608
- batch(() => {
609
- updateProperties.call(this, context, ['content','id'])
610
- })
611
- }
612
- }
613
-
614
- /**
615
- * sets the innerHTML and title and id properties of any HTML element
616
- */
617
- export function element(context, ...extraprops)
618
- {
619
- const el = context.element
620
- let value = context.value
621
- let valueIsString = false
622
- if (typeof value!='undefined' && value!==null) {
623
- let strValue = ''+value
624
- if (typeof value!='object' || strValue.substring(0,8)!='[object ') {
625
- value = { innerHTML: value }
626
- valueIsString = true
627
- }
628
- }
629
- const props = ['innerHTML','title','id','className'].concat(extraprops)
630
- setProperties(el, value, ...props)
631
- if (writesFromDom(this, context)) {
632
- trackDomField.call(this, context.element, props, valueIsString)
633
- }
634
- }
635
-
636
- /**
637
- * Sets a list of properties on a dom element, equal to
638
- * the string value of a data object
639
- * only updates the dom element if the property doesn't match
640
- */
641
- export function setProperties(el, data, ...properties) {
642
- if (!data || typeof data!=='object') {
643
- return
644
- }
645
- for (const property of properties) {
646
- if (typeof data[property] === 'undefined') {
647
- continue
648
- }
649
- if (matchValue(el[property], data[property])) {
650
- continue
651
- }
652
- if (data[property] === null) {
653
- el[property] = ''
654
- } else {
655
- el[property] = ''+data[property]
656
- }
657
- }
658
- }
659
-
660
-
661
- function updateProperties(context, properties)
662
- {
663
- trackDomField.call(this, context.element, properties, false)
664
- }
665
-
666
- export function getProperties(el, ...properties) {
667
- const result = {}
668
- for (const property of properties) {
669
- switch(property) {
670
- default:
671
- result[property] = el[property]
672
- break
673
- }
674
- }
675
- return result
676
- }
677
-
678
- /**
679
- * Returns true if a matches b, either by having the
680
- * same string value, or matching string :empty against a falsy value
681
- */
682
- export function matchValue(a,b)
683
- {
684
- if (a==':empty' && !b) {
685
- return true
686
- }
687
- if (b==':empty' && !a) {
688
- return true
689
- }
690
- if (''+a == ''+b) {
691
- return true
692
- }
693
- return false
694
- }