@srfnstack/fntags 0.2.0 → 0.3.2

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/fntags.mjs +194 -105
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@srfnstack/fntags",
3
- "version": "0.2.0",
3
+ "version": "0.3.2",
4
4
  "author": "Robert Kempton <r@snow87.com>",
5
5
  "private": false,
6
6
  "homepage": "https://github.com/srfnstack/fntags",
package/src/fntags.mjs CHANGED
@@ -10,9 +10,10 @@
10
10
  * @param children optional attrs and children for the element
11
11
  * @returns HTMLElement an html element
12
12
  *
13
-
14
13
  */
15
- export const h = (tag, ...children) => {
14
+ export function h () {
15
+ const tag = arguments[0]
16
+ let firstChildIdx = 1
16
17
  let element
17
18
  if (tag.startsWith('ns=')) {
18
19
  element = document.createElementNS(...(tag.slice(3).split('|')))
@@ -20,18 +21,15 @@ export const h = (tag, ...children) => {
20
21
  element = document.createElement(tag)
21
22
  }
22
23
 
23
- if (isAttrs(children[0])) {
24
- const attrs = children.shift()
24
+ if (isAttrs(arguments[firstChildIdx])) {
25
+ const attrs = arguments[firstChildIdx]
26
+ firstChildIdx += 1
25
27
  for (const a in attrs) {
26
- let attr = attrs[a]
27
- if (typeof attr === 'function' && attr.isBoundAttribute) {
28
- attr.init(a, element)
29
- attr = attr()
30
- }
31
- setAttribute(a, attr, element)
28
+ setAttribute(a, attrs[a], element)
32
29
  }
33
30
  }
34
- for (const child of children) {
31
+ for (let i = firstChildIdx; i < arguments.length; i++) {
32
+ const child = arguments[i]
35
33
  if (Array.isArray(child)) {
36
34
  for (const c of child) {
37
35
  element.append(renderNode(c))
@@ -43,6 +41,68 @@ export const h = (tag, ...children) => {
43
41
  return element
44
42
  }
45
43
 
44
+ /**
45
+ * Create a compiled template function. The returned function takes a single object that contains the properties
46
+ * defined in the template.
47
+ *
48
+ * This allows fast rendering by precreating a dom element with the entire template structure and cloning and populating
49
+ * the clone with data from the provided context. This avoids the work of having to re-execute the tag functions
50
+ * one by one and can speed up situations where the same elements are created many times.
51
+ *
52
+ * You cannot bind state to the initial template. If you attempt to, the state will be read, but the elements will
53
+ * not be updated when the state changes because they will not be bound to the cloned element.
54
+ *
55
+ * All state bindings must be passed to the compiled template function to work correctly.
56
+ * @param templateFn {function(object): Node}
57
+ * @return {function(*): Node}
58
+ */
59
+ export const fntemplate = templateFn => {
60
+ const placeholders = { }
61
+ let id = 1
62
+ const initContext = prop => {
63
+ const placeholder = (element, type, attrOrStyle) => {
64
+ let selector = element.selector
65
+ if (!selector) {
66
+ selector = `fntpl-${prop}-${id++}`
67
+ element.selector = selector
68
+ element.classList.add(selector)
69
+ }
70
+ if (!placeholders[selector]) placeholders[selector] = []
71
+ placeholders[selector].push({ prop, type, attrOrStyle })
72
+ }
73
+ placeholder.isTemplatePlaceholder = true
74
+ return placeholder
75
+ }
76
+ // The initial render is cloned to prevent invalid state bindings from changing it
77
+ const rendered = templateFn(initContext).cloneNode(true)
78
+ return ctx => {
79
+ const clone = rendered.cloneNode(true)
80
+ for (const selectorClass in placeholders) {
81
+ const targetElement = clone.classList.contains(selectorClass) ? clone : clone.getElementsByClassName(selectorClass)[0]
82
+ targetElement.classList.remove(selectorClass)
83
+ for (const placeholder of placeholders[selectorClass]) {
84
+ if (!ctx[placeholder.prop]) {
85
+ console.warn(`No value provided for template prop: ${placeholder.prop}`)
86
+ }
87
+ switch (placeholder.type) {
88
+ case 'node':
89
+ targetElement.replaceWith(renderNode(ctx[placeholder.prop]))
90
+ break
91
+ case 'attr':
92
+ setAttribute(placeholder.attrOrStyle, ctx[placeholder.prop], targetElement)
93
+ break
94
+ case 'style':
95
+ setStyle(placeholder.attrOrStyle, ctx[placeholder.prop], targetElement)
96
+ break
97
+ default:
98
+ throw new Error(`Unexpected bindType ${placeholder.type}`)
99
+ }
100
+ }
101
+ }
102
+ return clone
103
+ }
104
+ }
105
+
46
106
  /**
47
107
  * Create a state object that can be bound to.
48
108
  * @param initialValue The initial state
@@ -76,90 +136,83 @@ export const fnstate = (initialValue, mapKey) => {
76
136
  }
77
137
 
78
138
  /**
79
- * Bind the values of this state to the given element.
80
- * Values are items/elements of an array.
81
- * If the current value is not an array, this will behave the same as bindAs.
82
- *
83
- * @param parent The parent to bind the children to.
84
- * @param element The element to bind to. If not a function, an update function must be passed
85
- * @param update If passed this will be executed directly when the state of any value changes with no other intervention
86
- */
139
+ * Bind the values of this state to the given element.
140
+ * Values are items/elements of an array.
141
+ * If the current value is not an array, this will behave the same as bindAs.
142
+ *
143
+ * @param parent The parent to bind the children to.
144
+ * @param element The element to bind to. If not a function, an update function must be passed
145
+ * @param update If passed this will be executed directly when the state of any value changes with no other intervention
146
+ */
87
147
  ctx.state.bindChildren = (parent, element, update) => doBindChildren(ctx, parent, element, update)
88
148
 
89
149
  /**
90
- * Bind this state to the given element
91
- *
92
- * @param element The element to bind to. If not a function, an update function must be passed
93
- * @param update If passed this will be executed directly when the state changes with no other intervention
94
- * @returns {(HTMLDivElement|Text)[]|HTMLDivElement|Text}
95
- */
96
- ctx.state.bindAs = (element, update) => doBindAs(ctx, element, update)
97
-
98
- /**
99
- * Bind this state as it's value
150
+ * Bind this state to the given element
100
151
  *
152
+ * @param [element] The element to bind to. If not a function, an update function must be passed. If not passed, defaults to the state's value
153
+ * @param [update] If passed this will be executed directly when the state changes with no other intervention
101
154
  * @returns {(HTMLDivElement|Text)[]|HTMLDivElement|Text}
102
155
  */
103
- ctx.state.bindSelf = () => doBindAs(ctx, ctx.state)
156
+ ctx.state.bindAs = (element, update) => doBindAs(ctx, element ?? ctx.state, update)
104
157
 
105
158
  /**
106
- * Bind attribute values to state changes
107
- * @param attribute A function that returns an attribute value
108
- * @returns {function(): *} A function that calls the passed function, with some extra metadata
109
- */
110
- ctx.state.bindAttr = (attribute) => doBindAttr(ctx.state, attribute)
159
+ * Bind attribute values to state changes
160
+ * @param [attribute] A function that returns an attribute value. If not passed, defaults to the state's value
161
+ * @returns {function(): *} A function that calls the passed function, with some extra metadata
162
+ */
163
+ ctx.state.bindAttr = (attribute) => doBindAttr(ctx.state, attribute ?? ctx.state)
111
164
 
112
165
  /**
113
- * Bind style values to state changes
114
- * @param style A function that returns a style's value
115
- * @returns {function(): *} A function that calls the passed function, with some extra metadata
116
- */
117
- ctx.state.bindStyle = (style) => doBindStyle(ctx.state, style)
166
+ * Bind style values to state changes
167
+ * @param [style] A function that returns a style's value. If not passed, defaults to the state's value
168
+ * @returns {function(): *} A function that calls the passed function, with some extra metadata
169
+ */
170
+ ctx.state.bindStyle = (style) => doBindStyle(ctx.state, style ?? ctx.state)
118
171
 
119
172
  /**
120
- * Bind select and deselect to an element
121
- * @param element The element to bind to. If not a function, an update function must be passed
122
- * @param update If passed this will be executed directly when the state changes with no other intervention
123
- */
124
- ctx.state.bindSelect = (element, update) => doBindSelect(ctx, element, update)
173
+ * Bind select and deselect to an element
174
+ * @param [element] The element to bind to. If not a function, an update function must be passed. If not passed, defaults to the state's value
175
+ * @param update If passed this will be executed directly when the state changes with no other intervention
176
+ */
177
+ ctx.state.bindSelect = (element, update) => doBindSelect(ctx, element ?? ctx.state, update)
125
178
 
126
179
  /**
127
- * Bind select and deselect to an attribute
128
- * @param attribute A function that returns an attribute value
129
- * @returns {function(): *} A function that calls the passed function, with some extra metadata
130
- */
131
- ctx.state.bindSelectAttr = (attribute) => doBindSelectAttr(ctx, attribute)
180
+ * Bind select and deselect to an attribute
181
+ * @param [attribute] A function that returns an attribute value. If not passed, defaults to the state's value
182
+ * @returns {function(): *} A function that calls the passed function, with some extra metadata
183
+ */
184
+ ctx.state.bindSelectAttr = (attribute) => doBindSelectAttr(ctx, attribute ?? ctx.state)
132
185
 
133
186
  /**
134
- * Mark the element with the given key as selected. This causes the bound select functions to be executed.
135
- */
187
+ * Mark the element with the given key as selected. This causes the bound select functions to be executed.
188
+ */
136
189
  ctx.state.select = (key) => doSelect(ctx, key)
137
190
 
138
191
  /**
139
- * Get the currently selected key
140
- * @returns {*}
141
- */
192
+ * Get the currently selected key
193
+ * @returns {*}
194
+ */
142
195
  ctx.state.selected = () => ctx.selected
143
196
 
144
197
  ctx.state.isFnState = true
145
198
 
146
199
  /**
147
- * Perform an Object.assign on the current state using the provided update
148
- */
200
+ * Perform an Object.assign on the current state using the provided update
201
+ */
149
202
  ctx.state.assign = (update) => ctx.state(Object.assign(ctx.currentValue, update))
150
203
 
151
204
  /**
152
- * Get a value at the given property path, an error is thrown if the value is not an object
153
- *
154
- * This returns a reference to the real current value. If you perform any modifications to the object, be sure to call setPath after you're done or the changes
155
- * will not be reflected correctly.
156
- */
205
+ * Get a value at the given property path, an error is thrown if the value is not an object
206
+ *
207
+ * This returns a reference to the real current value. If you perform any modifications to the object, be sure to call setPath after you're done or the changes
208
+ * will not be reflected correctly.
209
+ */
157
210
  ctx.state.getPath = (path) => {
158
211
  if (typeof path !== 'string') {
159
- throw new Error('Invalid path').stack
212
+ throw new Error('Invalid path')
160
213
  }
161
214
  if (typeof ctx.currentValue !== 'object') {
162
- throw new Error('Value is not an object').stack
215
+ throw new Error('Value is not an object')
163
216
  }
164
217
  return path
165
218
  .split('.')
@@ -176,11 +229,11 @@ export const fnstate = (initialValue, mapKey) => {
176
229
  }
177
230
 
178
231
  /**
179
- * Set a value at the given property path
180
- * @param path The JSON path of the value to set
181
- * @param value The value to set the path to
182
- * @param fillWithObjects Whether to non object values with new empty objects.
183
- */
232
+ * Set a value at the given property path
233
+ * @param path The JSON path of the value to set
234
+ * @param value The value to set the path to
235
+ * @param fillWithObjects Whether to non object values with new empty objects.
236
+ */
184
237
  ctx.state.setPath = (path, value, fillWithObjects = false) => {
185
238
  const s = path.split('.')
186
239
  const parent = s
@@ -199,19 +252,19 @@ export const fnstate = (initialValue, mapKey) => {
199
252
  parent[s.slice(-1)] = value
200
253
  ctx.state(ctx.currentValue)
201
254
  } else {
202
- throw new Error(`No object at path ${path}`).stack
255
+ throw new Error(`No object at path ${path}`)
203
256
  }
204
257
  }
205
258
 
206
259
  /**
207
- * Register a callback that will be executed whenever the state is changed
208
- * @return a function to stop the subscription
209
- */
260
+ * Register a callback that will be executed whenever the state is changed
261
+ * @return a function to stop the subscription
262
+ */
210
263
  ctx.state.subscribe = (callback) => doSubscribe(ctx, ctx.observers, callback)
211
264
 
212
265
  /**
213
- * Remove all of the observers and optionally reset the value to it's initial value
214
- */
266
+ * Remove all of the observers and optionally reset the value to it's initial value
267
+ */
215
268
  ctx.state.reset = (reInit) => doReset(ctx, reInit, initialValue)
216
269
 
217
270
  return ctx.state
@@ -244,7 +297,7 @@ const doBindSelectAttr = function (ctx, attribute) {
244
297
 
245
298
  function createBoundAttr (attr) {
246
299
  if (typeof attr !== 'function') {
247
- throw new Error('You must pass a function to bindAttr').stack
300
+ throw new Error('You must pass a function to bindAttr')
248
301
  }
249
302
  const boundAttr = () => attr()
250
303
  boundAttr.isBoundAttribute = true
@@ -259,7 +312,7 @@ function doBindAttr (state, attribute) {
259
312
 
260
313
  function doBindStyle (state, style) {
261
314
  if (typeof style !== 'function') {
262
- throw new Error('You must pass a function to bindStyle').stack
315
+ throw new Error('You must pass a function to bindStyle')
263
316
  }
264
317
  const boundStyle = () => style()
265
318
  boundStyle.isBoundStyle = true
@@ -289,10 +342,10 @@ function doSelect (ctx, key) {
289
342
  function doBindChildren (ctx, parent, element, update) {
290
343
  parent = renderNode(parent)
291
344
  if (parent === undefined) {
292
- throw new Error('You must provide a parent element to bind the children to. aka Need Bukkit.').stack
345
+ throw new Error('You must provide a parent element to bind the children to. aka Need Bukkit.')
293
346
  }
294
347
  if (typeof element !== 'function' && typeof update !== 'function') {
295
- throw new Error('You must pass an update function when passing a non function element').stack
348
+ throw new Error('You must pass an update function when passing a non function element')
296
349
  }
297
350
  if (typeof ctx.mapKey !== 'function') {
298
351
  console.warn('Using value index as key, may not work correctly when moving items...')
@@ -327,7 +380,7 @@ function doBindChildren (ctx, parent, element, update) {
327
380
 
328
381
  const doBind = function (ctx, element, update, handleUpdate, handleReplace) {
329
382
  if (typeof element !== 'function' && typeof update !== 'function') {
330
- throw new Error('You must pass an update function when passing a non function element').stack
383
+ throw new Error('You must pass an update function when passing a non function element')
331
384
  }
332
385
  if (typeof update === 'function') {
333
386
  const boundElement = renderNode(evaluateElement(element, ctx.currentValue))
@@ -426,7 +479,7 @@ function arrangeElements (ctx, bindContext) {
426
479
  }
427
480
  const key = keyMapper(ctx.mapKey, valueState())
428
481
  if (keys[key]) {
429
- throw new Error('Duplicate keys in a bound array are not allowed.').stack
482
+ throw new Error('Duplicate keys in a bound array are not allowed.')
430
483
  }
431
484
  keys[key] = i
432
485
  keysArr[i] = key
@@ -510,12 +563,21 @@ const evaluateElement = (element, value) => {
510
563
  * Convert non objects (objects are assumed to be nodes) to text nodes and allow promises to resolve to nodes
511
564
  */
512
565
  export const renderNode = (node) => {
513
- if (node && typeof node === 'object' && node.then === undefined) {
514
- return node
515
- } else if (node && typeof node === 'object' && typeof node.then === 'function') {
516
- const temp = marker()
517
- node.then(el => temp.replaceWith(renderNode(el))).catch(e => console.error('Caught failed node promise.', e))
518
- return temp
566
+ if (node && node.isTemplatePlaceholder) {
567
+ const element = h('div')
568
+ node(element, 'node')
569
+ return element
570
+ } else if (node && typeof node === 'object') {
571
+ if (typeof node.then === 'function') {
572
+ let temp = h('div', { style: 'display:none', class: 'fntags-promise-marker' })
573
+ node.then(el => {
574
+ temp.replaceWith(renderNode(el))
575
+ temp = null
576
+ }).catch(e => console.error('Caught failed node promise.', e))
577
+ return temp
578
+ } else {
579
+ return node
580
+ }
519
581
  } else if (typeof node === 'function') {
520
582
  return renderNode(node())
521
583
  } else {
@@ -523,6 +585,9 @@ export const renderNode = (node) => {
523
585
  }
524
586
  }
525
587
 
588
+ /**
589
+ * All of these attributes must be set to an actual boolean to function correctly
590
+ */
526
591
  const booleanAttributes = {
527
592
  allowfullscreen: true,
528
593
  allowpaymentrequest: true,
@@ -552,22 +617,37 @@ const booleanAttributes = {
552
617
  }
553
618
 
554
619
  const setAttribute = function (attrName, attr, element) {
555
- if (attrName === 'value') {
620
+ if (typeof attr === 'function') {
621
+ if (attr.isBoundAttribute) {
622
+ attr.init(attrName, element)
623
+ attr = attr()
624
+ } else if (attr.isTemplatePlaceholder) {
625
+ attr(element, 'attr', attrName)
626
+ return
627
+ } else if (attrName.startsWith('on')) {
628
+ element.addEventListener(attrName.substring(2), attr)
629
+ return
630
+ } else {
631
+ attr = attr()
632
+ }
633
+ }
634
+ if (attrName === 'style' && typeof attr === 'object') {
635
+ for (const style in attr) {
636
+ setStyle(style, attr[style], element)
637
+ }
638
+ } else if (attrName === 'class') {
639
+ //special handling for class to ensure the selector classes from fntemplate don't get overwritten
640
+ if(element.selector && element.className) {
641
+ element.className += ` ${attr}`
642
+ } else {
643
+ element.className = attr
644
+ }
645
+ } else if (attrName === 'value') {
556
646
  element.setAttribute('value', attr)
557
647
  // html5 nodes like range don't update unless the value property on the object is set
558
648
  element.value = attr
559
649
  } else if (booleanAttributes[attrName]) {
560
650
  element[attrName] = !!attr
561
- } else if (attrName === 'style' && typeof attr === 'object') {
562
- for (const style in attr) {
563
- if (typeof attr[style] === 'function' && attr[style].isBoundStyle) {
564
- attr[style].init(style, element)
565
- attr[style] = attr[style]()
566
- }
567
- element.style[style] = attr[style] && attr[style].toString()
568
- }
569
- } else if (typeof attr === 'function' && attrName.startsWith('on')) {
570
- element.addEventListener(attrName.substring(2), attr)
571
651
  } else {
572
652
  if (attrName.startsWith('ns=')) {
573
653
  element.setAttributeNS(...(attrName.slice(3).split('|')), attr)
@@ -577,18 +657,27 @@ const setAttribute = function (attrName, attr, element) {
577
657
  }
578
658
  }
579
659
 
580
- export const isAttrs = (val) => val !== null && typeof val === 'object' && val.nodeType === undefined && !Array.isArray(val) && typeof val.then !== 'function'
660
+ const setStyle = (style, styleValue, element) => {
661
+ if (typeof styleValue === 'function') {
662
+ if (styleValue.isBoundStyle) {
663
+ styleValue.init(style, element)
664
+ styleValue = styleValue()
665
+ } else if (styleValue.isTemplatePlaceholder) {
666
+ styleValue(element, 'style', style)
667
+ return
668
+ } else {
669
+ styleValue = styleValue()
670
+ }
671
+ }
672
+ element.style[style] = styleValue && styleValue.toString()
673
+ }
674
+
675
+ export const isAttrs = (val) => val && typeof val === 'object' && val.nodeType === undefined && !Array.isArray(val) && typeof val.then !== 'function'
581
676
  /**
582
677
  * helper to get the attr object
583
678
  */
584
679
  export const getAttrs = (children) => Array.isArray(children) && isAttrs(children[0]) ? children[0] : {}
585
680
 
586
- /**
587
- * A hidden div node to mark your place in the dom
588
- * @returns {HTMLDivElement}
589
- */
590
- const marker = (attrs) => h('div', Object.assign(attrs || {}, { style: 'display:none' }))
591
-
592
681
  /**
593
682
  * A function to create an element with a pre-defined style.
594
683
  * For example, the flex* elements in fnelements.