@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 +1 -1
- package/src/fntags.d.mts +13 -2
- package/src/fntags.d.mts.map +1 -1
- package/src/fntags.mjs +110 -6
package/package.json
CHANGED
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
|
/**
|
package/src/fntags.d.mts.map
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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()
|