@srfnstack/fntags 1.2.6 → 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.6",
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;AAqfD;;;;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;;;;;;;;;kCA18BmC,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()
@@ -489,7 +511,17 @@ const doBind = function (ctx, element, handleReplace) {
489
511
  const prevRenderCleanups = activeRenderCleanups
490
512
  const thisRenderCleanups = []
491
513
  activeRenderCleanups = thisRenderCleanups
492
- const elCtx = { current: renderNode(evaluateElement(element, ctx.currentValue)), cleanups: thisRenderCleanups }
514
+ const evaluated = evaluateElement(element, ctx.currentValue)
515
+ const childElCtx = (typeof evaluated === 'function' && evaluated.isBindGetter)
516
+ ? evaluated.elCtx
517
+ : null
518
+ const elCtx = { current: renderNode(evaluated), cleanups: thisRenderCleanups }
519
+ // When the evaluated element is itself a bind getter (a nested bindAs/bindSelect),
520
+ // register this elCtx as a back-ref on the child so that later child-driven
521
+ // node swaps keep this elCtx.current in sync with the live DOM node.
522
+ if (childElCtx && childElCtx.current === elCtx.current) {
523
+ addParentRef(childElCtx, elCtx)
524
+ }
493
525
  // Suppress tracking during handleReplace — the driving subscription is
494
526
  // registered separately below as part of a cascading cleanup.
495
527
  activeRenderCleanups = null
@@ -504,10 +536,68 @@ const doBind = function (ctx, element, handleReplace) {
504
536
  if (elCtx.drivingUnsub) elCtx.drivingUnsub()
505
537
  if (elCtx.selectUnsub) elCtx.selectUnsub()
506
538
  runCleanups(elCtx)
539
+ // Mark dead so any child elCtx that still holds us in its parentRefs
540
+ // can prune us lazily on its next propagateReplace walk.
541
+ elCtx.dead = true
507
542
  })
508
543
  }
509
544
 
510
- return () => elCtx.current
545
+ const getter = () => elCtx.current
546
+ getter.isBindGetter = true
547
+ getter.elCtx = elCtx
548
+ return getter
549
+ }
550
+
551
+ // Register a back-ref from a child bind elCtx to a parent elCtx, avoiding
552
+ // duplicate entries when the same parent re-renders against the same child.
553
+ function addParentRef (childElCtx, parentElCtx) {
554
+ if (!childElCtx.parentRefs) {
555
+ childElCtx.parentRefs = [parentElCtx]
556
+ } else if (!childElCtx.parentRefs.includes(parentElCtx)) {
557
+ childElCtx.parentRefs.push(parentElCtx)
558
+ }
559
+ }
560
+
561
+ // Walk the back-ref chain and update any ancestor elCtx whose .current still
562
+ // points at the old DOM node. Used when a nested bindAs swaps its node so the
563
+ // outer bindAs keeps a live reference instead of a detached one. Prunes any
564
+ // dead ancestors encountered so parentRefs does not retain torn-down elCtxs.
565
+ function propagateReplace (elCtx, oldNode, newNode) {
566
+ const refs = elCtx.parentRefs
567
+ if (!refs) return
568
+ for (let i = refs.length - 1; i >= 0; i--) {
569
+ const parent = refs[i]
570
+ if (parent.dead) {
571
+ refs.splice(i, 1)
572
+ continue
573
+ }
574
+ if (parent.current === oldNode) {
575
+ parent.current = newNode
576
+ propagateReplace(parent, oldNode, newNode)
577
+ }
578
+ }
579
+ }
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
+ }
511
601
  }
512
602
 
513
603
  const updateReplacer = (ctx, element, elCtx) => (_, oldValue) => {
@@ -520,7 +610,11 @@ const updateReplacer = (ctx, element, elCtx) => (_, oldValue) => {
520
610
  const prevRenderCleanups = activeRenderCleanups
521
611
  const thisRenderCleanups = []
522
612
  activeRenderCleanups = thisRenderCleanups
523
- let rendered = renderNode(evaluateElement(element, ctx.currentValue, oldValue))
613
+ const evaluated = evaluateElement(element, ctx.currentValue, oldValue)
614
+ const childElCtx = (typeof evaluated === 'function' && evaluated.isBindGetter)
615
+ ? evaluated.elCtx
616
+ : null
617
+ let rendered = renderNode(evaluated)
524
618
  activeRenderCleanups = prevRenderCleanups
525
619
  elCtx.cleanups = thisRenderCleanups
526
620
 
@@ -533,14 +627,24 @@ const updateReplacer = (ctx, element, elCtx) => (_, oldValue) => {
533
627
  bindContext.boundElementByKey[elCtx.current.key] = rendered
534
628
  }
535
629
  }
630
+ // Re-establish the parent-ref link when a re-render resolves to another
631
+ // nested bind getter, so future child swaps propagate back to this elCtx.
632
+ if (childElCtx && childElCtx.current === rendered) {
633
+ addParentRef(childElCtx, elCtx)
634
+ }
536
635
  // Perform this action on the next event loop to give the parent a chance to render
537
636
  new Promise((resolve) => {
538
637
  // Guard: skip replaceWith when the same element is returned (e.g. when
539
638
  // toggling between two pre-built element references). el.replaceWith(el)
540
639
  // is a no-op in the DOM but wastes a microtask.
541
640
  if (rendered !== elCtx.current) {
641
+ const oldCurrent = elCtx.current
642
+ if (devMode) warnIfReplacingFocused(oldCurrent)
542
643
  elCtx.current.replaceWith(rendered)
543
644
  elCtx.current = rendered
645
+ // Keep any ancestor bindAs whose elCtx shared this node in sync with
646
+ // the new node, so their next replaceWith targets a live DOM node.
647
+ propagateReplace(elCtx, oldCurrent, rendered)
544
648
  }
545
649
  rendered = null
546
650
  resolve()