@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.
package/src/bind.mjs CHANGED
@@ -1,522 +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
- import { DEP } from './symbols.mjs'
5
-
6
- /**
7
- * Implements one way databinding, updating dom elements with matching attributes
8
- * to changes in signals (see state.mjs)
9
- *
10
- * @class
11
- */
12
- class SimplyBind
13
- {
14
-
15
- /**
16
- * @param Object options - a set of options for this instance, options may include:
17
- * - root (signal) (required) - the root data object that contains al signals that can be bound
18
- * - container (HTMLElement) - the dom element to use as the root for all bindings
19
- * - attribute (string) - the prefix for the field, edit, list and map attributes, e.g. 'data-bind'
20
- * - transformers (object name:function) - a map of transformer names and functions
21
- * - render (object with field, list and map properties); edit uses field renderers
22
- */
23
- constructor(options)
24
- {
25
- /**
26
- * A map of HTMLElements and the data bindings on each, in the form of
27
- * the connectedSignal returned by the (throttled)Effect.
28
- * @type {Map}
29
- * @public
30
- */
31
- this.bindings = new Map()
32
-
33
- const defaultTransformers = {
34
- escape_html,
35
- fixed_content
36
- }
37
- const defaultOptions = {
38
- container: document.body,
39
- attribute: 'data-flow',
40
- transformers: defaultTransformers,
41
- render: {
42
- field: [render.field],
43
- list: [render.list],
44
- map: [render.map]
45
- },
46
- renderers: {
47
- 'INPUT':render.input,
48
- 'TEXTAREA':render.input,
49
- 'BUTTON':render.button,
50
- 'SELECT':render.select,
51
- 'A':render.anchor,
52
- 'IMG':render.image,
53
- 'IFRAME':render.iframe,
54
- 'META':render.meta,
55
- 'TEMPLATE':null,
56
- '*':render.element
57
- },
58
- twoway: false
59
- }
60
- if (!options?.root) {
61
- throw new Error('bind needs at least options.root set')
62
- }
63
- this.options = Object.assign({}, defaultOptions, options)
64
- if (options.transformers) {
65
- this.options.transformers = Object.assign({}, defaultTransformers, options?.transformers)
66
- }
67
- const attribute = this.options.attribute
68
- const bindAttributes = [attribute+'-field',attribute+'-edit',attribute+'-list',attribute+'-map']
69
- const transformAttribute = attribute+'-transform'
70
-
71
- const getBindingAttribute = (el) => {
72
- const foundAttribute = bindAttributes.find(attr => el.hasAttribute(attr))
73
- if (!foundAttribute) {
74
- console.error('No matching attribute found',el,bindAttributes)
75
- }
76
- return foundAttribute
77
- }
78
-
79
- // sets up the effect that updates the element if its
80
- // data binding value changes
81
- const renderElement = (el) => {
82
- this.bindings.set(el, throttledEffect(() => {
83
- if (!el.isConnected) {
84
- // el is no longer part of this document
85
- untrack(el, this.getBindingPath(el))
86
- const binding = this.bindings.get(el)
87
- if (binding) {
88
- destroy(binding)
89
- this.bindings.delete(el)
90
- }
91
- // doing this here instead of in a mutationobserver
92
- // allows an element to be temporary removed and then inserted
93
- // without the binding having to be reset
94
- return
95
- }
96
- let context = {
97
- templates: el.querySelectorAll(':scope > template'),
98
- attribute: getBindingAttribute(el)
99
- }
100
- context.edit = context.attribute === this.options.attribute+'-edit'
101
- context.path = this.getBindingPath(el)
102
- context.value = getValueByPath(this.options.root, context.path)
103
- context.element = el
104
- track(el, context)
105
- runTransformers(context)
106
- }, 50))
107
- }
108
-
109
- // finds and runs applicable transformers
110
- // creates a stack of transformers, calls the topmost
111
- // each transformer can opt to call the next or not
112
- // transformers should return the context object (possibly altered)
113
- const runTransformers = (context) => {
114
- let transformers
115
- switch(context.attribute) {
116
- case this.options.attribute+'-field':
117
- case this.options.attribute+'-edit':
118
- transformers = Array.from(this.options.render.field)
119
- break
120
- case this.options.attribute+'-list':
121
- transformers = Array.from(this.options.render.list)
122
- break
123
- case this.options.attribute+'-map':
124
- transformers = Array.from(this.options.render.map)
125
- break
126
- default:
127
- throw new Error('no valid context attribute specified',context)
128
- break
129
- }
130
- if (context.element.hasAttribute(transformAttribute)) {
131
- context.element.getAttribute(transformAttribute)
132
- .split(' ').filter(Boolean)
133
- .forEach(t => {
134
- if (this.options.transformers[t]) {
135
- transformers.push(this.options.transformers[t])
136
- } else {
137
- console.warn('No transformer with name '+t+' configured', {cause:context.element})
138
- }
139
- })
140
- }
141
- let next
142
- for (let transformer of transformers) {
143
- next = ((next, transformer) => {
144
- return (context) => {
145
- return transformer.call(this, context, next)
146
- }
147
- })(next, transformer)
148
- }
149
- next(context)
150
- }
151
-
152
- // given a set of elements with data bind attribute
153
- // this renders each of those elements
154
- const applyBindings = (bindings) => {
155
- for (let bindingEl of bindings) {
156
- if (!this.bindings.get(bindingEl)) { // bindingEl may have moved from somewhere else in this document
157
- renderElement(bindingEl)
158
- }
159
- }
160
- }
161
-
162
- // this handles the mutation observer changes
163
- // if any element is added, and has a data bind attribute
164
- // it applies that data binding
165
- const updateBindings = (changes) => {
166
- const selector = `[${attribute}-field],[${attribute}-edit],[${attribute}-list],[${attribute}-map]`
167
- for (const change of changes) {
168
- if (change.type=="childList" && change.addedNodes) {
169
- for (let node of change.addedNodes) {
170
- if (node instanceof HTMLElement) {
171
- let bindings = Array.from(node.querySelectorAll(selector))
172
- if (node.matches(selector)) {
173
- bindings.unshift(node)
174
- }
175
- if (bindings.length) {
176
- applyBindings(bindings)
177
- }
178
- }
179
- }
180
- }
181
- }
182
- }
183
-
184
- // this responds to elements getting added to the dom
185
- // and if any have data bind attributes, it applies those bindings
186
- this.observer = new MutationObserver((changes) => {
187
- updateBindings(changes)
188
- })
189
-
190
- this.observer.observe(this.options.container, {
191
- subtree: true,
192
- childList: true
193
- })
194
-
195
- // this finds elements with data binding attributes and applies those bindings
196
- // must come after setting up the observer, or included templates
197
- // won't trigger their own bindings
198
- const bindings = this.options.container.querySelectorAll(
199
- ':is(['+this.options.attribute+'-field]'+
200
- ',['+this.options.attribute+'-edit]'+
201
- ',['+this.options.attribute+'-list]'+
202
- ',['+this.options.attribute+'-map]):not(template)'
203
- )
204
- try {
205
- if (bindings.length) {
206
- applyBindings(bindings)
207
- }
208
- } catch (error) {
209
- this.destroy()
210
- throw error
211
- }
212
-
213
- }
214
-
215
- /**
216
- * Finds the first matching template and creates a new DocumentFragment
217
- * with the correct data bind attributes in it (prepends the current path)
218
- * @param Context context
219
- * @return DocumentFragment
220
- */
221
- applyTemplate(context)
222
- {
223
- const path = context.path
224
- const parent = context.parent
225
- const templates = context.templates
226
- const list = context.list
227
- const index = context.index
228
- const value = list ? list[index] : context.value
229
-
230
- let template = this.findTemplate(templates, value)
231
- if (!template) {
232
- let result = new DocumentFragment()
233
- result.innerHTML = '<!-- no matching template -->'
234
- return result
235
- }
236
- let clone = template.content.cloneNode(true)
237
- if (!clone.children?.length) {
238
- return clone
239
- }
240
- if (clone.children.length>1) {
241
- throw new Error('template must contain a single root node', { cause: template })
242
- }
243
- const attribute = this.options.attribute
244
-
245
- const attributes = [attribute+'-field',attribute+'-edit',attribute+'-list',attribute+'-map']
246
- const bindings = clone.querySelectorAll(`[${attribute}-field],[${attribute}-edit],[${attribute}-list],[${attribute}-map]`)
247
- for (let binding of bindings) {
248
- if (binding.tagName=='TEMPLATE') {
249
- continue
250
- }
251
- const attr = attributes.find(attr => binding.hasAttribute(attr))
252
- let bind = binding.getAttribute(attr)
253
- bind = this.applyLinks(template.links, bind)
254
- if (bind.substring(0, ':root.'.length)==':root.') {
255
- binding.setAttribute(attr, bind.substring(':root.'.length))
256
- } else if (bind==':value' && index!=null) {
257
- binding.setAttribute(attr, path+'.'+index)
258
- } else if (index!=null) {
259
- binding.setAttribute(attr, path+'.'+index+'.'+bind)
260
- } else {
261
- binding.setAttribute(attr, parent+bind)
262
- }
263
- }
264
- this.applyTemplateCommandValues(clone, template.links, path, index)
265
- if (typeof index !== 'undefined') {
266
- clone.children[0].setAttribute(attribute+'-key',index)
267
- }
268
- // keep track of the used template and value reference, so list items can be
269
- // reused when an array insertion shifts their numeric index.
270
- clone.children[0][DEP.TEMPLATE] = template
271
- clone.children[0][DEP.VALUE] = value
272
-
273
- // return clone, not the firstChild, so that all whitespace is cloned as well
274
- return clone
275
- }
276
-
277
- applyTemplateCommandValues(fragment, links, path, index)
278
- {
279
- const valueAttribute = this.options.attribute+'-value'
280
- const valuePathAttribute = this.options.attribute+'-value-path'
281
- const valueSelector = '['+valueAttribute+']'
282
- const elements = Array.from(fragment.querySelectorAll(valueSelector))
283
-
284
- for (const element of elements) {
285
- let value = element.getAttribute(valueAttribute)
286
- value = this.applyLinks(links, value)
287
- const resolved = templateCommandValue(value, path, index)
288
- if (!resolved) {
289
- continue
290
- }
291
- if (Object.hasOwn(resolved, 'path')) {
292
- element.setAttribute(valuePathAttribute, resolved.path)
293
- } else {
294
- element.setAttribute(valueAttribute, resolved.value)
295
- element.removeAttribute(valuePathAttribute)
296
- }
297
- }
298
- }
299
-
300
- parseLinks(links)
301
- {
302
- let result = {}
303
- links = links.split(';').map(link => link.trim())
304
- for (let link of links) {
305
- link = link.split('=')
306
- result[link[0].trim()] = link[1].trim()
307
- }
308
- return result
309
- }
310
-
311
- applyLinks(links, value)
312
- {
313
- for (let link in links) {
314
- if (value.startsWith(link+'.')) {
315
- return links[link] + value.substr(link.length)
316
- } else if (value==link) {
317
- return links[link]
318
- }
319
- }
320
- return value
321
- }
322
-
323
- /**
324
- * Returns the path referenced in either the field, list or map attribute
325
- * @param HTMLElement el
326
- * @return string The path referenced, or void
327
- */
328
- getBindingPath(el)
329
- {
330
- const attributes = [
331
- this.options.attribute+'-field',
332
- this.options.attribute+'-edit',
333
- this.options.attribute+'-list',
334
- this.options.attribute+'-map'
335
- ]
336
- for (let attr of attributes) {
337
- if (el.hasAttribute(attr)) {
338
- return el.getAttribute(attr)
339
- }
340
- }
341
- }
342
-
343
- /**
344
- * Finds the first template from an array of templates that
345
- * matches the given value.
346
- */
347
- findTemplate(templates, value)
348
- {
349
- const templateMatches = t => {
350
- // find the value to match against (e.g. data-bind="foo")
351
- let path = this.getBindingPath(t)
352
- let currentItem
353
- if (path) {
354
- if (path.substr(0,6)==':root.') {
355
- currentItem = getValueByPath(this.options.root, path.substring(6))
356
- } else {
357
- currentItem = getValueByPath(value, path)
358
- }
359
- } else {
360
- currentItem = value
361
- }
362
-
363
- // then check the value against pattern, if set (e.g. data-bind-match="bar")
364
- const strItem = ''+currentItem
365
- let matches = t.getAttribute(this.options.attribute+'-match')
366
- if (matches) {
367
- if (matches===':empty' && !currentItem) {
368
- return t
369
- } else if (matches===':notempty' && currentItem) {
370
- return t
371
- }
372
- if (strItem == matches) {
373
- return t
374
- }
375
- }
376
- if (!matches) {
377
- // no data-bind-match is set, so return this template
378
- return t
379
- }
380
- }
381
- let template = Array.from(templates).find(templateMatches)
382
- let links = null
383
- if (template?.hasAttribute(this.options.attribute+'-link')) {
384
- links = this.parseLinks(template.getAttribute(this.options.attribute+'-link'))
385
- }
386
- let rel = template?.getAttribute('rel')
387
- if (rel) {
388
- let replacement = document.querySelector('template#'+rel)
389
- if (!replacement) {
390
- throw new Error('Could not find template with id '+rel)
391
- }
392
- template = replacement
393
- }
394
- if (template) {
395
- template.links = links
396
- }
397
- return template
398
- }
399
-
400
- destroy()
401
- {
402
- this.bindings.forEach((binding, element) => {
403
- untrack(element, this.getBindingPath(element))
404
- destroy(binding)
405
- })
406
- this.bindings = new Map()
407
- this.observer.disconnect()
408
- }
409
-
410
- }
411
-
412
- /**
413
- * Returns a new instance of SimplyBind. This is the normal start
414
- * of a data bind flow
415
- */
416
- export function bind(options)
417
- {
418
- return new SimplyBind(options)
419
- }
420
-
421
- const tracking = new Map()
422
-
423
- export function trace(path)
424
- {
425
- return tracking.get(path)
426
- }
427
-
428
- function track(el, context) {
429
- untrack(el)
430
- if (!tracking.has(context.path)) {
431
- tracking.set(context.path, [context])
432
- } else {
433
- tracking.get(context.path).push(context)
434
- }
435
- }
436
-
437
- function untrack(el, path) {
438
- if (path) {
439
- let list = tracking.get(path)
440
- if (list) {
441
- list = list.filter(context => context.element !== el)
442
- tracking.set(path, list)
443
- }
444
- return
445
- }
446
- tracking.forEach((list, trackedPath) => {
447
- list = list.filter(context => context.element !== el)
448
- tracking.set(trackedPath, list)
449
- })
450
- }
451
-
452
-
453
-
454
- function templateCommandValue(value, path, index)
455
- {
456
- if (!value || value[0] !== ':') {
457
- return null
458
- }
459
- if (value === ':key') {
460
- return { value: ''+index }
461
- }
462
- if (value === ':value') {
463
- return { path: templateItemPath(path, index) }
464
- }
465
- if (value.startsWith(':value.')) {
466
- return { path: joinPath(templateItemPath(path, index), value.substring(':value'.length)) }
467
- }
468
- if (value.startsWith(':root.')) {
469
- return { path: value.substring(':root.'.length) }
470
- }
471
- return null
472
- }
473
-
474
- function templateItemPath(path, index)
475
- {
476
- if (typeof index === 'undefined') {
477
- return path
478
- }
479
- return joinPath(path, '.'+index)
480
- }
481
-
482
- function joinPath(path, suffix)
483
- {
484
- if (!path) {
485
- return suffix.replace(/^\./, '')
486
- }
487
- return path+suffix
488
- }
489
-
490
- /**
491
- * Returns the value by walking the given path as a json pointer, starting at root
492
- * if you have a property with a '.' in its name urlencode the '.', e.g: %46
493
- *
494
- * @param HTMLElement root
495
- * @param string path e.g. 'foo.bar'
496
- * @return mixed the value found by walking the path from the root object or undefined
497
- */
498
- export function getValueByPath(root, path)
499
- {
500
- let parts = path.split('.')
501
- let curr = root
502
- let part
503
- part = parts.shift()
504
- let prevPart = null
505
- while (part && curr) {
506
- part = decodeURIComponent(part)
507
- if (part=='0' && !Array.isArray(curr)) {
508
- // ignore so that data-flow-list="nonarray" will work
509
- } else if (part==':key') {
510
- curr = prevPart
511
- } else if (part==':value') {
512
- // do nothing
513
- } else if (Array.isArray(curr) && typeof curr[part]=='undefined' && curr[0]) {
514
- curr = curr[0][part] // so that data-flow-field="array.foo" works
515
- } else {
516
- curr = curr[part]
517
- }
518
- prevPart = part
519
- part = parts.shift()
520
- }
521
- return curr
522
- }
1
+ export * from '@muze-labs/simplyflow-bind'