simplyview 3.0.5 → 3.1.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 DELETED
@@ -1,649 +0,0 @@
1
- import { throttledEffect, destroy } from './state.mjs'
2
-
3
- class SimplyBind {
4
- constructor(options) {
5
- this.bindings = new Map()
6
- const defaultOptions = {
7
- container: document.body,
8
- attribute: 'data-bind',
9
- transformers: [],
10
- defaultTransformers: {
11
- field: [defaultFieldTransformer],
12
- list: [defaultListTransformer],
13
- map: [defaultMapTransformer]
14
- }
15
- }
16
- if (!options?.root) {
17
- throw new Error('bind needs at least options.root set')
18
- }
19
- this.options = Object.assign({}, defaultOptions, options)
20
-
21
- const attribute = this.options.attribute
22
- const bindAttributes = [attribute+'-field',attribute+'-list',attribute+'-map']
23
- const bindSelector = `[${attribute}-field],[${attribute}-list],[${attribute}-map]`
24
-
25
- const getBindingAttribute = (el) => {
26
- const foundAttribute = bindAttributes.find(attr => el.hasAttribute(attr))
27
- if (!foundAttribute) {
28
- console.error('No matching attribute found',el)
29
- }
30
- return foundAttribute
31
- }
32
-
33
- // sets up the effect that updates the element if its
34
- // data binding value changes
35
-
36
- const render = (el) => {
37
- this.bindings.set(el, throttledEffect(() => {
38
- const context = {
39
- templates: el.querySelectorAll(':scope > template'),
40
- attribute: getBindingAttribute(el)
41
- }
42
- context.path = this.getBindingPath(el)
43
- context.value = getValueByPath(this.options.root, context.path)
44
- context.element = el
45
- runTransformers(context)
46
- }, 100))
47
- }
48
-
49
- // finds and runs applicable transformers
50
- // creates a stack of transformers, calls the topmost
51
- // each transformer can opt to call the next or not
52
- // transformers should return the context object (possibly altered)
53
- const runTransformers = (context) => {
54
- let transformers
55
- switch(context.attribute) {
56
- case this.options.attribute+'-field':
57
- transformers = this.options.defaultTransformers.field || []
58
- break
59
- case this.options.attribute+'-list':
60
- transformers = this.options.defaultTransformers.list || []
61
- break
62
- case this.options.attribute+'-map':
63
- transformers = this.options.defaultTransformers.map || []
64
- break
65
- }
66
- if (context.element.dataset.transform) {
67
- context.element.dataset.transform.split(' ').filter(Boolean).forEach(t => {
68
- if (this.options.transformers[t]) {
69
- transformers.push(this.options.transformers[t])
70
- } else {
71
- console.warn('No transformer with name '+t+' configured', {cause:context.element})
72
- }
73
- })
74
- }
75
- let next
76
- for (let transformer of transformers) {
77
- next = ((next, transformer) => {
78
- return (context) => {
79
- return transformer.call(this, context, next)
80
- }
81
- })(next, transformer)
82
- }
83
- next(context)
84
- }
85
-
86
- // given a set of elements with data bind attribute
87
- // this renders each of those elements
88
- const applyBindings = (bindings) => {
89
- for (let bindingEl of bindings) {
90
- render(bindingEl)
91
- }
92
- }
93
-
94
- // this handles the mutation observer changes
95
- // if any element is added, and has a data bind attribute
96
- // it applies that data binding
97
- const updateBindings = (changes) => {
98
- const selector = `[${attribute}-field],[${attribute}-list],[${attribute}-map]`
99
- for (const change of changes) {
100
- if (change.type=="childList" && change.addedNodes) {
101
- for (let node of change.addedNodes) {
102
- if (node instanceof HTMLElement) {
103
- let bindings = Array.from(node.querySelectorAll(selector))
104
- if (node.matches(selector)) {
105
- bindings.unshift(node)
106
- }
107
- if (bindings.length) {
108
- applyBindings(bindings)
109
- }
110
- }
111
- }
112
- }
113
- }
114
- }
115
-
116
- // this responds to elements getting added to the dom
117
- // and if any have data bind attributes, it applies those bindings
118
- this.observer = new MutationObserver((changes) => {
119
- updateBindings(changes)
120
- })
121
-
122
- this.observer.observe(options.container, {
123
- subtree: true,
124
- childList: true
125
- })
126
-
127
- // this finds elements with data binding attributes and applies those bindings
128
- // must come after setting up the observer, or included templates
129
- // won't trigger their own bindings
130
- const bindings = this.options.container.querySelectorAll(
131
- '['+this.options.attribute+'-field]'+
132
- ',['+this.options.attribute+'-list]'+
133
- ',['+this.options.attribute+'-map]'
134
- )
135
- if (bindings.length) {
136
- applyBindings(bindings)
137
- }
138
-
139
- }
140
-
141
- /**
142
- * Finds the first matching template and creates a new DocumentFragment
143
- * with the correct data bind attributes in it (prepends the current path)
144
- */
145
- applyTemplate(context) {
146
- const path = context.path
147
- const templates = context.templates
148
- const list = context.list
149
- const index = context.index
150
- const parent = context.parent
151
- const value = list ? list[index] : context.value
152
-
153
- let template = this.findTemplate(templates, value)
154
- if (!template) {
155
- let result = new DocumentFragment()
156
- result.innerHTML = '<!-- no matching template -->'
157
- return result
158
- }
159
- let clone = template.content.cloneNode(true)
160
- if (!clone.children?.length) {
161
- return clone
162
- }
163
- if (clone.children.length>1) {
164
- throw new Error('template must contain a single root node', { cause: template })
165
- }
166
- const attribute = this.options.attribute
167
- const attributes = [attribute+'-field',attribute+'-list',attribute+'-map']
168
- const bindings = clone.querySelectorAll(`[${attribute}-field],[${attribute}-list],[${attribute}-map]`)
169
- for (let binding of bindings) {
170
- const attr = attributes.find(attr => binding.hasAttribute(attr))
171
- const bind = binding.getAttribute(attr)
172
- if (bind.substring(0, ':root.'.length)==':root.') {
173
- binding.setAttribute(attr, bind.substring(':root.'.length))
174
- } else if (bind==':value' && index!=null) {
175
- binding.setAttribute(attr, path+'.'+index)
176
- } else if (index!=null) {
177
- binding.setAttribute(attr, path+'.'+index+'.'+bind)
178
- } else {
179
- binding.setAttribute(attr, parent+'.'+bind)
180
- }
181
- }
182
- if (typeof index !== 'undefined') {
183
- clone.children[0].setAttribute(attribute+'-key',index)
184
- }
185
- // keep track of the used template, so if that changes, the
186
- // item can be updated
187
- clone.children[0].$bindTemplate = template
188
- return clone
189
- }
190
-
191
- getBindingPath(el) {
192
- const attributes = [
193
- this.options.attribute+'-field',
194
- this.options.attribute+'-list',
195
- this.options.attribute+'-map'
196
- ]
197
- for (let attr of attributes) {
198
- if (el.hasAttribute(attr)) {
199
- return el.getAttribute(attr)
200
- }
201
- }
202
- }
203
-
204
- /**
205
- * Finds the first template from an array of templates that
206
- * matches the given value.
207
- */
208
- findTemplate(templates, value) {
209
- const templateMatches = t => {
210
- // find the value to match against (e.g. data-bind="foo")
211
- let path = this.getBindingPath(t)
212
- let currentItem
213
- if (path) {
214
- if (path.substr(0,6)==':root.') {
215
- currentItem = getValueByPath(this.options.root, path)
216
- } else {
217
- currentItem = getValueByPath(value, path)
218
- }
219
- } else {
220
- currentItem = value
221
- }
222
-
223
- // then check the value against pattern, if set (e.g. data-bind-match="bar")
224
- const strItem = ''+currentItem
225
- let matches = t.getAttribute(this.options.attribute+'-match')
226
- if (matches) {
227
- if (matches===':empty' && !currentItem) {
228
- return t
229
- } else if (matches===':notempty' && currentItem) {
230
- return t
231
- }
232
- if (strItem.match(matches)) {
233
- return t
234
- }
235
- }
236
- if (!matches && currentItem!==null && currentItem!==undefined) {
237
- //FIXME: this doesn't run templates in lists where list entry is null
238
- //which messes up the count
239
- //
240
- // no data-bind-match is set, so return this template
241
- return t
242
- }
243
- }
244
- let template = Array.from(templates).find(templateMatches)
245
- let rel = template?.getAttribute('rel')
246
- if (rel) {
247
- let replacement = document.querySelector('template#'+rel)
248
- if (!replacement) {
249
- throw new Error('Could not find template with id '+rel)
250
- }
251
- template = replacement
252
- }
253
- return template
254
- }
255
-
256
- destroy() {
257
- this.bindings.forEach(binding => {
258
- destroy(binding)
259
- })
260
- this.bindings = new Map()
261
- this.observer.disconnect()
262
- }
263
-
264
- }
265
-
266
- /**
267
- * Returns a new instance of SimplyBind. This is the normal start
268
- * of a data bind flow
269
- */
270
- export function bind(options)
271
- {
272
- return new SimplyBind(options)
273
- }
274
-
275
- /**
276
- * Returns true if a matches b, either by having the
277
- * same string value, or matching string :empty against a falsy value
278
- */
279
- export function matchValue(a,b) {
280
- if (a==':empty' && !b) {
281
- return true
282
- }
283
- if (b==':empty' && !a) {
284
- return true
285
- }
286
- if (''+a == ''+b) {
287
- return true
288
- }
289
- return false
290
- }
291
-
292
- /**
293
- * Returns the value by walking the given path
294
- * as a json pointer, starting at root
295
- * if you have a property with a '.' in its name
296
- * urlencode the '.', e.g: %46
297
- */
298
- export function getValueByPath(root, path)
299
- {
300
- let parts = path.split('.');
301
- let curr = root;
302
- let part, prevPart;
303
- while (parts.length && curr) {
304
- part = parts.shift()
305
- if (part==':key') {
306
- return prevPart
307
- } else if (part==':value') {
308
- return curr
309
- } else if (part==':root') {
310
- curr = root
311
- } else {
312
- part = decodeURIComponent(part)
313
- curr = curr[part];
314
- prevPart = part
315
- }
316
- }
317
- return curr
318
- }
319
-
320
- /**
321
- * Default transformer for data binding
322
- * Will be used unless overriden in the SimplyBind options parameter
323
- */
324
- export function defaultFieldTransformer(context) {
325
- const el = context.element
326
- const templates = context.templates
327
- const templatesCount = templates.length
328
- const path = context.path
329
- const value = context.value
330
- const attribute = this.options.attribute
331
-
332
- if (templates?.length) {
333
- transformLiteralByTemplates.call(this, context)
334
- } else if (el.tagName=='INPUT') {
335
- transformInput.call(this, context)
336
- } else if (el.tagName=='BUTTON') {
337
- transformButton.call(this, context)
338
- } else if (el.tagName=='SELECT') {
339
- transformSelect.call(this, context)
340
- } else if (el.tagName=='A') {
341
- transformAnchor.call(this, context)
342
- } else {
343
- transformElement.call(this, context)
344
- }
345
- return context
346
- }
347
-
348
- export function defaultListTransformer(context) {
349
- const el = context.element
350
- const templates = context.templates
351
- const templatesCount = templates.length
352
- const path = context.path
353
- const value = context.value
354
- const attribute = this.options.attribute
355
-
356
- if (!Array.isArray(value)) {
357
- console.error('Value is not an array.', el, value)
358
- } else if (!templates?.length) {
359
- console.error('No templates found in', el)
360
- } else {
361
- transformArrayByTemplates.call(this, context)
362
- }
363
- return context
364
- }
365
-
366
- export function defaultMapTransformer(context) {
367
- const el = context.element
368
- const templates = context.templates
369
- const templatesCount = templates.length
370
- const path = context.path
371
- const value = context.value
372
- const attribute = this.options.attribute
373
-
374
- if (typeof value != 'object') {
375
- console.error('Value is not an object.', el, value)
376
- } else if (!templates?.length) {
377
- console.error('No templates found in', el)
378
- } else {
379
- transformObjectByTemplates.call(this, context)
380
- }
381
- return context
382
- }
383
-
384
-
385
- /**
386
- * Renders an array value by applying templates for each entry
387
- * Replaces or removes existing DOM children if needed
388
- * Reuses (doesn't touch) DOM children if template doesn't change
389
- * FIXME: this doesn't handle situations where there is no matching template
390
- * this messes up self healing. check transformObjectByTemplates for a better implementation
391
- */
392
- export function transformArrayByTemplates(context) {
393
- const el = context.element
394
- const templates = context.templates
395
- const templatesCount = templates.length
396
- const path = context.path
397
- const value = context.value
398
- const attribute = this.options.attribute
399
-
400
- let items = el.querySelectorAll(':scope > ['+attribute+'-key]')
401
- // do single merge strategy for now, in future calculate optimal merge strategy from a number
402
- // now just do a delete if a key <= last key, insert if a key >= last key
403
- let lastKey = 0
404
- let skipped = 0
405
- context.list = value
406
- for (let item of items) {
407
- let currentKey = parseInt(item.getAttribute(attribute+'-key'))
408
- if (currentKey>lastKey) {
409
- // insert before
410
- context.index = lastKey
411
- el.insertBefore(this.applyTemplate(context), item)
412
- } else if (currentKey<lastKey) {
413
- // remove this
414
- item.remove()
415
- } else {
416
- // check that all data-bind params start with current json path or ':root', otherwise replaceChild
417
- let bindings = Array.from(item.querySelectorAll(`[${attribute}]`))
418
- if (item.matches(`[${attribute}]`)) {
419
- bindings.unshift(item)
420
- }
421
- let needsReplacement = bindings.find(b => {
422
- let databind = b.getAttribute(attribute)
423
- return (databind.substr(0,5)!==':root'
424
- && databind.substr(0, path.length)!==path)
425
- })
426
- if (!needsReplacement) {
427
- if (item.$bindTemplate) {
428
- let newTemplate = this.findTemplate(templates, value[lastKey])
429
- if (newTemplate != item.$bindTemplate){
430
- needsReplacement = true
431
- if (!newTemplate) {
432
- skipped++
433
- }
434
- }
435
- }
436
- }
437
- if (needsReplacement) {
438
- context.index = lastKey
439
- el.replaceChild(this.applyTemplate(context), item)
440
- }
441
- }
442
- lastKey++
443
- if (lastKey>=value.length) {
444
- break
445
- }
446
- }
447
- items = el.querySelectorAll(':scope > ['+attribute+'-key]')
448
- let length = items.length + skipped
449
- if (length > value.length) {
450
- while (length > value.length) {
451
- let child = el.querySelectorAll(':scope > :not(template)')?.[length-1]
452
- child?.remove()
453
- length--
454
- }
455
- } else if (length < value.length ) {
456
- while (length < value.length) {
457
- context.index = length
458
- el.appendChild(this.applyTemplate(context))
459
- length++
460
- }
461
- }
462
- }
463
-
464
- /**
465
- * Renders an object value by applying templates for each entry (Object.entries)
466
- * Replaces,moves or removes existing DOM children if needed
467
- * Reuses (doesn't touch) DOM children if template doesn't change
468
- */
469
- export function transformObjectByTemplates(context) {
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
- context.list = value
477
-
478
- let items = Array.from(el.querySelectorAll(':scope > ['+attribute+'-key]'))
479
- for (let key in context.list) {
480
- context.index = key
481
- let item = items.shift()
482
- if (!item) { // more properties than rendered items
483
- let clone = this.applyTemplate(context)
484
- if (clone.firstElementChild) {
485
- el.appendChild(clone)
486
- }
487
- continue
488
- }
489
- if (item.getAttribute[attribute+'-key']!=key) {
490
- // next item doesn't match key
491
- items.unshift(item) // put item back for next cycle
492
- let outOfOrderItem = el.querySelector(':scope > ['+attribute+'-key="'+key+'"]') //FIXME: escape key
493
- if (!outOfOrderItem) {
494
- let clone = this.applyTemplate(context)
495
- if (clone.firstElementChild) {
496
- el.insertBefore(clone, item)
497
- }
498
- continue // new template doesn't need replacement, so continue
499
- } else {
500
- el.insertBefore(outOfOrderItem, item)
501
- item = outOfOrderItem // check needsreplacement next
502
- items = items.filter(i => i!=outOfOrderItem)
503
- }
504
- }
505
- let newTemplate = this.findTemplate(templates, value[key])
506
- if (newTemplate != item.$bindTemplate){
507
- let clone = this.applyTemplate(context)
508
- el.replaceChild(clone, item)
509
- }
510
- }
511
- // clean up remaining items
512
- while (items.length) {
513
- item = items.shift()
514
- item.remove()
515
- }
516
- }
517
-
518
- function getParentPath(el, attribute) {
519
- const parentEl = el.parentElement?.closest(`[${attribute}-list],[${attribute}-map]`)
520
- if (!parentEl) {
521
- return ':root'
522
- }
523
- if (parentEl.hasAttribute(`${attribute}-list`)) {
524
- return parentEl.getAttribute(`${attribute}-list`)
525
- }
526
- return parentEl.getAttribute(`${attribute}-map`)
527
- }
528
-
529
- /**
530
- * transforms the contents of an html element by rendering
531
- * a matching template, once.
532
- * data-bind attributes inside the template use the same
533
- * parent path as this html element uses
534
- */
535
- export function transformLiteralByTemplates(context) {
536
- const el = context.element
537
- const templates = context.templates
538
- const value = context.value
539
- const attribute = this.options.attribute
540
-
541
- const rendered = el.querySelector(':scope > :not(template)')
542
- const template = this.findTemplate(templates, value)
543
-
544
- context.parent = getParentPath(el, attribute)
545
- if (rendered) {
546
- if (template) {
547
- if (rendered?.$bindTemplate != template) {
548
- const clone = this.applyTemplate(context)
549
- el.replaceChild(clone, rendered)
550
- }
551
- } else {
552
- el.removeChild(rendered)
553
- }
554
- } else if (template) {
555
- const clone = this.applyTemplate(context)
556
- el.appendChild(clone)
557
- }
558
- }
559
-
560
- /**
561
- * transforms a single input type
562
- * for radio/checkbox inputs it only sets the checked attribute to true/false
563
- * if the value attribute matches the current value
564
- * for other inputs the value attribute is updated
565
- * FIXME: handle radio/checkboxes in separate transformer
566
- */
567
- export function transformInput(context) {
568
- const el = context.element
569
- const value = context.value
570
-
571
- if (el.type=='checkbox' || el.type=='radio') {
572
- if (matchValue(el.value, value)) {
573
- el.checked = true
574
- } else {
575
- el.checked = false
576
- }
577
- } else if (!matchValue(el.value, value)) {
578
- el.value = ''+value
579
- }
580
- }
581
-
582
- /**
583
- * Sets the value of the button, doesn't touch the innerHTML
584
- */
585
- export function transformButton(context) {
586
- const el = context.element
587
- const value = context.value
588
-
589
- if (!matchValue(el.value,value)) {
590
- el.value = ''+value
591
- }
592
- }
593
-
594
- /**
595
- * Sets the selected attribute of select options
596
- */
597
- export function transformSelect(context) {
598
- const el = context.element
599
- const value = context.value
600
-
601
- if (el.multiple) {
602
- if (Array.isArray(value)) {
603
- for (let option of el.options) {
604
- if (value.indexOf(option.value)===false) {
605
- option.selected = false
606
- } else {
607
- option.selected = true
608
- }
609
- }
610
- }
611
- } else {
612
- let option = el.options.find(o => matchValue(o.value,value))
613
- if (option) {
614
- option.selected = true
615
- }
616
- }
617
- }
618
-
619
- /**
620
- * Sets the innerHTML and href attribute of an anchor
621
- * TODO: support target, title, etc. attributes
622
- */
623
- export function transformAnchor(context) {
624
- const el = context.element
625
- const value = context.value
626
-
627
- if (value?.innerHTML && !matchValue(el.innerHTML, value.innerHTML)) {
628
- el.innerHTML = ''+value.innerHTML
629
- }
630
- if (value?.href && !matchValue(el.href,value.href)) {
631
- el.href = ''+value.href
632
- }
633
- }
634
-
635
- /**
636
- * sets the innerHTML of any HTML element
637
- */
638
- export function transformElement(context) {
639
- const el = context.element
640
- const value = context.value
641
-
642
- if (!matchValue(el.innerHTML, value)) {
643
- if (typeof value=='undefined' || value==null) {
644
- el.innerHTML = ''
645
- } else {
646
- el.innerHTML = ''+value
647
- }
648
- }
649
- }