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