@srfnstack/fntags 0.2.1 → 0.3.3

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 +149 -52
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@srfnstack/fntags",
3
- "version": "0.2.1",
3
+ "version": "0.3.3",
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
@@ -1,40 +1,40 @@
1
1
  /**
2
2
  * A function to create dom elements with the given attributes and children.
3
- * If an argument is a non-node object it is considered an attributes object, attributes are combined with Object.assign in the order received.
4
- * All standard html attributes can be passed, as well as any other property.
5
- * Strings are added as attributes via setAttribute, functions are added as event listeners, other types are set as properties.
3
+ *
4
+ * The first element of the children array can be an object containing element attributes.
5
+ * The attribute names are the standard attribute names used in html, and should all be lower case as usual.
6
+ *
7
+ * Any attribute starting with 'on' that is a function is added as an event listener with the 'on' removed.
8
+ * i.e. { onclick: fn } gets added to the element as element.addEventListener('click', fn)
9
+ *
10
+ * The style attribute can be an object and the properties of the object will be added as style properties to the element.
11
+ * i.e. { style: { color: blue } } becomes element.style.color = blue
6
12
  *
7
13
  * The rest of the arguments will be considered children of this element and appended to it in the same order as passed.
8
14
  *
9
15
  * @param tag html tag to use when created the element
10
- * @param children optional attrs and children for the element
16
+ * @param children optional attributes object and children for the element
11
17
  * @returns HTMLElement an html element
12
18
  *
13
19
  */
14
- export function h () {
15
- const tag = arguments[0]
16
- let firstChildIdx = 1
20
+ export function h (tag, ...children) {
21
+ let firstChildIdx = 0
17
22
  let element
18
23
  if (tag.startsWith('ns=')) {
19
- element = document.createElementNS(...(tag.slice(3).split('|')))
24
+ element = document.createElementNS(...( tag.slice(3).split('|') ))
20
25
  } else {
21
26
  element = document.createElement(tag)
22
27
  }
23
28
 
24
- if (isAttrs(arguments[firstChildIdx])) {
25
- const attrs = arguments[firstChildIdx]
29
+ if (isAttrs(children[firstChildIdx])) {
30
+ const attrs = children[firstChildIdx]
26
31
  firstChildIdx += 1
27
32
  for (const a in attrs) {
28
- let attr = attrs[a]
29
- if (typeof attr === 'function' && attr.isBoundAttribute) {
30
- attr.init(a, element)
31
- attr = attr()
32
- }
33
- setAttribute(a, attr, element)
33
+ setAttribute(a, attrs[a], element)
34
34
  }
35
35
  }
36
- for (let i = firstChildIdx; i < arguments.length; i++) {
37
- const child = arguments[i]
36
+ for (let i = firstChildIdx; i < children.length; i++) {
37
+ const child = children[i]
38
38
  if (Array.isArray(child)) {
39
39
  for (const c of child) {
40
40
  element.append(renderNode(c))
@@ -46,16 +46,86 @@ export function h () {
46
46
  return element
47
47
  }
48
48
 
49
+ /**
50
+ * Create a compiled template function. The returned function takes a single object that contains the properties
51
+ * defined in the template.
52
+ *
53
+ * This allows fast rendering by pre-creating a dom element with the entire template structure then cloning and populating
54
+ * the clone with data from the provided context. This avoids the work of having to re-execute the tag functions
55
+ * one by one and can speed up situations where the a similar element is created many times.
56
+ *
57
+ * You cannot bind state to the initial template. If you attempt to, the state will be read, but the elements will
58
+ * not be updated when the state changes because they will not be bound to the cloned element.
59
+ * Thus, all state bindings must be passed in the context to the compiled template to work correctly.
60
+ * @param templateFn {function(object): Node}
61
+ * @return {function(*): Node}
62
+ */
63
+ export const fntemplate = templateFn => {
64
+ if (typeof templateFn !== 'function') {
65
+ throw new Error('You must pass a function to fntemplate. The function must return an html node.')
66
+ }
67
+ const placeholders = {}
68
+ let id = 1
69
+ const initContext = prop => {
70
+ if (!prop || typeof prop !== 'string') {
71
+ throw new Error('You must pass a non empty string prop name to the context function.')
72
+ }
73
+ const placeholder = (element, type, attrOrStyle) => {
74
+ let selector = element.__fnselector
75
+ if (!selector) {
76
+ selector = `fntpl-${id++}`
77
+ element.__fnselector = selector
78
+ element.classList.add(selector)
79
+ }
80
+ if (!placeholders[selector]) placeholders[selector] = []
81
+ placeholders[selector].push({ prop, type, attrOrStyle })
82
+ }
83
+ placeholder.isTemplatePlaceholder = true
84
+ return placeholder
85
+ }
86
+ // The initial render is cloned to prevent invalid state bindings from changing it
87
+ const compiled = templateFn(initContext).cloneNode(true)
88
+ return ctx => {
89
+ const clone = compiled.cloneNode(true)
90
+ for (const selectorClass in placeholders) {
91
+ let targetElement = clone.getElementsByClassName(selectorClass)[0]
92
+ if (!targetElement) {
93
+ if (clone.classList.contains(selectorClass)) {
94
+ targetElement = clone
95
+ } else {
96
+ throw new Error(`Cannot find template element for selectorClass ${selectorClass}`)
97
+ }
98
+ }
99
+ targetElement.classList.remove(selectorClass)
100
+ for (const placeholder of placeholders[selectorClass]) {
101
+ switch (placeholder.type) {
102
+ case 'node':
103
+ targetElement.replaceWith(renderNode(ctx[placeholder.prop]))
104
+ break
105
+ case 'attr':
106
+ setAttribute(placeholder.attrOrStyle, ctx[placeholder.prop], targetElement)
107
+ break
108
+ case 'style':
109
+ setStyle(placeholder.attrOrStyle, ctx[placeholder.prop], targetElement)
110
+ break
111
+ default:
112
+ throw new Error(`Unexpected bindType ${placeholder.type}`)
113
+ }
114
+ }
115
+ }
116
+ return clone
117
+ }
118
+ }
119
+
49
120
  /**
50
121
  * Create a state object that can be bound to.
51
122
  * @param initialValue The initial state
52
123
  * @param mapKey A map function to extract a key from an element in the array. Receives the array value to extract the key from.
53
124
  * @returns function A function that can be used to get and set the state.
54
- * When getting the state, you get the actual reference to the underlying value. If you perform modifications to the object, be sure to set the value
55
- * when you're done or the changes won't be reflected correctly.
125
+ * When getting the state, you get the actual reference to the underlying value.
126
+ * If you perform modifications to the value, be sure to call the state function with the updated value when you're done
127
+ * or the changes won't be reflected correctly and binding updates won't be triggered even though the state appears to be correct.
56
128
  *
57
- * SideNote: this _could_ be implemented such that it returned a clone, however that would add a great deal of overhead, and a lot of code. Thus, the decision
58
- * was made that it's up to the caller to ensure that the fnstate is called whenever there are modifications.
59
129
  */
60
130
  export const fnstate = (initialValue, mapKey) => {
61
131
  const ctx = {
@@ -66,7 +136,7 @@ export const fnstate = (initialValue, mapKey) => {
66
136
  nextId: 0,
67
137
  mapKey,
68
138
  state (newState) {
69
- if (arguments.length === 0 || (arguments.length === 1 && arguments[0] === ctx.state)) {
139
+ if (arguments.length === 0 || ( arguments.length === 1 && arguments[0] === ctx.state )) {
70
140
  return ctx.currentValue
71
141
  } else {
72
142
  ctx.currentValue = newState
@@ -92,46 +162,39 @@ export const fnstate = (initialValue, mapKey) => {
92
162
  /**
93
163
  * Bind this state to the given element
94
164
  *
95
- * @param element The element to bind to. If not a function, an update function must be passed
96
- * @param update If passed this will be executed directly when the state changes with no other intervention
97
- * @returns {(HTMLDivElement|Text)[]|HTMLDivElement|Text}
98
- */
99
- ctx.state.bindAs = (element, update) => doBindAs(ctx, element, update)
100
-
101
- /**
102
- * Bind this state as it's value
103
- *
165
+ * @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
166
+ * @param [update] If passed this will be executed directly when the state changes with no other intervention
104
167
  * @returns {(HTMLDivElement|Text)[]|HTMLDivElement|Text}
105
168
  */
106
- ctx.state.bindSelf = () => doBindAs(ctx, ctx.state)
169
+ ctx.state.bindAs = (element, update) => doBindAs(ctx, element ?? ctx.state, update)
107
170
 
108
171
  /**
109
172
  * Bind attribute values to state changes
110
- * @param attribute A function that returns an attribute value
173
+ * @param [attribute] A function that returns an attribute value. If not passed, defaults to the state's value
111
174
  * @returns {function(): *} A function that calls the passed function, with some extra metadata
112
175
  */
113
- ctx.state.bindAttr = (attribute) => doBindAttr(ctx.state, attribute)
176
+ ctx.state.bindAttr = (attribute) => doBindAttr(ctx.state, attribute ?? ctx.state)
114
177
 
115
178
  /**
116
179
  * Bind style values to state changes
117
- * @param style A function that returns a style's value
180
+ * @param [style] A function that returns a style's value. If not passed, defaults to the state's value
118
181
  * @returns {function(): *} A function that calls the passed function, with some extra metadata
119
182
  */
120
- ctx.state.bindStyle = (style) => doBindStyle(ctx.state, style)
183
+ ctx.state.bindStyle = (style) => doBindStyle(ctx.state, style ?? ctx.state)
121
184
 
122
185
  /**
123
186
  * Bind select and deselect to an element
124
- * @param element The element to bind to. If not a function, an update function must be passed
187
+ * @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
125
188
  * @param update If passed this will be executed directly when the state changes with no other intervention
126
189
  */
127
- ctx.state.bindSelect = (element, update) => doBindSelect(ctx, element, update)
190
+ ctx.state.bindSelect = (element, update) => doBindSelect(ctx, element ?? ctx.state, update)
128
191
 
129
192
  /**
130
193
  * Bind select and deselect to an attribute
131
- * @param attribute A function that returns an attribute value
194
+ * @param [attribute] A function that returns an attribute value. If not passed, defaults to the state's value
132
195
  * @returns {function(): *} A function that calls the passed function, with some extra metadata
133
196
  */
134
- ctx.state.bindSelectAttr = (attribute) => doBindSelectAttr(ctx, attribute)
197
+ ctx.state.bindSelectAttr = (attribute) => doBindSelectAttr(ctx, attribute ?? ctx.state)
135
198
 
136
199
  /**
137
200
  * Mark the element with the given key as selected. This causes the bound select functions to be executed.
@@ -291,7 +354,7 @@ function doSelect (ctx, key) {
291
354
 
292
355
  function doBindChildren (ctx, parent, element, update) {
293
356
  parent = renderNode(parent)
294
- if (parent === undefined) {
357
+ if (parent === undefined || parent.nodeType === undefined) {
295
358
  throw new Error('You must provide a parent element to bind the children to. aka Need Bukkit.')
296
359
  }
297
360
  if (typeof element !== 'function' && typeof update !== 'function') {
@@ -303,7 +366,7 @@ function doBindChildren (ctx, parent, element, update) {
303
366
  }
304
367
 
305
368
  if (!Array.isArray(ctx.currentValue)) {
306
- return ctx.state.bindAs(element, update)
369
+ throw new Error('You can only use bindChildren with a state that contains an array. try myState([mystate]) before calling this function.')
307
370
  }
308
371
  ctx.currentValue = ctx.currentValue.map(v => v.isFnState ? v : fnstate(v))
309
372
  ctx.bindContexts.push({ element, update, parent })
@@ -513,7 +576,11 @@ const evaluateElement = (element, value) => {
513
576
  * Convert non objects (objects are assumed to be nodes) to text nodes and allow promises to resolve to nodes
514
577
  */
515
578
  export const renderNode = (node) => {
516
- if (node && typeof node === 'object') {
579
+ if (node && node.isTemplatePlaceholder) {
580
+ const element = h('div')
581
+ node(element, 'node')
582
+ return element
583
+ } else if (node && typeof node === 'object') {
517
584
  if (typeof node.then === 'function') {
518
585
  let temp = h('div', { style: 'display:none', class: 'fntags-promise-marker' })
519
586
  node.then(el => {
@@ -563,13 +630,30 @@ const booleanAttributes = {
563
630
  }
564
631
 
565
632
  const setAttribute = function (attrName, attr, element) {
633
+ if (typeof attr === 'function') {
634
+ if (attr.isBoundAttribute) {
635
+ attr.init(attrName, element)
636
+ attr = attr()
637
+ } else if (attr.isTemplatePlaceholder) {
638
+ attr(element, 'attr', attrName)
639
+ return
640
+ } else if (attrName.startsWith('on')) {
641
+ element.addEventListener(attrName.substring(2), attr)
642
+ return
643
+ } else {
644
+ attr = attr()
645
+ }
646
+ }
566
647
  if (attrName === 'style' && typeof attr === 'object') {
567
648
  for (const style in attr) {
568
- if (typeof attr[style] === 'function' && attr[style].isBoundStyle) {
569
- attr[style].init(style, element)
570
- attr[style] = attr[style]()
571
- }
572
- element.style[style] = attr[style] && attr[style].toString()
649
+ setStyle(style, attr[style], element)
650
+ }
651
+ } else if (attrName === 'class') {
652
+ //special handling for class to ensure the selector classes from fntemplate don't get overwritten
653
+ if (element.__fnselector && element.className) {
654
+ element.className += ` ${attr}`
655
+ } else {
656
+ element.className = attr
573
657
  }
574
658
  } else if (attrName === 'value') {
575
659
  element.setAttribute('value', attr)
@@ -577,17 +661,30 @@ const setAttribute = function (attrName, attr, element) {
577
661
  element.value = attr
578
662
  } else if (booleanAttributes[attrName]) {
579
663
  element[attrName] = !!attr
580
- } else if (typeof attr === 'function' && attrName.startsWith('on')) {
581
- element.addEventListener(attrName.substring(2), attr)
582
664
  } else {
583
665
  if (attrName.startsWith('ns=')) {
584
- element.setAttributeNS(...(attrName.slice(3).split('|')), attr)
666
+ element.setAttributeNS(...( attrName.slice(3).split('|') ), attr)
585
667
  } else {
586
668
  element.setAttribute(attrName, attr)
587
669
  }
588
670
  }
589
671
  }
590
672
 
673
+ const setStyle = (style, styleValue, element) => {
674
+ if (typeof styleValue === 'function') {
675
+ if (styleValue.isBoundStyle) {
676
+ styleValue.init(style, element)
677
+ styleValue = styleValue()
678
+ } else if (styleValue.isTemplatePlaceholder) {
679
+ styleValue(element, 'style', style)
680
+ return
681
+ } else {
682
+ styleValue = styleValue()
683
+ }
684
+ }
685
+ element.style[style] = styleValue && styleValue.toString()
686
+ }
687
+
591
688
  export const isAttrs = (val) => val && typeof val === 'object' && val.nodeType === undefined && !Array.isArray(val) && typeof val.then !== 'function'
592
689
  /**
593
690
  * helper to get the attr object