@srfnstack/fntags 1.2.7 → 1.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@srfnstack/fntags",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "author": "Robert Kempton <r@snow87.com>",
5
5
  "private": false,
6
6
  "homepage": "https://github.com/srfnstack/fntags",
package/src/fntags.d.mts CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Toggle dev-mode warnings. Defaults to true. Call `setDevMode(false)` in
3
+ * production to skip diagnostic checks such as the focus-loss warning fired
4
+ * when bindAs replaces a subtree containing the focused element.
5
+ * @param {boolean} on
6
+ */
7
+ export function setDevMode(on: boolean): void;
1
8
  /**
2
9
  * A function to create dom elements with the given attributes and children.
3
10
  *
@@ -24,12 +31,14 @@ export function h<T extends HTMLElement | SVGElement>(tag: string, ...children:
24
31
  * @typedef FnStateObj A container for a state value that can be bound to.
25
32
  * @property {(element?: (newValue: T, oldValue: T)=>(Node|any))=>Node} bindAs Bind this state to the given element function. This causes the element to be replaced when the state changes.
26
33
  * If called with no parameters, the state's value will be rendered as an element.
34
+ * Do not wrap form inputs (input, textarea, select) in bindAs — it replaces the element on every update and the focused input will lose focus mid-keystroke. Bind the `value` attribute with bindAttr instead.
27
35
  * @property {(parent: (()=>(Node|any))|any|Node, element: (childState: FnState)=>(Node|any))=>Node} bindChildren Bind the values of this state to the given element.
28
36
  * Values are items/elements of an array.
29
37
  * If the current value is not an array, this will behave the same as bindAs.
30
38
  * @property {(prop: string)=>Node} bindProp Bind to a property of an object stored in this state instead of the state itself.
31
39
  * Shortcut for `mystate.bindAs((current)=> current[prop])`
32
- * @property {(attribute?: (newValue: T, oldValue: T)=>(string|any))=>any} bindAttr Bind attribute values to state changes
40
+ * @property {(attribute?: (newValue: T, oldValue: T)=>(string|any))=>any} bindAttr Bind attribute values to state changes.
41
+ * Prefer this over bindAs for form inputs: binding the `value` attribute updates the element in place without replacing it, so focus and caret position are preserved.
33
42
  * @property {(style?: (newValue: T, oldValue: T)=>string) => string} bindStyle Bind style values to state changes
34
43
  * @property {(element?: (selectedKey: any)=>(Node|any))=>Node} bindSelect Bind selected state to an element
35
44
  * @property {(attribute?: (selectedKey: any)=>(string|any))=>any} bindSelectAttr Bind selected state to an attribute
@@ -147,6 +156,7 @@ export type FnStateObj<T> = {
147
156
  /**
148
157
  * Bind this state to the given element function. This causes the element to be replaced when the state changes.
149
158
  * If called with no parameters, the state's value will be rendered as an element.
159
+ * Do not wrap form inputs (input, textarea, select) in bindAs — it replaces the element on every update and the focused input will lose focus mid-keystroke. Bind the `value` attribute with bindAttr instead.
150
160
  */
151
161
  bindAs: (element?: (newValue: T, oldValue: T) => (Node | any)) => Node;
152
162
  /**
@@ -161,7 +171,8 @@ export type FnStateObj<T> = {
161
171
  */
162
172
  bindProp: (prop: string) => Node;
163
173
  /**
164
- * Bind attribute values to state changes
174
+ * Bind attribute values to state changes.
175
+ * Prefer this over bindAs for form inputs: binding the `value` attribute updates the element in place without replacing it, so focus and caret position are preserved.
165
176
  */
166
177
  bindAttr: (attribute?: (newValue: T, oldValue: T) => (string | any)) => any;
167
178
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"fntags.d.mts","sourceRoot":"","sources":["fntags.mjs"],"names":[],"mappings":"AAcA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,2DALW,MAAM,eACH,CAAC,IAAI,MAAO,CAAC,OA6C1B;AAUD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH;;;GAGG;AAEH;;;;;;;;;;GAUG;AACH,iEAPgB,GAAG,cAqIlB;AAgjBD;;;;GAIG;AACH,iCAHW,GAAG,GACD,IAAI,CAuBhB;AAqFD;;;;GAIG;AACH,6BAHW,GAAG,GACD,OAAO,CAInB;AAED;;;;GAIG;AACH,mCAHW,GAAG,GACF,MAAM,CAIjB;AAED;;;;;;;;;;GAUG;AACH,kEALW,MAAM,GAAC,MAAM,OACb,MAAM,YACN,MAAM,EAAE,GAAC,IAAI,EAAE,KAezB;AAOD;;;;;;;;GAQG;AACH,wCAJW,MAAM,0BAiBhB;AAED;;;;;;;;;GASG;AACH,oCALW,MAAM,gBACN,GAAG,eACE,GAAG,KAAK,MAAM,OAc7B;AAED;;;;;;;;GAQG;AACH,mCAJW,WAAW,SACX,CAAC,MAAI,IAAI,CAAC,GAAC,IAAI,GACb,WAAW,CAWvB;AAED;;;;;;;;;;;;;;;GAeG;AACH,qDAJkB,IAAI,iBACH,IAAI,CA+DtB;;;;;;;;;kCArgCmC,CAAC,YAAY,CAAC,KAAG,CAAC,IAAI,GAAC,GAAG,CAAC,KAAG,IAAI;;;;;;2BAE/C,CAAC,MAAI,CAAC,IAAI,GAAC,GAAG,CAAC,CAAC,GAAC,GAAG,GAAC,IAAI,gCAAkC,CAAC,IAAI,GAAC,GAAG,CAAC,KAAG,IAAI;;;;;qBAG9E,MAAM,KAAG,IAAI;;;;sCAEI,CAAC,YAAY,CAAC,KAAG,CAAC,MAAM,GAAC,GAAG,CAAC,KAAG,GAAG;;;;mCACvC,CAAC,YAAY,CAAC,KAAG,MAAM,KAAK,MAAM;;;;yCAC7B,GAAG,KAAG,CAAC,IAAI,GAAC,GAAG,CAAC,KAAG,IAAI;;;;+CACrB,GAAG,KAAG,CAAC,MAAM,GAAC,GAAG,CAAC,KAAG,GAAG;;;;;;kBAC7C,GAAG,KAAG,IAAI;;;;cAGhB,MAAK,GAAG;;;;;qBACC,CAAC,KAAG,IAAI;;;;;;oBAEV,MAAM,KAAG,GAAG;;;;oBAGZ,MAAM,SAAS,GAAG,mBAAmB,OAAO,KAAG,IAAI;;;;uCAClC,CAAC,YAAY,CAAC,KAAG,IAAI,KAAK,IAAI;;;;eACtD,OAAO;;;;;sDAKqB,CAAC,KAAG,CAAC"}
1
+ {"version":3,"file":"fntags.d.mts","sourceRoot":"","sources":["fntags.mjs"],"names":[],"mappings":"AAcA;;;;;GAKG;AACH,+BAFW,OAAO,QAIjB;AAQD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,2DALW,MAAM,eACH,CAAC,IAAI,MAAO,CAAC,OA6C1B;AAUD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH;;;GAGG;AAEH;;;;;;;;;;GAUG;AACH,iEAPgB,GAAG,cAqIlB;AA2kBD;;;;GAIG;AACH,iCAHW,GAAG,GACD,IAAI,CAuBhB;AAqFD;;;;GAIG;AACH,6BAHW,GAAG,GACD,OAAO,CAInB;AAED;;;;GAIG;AACH,mCAHW,GAAG,GACF,MAAM,CAIjB;AAED;;;;;;;;;;GAUG;AACH,kEALW,MAAM,GAAC,MAAM,OACb,MAAM,YACN,MAAM,EAAE,GAAC,IAAI,EAAE,KAezB;AAOD;;;;;;;;GAQG;AACH,wCAJW,MAAM,0BAiBhB;AAED;;;;;;;;;GASG;AACH,oCALW,MAAM,gBACN,GAAG,eACE,GAAG,KAAK,MAAM,OAc7B;AAED;;;;;;;;GAQG;AACH,mCAJW,WAAW,SACX,CAAC,MAAI,IAAI,CAAC,GAAC,IAAI,GACb,WAAW,CAWvB;AAED;;;;;;;;;;;;;;;GAeG;AACH,qDAJkB,IAAI,iBACH,IAAI,CA+DtB;;;;;;;;;;kCAliCmC,CAAC,YAAY,CAAC,KAAG,CAAC,IAAI,GAAC,GAAG,CAAC,KAAG,IAAI;;;;;;2BAG/C,CAAC,MAAI,CAAC,IAAI,GAAC,GAAG,CAAC,CAAC,GAAC,GAAG,GAAC,IAAI,gCAAkC,CAAC,IAAI,GAAC,GAAG,CAAC,KAAG,IAAI;;;;;qBAG9E,MAAM,KAAG,IAAI;;;;;sCAEI,CAAC,YAAY,CAAC,KAAG,CAAC,MAAM,GAAC,GAAG,CAAC,KAAG,GAAG;;;;mCAEvC,CAAC,YAAY,CAAC,KAAG,MAAM,KAAK,MAAM;;;;yCAC7B,GAAG,KAAG,CAAC,IAAI,GAAC,GAAG,CAAC,KAAG,IAAI;;;;+CACrB,GAAG,KAAG,CAAC,MAAM,GAAC,GAAG,CAAC,KAAG,GAAG;;;;;;kBAC7C,GAAG,KAAG,IAAI;;;;cAGhB,MAAK,GAAG;;;;;qBACC,CAAC,KAAG,IAAI;;;;;;oBAEV,MAAM,KAAG,GAAG;;;;oBAGZ,MAAM,SAAS,GAAG,mBAAmB,OAAO,KAAG,IAAI;;;;uCAClC,CAAC,YAAY,CAAC,KAAG,IAAI,KAAK,IAAI;;;;eACtD,OAAO;;;;;sDAKqB,CAAC,KAAG,CAAC"}
package/src/fntags.mjs CHANGED
@@ -6,6 +6,22 @@
6
6
  // up when the bindAs re-renders. null means we are not inside a render.
7
7
  let activeRenderCleanups = null
8
8
 
9
+ // When on, the framework emits console warnings for misuse that is easy to
10
+ // hit and hard to diagnose (e.g. bindAs replacing a focused input, omitting
11
+ // a key mapper from bindChildren). The checks are cheap but non-zero, so
12
+ // production can opt out via setDevMode(false).
13
+ let devMode = true
14
+
15
+ /**
16
+ * Toggle dev-mode warnings. Defaults to true. Call `setDevMode(false)` in
17
+ * production to skip diagnostic checks such as the focus-loss warning fired
18
+ * when bindAs replaces a subtree containing the focused element.
19
+ * @param {boolean} on
20
+ */
21
+ export function setDevMode (on) {
22
+ devMode = !!on
23
+ }
24
+
9
25
  function runCleanups (elCtx) {
10
26
  if (!elCtx.cleanups) return
11
27
  for (const unsub of elCtx.cleanups) unsub()
@@ -88,12 +104,14 @@ function hasNs (val) {
88
104
  * @typedef FnStateObj A container for a state value that can be bound to.
89
105
  * @property {(element?: (newValue: T, oldValue: T)=>(Node|any))=>Node} bindAs Bind this state to the given element function. This causes the element to be replaced when the state changes.
90
106
  * If called with no parameters, the state's value will be rendered as an element.
107
+ * Do not wrap form inputs (input, textarea, select) in bindAs — it replaces the element on every update and the focused input will lose focus mid-keystroke. Bind the `value` attribute with bindAttr instead.
91
108
  * @property {(parent: (()=>(Node|any))|any|Node, element: (childState: FnState)=>(Node|any))=>Node} bindChildren Bind the values of this state to the given element.
92
109
  * Values are items/elements of an array.
93
110
  * If the current value is not an array, this will behave the same as bindAs.
94
111
  * @property {(prop: string)=>Node} bindProp Bind to a property of an object stored in this state instead of the state itself.
95
112
  * Shortcut for `mystate.bindAs((current)=> current[prop])`
96
- * @property {(attribute?: (newValue: T, oldValue: T)=>(string|any))=>any} bindAttr Bind attribute values to state changes
113
+ * @property {(attribute?: (newValue: T, oldValue: T)=>(string|any))=>any} bindAttr Bind attribute values to state changes.
114
+ * Prefer this over bindAs for form inputs: binding the `value` attribute updates the element in place without replacing it, so focus and caret position are preserved.
97
115
  * @property {(style?: (newValue: T, oldValue: T)=>string) => string} bindStyle Bind style values to state changes
98
116
  * @property {(element?: (selectedKey: any)=>(Node|any))=>Node} bindSelect Bind selected state to an element
99
117
  * @property {(attribute?: (selectedKey: any)=>(string|any))=>any} bindSelectAttr Bind selected state to an attribute
@@ -428,7 +446,9 @@ function doBindChildren (parent, element) {
428
446
  throw new Error('You must pass a function to produce child elements.')
429
447
  }
430
448
  if (typeof ctx.mapKey !== 'function') {
431
- console.warn('Using value index as key, may not work correctly when moving items...')
449
+ if (devMode) {
450
+ console.warn('bindChildren: no mapKey provided — falling back to array index as key, which can misbehave when items are moved, inserted, or removed. Pass a mapKey function as the second argument to fnstate(array, item => item.id).')
451
+ }
432
452
  ctx.mapKey = (_, i) => i
433
453
  }
434
454
 
@@ -459,7 +479,9 @@ function doBindChildren (parent, element) {
459
479
 
460
480
  this.subscribe((_, oldState) => {
461
481
  if (!Array.isArray(ctx.currentValue)) {
462
- console.warn('A state used with bindChildren was updated to a non array value. This will be converted to an array of 1 and the state will be updated.')
482
+ if (devMode) {
483
+ console.warn('bindChildren: state was updated to a non-array value. Wrapping it in a single-element array — if you meant to replace the whole binding, use bindAs instead.')
484
+ }
463
485
  new Promise((resolve) => {
464
486
  this([ctx.currentValue])
465
487
  resolve()
@@ -556,6 +578,28 @@ function propagateReplace (elCtx, oldNode, newNode) {
556
578
  }
557
579
  }
558
580
 
581
+ // Emit a dev-mode warning when a bindAs replacement would destroy a subtree
582
+ // containing the focused element. This is the most common fntags footgun:
583
+ // wrapping an <input> in bindAs replaces the element on every keystroke, so
584
+ // the user loses focus and caret position mid-edit. The fix is almost always
585
+ // to bind the `value` attribute with bindAttr instead. We only check once per
586
+ // replacement and only touch the DOM when an element is actually focused.
587
+ function warnIfReplacingFocused (oldNode) {
588
+ if (typeof document === 'undefined') return
589
+ const active = document.activeElement
590
+ if (!active || active === document.body) return
591
+ if (oldNode && oldNode.nodeType === 1 && (oldNode === active || oldNode.contains(active))) {
592
+ console.warn(
593
+ 'bindAs is about to replace a subtree containing the focused element <' +
594
+ active.tagName.toLowerCase() + '>. The replacement will steal focus and ' +
595
+ 'reset caret/selection. For form inputs, bind the `value` attribute with ' +
596
+ 'bindAttr instead of wrapping the input in bindAs. ' +
597
+ 'Call setDevMode(false) to silence this warning.',
598
+ active
599
+ )
600
+ }
601
+ }
602
+
559
603
  const updateReplacer = (ctx, element, elCtx) => (_, oldValue) => {
560
604
  // Clean up subscriptions from the previous render before re-rendering.
561
605
  // This prevents nested bindAs/bindAttr/bindStyle subscriptions from
@@ -595,6 +639,7 @@ const updateReplacer = (ctx, element, elCtx) => (_, oldValue) => {
595
639
  // is a no-op in the DOM but wastes a microtask.
596
640
  if (rendered !== elCtx.current) {
597
641
  const oldCurrent = elCtx.current
642
+ if (devMode) warnIfReplacingFocused(oldCurrent)
598
643
  elCtx.current.replaceWith(rendered)
599
644
  elCtx.current = rendered
600
645
  // Keep any ancestor bindAs whose elCtx shared this node in sync with