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/dist/simply.flow.js +403 -425
- package/dist/simply.flow.min.js +1 -1
- package/dist/simply.flow.min.js.map +4 -4
- package/package.json +1 -1
- package/src/bind.mjs +83 -527
- package/src/bind.render.mjs +422 -0
- package/src/bind.transformers.mjs +25 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Default renderers for data binding
|
|
3
|
+
* Will be used unless overriden in the SimplyBind options parameter
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* This function is used by default to render dom elements with the `data-flow-field` attribute.
|
|
8
|
+
* It will switch to only switching in template content if the context has any templates.
|
|
9
|
+
* Otherwise it will call the matching render function depending on the tagName of the
|
|
10
|
+
* context.element
|
|
11
|
+
*/
|
|
12
|
+
export function field(context)
|
|
13
|
+
{
|
|
14
|
+
if (context.templates?.length) {
|
|
15
|
+
fieldByTemplates.call(this, context)
|
|
16
|
+
// TODO: check if existence of one or more templates must mean that
|
|
17
|
+
// only the template rendering is applied, instead of also rendering attributes
|
|
18
|
+
} else if (Object.hasOwnProperty.call(this.options.renderers, context.element.tagName)) {
|
|
19
|
+
const renderer = this.options.renderers[context.element.tagName]
|
|
20
|
+
if (renderer) {
|
|
21
|
+
renderer.call(this, context)
|
|
22
|
+
}
|
|
23
|
+
} else if (this.options.renderers['*']) {
|
|
24
|
+
this.options.renderers['*'].call(this, context)
|
|
25
|
+
}
|
|
26
|
+
return context
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* This function is used by default to render DOM elements with the `data-flow-list` attribute.
|
|
31
|
+
* The context.value must be an array. And context.templates must not be empty.
|
|
32
|
+
*/
|
|
33
|
+
export function list(context)
|
|
34
|
+
{
|
|
35
|
+
if (!Array.isArray(context.value)) {
|
|
36
|
+
console.error('Value is not an array.', context.element, context.path, context.value)
|
|
37
|
+
} else if (!context.templates?.length) {
|
|
38
|
+
console.error('No templates found in', context.element)
|
|
39
|
+
} else {
|
|
40
|
+
arrayByTemplates.call(this, context)
|
|
41
|
+
}
|
|
42
|
+
return context
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* This function is used by default to render DOM elements with the `data-flow-map` attribute.
|
|
47
|
+
* The context.value must be a non-null object. And context.templates must not be empty.
|
|
48
|
+
*/
|
|
49
|
+
export function map(context)
|
|
50
|
+
{
|
|
51
|
+
if (typeof context.value != 'object' || !context.value) {
|
|
52
|
+
console.error('Value is not an object.', context.element, context.path, context.value)
|
|
53
|
+
} else if (!context.templates?.length) {
|
|
54
|
+
console.error('No templates found in', context.element)
|
|
55
|
+
} else {
|
|
56
|
+
objectByTemplates.call(this, context)
|
|
57
|
+
}
|
|
58
|
+
return context
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Renders an array value by applying templates for each entry
|
|
63
|
+
* Replaces or removes existing DOM children if needed
|
|
64
|
+
* Reuses (doesn't touch) DOM children if template doesn't change
|
|
65
|
+
* FIXME: this doesn't handle situations where there is no matching template
|
|
66
|
+
* this messes up self healing. check renderObjectByTemplates for a better implementation
|
|
67
|
+
*/
|
|
68
|
+
export function arrayByTemplates(context)
|
|
69
|
+
{
|
|
70
|
+
const attribute = this.options.attribute
|
|
71
|
+
|
|
72
|
+
let items = context.element.querySelectorAll(':scope > ['+attribute+'-key]')
|
|
73
|
+
// do single merge strategy for now, in future calculate optimal merge strategy from a number
|
|
74
|
+
// now just do a delete if a key <= last key, insert if a key >= last key
|
|
75
|
+
let lastKey = 0
|
|
76
|
+
let skipped = 0
|
|
77
|
+
context.list = context.value
|
|
78
|
+
for (let item of items) {
|
|
79
|
+
let currentKey = parseInt(item.getAttribute(attribute+'-key'))
|
|
80
|
+
if (currentKey>lastKey) {
|
|
81
|
+
// insert before
|
|
82
|
+
context.index = lastKey
|
|
83
|
+
context.element.insertBefore(this.applyTemplate(context), item)
|
|
84
|
+
} else if (currentKey<lastKey) {
|
|
85
|
+
// remove this
|
|
86
|
+
item.remove()
|
|
87
|
+
} else {
|
|
88
|
+
// check that all data-bind params start with current json path or ':root', otherwise replaceChild
|
|
89
|
+
let bindings = Array.from(item.querySelectorAll(`[${attribute}]`))
|
|
90
|
+
if (item.matches(`[${attribute}]`)) {
|
|
91
|
+
bindings.unshift(item)
|
|
92
|
+
}
|
|
93
|
+
let needsReplacement = bindings.find(b => {
|
|
94
|
+
let databind = b.getAttribute(attribute)
|
|
95
|
+
return (databind.substr(0,5)!==':root'
|
|
96
|
+
&& databind.substr(0, context.path.length)!==context.path)
|
|
97
|
+
})
|
|
98
|
+
if (!needsReplacement) {
|
|
99
|
+
if (item[Symbol.bindTemplate]) {
|
|
100
|
+
let newTemplate = this.findTemplate(context.templates, context.list[lastKey])
|
|
101
|
+
if (newTemplate != item[Symbol.bindTemplate]){
|
|
102
|
+
needsReplacement = true
|
|
103
|
+
if (!newTemplate) {
|
|
104
|
+
skipped++
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (needsReplacement) {
|
|
110
|
+
context.index = lastKey
|
|
111
|
+
context.element.replaceChild(this.applyTemplate(context), item)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
lastKey++
|
|
115
|
+
if (lastKey>=context.value.length) {
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
items = context.element.querySelectorAll(':scope > ['+attribute+'-key]')
|
|
120
|
+
let length = items.length + skipped
|
|
121
|
+
if (length > context.value.length) {
|
|
122
|
+
while (length > context.value.length) {
|
|
123
|
+
let child = context.element.querySelectorAll(':scope > :not(template)')?.[length-1]
|
|
124
|
+
child?.remove()
|
|
125
|
+
length--
|
|
126
|
+
}
|
|
127
|
+
} else if (length < context.value.length ) {
|
|
128
|
+
while (length < context.value.length) {
|
|
129
|
+
context.index = length
|
|
130
|
+
context.element.appendChild(this.applyTemplate(context))
|
|
131
|
+
length++
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Renders an object value by applying templates for each entry (Object.entries)
|
|
138
|
+
* Replaces,moves or removes existing DOM children if needed
|
|
139
|
+
* Reuses (doesn't touch) DOM children if template doesn't change
|
|
140
|
+
*/
|
|
141
|
+
export function objectByTemplates(context)
|
|
142
|
+
{
|
|
143
|
+
const attribute = this.options.attribute
|
|
144
|
+
context.list = context.value
|
|
145
|
+
|
|
146
|
+
let items = Array.from(context.element.querySelectorAll(':scope > ['+attribute+'-key]'))
|
|
147
|
+
for (let key in context.list) {
|
|
148
|
+
context.index = key
|
|
149
|
+
let item = items.shift()
|
|
150
|
+
if (!item) { // more properties than rendered items
|
|
151
|
+
let clone = this.applyTemplate(context)
|
|
152
|
+
context.element.appendChild(clone)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
if (item.getAttribute[attribute+'-key']!=key) {
|
|
156
|
+
// next item doesn't match key
|
|
157
|
+
items.unshift(item) // put item back for next cycle
|
|
158
|
+
let outOfOrderItem = context.element.querySelector(':scope > ['+attribute+'-key="'+key+'"]') //FIXME: escape key
|
|
159
|
+
if (!outOfOrderItem) {
|
|
160
|
+
let clone = this.applyTemplate(context)
|
|
161
|
+
context.element.insertBefore(clone, item)
|
|
162
|
+
continue // new template doesn't need replacement, so continue
|
|
163
|
+
} else {
|
|
164
|
+
context.element.insertBefore(outOfOrderItem, item)
|
|
165
|
+
item = outOfOrderItem // check needsreplacement next
|
|
166
|
+
items = items.filter(i => i!=outOfOrderItem)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
let newTemplate = this.findTemplate(context.templates, context.list[context.index])
|
|
170
|
+
if (newTemplate != item[Symbol.bindTemplate]){
|
|
171
|
+
let clone = this.applyTemplate(context)
|
|
172
|
+
context.element.replaceChild(clone, item)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// clean up remaining items
|
|
176
|
+
while (items.length) {
|
|
177
|
+
let item = items.shift()
|
|
178
|
+
item.remove()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* renders the contents of an html element by rendering
|
|
184
|
+
* a matching template, once.
|
|
185
|
+
*/
|
|
186
|
+
export function fieldByTemplates(context)
|
|
187
|
+
{
|
|
188
|
+
const rendered = context.element.querySelector(':scope > :not(template)')
|
|
189
|
+
const template = this.findTemplate(context.templates, context.value)
|
|
190
|
+
context.parent = getParentPath(context.element)
|
|
191
|
+
if (rendered) {
|
|
192
|
+
if (template) {
|
|
193
|
+
if (rendered?.[Symbol.bindTemplate] != template) {
|
|
194
|
+
const clone = this.applyTemplate(context)
|
|
195
|
+
context.element.replaceChild(clone, rendered)
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
context.element.removeChild(rendered)
|
|
199
|
+
}
|
|
200
|
+
} else if (template) {
|
|
201
|
+
const clone = this.applyTemplate(context)
|
|
202
|
+
context.element.appendChild(clone)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getParentPath(el, attribute)
|
|
207
|
+
{
|
|
208
|
+
const parentEl = el.parentElement?.closest(`[${attribute}-list],[${attribute}-map]`)
|
|
209
|
+
if (!parentEl) {
|
|
210
|
+
return ''
|
|
211
|
+
}
|
|
212
|
+
if (parentEl.hasAttribute(`${attribute}-list`)) {
|
|
213
|
+
return parentEl.getAttribute(`${attribute}-list`)+'.'
|
|
214
|
+
}
|
|
215
|
+
return parentEl.getAttribute(`${attribute}-map`)+'.'
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* renders a single input type
|
|
220
|
+
* for radio/checkbox inputs it only sets the checked attribute to true/false
|
|
221
|
+
* if the value attribute matches the current value
|
|
222
|
+
* for other inputs the value attribute is updated
|
|
223
|
+
*/
|
|
224
|
+
export function input(context)
|
|
225
|
+
{
|
|
226
|
+
const el = context.element
|
|
227
|
+
let value = context.value
|
|
228
|
+
|
|
229
|
+
element(context)
|
|
230
|
+
if (typeof value == 'undefined') {
|
|
231
|
+
value = ''
|
|
232
|
+
}
|
|
233
|
+
if (el.type=='checkbox' || el.type=='radio') {
|
|
234
|
+
if (matchValue(el.value, value)) {
|
|
235
|
+
el.checked = true
|
|
236
|
+
} else {
|
|
237
|
+
el.checked = false
|
|
238
|
+
}
|
|
239
|
+
} else if (!matchValue(el.value, value)) {
|
|
240
|
+
el.value = ''+value
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Sets the value of the button, doesn't touch the innerHTML
|
|
246
|
+
*/
|
|
247
|
+
export function button(context)
|
|
248
|
+
{
|
|
249
|
+
element(context)
|
|
250
|
+
setProperties(context.element, context.value, 'value')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Sets the selected attribute of select options
|
|
255
|
+
*/
|
|
256
|
+
export function select(context)
|
|
257
|
+
{
|
|
258
|
+
const el = context.element
|
|
259
|
+
let value = context.value
|
|
260
|
+
|
|
261
|
+
if (value === null) {
|
|
262
|
+
value = ''
|
|
263
|
+
}
|
|
264
|
+
if (typeof value!='object') {
|
|
265
|
+
if (el.multiple) {
|
|
266
|
+
if (Array.isArray(value)) { //FIXME: cannot be true, since typeof != 'object'
|
|
267
|
+
for (let option of el.options) {
|
|
268
|
+
if (value.indexOf(option.value)===false) {
|
|
269
|
+
option.selected = false
|
|
270
|
+
} else {
|
|
271
|
+
option.selected = true
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
let option = el.options.find(o => matchValue(o.value,value))
|
|
277
|
+
if (option) {
|
|
278
|
+
option.selected = true
|
|
279
|
+
option.setAttribute('selected', true)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} else { // value is a non-null object
|
|
283
|
+
if (value.options) {
|
|
284
|
+
setSelectOptions(el, value.options)
|
|
285
|
+
}
|
|
286
|
+
if (value.selected) {
|
|
287
|
+
select(Object.asssign({}, context, {value:value.selected}))
|
|
288
|
+
}
|
|
289
|
+
setProperties(el, value, 'name', 'id', 'selectedIndex', 'className') // allow innerHTML? if so call element instead
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* adds a single option to a select element. The option.text property is optional, if not set option.value is used.
|
|
295
|
+
* @param select The select element
|
|
296
|
+
* @param option An option descriptor, either a string, object with {text,value,defaultSelected,selected} properties or an Option object
|
|
297
|
+
*/
|
|
298
|
+
export function addOption(select, option)
|
|
299
|
+
{
|
|
300
|
+
if (!option) {
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
if (typeof option !== 'object') {
|
|
304
|
+
select.options.add(new Option(''+option))
|
|
305
|
+
} else if (option.text) {
|
|
306
|
+
select.options.add(new Option(option.text, option.value, option.defaultSelected, option.selected))
|
|
307
|
+
} else if (typeof option.value != 'undefined') {
|
|
308
|
+
select.options.add(new Option(''+option.value, option.value, option.defaultSelected, option.selected))
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* This function clears all existing options of a select element, and adds the specified options.
|
|
314
|
+
*/
|
|
315
|
+
export function setSelectOptions(select,options)
|
|
316
|
+
{
|
|
317
|
+
//@TODO: only update in case of changes?
|
|
318
|
+
select.innerHTML = ''
|
|
319
|
+
if (Array.isArray(options)) {
|
|
320
|
+
for (const option of options) {
|
|
321
|
+
addOption(select, option)
|
|
322
|
+
}
|
|
323
|
+
} else if (options && typeof options == 'object') {
|
|
324
|
+
for (const option in options) {
|
|
325
|
+
addOption(select, { text: options[option], value: option })
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Sets the innerHTML and href, id, title, target, name, newwindow, nofollow attributes of an anchor
|
|
332
|
+
*/
|
|
333
|
+
export function anchor(context)
|
|
334
|
+
{
|
|
335
|
+
element(context)
|
|
336
|
+
setProperties(context.element, context.value, 'target', 'href', 'name', 'newwindow', 'nofollow')
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Sets the title, id, alt and src attributes of an image.
|
|
341
|
+
*/
|
|
342
|
+
export function image(context)
|
|
343
|
+
{
|
|
344
|
+
setProperties(context.element, context.value, 'title', 'alt', 'src', 'id')
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Sets the title, id and src attribute of an iframe
|
|
349
|
+
*/
|
|
350
|
+
export function iframe(context)
|
|
351
|
+
{
|
|
352
|
+
setProperties(context.element, context.value, 'title', 'src', 'id')
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Sets the content and id attribute of a meta element
|
|
357
|
+
*/
|
|
358
|
+
export function meta(context)
|
|
359
|
+
{
|
|
360
|
+
setProperties(context.element, context.value, 'content', 'id')
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* sets the innerHTML and title and id properties of any HTML element
|
|
365
|
+
*/
|
|
366
|
+
export function element(context)
|
|
367
|
+
{
|
|
368
|
+
const el = context.element
|
|
369
|
+
let value = context.value
|
|
370
|
+
|
|
371
|
+
if (typeof value=='undefined' || value==null) {
|
|
372
|
+
value = ''
|
|
373
|
+
}
|
|
374
|
+
let strValue = ''+value
|
|
375
|
+
if (typeof value!='object' || strValue.substring(0,8)!='[object ') {
|
|
376
|
+
el.innerHTML = strValue
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
setProperties(el, value, 'innerHTML', 'title', 'id', 'className')
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Sets a list of properties on a dom element, equal to
|
|
384
|
+
* the string value of a data object
|
|
385
|
+
* only updates the dom element if the property doesn't match
|
|
386
|
+
*/
|
|
387
|
+
export function setProperties(el, data, ...properties) {
|
|
388
|
+
if (!data || typeof data!=='object') {
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
for (const property of properties) {
|
|
392
|
+
if (typeof data[property] === 'undefined') {
|
|
393
|
+
continue
|
|
394
|
+
}
|
|
395
|
+
if (matchValue(el[property], data[property])) {
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
if (data[property] === null) {
|
|
399
|
+
el[property] = ''
|
|
400
|
+
} else {
|
|
401
|
+
el[property] = ''+data[property]
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Returns true if a matches b, either by having the
|
|
408
|
+
* same string value, or matching string :empty against a falsy value
|
|
409
|
+
*/
|
|
410
|
+
export function matchValue(a,b)
|
|
411
|
+
{
|
|
412
|
+
if (a==':empty' && !b) {
|
|
413
|
+
return true
|
|
414
|
+
}
|
|
415
|
+
if (b==':empty' && !a) {
|
|
416
|
+
return true
|
|
417
|
+
}
|
|
418
|
+
if (''+a == ''+b) {
|
|
419
|
+
return true
|
|
420
|
+
}
|
|
421
|
+
return false
|
|
422
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function escape_html(context, next) {
|
|
2
|
+
let content = context.value.innerHTML
|
|
3
|
+
if (typeof context.value == 'string') {
|
|
4
|
+
content = context.value
|
|
5
|
+
context.value = { innerHTML: content }
|
|
6
|
+
}
|
|
7
|
+
if (content) {
|
|
8
|
+
content = content.replace(/&/g, '&')
|
|
9
|
+
.replace(/</g, '<')
|
|
10
|
+
.replace(/>/g, '>')
|
|
11
|
+
.replace(/"/g, '"')
|
|
12
|
+
.replace(/'/g, ''');
|
|
13
|
+
context.value.innerHTML = content
|
|
14
|
+
}
|
|
15
|
+
next(context)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function fixed_content(context, next) {
|
|
19
|
+
if (typeof context.value == 'string') {
|
|
20
|
+
context.value = {}
|
|
21
|
+
} else {
|
|
22
|
+
delete context.value.innerHTML
|
|
23
|
+
}
|
|
24
|
+
next(context)
|
|
25
|
+
}
|